Comandos de consulta y / o especificaciones bien diseñados

90

He estado buscando durante bastante tiempo una buena solución a los problemas que presenta el patrón de repositorio típico (lista creciente de métodos para consultas especializadas, etc., consulte: http://ayende.com/blog/3955/repository- es-el-nuevo-singleton ).

Me gusta mucho la idea de utilizar consultas de comandos, especialmente mediante el uso del patrón de especificación. Sin embargo, mi problema con la especificación es que solo se relaciona con los criterios de selecciones simples (básicamente, la cláusula where), y no se ocupa de las otras cuestiones de las consultas, como unión, agrupación, selección o proyección de subconjuntos, etc. Básicamente, todos los aros adicionales por los que deben pasar muchas consultas para obtener el conjunto correcto de datos.

(nota: utilizo el término "comando" como en el patrón de comando, también conocido como objetos de consulta. No estoy hablando de comando como en la separación comando / consulta donde se hace una distinción entre consultas y comandos (actualizar, eliminar, insertar))

Así que estoy buscando alternativas que encapsulen toda la consulta, pero que sigan siendo lo suficientemente flexibles como para que no solo esté intercambiando repositorios de espaguetis por una explosión de clases de comando.

He usado, por ejemplo, Linqspecs, y aunque encuentro algo de valor en poder asignar nombres significativos a los criterios de selección, simplemente no es suficiente. Quizás estoy buscando una solución combinada que combine múltiples enfoques.

Estoy buscando soluciones que otros puedan haber desarrollado para abordar este problema o abordar un problema diferente, pero aún satisfacen estos requisitos. En el artículo vinculado, Ayende sugiere usar el contexto nHibernate directamente, pero creo que eso complica en gran medida su capa empresarial porque ahora también debe contener información de consulta.

Ofreceré una recompensa por esto, tan pronto como pase el período de espera. Por lo tanto, haga que sus soluciones sean dignas de recompensa, con buenas explicaciones, seleccionaré la mejor solución y votaré a los finalistas.

NOTA: Estoy buscando algo basado en ORM. No tiene que ser EF o nHibernate explícitamente, pero esos son los más comunes y encajarían mejor. Si se puede adaptar fácilmente a otros ORM, sería una ventaja. Linq compatible también sería bueno.

ACTUALIZACIÓN: Estoy realmente sorprendido de que no haya muchas buenas sugerencias aquí. Parece que las personas son totalmente CQRS o están completamente en el campo del repositorio. La mayoría de mis aplicaciones no son lo suficientemente complejas como para garantizar CQRS (algo que la mayoría de los defensores de CQRS dicen que no debes usarlo).

ACTUALIZACIÓN: Parece haber un poco de confusión aquí. No busco una nueva tecnología de acceso a datos, sino una interfaz razonablemente bien diseñada entre el negocio y los datos.

Idealmente, lo que estoy buscando es algún tipo de cruce entre los objetos de consulta, el patrón de especificación y el repositorio. Como dije anteriormente, el patrón de especificación solo se ocupa del aspecto de la cláusula where, y no de los otros aspectos de la consulta, como combinaciones, sub-selecciones, etc. Los repositorios se ocupan de toda la consulta, pero se salen de control después de un tiempo. . Los objetos de consulta también se ocupan de toda la consulta, pero no quiero simplemente reemplazar los repositorios con explosiones de objetos de consulta.

Erik Funkenbusch
fuente
5
Fantástica pregunta. A mí también me gustaría ver qué personas con más experiencia de la que sugiero. Estoy trabajando en una base de código en este momento donde el repositorio genérico también contiene sobrecargas para objetos Command u objetos Query, cuya estructura es similar a lo que Ayende describe en su blog. PD: Esto también podría atraer la atención de los programadores.
Simon Whitehead
¿Por qué no usar un repositorio que exponga IQueryable si no le importa la dependencia de LINQ? Un enfoque común es un repositorio genérico, y luego, cuando necesite la lógica reutilizable anterior, cree un tipo de repositorio derivado con sus métodos adicionales.
devdigital
@devdigital: la dependencia de Linq no es lo mismo que la dependencia de la implementación de datos. Me gustaría usar Linq para objetos, para poder ordenar o realizar otras funciones de capa empresarial. Pero eso no significa que quiera dependencias en la implementación del modelo de datos. De lo que realmente estoy hablando aquí es de la interfaz capa / nivel. Como ejemplo, quiero poder cambiar una consulta y no tener que cambiarla en 200 lugares, que es lo que sucede si inserta IQueryable directamente en el modelo de negocio.
Erik Funkenbusch
1
@devdigital, que básicamente traslada los problemas con un repositorio a su capa empresarial. Solo estás barajando el problema.
Erik Funkenbusch

Respuestas:

94

Descargo de responsabilidad: dado que aún no hay buenas respuestas, decidí publicar una parte de una excelente publicación de blog que leí hace un tiempo, copiada casi literalmente. Puede encontrar la publicación completa del blog aquí . Asi que aqui esta:


Podemos definir las siguientes dos interfaces:

public interface IQuery<TResult>
{
}

public interface IQueryHandler<TQuery, TResult> where TQuery : IQuery<TResult>
{
    TResult Handle(TQuery query);
}

Los IQuery<TResult>especifica un mensaje que define una consulta específica con los datos que devuelve usando el TResulttipo genérico. Con la interfaz definida previamente podemos definir un mensaje de consulta como este:

public class FindUsersBySearchTextQuery : IQuery<User[]>
{
    public string SearchText { get; set; }
    public bool IncludeInactiveUsers { get; set; }
}

Esta clase define una operación de consulta con dos parámetros, lo que dará como resultado una matriz de Userobjetos. La clase que maneja este mensaje se puede definir de la siguiente manera:

public class FindUsersBySearchTextQueryHandler
    : IQueryHandler<FindUsersBySearchTextQuery, User[]>
{
    private readonly NorthwindUnitOfWork db;

    public FindUsersBySearchTextQueryHandler(NorthwindUnitOfWork db)
    {
        this.db = db;
    }

    public User[] Handle(FindUsersBySearchTextQuery query)
    {
        return db.Users.Where(x => x.Name.Contains(query.SearchText)).ToArray();
    }
}

Ahora podemos permitir que los consumidores dependan de la IQueryHandlerinterfaz genérica :

public class UserController : Controller
{
    IQueryHandler<FindUsersBySearchTextQuery, User[]> findUsersBySearchTextHandler;

    public UserController(
        IQueryHandler<FindUsersBySearchTextQuery, User[]> findUsersBySearchTextHandler)
    {
        this.findUsersBySearchTextHandler = findUsersBySearchTextHandler;
    }

    public View SearchUsers(string searchString)
    {
        var query = new FindUsersBySearchTextQuery
        {
            SearchText = searchString,
            IncludeInactiveUsers = false
        };

        User[] users = this.findUsersBySearchTextHandler.Handle(query);    
        return View(users);
    }
}

Inmediatamente este modelo nos da mucha flexibilidad, porque ahora podemos decidir qué inyectar en el UserController. Podemos inyectar una implementación completamente diferente, o una que envuelva la implementación real, sin tener que hacer cambios en UserController(y en todos los demás consumidores de esa interfaz).

La IQuery<TResult>interfaz nos brinda soporte en tiempo de compilación al especificar o inyectar IQueryHandlersen nuestro código. Cuando cambiamos el FindUsersBySearchTextQueryregresar UserInfo[]lugar (mediante la implementación IQuery<UserInfo[]>), la UserControllerdejará de recopilar, ya que el tipo de restricción genérica sobre IQueryHandler<TQuery, TResult>no será capaz de asignar FindUsersBySearchTextQuerya User[].

IQueryHandlerSin embargo, inyectar la interfaz en un consumidor tiene algunos problemas menos obvios que aún deben abordarse. El número de dependencias de nuestros consumidores puede ser demasiado grande y puede llevar a una sobreinyección del constructor, cuando un constructor toma demasiados argumentos. El número de consultas que ejecuta una clase puede cambiar con frecuencia, lo que requeriría cambios constantes en el número de argumentos del constructor.

Podemos solucionar el problema de tener que inyectar demasiados IQueryHandlerscon una capa extra de abstracción. Creamos un mediador que se ubica entre los consumidores y los manejadores de consultas:

public interface IQueryProcessor
{
    TResult Process<TResult>(IQuery<TResult> query);
}

El IQueryProcessores una interfaz no genérico con un método genérico. Como puede ver en la definición de la interfaz, IQueryProcessordepende de la IQuery<TResult>interfaz. Esto nos permite tener soporte de tiempo de compilación en nuestros consumidores que dependen del IQueryProcessor. Reescribamos el UserControllerpara usar el nuevo IQueryProcessor:

public class UserController : Controller
{
    private IQueryProcessor queryProcessor;

    public UserController(IQueryProcessor queryProcessor)
    {
        this.queryProcessor = queryProcessor;
    }

    public View SearchUsers(string searchString)
    {
        var query = new FindUsersBySearchTextQuery
        {
            SearchText = searchString,
            IncludeInactiveUsers = false
        };

        // Note how we omit the generic type argument,
        // but still have type safety.
        User[] users = this.queryProcessor.Process(query);

        return this.View(users);
    }
}

El UserControllerahora depende de un IQueryProcessorque pueda manejar todas nuestras consultas. El UserController's SearchUsersmétodo llama al IQueryProcessor.Processmétodo que pasa en un objeto de consulta inicializado. Dado que FindUsersBySearchTextQueryimplementa la IQuery<User[]>interfaz, podemos pasarla al Execute<TResult>(IQuery<TResult> query)método genérico . Gracias a la inferencia de tipos de C #, el compilador puede determinar el tipo genérico y esto nos ahorra tener que indicar explícitamente el tipo. ProcessTambién se conoce el tipo de retorno del método.

Ahora es responsabilidad de la implementación de IQueryProcessorencontrar el derecho IQueryHandler. Esto requiere algo de escritura dinámica y, opcionalmente, el uso de un marco de inyección de dependencia, y todo se puede hacer con solo unas pocas líneas de código:

sealed class QueryProcessor : IQueryProcessor
{
    private readonly Container container;

    public QueryProcessor(Container container)
    {
        this.container = container;
    }

    [DebuggerStepThrough]
    public TResult Process<TResult>(IQuery<TResult> query)
    {
        var handlerType = typeof(IQueryHandler<,>)
            .MakeGenericType(query.GetType(), typeof(TResult));

        dynamic handler = container.GetInstance(handlerType);

        return handler.Handle((dynamic)query);
    }
}

La QueryProcessorclase construye un IQueryHandler<TQuery, TResult>tipo específico basado en el tipo de instancia de consulta proporcionada. Este tipo se utiliza para pedir a la clase de contenedor proporcionada que obtenga una instancia de ese tipo. Desafortunadamente, necesitamos llamar al Handlemétodo usando la reflexión (usando la palabra clave dinámica C # 4.0 en este caso), porque en este punto es imposible lanzar la instancia del controlador, ya que el TQueryargumento genérico no está disponible en tiempo de compilación. Sin embargo, a menos Handleque se cambie el nombre del método o se obtengan otros argumentos, esta llamada nunca fallará y, si lo desea, es muy fácil escribir una prueba unitaria para esta clase. El uso de la reflexión producirá una ligera caída, pero no es nada de lo que preocuparse.


Para responder a una de sus inquietudes:

Así que estoy buscando alternativas que encapsulen toda la consulta, pero que sigan siendo lo suficientemente flexibles como para que no solo esté intercambiando repositorios de espaguetis por una explosión de clases de comando.

Una consecuencia de usar este diseño es que habrá muchas clases pequeñas en el sistema, pero tener muchas clases pequeñas / enfocadas (con nombres claros) es algo bueno. Este enfoque es claramente mucho mejor que tener muchas sobrecargas con diferentes parámetros para el mismo método en un repositorio, ya que puede agruparlos en una clase de consulta. Por lo tanto, todavía obtiene muchas menos clases de consulta que métodos en un repositorio.

david.s
fuente
2
Parece que recibiste el premio. Me gustan los conceptos, solo esperaba que alguien presentara algo realmente diferente. Felicidades.
Erik Funkenbusch
1
@FuriCuri, ¿una sola clase realmente necesita 5 consultas? Quizás podrías considerar eso como una clase con demasiadas responsabilidades. Alternativamente, si las consultas se están agregando, tal vez deberían ser una sola consulta. Estas son solo sugerencias, por supuesto.
Sam
1
@stakx Tienes toda la razón en que en mi ejemplo inicial el TResultparámetro genérico de la IQueryinterfaz no es útil. Sin embargo, en mi respuesta actualizada, TResultel Processmétodo de IQueryProcessorpara resolver el IQueryHandleren tiempo de ejecución usa el parámetro .
david.s
1
También tengo un blog con una implementación muy similar lo que me hace pensar que estoy en el camino correcto, este es el enlace jupaol.blogspot.mx/2012/11/… y lo he estado usando por un tiempo en aplicaciones PROD, pero he tenido un problema con este enfoque. Encadenando y reutilizando consultas Digamos que tengo varias consultas pequeñas que necesitan ser combinadas para crear consultas más complejas, terminé simplemente duplicando el código pero estoy buscando un enfoque mucho mejor y más limpio. ¿Algunas ideas?
Jupaol
4
@Cemre Terminé encapsulando mis consultas en los métodos de extensión que regresan IQueryabley asegurándome de no enumerar la colección, luego desde el QueryHandleracabo de llamar / encadenar las consultas. Esto me dio la flexibilidad de realizar pruebas unitarias de mis consultas y encadenarlas. Tengo un servicio de aplicación además de mi QueryHandler, y mi controlador está a cargo de hablar directamente con el servicio en lugar del controlador
Jupaol
4

Mi forma de lidiar con eso es en realidad simplista y agnóstica de ORM. Mi punto de vista para un repositorio es el siguiente: el trabajo del repositorio es proporcionar a la aplicación el modelo requerido para el contexto, por lo que la aplicación solo le pide al repositorio lo que quiere, pero no le dice cómo obtenerlo.

Proporciono al método de repositorio un Criterio (sí, estilo DDD), que será utilizado por el repositorio para crear la consulta (o lo que sea necesario, puede ser una solicitud de servicio web). Las uniones y grupos en mi humilde opinión son detalles de cómo, no el qué y un criterio debería ser solo la base para construir una cláusula de dónde.

Modelo = el objeto final o la estructura de datos que necesita la aplicación.

public class MyCriteria
{
   public Guid Id {get;set;}
   public string Name {get;set;}
    //etc
 }

 public interface Repository
  {
       MyModel GetModel(Expression<Func<MyCriteria,bool>> criteria);
   }

Probablemente pueda usar los criterios ORM (Nhibernate) directamente si lo desea. La implementación del repositorio debe saber cómo utilizar los Criterios con el almacenamiento subyacente o DAO.

No conozco su dominio y los requisitos del modelo, pero sería extraño si la mejor manera es que la aplicación cree la consulta. ¿El modelo cambia tanto que no se puede definir algo estable?

Esta solución claramente requiere un código adicional, pero no acopla el resto a un ORM o lo que sea que esté usando para acceder al almacenamiento. El repositorio hace su trabajo para actuar como una fachada y, en mi opinión, está limpio y el código de 'traducción de criterios' es reutilizable

MikeSW
fuente
Esto no resuelve los problemas del crecimiento del repositorio y de tener una lista de métodos en constante expansión para devolver varios tipos de datos. Entiendo que es posible que no vea un problema con esto (muchas personas no lo hacen), pero otros lo ven de manera diferente (sugiero leer el artículo al que he vinculado, hay muchas otras personas con opiniones similares).
Erik Funkenbusch
1
Lo abordo, porque los criterios hacen que muchos métodos sean innecesarios. Por supuesto, no de todos ellos no puedo decir mucho sin saber nada sobre el tintineo que necesita. Sin embargo, estoy bajo la impresión de que desea consultar directamente la base de datos, por lo que probablemente haya un repositorio en el camino. Si necesita trabajar directamente con el almacenamiento relacional, hágalo directamente, sin necesidad de un repositorio. Y como nota, es molesto la cantidad de personas que citan a Ayende con esa publicación. No estoy de acuerdo con eso y creo que muchos desarrolladores simplemente están usando el patrón de manera incorrecta.
MikeSW
1
Puede reducir un poco el problema, pero dada una aplicación lo suficientemente grande, aún creará repositorios monstruosos. No estoy de acuerdo con la solución de Ayende de usar nHibernate directamente en la lógica principal, pero estoy de acuerdo con él sobre lo absurdo del crecimiento del repositorio fuera de control. No quiero consultar directamente la base de datos, pero tampoco solo quiero mover el problema de un repositorio a una explosión de objetos de consulta.
Erik Funkenbusch
2

Hice esto, apoyé esto y deshice esto.

El principal problema es este: no importa cómo lo hagas, la abstracción agregada no te otorga independencia. Se filtrará por definición. En esencia, está inventando una capa completa solo para hacer que su código se vea lindo ... pero no reduce el mantenimiento, mejora la legibilidad ni le otorga ningún tipo de agnosticismo en el modelo.

La parte divertida es que respondió su propia pregunta en respuesta a la respuesta de Olivier: "esto es esencialmente duplicar la funcionalidad de Linq sin todos los beneficios que obtiene de Linq".

Pregúntate: ¿cómo no podría ser?

Stu
fuente
Bueno, definitivamente he experimentado los problemas de integrar Linq en su capa empresarial. Es muy poderoso, pero cuando hacemos cambios en el modelo de datos es una pesadilla. Las cosas mejoran con los repositorios, porque puedo hacer los cambios en un lugar localizado sin afectar mucho la capa empresarial (excepto cuando también hay que cambiar la capa empresarial para admitir los cambios). Pero, los repositorios se convierten en estas capas hinchadas que violan el SRP masivamente. Entiendo tu punto, pero tampoco resuelve ningún problema.
Erik Funkenbusch
Si su capa de datos usa LINQ y los cambios en el modelo de datos requieren cambios en su capa empresarial ... no está creando capas correctamente.
Stu
Pensé que estabas diciendo que ya no agregaste esa capa. Cuando dice que la abstracción agregada no le brinda nada, eso implica que está de acuerdo con Ayende en pasar la sesión nHibernate (o contexto EF) directamente a su capa empresarial.
Erik Funkenbusch
1

Puede utilizar una interfaz fluida. La idea básica es que los métodos de una clase devuelven la instancia actual de esta misma clase después de haber realizado alguna acción. Esto le permite encadenar llamadas a métodos.

Al crear una jerarquía de clases adecuada, puede crear un flujo lógico de métodos accesibles.

public class FinalQuery
{
    protected string _table;
    protected string[] _selectFields;
    protected string _where;
    protected string[] _groupBy;
    protected string _having;
    protected string[] _orderByDescending;
    protected string[] _orderBy;

    protected FinalQuery()
    {
    }

    public override string ToString()
    {
        var sb = new StringBuilder("SELECT ");
        AppendFields(sb, _selectFields);
        sb.AppendLine();

        sb.Append("FROM ");
        sb.Append("[").Append(_table).AppendLine("]");

        if (_where != null) {
            sb.Append("WHERE").AppendLine(_where);
        }

        if (_groupBy != null) {
            sb.Append("GROUP BY ");
            AppendFields(sb, _groupBy);
            sb.AppendLine();
        }

        if (_having != null) {
            sb.Append("HAVING").AppendLine(_having);
        }

        if (_orderBy != null) {
            sb.Append("ORDER BY ");
            AppendFields(sb, _orderBy);
            sb.AppendLine();
        } else if (_orderByDescending != null) {
            sb.Append("ORDER BY ");
            AppendFields(sb, _orderByDescending);
            sb.Append(" DESC").AppendLine();
        }

        return sb.ToString();
    }

    private static void AppendFields(StringBuilder sb, string[] fields)
    {
        foreach (string field in fields) {
            sb.Append(field).Append(", ");
        }
        sb.Length -= 2;
    }
}

public class GroupedQuery : FinalQuery
{
    protected GroupedQuery()
    {
    }

    public GroupedQuery Having(string condition)
    {
        if (_groupBy == null) {
            throw new InvalidOperationException("HAVING clause without GROUP BY clause");
        }
        if (_having == null) {
            _having = " (" + condition + ")";
        } else {
            _having += " AND (" + condition + ")";
        }
        return this;
    }

    public FinalQuery OrderBy(params string[] fields)
    {
        _orderBy = fields;
        return this;
    }

    public FinalQuery OrderByDescending(params string[] fields)
    {
        _orderByDescending = fields;
        return this;
    }
}

public class Query : GroupedQuery
{
    public Query(string table, params string[] selectFields)
    {
        _table = table;
        _selectFields = selectFields;
    }

    public Query Where(string condition)
    {
        if (_where == null) {
            _where = " (" + condition + ")";
        } else {
            _where += " AND (" + condition + ")";
        }
        return this;
    }

    public GroupedQuery GroupBy(params string[] fields)
    {
        _groupBy = fields;
        return this;
    }
}

Lo llamarías así

string query = new Query("myTable", "name", "SUM(amount) AS total")
    .Where("name LIKE 'A%'")
    .GroupBy("name")
    .Having("COUNT(*) > 2")
    .OrderBy("name")
    .ToString();

Solo puede crear una nueva instancia de Query. Las otras clases tienen un constructor protegido. El objetivo de la jerarquía es "deshabilitar" los métodos. Por ejemplo, el GroupBymétodo devuelve a GroupedQuerywhich es la clase base de Queryy no tiene un Wheremétodo (el método where está declarado en Query). Por lo tanto, no es posible llamar Wheredespués GroupBy.

Sin embargo, no es perfecto. Con esta jerarquía de clases, puede ocultar miembros sucesivamente, pero no mostrar nuevos. Por lo tanto, Havinglanza una excepción cuando se llama antes GroupBy.

Tenga en cuenta que es posible llamar Wherevarias veces. Esto agrega nuevas condiciones ANDa las condiciones existentes. Esto facilita la construcción de filtros mediante programación a partir de condiciones únicas. Lo mismo es posible con Having.

Los métodos que aceptan listas de campos tienen un parámetro params string[] fields. Le permite pasar nombres de campo único o una matriz de cadenas.


Las interfaces fluidas son muy flexibles y no requieren que crees muchas sobrecargas de métodos con diferentes combinaciones de parámetros. Mi ejemplo funciona con cadenas, sin embargo, el enfoque se puede extender a otros tipos. También puede declarar métodos predefinidos para casos especiales o métodos que acepten tipos personalizados. También puede agregar métodos como ExecuteReadero ExceuteScalar<T>. Esto le permitiría definir consultas como esta

var reader = new Query<Employee>(new MonthlyReportFields{ IncludeSalary = true })
    .Where(new CurrentMonthCondition())
    .Where(new DivisionCondition{ DivisionType = DivisionType.Production})
    .OrderBy(new StandardMonthlyReportSorting())
    .ExecuteReader();

Incluso los comandos SQL construidos de esta manera pueden tener parámetros de comando y así evitar problemas de inyección de SQL y al mismo tiempo permitir que el servidor de base de datos almacene en caché los comandos. Esto no es un reemplazo para un mapeador O / R, pero puede ayudar en situaciones en las que crearía los comandos usando una simple concatenación de cadenas de otro modo.

Olivier Jacot-Descombes
fuente
3
Hmm ... Interesante, pero su solución parece tener problemas con las posibilidades de Inyección SQL, y realmente no crea declaraciones preparadas para la ejecución precompilada (por lo tanto, funciona más lentamente). Probablemente podría adaptarse para solucionar esos problemas, pero luego nos quedamos atrapados con los resultados del conjunto de datos seguros que no son de tipo y otras cosas. Preferiría una solución basada en ORM, y tal vez debería especificarlo explícitamente. Básicamente, se trata de duplicar la funcionalidad de Linq sin todos los beneficios que obtiene de Linq.
Erik Funkenbusch
Soy consciente de estos problemas. Esta es solo una solución rápida y sucia, que muestra cómo se puede construir una interfaz fluida. En una solución del mundo real, probablemente “integraría” su enfoque actual en una interfaz fluida adaptada a sus necesidades.
Olivier Jacot-Descombes