Estrategias dinámicas en C#

Cuando estuvimos viendo varias alternativas para aplicar OCP en aplicaciones desarrolladas con C# dejé un tema pendiente:

hay una alternativa que explicaré próximamente que, a mi parecer, permite alcanzar un punto de equilibrio para no acabar con muchas clases que sólo se usan una vez (como puede ocurrir con template method o estrategia) y a la vez mantener una estructura clara de código sin tener que pasar varios Action<T>/Func<T, R> cuya responsabilidad pueda resultar confusa.

Al utilizar los patrones template method o statregy, explicaba que puede producirse una situación poco deseable: si queremos modificar el comportamiento de un proceso, aunque sea en un sólo punto concreto de la aplicación, tenemos que definir una nueva clase entera. En el caso de template method sería una clase derivada de la clase base con el algoritmo, en el caso de strategy una nueva implementación del interface con la estrategia.

Por el contrario, si usábamos la técnica de inyectar funciones y teníamos que pasar varias funciones o acciones, podíamos acabar con métodos cuya signatura resultaba confusa y quedaba poco claro que representaba cada funcion o acción que estábamos pasando.

La alternativa que, a día de hoy, más me gusta para resolver ambos problemas, es aplicar una mezcla de dos conceptos: parameter object y una pizca de fluent interface.

Para enterderlo mejor, vamos a ver un ejemplo. Supongamos que tenemos que implementar un sistema en el que se van almacenando eventos en una base de datos y, periódicamente, es necesario revisar los eventos para decidir qué hacer con ellos. Podremos tener distintos procesos, cada uno de ellos especializado en el tratamiento de distintos eventos, pero todos ellos tendrán un algoritmo común, similar a este:

iniciar transacción en la base de datos
por cada evento que hay almacenado en la tabla
    si es un evento interesante para este proceso
        procesar el evento
        marcar el evento como procesado en la base de datos
finalizar transacción

Las partes dependientes de cada proceso son las que están marcadas en negrita en el pseudocódigo de arriba, es decir:

  • Decidir si un evento es interesante para el proceso y, por tanto, debe ser procesado
  • Realizar el proceso del evento

Lo que vamos a hacer es implementar ese algoritmo basándonos en el patrón estrategia, pero adaptándolo para poder definir estrategias dinámicamente, sin necesidad de crear nuevas clases.

¡Quiero ver el código!

Vamos a ver paso a paso el código. Primero definimos una clase, Event, para representar los eventos y otra clase, EventSystem, encargada de gestionar la revisión de eventos pendientes:

public class Event
{
    public Guid Guid { get; set; }
    public string Source { get; set; }
    public string Message { get; set; }
    public DateTime Timestamp { get; set; }
}

public class EventSystem
{
    public void ReviewPendingEvents(ReviewStrategy strategy)
    {
        var connectionString = ConfigurationManager.ConnectionStrings
                                   .Cast<string>().First();

        using (var connection = new SqlConnection(connectionString))
        {
            connection.Open();

            using (var tx = connection.BeginTransaction())
            using (var command = new SqlCommand("select Top 100 * from [Events] where [Processed] = 0 order by [Timestamp] asc", connection, tx))
            using (var reader = command.ExecuteReader())
            {
                while (reader.Read())
                {
                    var @event = GetEventFromReader(reader);

                    if (strategy.IsInterestedIn(@event))
                    {
                        strategy.Handle(@event);
                    
                        MarkAsProcessed(@event, connection, tx);
                    }
                }
            }
        }
    }

    private void MarkAsProcessed(Event @event, SqlConnection connection, SqlTransaction transaction)
    {
        using (var command = new SqlCommand("update [Events] set [Processed] = 1 where Guid = @guid", connection, transaction))
        {
            command.Parameters.AddWithValue("@guid", @event.Guid);
            command.ExecuteNonQuery();
        }
    }

    private Event GetEventFromReader(IDataRecord reader)
    {
        return new Event
        {
            Guid = new Guid(Convert.ToString(reader["Guid"])),
            Source = Convert.ToString(reader["Source"]),
            Message = Convert.ToString(reader["Message"]),
            Timestamp = Convert.ToDateTime(reader["Timestamp"])
        };
    }
}

Inciso: el código de arriba es para un ejemplo. Si fuera código real, sería mejor implementar Event como un objeto inmutable y usar Massive o Dapper para realizar el acceso a la base de datos.

Como veis, EventSystem está asumiendo toda la responsabilidad de orquestar el proceso, accediendo a la base de datos, gestionando la transacción y leyendo de la tabla. Seguramente todavía hace demasiadas cosas y sería posible extraer la funcionalidad de lectura de datos a otra clase, pero tampoco quiero complicar mucho el ejemplo. El hecho es que la parte variable del proceso, lo que antes marcábamos en negrita en el pseudocódigo de arriba, queda delegado a un objeto ReviewStrategy.

La clase ReviewStrategy implementa el patrón estrategia de una forma un poco peculiar. Su aspecto es el siguiente:

public class ReviewStrategy
{
    public Func<Event, bool> IsInterestedIn { get; private set; }
    public Action<Event> Handle { get; private set; }

    private ReviewStrategy()
    {
        IsInterestedIn = msg => false;
        Handle = msg => { };
    }

    public static ReviewStrategy InterestedIn(Func<Event, bool> interestedIn)
    {
        return new ReviewStrategy {IsInterestedIn = interestedIn};
    }

    public ReviewStrategy HandleUsing(Action<Event> handler)
    {
        Handle = handler;
        return this;
    }
}

Acabamos de definir una clase que, en realidad, no contiene ningún tipo de lógica. Toda la lógica que debería contener esta clase, los métodos bool IsInterestedIn(Event @event) y void Handle(Event @event), en realidad se delegan a un Func<Event, bool> y a un Action<Event> que se pueden configurar desde el exterior.

Esta clase no es más que el resultado de aplicar una refactorización de tipo parameter object a la inyección de funciones que haciamos en el post anterior. Con esto evitamos tener que pasar muchas expresiones lambda como parámetro, quedando así el código más claro.

Los otros dos métodos que aparecen en la clase ReviewStrategy, InterestedIn y HandleUsing, no son más que una ayuda para construir objetos ReviewStrategy usando un pequeño fluent interface que nos ayude a identificar más claramente qué hace cada expresión lambda, evitando así otro de los problemas que tenía la inyección de Func/Action, que era la dificultad para saber qué representaba cada expresión lamba.

Al usar esta clase, nuestro código tendrá un aspecto similar a éste:

public class Program
{
    public static void Main()
    {
        var system = new EventSystem();

        // Revisamos los eventos generados por SQL Server y se los 
        // dejamos a los DBAs para que se entretengan
        system.ReviewPendingEvents(ReviewStrategy
            .InterestedIn(@event => @event.Source == "SQL_SERVER")
            .HandleUsing(@event => SendToDBAs(@event)));

   
        // Solucionamos de una vez por todas todos los problemas con las
        // actualizaciones de Java
        system.ReviewPendingEvents(ReviewStrategy
            .InterestedIn(@event => @event.Message.Contains("Actualización de Java Disponible"))
            .HandleUsing(@event => UninstallJVM()));
    }

    private static void SendToDBAs(Event @event)
    {
        // TODO: ...
    }

    private static void UninstallJVM()
    {
        // TODO: ...
    }
}

Resumiendo…

Aprovechando las características funcionales de C#, con este método se pueden definir estrategias dinámicamente, de forma sencilla y sin necesidad de crear clases nuevas. Además no cierra la puerta a emplear estrategias tradicionales.

Si tenemos una configuración de estrategia que se repite en muchas clases, se puede encapsular en su propia clase derivada de estrategia o tener una factoría en alguna parte para crearla.