lunes, 7 de noviembre de 2011

Builders genéricos: Agregando funcionalidad (3)

Esta es la tercera parte de la serie de posts que escribí sobre Builders genéricos.
Escribo los links de los post anteriores:

Builders genéricos: Introducción (1)
Builders genéricos: Implementación (2)


En este post voy a dar ejemplos más avanzados en los cuales me es muy útil utilizar el concepto de Builder. Quizás existan mejores formas de hacer lo que les voy a explicar (escucho sugerencias), pero hasta ahora, este patrón me dio buenos resultados, sobre todo para realizar los Tests de mis aplicaciones.

Primero partamos de la base que nos dejó mi post anterior:

Tenemos una interfaz IBuilder que recibe un TObject como parámetro genérico cuyo método principal es Build. Este método nos permite construir objetos de tipo TObject:

public interface IBuilder<TBuilder, TObject>
    where TBuilder : IBuilder<TBuilder, TObject>
{
    TBuilder With(Action<TObject> setProperties);
    TObject Build();
}


Aparte, también tenemos nuestra implementación abstracta que nos permite construir un objeto de tipo TObject. Para esto tuvimos que agregarle la restricción a TObject de que debía tener como mínimo un constructor público sin parámetros ( new() ):

public abstract class AbstractBuilder<TBuilder, TObject>
    : IBuilder<TBuilder, TObject>
    where TBuilder : AbstractBuilder<TBuilder, TObject>
    where TObject : class, new()
{
    protected TObject obj;
    protected AbstractBuilder()
    {
        obj = new TObject();
    }

    public virtual TBuilder With(Action<TObject> setProperties)
    {
        if (setProperties == null)
            throw new ArgumentNullException("setProperties");
        setProperties(obj);
        return this as TBuilder;
    }

    public virtual TObject Build()
    {
        TObject objAux = obj;
        obj = null;
        return objAux;
    }
}


Ahora comenzaremos a utilizarlo y extenderemos su funcionalidad o la simplificaremos :) cuando veamos que es posible.

Revisemos primero la interfaz IBuilder<TObject>.

Leyendo uno de los posts del Blog de Jorge Rowies que hablaba sobre el Principio de Separación de Interfaces - SOLID se me cruzó por la mente que mi interfaz IBuilder, aunque en apariencia resulte simple estaba haciendo algo más que lo que debería hacer.

¿Qué es lo que dice el principio de separación de interfaces (Interface Segregation Principle o ISP)?
El principio dice: Una clase cliente no debe ser forzada a depender de interfaces que no usa.

En nuestro caso, tenemos una interfaz de un Builder que contiene un método Build para generar un objeto :) pero aparte tiene el método With que recibe un Action para setear sus propiedades :(.

Planteemos un ejemplo para ver que es lo que no está del todo bien:

Tenemos la siguiente clase que representa un número aleatorio cuyo único constructor recibe un número entero:

public class RandomNumber
{
    public RandomNumber(int num)
    {
        IntegerValue = num;
    }
    public int IntegerValue { get; private set; }
}

El Builder genérico no nos sirve porque la clase RandomNumber no cumple la restricción de tener al menos un constructor público sin parámetros. Por lo tanto nos creamos una implementación customizada que nos devuelva esta clase pero que implemente IBuilder:

public class RandomNumberBuilder : IBuilder<RandomNumberBuilder,RandomNumber>
{
    public RandomNumber Build()
    {
        // Implementación sólo de ejemplo
        return new RandomNumber(new Random(Guid.NewGuid().GetHashCode()).Next());
    }
    public RandomNumberBuilder With(Action<RandomNumber> setProperties)
    {
        throw new NotSupportedException("Este builder no permite setear propiedades!");
    }
}


A simple vista nos damos cuenta que hay 2 cosas que nos hacen ruido:

  1. Debemos implementar el método With aún cuando ya sabemos de antemano que la clase RandomNumber no permite el seteo de propiedades. Como primer solución, en el cuerpo de este método lanzamos una excepción de tipo "método no soportado".
  2. Por otro lado, como el método With debe devolver la instancia del builder, debemos pasarle por parámetro al propio Builder que nunca se utilizará dentro de la clase.

¿Cómo debería estar implementada nuestra clase RandomNumberBuilder?

public class RandomNumberBuilder : IBuilder<RandomNumber>
{
    public RandomNumber Build()
    {
        return new RandomNumber(new Random(Guid.NewGuid().GetHashCode()).Next());
    }
}

Utilizando el principio de ISP simplemente separamos en 2 la interfaz:

public interface IBuilder<TObject>
{
    TObject Build();
}

public interface IBuilderWithSetters<TBuilder,TObject>
    : IBuilder<TObject>
    where TBuilder : IBuilderWithSetters<TBuilder, TObject>
{
    TBuilder With(Action<TObject> setProperties);
}

De esta forma queda más reusable la interfaz IBuilder y es mucho más clara la función que debe cumplir.

Con este cambio, nuestro Generic Builder quedaría igual que antes sólo que implementaría la interfaz IBuilderWithSetters<TBuilder,TObject>:

public abstract class AbstractBuilder<TBuilder, TObject> 
        : IBuilderWithSetters<TBuilder,TObject>
    where TBuilder : AbstractBuilder<TBuilder, TObject>
    where TObject : class, new()
{
    // Misma implementación que antes
}


Otro ejemplo. Utilizaremos la clase Person definida en el post anterior junto con el siguiente builder:

public class Person
{
    public string Name { get; set; }
    public int? Age { get; set; }
}

public class PersonBuilder : AbstractBuilder<PersonBuilder,Person>
{
}

Supongamos que en lugar de querer crear un objeto, queremos crear una colección de objetos Person con valores iguales(que no es lo mismo que una colección de instancias iguales). Para crear 10 elementos la solución sería la siguiente:

[Test]
public void TestBuildCollection()
{
    var people = new List<Person>();
    for (int i = 0; i < 10; i++)
    {
        people.Add(
            new PersonBuilder()
                .With(person => person.Name = "Juan")
                .With(person => person.Age = 30)
                .Build());
    }
    Assert.That(people.Count(), Is.EqualTo(10));
    foreach (Person p in people)
    {
        Assert.That(p, Is.Not.Null);
        Assert.That(p.Name, Is.EqualTo("Juan"));
        Assert.That(p.Age, Is.EqualTo(30));
    }
}

Esta solución funciona correctamente, sin embargo estamos creando 10 builders exactamente iguales, 1 por cada objeto Person que queremos crear.

Una segunda posible solución podría haber sido la siguiente:

[Test]
public void TestBuildCollection()
{
    var people = new List<Person>();
    var personBuilder = new PersonBuilder()
        .With(person => person.Name = "Juan")
        .With(person => person.Age = 30);
    for (int i = 0; i < 10; i++)
    {
        people.Add(personBuilder.Build());
    }
    Assert.That(people.Count(), Is.EqualTo(10));
    foreach (Person p in people)
    {
        Assert.That(p, Is.Not.Null);
        Assert.That(p.Name, Is.EqualTo("Juan"));
        Assert.That(p.Age, Is.EqualTo(30));
    }
}

Sin embargo, cada vez que hacemos build, el campo "obj" del builder se setea a null ya que sino, una nueva llamada al método Build devolvería la misma instancia.

public virtual TObject Build()
{
   TObject objAux = obj;
   obj = null;   // Para prevenir utilizar una instancia creada previamente
   return objAux;
}

Por otro lado, si en vez de setear esa propiedad en null, le seteamos new TObject(), al correr el test nos encontraremos con un resultado raro. El primer objeto creado tendría todas sus propiedades con valores pero los demás estarían vacíos. Esto se debe a que cada llamada al método With se ejecuta inmediatamente y por lo tanto, en el nuevo objeto a crear este seteo no estaría registrado:

public virtual TObject Build()
{
   TObject objAux = obj;
   obj = new TObject();   // NO!!!.. Se están perdiendo todos los seteos
   return objAux;
}


¿Cómo podemos modificar nuestro Builder genérico para que el segundo test corra correctamente? 

En lugar de ejecutar inmediatamente las acciones las guardamos en una lista para luego ejecutarlas en cada llamada al método Build. Para no complicar la clase AbstractBuilder crearía una nueva llamada AbstractTemplateBuilder (le agregué la palabra Template porque lo que estoy haciendo es crear una plantilla de objetos).

Las clases AbstractTemplateBuilder y PersonBuilder quedarían de la siguiente manera:

public abstract class AbstractTemplateBuilder<TBuilder, TObject>
        : IBuilderWithSetters<TBuilder, TObject>
    where TBuilder : AbstractTemplateBuilder<TBuilder, TObject>
    where TObject : class, new()
{
    protected readonly IList<Action<TObject>> actions;
    protected AbstractTemplateBuilder()
    {
        actions = new List<Action<TObject>>();
    }

    public virtual TBuilder With(Action<TObject> setProperties)
    {
        if (setProperties == null)
            throw new ArgumentNullException("setProperties");
        actions.Add(setProperties);
        return (TBuilder)this;
    }

    public virtual TObject Build()
    {
        TObject toBuild = new TObject();
        foreach (Action<TObject> action in actions)
        {
            if (action != null)
                action(toBuild);
        }
        return toBuild;
    }
}

public class PersonBuilder : AbstractTemplateBuilder<TPersonbuilder, Person>
{
}

Ahora que podemos generar las clases mediante un template. ¿Podemos generalizar la obtención de la colección de objetos? La respuesta es Sí :)
¿Cómo quedaría el test?

[Test]
public void TestBuildCollection()
{
    var personBuilder = new PersonBuilder()
        .With(person => person.Name = "Juan")
        .With(person => person.Age = 30);

    var people = personBuilder.BuildCollection(10);

    Assert.That(people.Count(), Is.EqualTo(10));
    foreach (Person p in people)
    {
        Assert.That(p, Is.Not.Null);
        Assert.That(p.Name, Is.EqualTo("Juan"));
        Assert.That(p.Age, Is.EqualTo(30));
    }
}


Para que esto funcione, creamos la interfaz ICollectionBuilder:

public interface ICollectionBuilder<TObject>
{
    IEnumerable<TObject> BuildCollection(int length);
}

La implementación del método sería la siguiente:

public virtual IEnumerable<TObject> BuildCollection(int length)
{
    TObject toBuild = Build();
    for (int i = 0; i < length; i++)
    {
        yield return toBuild;
    }
}

Y para finalizar, ya que creamos una colección, ¿Por qué no permitir que se seteen valores específicos a cada elemento teniendo en cuenta la posición del elemento a agregar? Otra vez podemos utilizar los actions para permitir esto.

Por ejemplo, si quiero devolver una colección de personas con el mismo nombre pero con edades de 0 a 10 el test sería el siguiente:

[Test]
public void TestBuildCollection()
{
    var personBuilder = new PersonBuilder()
        .With(person => person.Name = "Juan")
        .With(person => person.Age = 30);

    var people = personBuilder
        .BuildCollection(10, (person, index) => person.Age = index);

    Assert.That(people.Count(), Is.EqualTo(10));
    int aux = 0;
    foreach (Person p in people)
    {
        Assert.That(p, Is.Not.Null);
        Assert.That(p.Name, Is.EqualTo("Juan"));
        Assert.That(p.Age, Is.EqualTo(aux++));
    }
}

Para no extenderme más, les dejo como quedarían la interfaz ICollectionBuilder y la implementación completa de AbstractTemplateBuilder:

public interface ICollectionBuilder<TObject>
{
    IEnumerable<TObject> BuildCollection(int length);
    IEnumerable<TObject> BuildCollection(int length, Action<TObject,int> setProperties);
}

public abstract class AbstractTemplateBuilder<TBuilder, TObject>
        : IBuilderWithSetters<TBuilder, TObject>,
            ICollectionBuilder<TObject>
    where TBuilder : AbstractTemplateBuilder<TBuilder, TObject>
    where TObject : class, new()
{
    protected readonly IList<Action<TObject>> actions;
    protected AbstractTemplateBuilder()
    {
        actions = new List<Action<TObject>>();
    }

    public virtual TBuilder With(Action<TObject> setProperties)
    {
        if (setProperties == null)
            throw new ArgumentNullException("setProperties");
        actions.Add(setProperties);
        return (TBuilder)this;
    }

    public virtual TObject Build()
    {
        TObject toBuild = new TObject();
        foreach (Action<TObject> action in actions)
        {
            if (action != null)
                action(toBuild);
        }
        return toBuild;
    }

    public virtual IEnumerable<TObject> BuildCollection(int length)
    {
        for (int i = 0; i < length; i++)
        {
            TObject toBuild = Build();
            yield return toBuild;
        }
    }

    public virtual IEnumerable<TObject> BuildCollection(int length, Action<TObject, int> setProperties)
    {
        for (int i = 0; i < length; i++)
        {
            TObject toBuild = Build();
            if (setProperties != null)
                setProperties(toBuild, i);
            yield return toBuild;
        }
    }
}

Todavía me quedaron unos ejemplos más avanzados pero ya me extendí demasiado. Quizás haya un 4° post si lo amerita.

Cualquier duda, consulta o sugerencia como siempre será bienvenida.
Hasta luego.

27/12/2015
Fix: BuildCollection tenía un error. Se estaba creando el objeto por fuera del foreach cuando debería haber sido dentro del mismo.

No hay comentarios:

Publicar un comentario