Comandos de consulta e / ou especificações bem projetados

90

Venho procurando há algum tempo por uma boa solução para os problemas apresentados pelo padrão típico de Repositório (lista crescente de métodos para consultas especializadas, etc. ver: http://ayende.com/blog/3955/repository- is-the-new-singleton ).

Eu realmente gosto da ideia de usar consultas de comando, particularmente por meio do uso do padrão de especificação. No entanto, meu problema com a especificação é que ela se refere apenas aos critérios de seleções simples (basicamente, a cláusula where), e não lida com as outras questões de consultas, como junção, agrupamento, seleção de subconjunto ou projeção, etc. basicamente, todos os obstáculos extras pelos quais muitas consultas devem passar para obter o conjunto correto de dados.

(nota: eu uso o termo "comando" como no padrão de Comando, também conhecido como objetos de consulta. Não estou falando sobre comando como na separação de comando / consulta, onde há uma distinção entre consultas e comandos (atualizar, excluir, inserir))

Portanto, estou procurando alternativas que encapsulem toda a consulta, mas ainda flexíveis o suficiente para que você não esteja apenas trocando Repositórios de espaguete por uma explosão de classes de comando.

Eu usei, por exemplo, Linqspecs, e embora eu ache algum valor em ser capaz de atribuir nomes significativos aos critérios de seleção, não é suficiente. Talvez eu esteja procurando uma solução combinada que combine várias abordagens.

Estou procurando soluções que outros possam ter desenvolvido para resolver esse problema ou resolver um problema diferente, mas ainda satisfaz esses requisitos. No artigo vinculado, Ayende sugere o uso do contexto nHibernate diretamente, mas acho que isso complica muito a sua camada de negócios porque agora também deve conter informações de consulta.

Estarei oferecendo uma recompensa por isso, assim que o período de espera terminar. Então, por favor, faça suas soluções valerem a pena, com boas explicações e eu irei selecionar a melhor solução e votar a favor dos segundos colocados.

NOTA: Estou procurando algo que seja baseado em ORM. Não precisa ser EF ou nHibernate explicitamente, mas esses são os mais comuns e se encaixam melhor. Se puder ser facilmente adaptado a outros ORMs, isso seria um bônus. Compatível com Linq também seria bom.

ATUALIZAÇÃO: Estou realmente surpreso que não haja muitas sugestões boas aqui. Parece que as pessoas são totalmente CQRS ou estão completamente no campo do Repositório. A maioria dos meus aplicativos não é complexa o suficiente para garantir o CQRS (algo com a maioria dos defensores do CQRS prontamente dizem que você não deve usá-lo).

ATUALIZAÇÃO: parece haver um pouco de confusão aqui. Não estou procurando uma nova tecnologia de acesso a dados, mas sim uma interface razoavelmente bem projetada entre negócios e dados.

Idealmente, o que estou procurando é algum tipo de cruzamento entre objetos de consulta, padrão de especificação e repositório. Como eu disse acima, o padrão de especificação lida apenas com o aspecto da cláusula where, e não com os outros aspectos da consulta, como junções, sub-seleções, etc. Os repositórios tratam de toda a consulta, mas ficam fora de controle depois de um tempo . Objetos de consulta também lidam com a consulta inteira, mas não quero simplesmente substituir repositórios por explosões de objetos de consulta.

Erik Funkenbusch
fonte
5
Pergunta fantástica. Eu também gostaria de ver quais pessoas com mais experiência do que eu sugiro. Estou trabalhando em uma base de código no momento em que o repositório genérico também contém sobrecargas para objetos Command ou objetos Query, cuja estrutura é semelhante ao que Ayende descreve em seu blog. PS: Isso também pode atrair alguma atenção dos programadores. SE.
Simon Whitehead de
Por que não apenas usar um repositório que expõe IQueryable se você não se importa com a dependência do LINQ? Uma abordagem comum é um repositório genérico e, quando você precisar da lógica reutilizável acima, crie um tipo de repositório derivado com seus métodos adicionais.
devdigital
@devdigital - Dependência do Linq não é o mesmo que dependência da implementação de dados. Gostaria de usar o Linq para objetos, para poder classificar ou executar outras funções da camada de negócios. Mas isso não significa que eu quero dependências na implementação do modelo de dados. O que estou realmente falando aqui é a interface de camada / camada. Por exemplo, quero ser capaz de alterar uma consulta e não ter que alterá-la em 200 lugares, que é o que acontece se você colocar IQueryable diretamente no modelo de negócios.
Erik Funkenbusch de
1
@devdigital - que basicamente apenas move os problemas com um repositório para a camada de negócios. Você está apenas embaralhando o problema.
Erik Funkenbusch

Respostas:

94

Aviso: Como ainda não há boas respostas, decidi postar uma parte de um ótimo post de blog que li há um tempo, copiado quase literalmente. Você pode encontrar a postagem completa do blog aqui . Então aqui está:


Podemos definir as duas interfaces a seguir:

public interface IQuery<TResult>
{
}

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

O IQuery<TResult>especifica uma mensagem que define uma consulta específica com os dados que retorna usando o TResulttipo genérico. Com a interface definida anteriormente, podemos definir uma mensagem de consulta como esta:

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

Esta classe define uma operação de consulta com dois parâmetros, que resultará em uma matriz de Userobjetos. A classe que lida com essa mensagem pode ser definida da seguinte maneira:

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();
    }
}

Agora podemos permitir que os consumidores dependam da IQueryHandlerinterface 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);
    }
}

Imediatamente, esse modelo nos dá muita flexibilidade, porque agora podemos decidir o que injetar no UserController. Podemos injetar uma implementação completamente diferente, ou que envolva a implementação real, sem ter que fazer alterações no UserController(e em todos os outros consumidores dessa interface).

A IQuery<TResult>interface nos dá suporte em tempo de compilação ao especificar ou injetar IQueryHandlersem nosso código. Quando alteramos o FindUsersBySearchTextQuerypara return em UserInfo[]vez (por implementação IQuery<UserInfo[]>), o UserControllerfalhará ao compilar, já que a restrição de tipo genérico em IQueryHandler<TQuery, TResult>não será capaz de mapear FindUsersBySearchTextQuerypara User[].

IQueryHandlerNo entanto, injetar a interface em um consumidor apresenta alguns problemas menos óbvios que ainda precisam ser resolvidos. O número de dependências de nossos consumidores pode ficar muito grande e pode levar à injeção excessiva do construtor - quando um construtor recebe muitos argumentos. O número de consultas que uma classe executa pode mudar com frequência, o que exigiria mudanças constantes no número de argumentos do construtor.

Podemos resolver o problema de ter que injetar muitos IQueryHandlerscom uma camada extra de abstração. Criamos um mediador que fica entre os consumidores e os manipuladores de consulta:

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

O IQueryProcessoré uma interface não genérica com um método genérico. Como você pode ver na definição da interface, o IQueryProcessordepende da IQuery<TResult>interface. Isso nos permite ter suporte de tempo de compilação em nossos consumidores que dependem do IQueryProcessor. Vamos reescrever o UserControllerpara usar o novo 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);
    }
}

O UserControlleragora depende de um IQueryProcessorque pode lidar com todas as nossas consultas. O UserController's SearchUsersmétodo chama o IQueryProcessor.Processmétodo passando um objeto de consulta inicializado. Como o FindUsersBySearchTextQueryimplementa a IQuery<User[]>interface, podemos passá-lo para o Execute<TResult>(IQuery<TResult> query)método genérico . Graças à inferência de tipo C #, o compilador é capaz de determinar o tipo genérico e isso nos economiza a necessidade de declarar explicitamente o tipo. O tipo de retorno do Processmétodo também é conhecido.

Agora é responsabilidade da implementação do IQueryProcessorencontrar o que é certo IQueryHandler. Isso requer alguma digitação dinâmica e, opcionalmente, o uso de uma estrutura de injeção de dependência, e tudo pode ser feito com apenas algumas linhas 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);
    }
}

A QueryProcessorclasse constrói um IQueryHandler<TQuery, TResult>tipo específico com base no tipo da instância de consulta fornecida. Este tipo é usado para solicitar à classe de contêiner fornecida para obter uma instância desse tipo. Infelizmente, precisamos chamar o Handlemétodo usando reflexão (usando a palavra-chave dymamic do C # 4.0 neste caso), porque neste ponto é impossível converter a instância do manipulador, uma vez que o TQueryargumento genérico não está disponível no momento da compilação. No entanto, a menos que o Handlemétodo seja renomeado ou obtenha outros argumentos, esta chamada nunca falhará e, se você quiser, é muito fácil escrever um teste de unidade para esta classe. Usar reflexão causará uma ligeira queda, mas não é nada para se preocupar.


Para responder a uma de suas preocupações:

Portanto, estou procurando alternativas que encapsulem toda a consulta, mas ainda flexíveis o suficiente para que você não esteja apenas trocando Repositórios de espaguete por uma explosão de classes de comando.

Uma consequência do uso desse design é que haverá muitas classes pequenas no sistema, mas ter muitas classes pequenas / focadas (com nomes claros) é uma coisa boa. Esta abordagem é claramente muito melhor do que ter muitas sobrecargas com parâmetros diferentes para o mesmo método em um repositório, já que você pode agrupá-los em uma classe de consulta. Portanto, você ainda obtém muito menos classes de consulta do que métodos em um repositório.

david.s
fonte
2
Parece que você recebeu o prêmio. Eu gosto dos conceitos, só esperava que alguém apresentasse algo realmente diferente. Parabéns.
Erik Funkenbusch
1
@FuriCuri, uma única classe realmente precisa de 5 consultas? Talvez você possa considerar isso uma classe com muitas responsabilidades. Como alternativa, se as consultas estão sendo agregadas, então talvez devam ser realmente uma única consulta. Essas são apenas sugestões, é claro.
Sam,
1
@stakx Você está absolutamente certo de que no meu exemplo inicial o TResultparâmetro genérico da IQueryinterface não é útil. No entanto, em minha resposta atualizada, o TResultparâmetro é usado pelo Processmétodo de IQueryProcessorpara resolver o IQueryHandlerem tempo de execução.
david.s
1
Também tenho um blog com uma implementação muito semelhante que me faz pensar que estou no caminho certo, este é o link jupaol.blogspot.mx/2012/11/… e já o uso há algum tempo em aplicativos PROD, mas eu tive um problema com essa abordagem. Encadeando e reutilizando consultas Digamos que eu tenha várias consultas pequenas que precisam ser combinadas para criar consultas mais complexas, acabei apenas duplicando o código, mas estou procurando por uma abordagem melhor e mais limpa. Alguma ideia?
Jupaol
4
@Cemre Acabei encapsulando minhas consultas em métodos de extensão retornando IQueryablee certificando-me de não enumerar a coleção, então a partir do QueryHandler, acabei de chamar / encadear as consultas. Isso me deu a flexibilidade de testar a unidade de minhas consultas e encadeá-las. Eu tenho um serviço de aplicativo além do meu QueryHandler, e meu controlador é responsável por falar diretamente com o serviço em vez do manipulador
Jupaol
4

Minha maneira de lidar com isso é realmente simplista e agnóstica de ORM. Minha visão para um repositório é a seguinte: O trabalho do repositório é fornecer ao aplicativo o modelo necessário para o contexto, então o aplicativo apenas pergunta ao repo o que ele deseja, mas não diz como obtê-lo.

Eu forneço o método de repositório com um Criteria (sim, estilo DDD), que será usado pelo repo para criar a consulta (ou o que for necessário - pode ser uma solicitação de serviço da web). Junções e grupos são detalhes de como, não o quê, e os critérios a devem ser apenas a base para construir uma cláusula where.

Model = o objeto final ou estrutura de dados necessária para o aplicativo.

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

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

Provavelmente, você pode usar os critérios ORM (Nhibernate) diretamente se quiser. A implementação do repositório deve saber como usar os critérios com o armazenamento subjacente ou DAO.

Não sei o seu domínio e os requisitos do modelo, mas seria estranho se a melhor maneira fosse que o aplicativo construísse a própria consulta. O modelo muda tanto que você não consegue definir algo estável?

Esta solução claramente requer algum código adicional, mas não acopla o resto do a um ORM ou o que você está usando para acessar o armazenamento. O repositório faz seu trabalho como uma fachada e IMO é limpo e o código de 'tradução de critérios' é reutilizável

MikeSW
fonte
Isso não resolve os problemas de crescimento do repositório e de ter uma lista cada vez maior de métodos para retornar vários tipos de dados. Eu entendo que você pode não ver um problema com isso (muitas pessoas não), mas outros vêem de forma diferente (sugiro a leitura do artigo que vinculei, há muitas outras pessoas com opiniões semelhantes).
Erik Funkenbusch
1
Eu abordo isso, porque os critérios tornam muitos métodos desnecessários. Claro, não de todos eles, não posso dizer muito sem saber nada sobre o que você precisa. Estou sob a impressão de que você deseja consultar diretamente o banco de dados, então, provavelmente, um repositório está apenas no caminho. Se você precisa trabalhar diretamente com o armazenamento relacional, vá em frente, sem a necessidade de um repositório. E como nota, é irritante quantas pessoas citam Ayende nessa postagem. Não concordo com isso e acho que muitos desenvolvedores estão apenas usando o padrão da maneira errada.
MikeSW
1
Isso pode reduzir um pouco o problema, mas dado um aplicativo grande o suficiente, ele ainda criará repositórios de monstros. Não concordo com a solução de Ayende de usar o nHibernate diretamente na lógica principal, mas concordo com ele sobre o absurdo do crescimento fora do controle do repositório. Não estou querendo consultar diretamente o banco de dados, mas também não quero apenas mover o problema de um repositório para uma explosão de objetos de consulta.
Erik Funkenbusch
2

Eu fiz isso, apoiei e desfiz isso.

O maior problema é este: não importa como você faça isso, a abstração adicionada não lhe garante independência. Ele vazará por definição. Em essência, você está inventando uma camada inteira apenas para fazer seu código parecer bonito ... mas isso não reduz a manutenção, melhora a legibilidade ou ganha qualquer tipo de agnosticismo de modelo.

A parte divertida é que você respondeu sua própria pergunta em resposta à resposta de Olivier: "isto é essencialmente duplicar a funcionalidade do Linq sem todos os benefícios que você obtém do Linq".

Pergunte a si mesmo: como poderia não ser?

Stu
fonte
Bem, eu definitivamente tive problemas para integrar o Linq em sua camada de negócios. É muito poderoso, mas quando fazemos alterações no modelo de dados, é um pesadelo. As coisas são melhoradas com repositórios, porque posso fazer as alterações em um local localizado sem afetar muito a camada de negócios (exceto quando você também precisa alterar a camada de negócios para suportar as mudanças). Mas, os repositórios se tornam essas camadas inchadas que violam o SRP maciçamente. Eu entendo seu ponto, mas também não resolve nenhum problema.
Erik Funkenbusch
Se a sua camada de dados usa LINQ e as alterações do modelo de dados exigem alterações na camada de negócios ... você não está criando camadas de maneira adequada.
Stu
Achei que você estivesse dizendo que não adicionava mais essa camada. Quando você diz que a abstração adicionada não lhe traz nada, isso implica que você concorda com Ayende sobre passar a sessão nHibernate (ou contexto EF) diretamente para a camada de negócios.
Erik Funkenbusch
1

Você pode usar uma interface fluente. A ideia básica é que os métodos de uma classe retornam a instância atual desta mesma classe depois de executar alguma ação. Isso permite encadear chamadas de método.

Ao criar uma hierarquia de classes apropriada, você pode criar um fluxo lógico de métodos acessíveis.

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;
    }
}

Você chamaria assim

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

Você só pode criar uma nova instância de Query. As outras classes possuem um construtor protegido. O ponto da hierarquia é "desabilitar" métodos. Por exemplo, o GroupBymétodo retorna a GroupedQueryque é a classe base de Querye não tem um Wheremétodo (o método where é declarado em Query). Portanto, não é possível ligar Wheredepois GroupBy.

No entanto, não é perfeito. Com esta hierarquia de classes, você pode ocultar membros sucessivamente, mas não mostrar novos. Portanto, Havinglança uma exceção quando é chamado antes GroupBy.

Observe que é possível ligar Wherevárias vezes. Isso adiciona novas condições com um ANDàs condições existentes. Isso torna mais fácil construir filtros programaticamente a partir de condições únicas. O mesmo é possível com Having.

Os métodos que aceitam listas de campos possuem um parâmetro params string[] fields. Ele permite que você passe nomes de campo único ou uma matriz de string.


As interfaces fluentes são muito flexíveis e não exigem que você crie muitas sobrecargas de métodos com diferentes combinações de parâmetros. Meu exemplo funciona com strings, porém a abordagem pode ser estendida a outros tipos. Você também pode declarar métodos predefinidos para casos especiais ou métodos que aceitam tipos personalizados. Você também pode adicionar métodos como ExecuteReaderou ExceuteScalar<T>. Isso permitiria a você 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();

Mesmo os comandos SQL construídos dessa forma podem ter parâmetros de comando e, assim, evitar problemas de injeção de SQL e, ao mesmo tempo, permitir que os comandos sejam armazenados em cache pelo servidor de banco de dados. Isso não é uma substituição para um O / R-mapper, mas pode ajudar em situações em que você criaria os comandos usando concatenação de string simples de outra forma.

Olivier Jacot-Descombes
fonte
3
Hmm .. Interessante, mas sua solução parece ter problemas com as possibilidades de injeção de SQL e realmente não cria instruções preparadas para execução pré-compilada (executando, portanto, mais lentamente). Provavelmente poderia ser adaptado para corrigir esses problemas, mas estamos presos aos resultados do conjunto de dados não seguros do tipo e tudo mais. Eu preferiria uma solução baseada em ORM, e talvez devesse especificar isso explicitamente. Isso é essencialmente duplicar a funcionalidade do Linq sem todos os benefícios que você obtém do Linq.
Erik Funkenbusch de
Estou ciente desses problemas. Esta é apenas uma solução rápida e suja, mostrando como uma interface fluente pode ser construída. Em uma solução do mundo real, você provavelmente “cozeria” sua abordagem existente em uma interface fluente adaptada às suas necessidades.
Olivier Jacot-Descombes