Especificações sobre esteróides

O tema das abstrações e todos os tipos de padrões encantadores é um bom terreno para o desenvolvimento de holívoros e disputas eternas: por um lado, seguimos o mainstream, todos os tipos de palavras da moda e códigos limpos, por outro lado, temos prática e realidade que sempre ditam suas próprias regras.

O que fazer se as abstrações começarem a "vazar", como usar os chips de linguagem e o que você pode extrair do padrão de "especificação" - veja abaixo.

Então, vamos ao que interessa. O artigo conterá as seguintes seções: para iniciantes, examinaremos qual é o padrão de “especificação” e por que sua aplicação a amostras do banco de dados em sua forma pura causa dificuldades.

Em seguida, voltamos às árvores de expressão, que são uma ferramenta muito poderosa, e vemos como elas podem nos ajudar.

no final, demonstrarei minha implementação da "especificação" em esteróides.

Vamos começar com as coisas básicas. Eu acho que todo mundo já ouviu falar sobre o padrão de "especificação", mas para quem não ouviu, aqui está sua definição da Wikipedia :

Uma “especificação” na programação é um padrão de design pelo qual a representação das regras da lógica de negócios pode ser transformada em uma cadeia de objetos conectados por operações da lógica booleana.

Este modelo destaca essas especificações (regras) na lógica de negócios que são adequadas para o "acoplamento" com outras pessoas. Um objeto de lógica comercial herda sua funcionalidade da classe agregada abstrata CompositeSpecification, que contém apenas um método IsSatisfiedBy que retorna um valor booleano. Após a instanciação, o objeto é encadeado com outros objetos. Como resultado, sem perder a flexibilidade na configuração da lógica de negócios, podemos facilmente adicionar novas regras.

Em outras palavras, uma especificação é um objeto que implementa a seguinte interface (descartando métodos para construir cadeias):

public interface ISpecification { bool IsSatisfiedBy(object candidate); } 

Tudo é simples e claro aqui. Mas agora vamos ver um exemplo do mundo real, no qual, além do domínio, existe uma infraestrutura que também é uma pessoa cruel: vamos ao caso do uso do ORM, um DBMS e especificações para filtrar dados em um banco de dados.

Para não ser infundado e não apontar o dedo, tomamos como exemplo a seguinte área de assunto: suponha que estamos desenvolvendo MMORPGs, temos usuários, cada usuário tem 1 ou mais caracteres e cada caractere tem um conjunto de itens ( assumimos que os itens são exclusivos para cada usuário) e runas de melhoria podem ser aplicadas a cada um dos itens, por sua vez. No total, na forma de um diagrama (consideraremos a classe ReadCharacter um pouco mais tarde, quando falarmos sobre consultas aninhadas):

imagem

Esse modelo está pouco conectado ao mundo real e também contém campos que refletem alguma conexão com o ORM usado, mas isso será suficiente para demonstrar o trabalho.

Suponha que desejemos filtrar todos os caracteres criados após a data especificada.
Para fazer isso, escrevemos uma especificação do seguinte formulário:

 public class CreatedAfter: ISpecification { private readonly DateTime _target; public CreatedAfter(DateTime target) { _target = target; } bool IsSatisfiedBy(object candidate) { var character = candidate as Character; if(character == null) return false; return character.CreatedAt > target; } } 

Bem, então, para aplicar esta especificação, fazemos o seguinte (daqui em diante considerarei o código baseado no NHibernate):

 var characters = await session.Query<Character>().ToListAsync(); var filter = new CreatedAfter(new DateTime(2020, 1, 1)); var newCharacters = characters.Where(x => filter.IsSatisfiedBy(x)).ToArray(); 

Contanto que nossa base seja pequena, tudo funcionará lindamente e rapidamente, mas se nosso jogo se tornar mais ou menos popular e ganhar dezenas de milhares de usuários, todo esse encanto consumirá memória, tempo e dinheiro, e é melhor atirar nessa fera imediatamente porque ele não é um inquilino. Nesta triste nota, adiaremos a especificação e voltaremos um pouco à minha prática.

Era uma vez, em um projeto muito, muito distante, eu tinha classes no meu código que continham lógica para recuperar dados do banco de dados. Eles pareciam algo assim:

 public class ICharacterDal { IEnumerable<Character> GetCharactersCreatedAfter(DateTime date); IEnumerable<Character> GetCharactersCreatedBefore(DateTime date); IEnumerable<Character> GetCharactersCreatedBetween(DateTime from, DateTime to); ... } 

e seu uso:

 var dal = new CharacterDal(); var createdCharacters = dal.GetCharactersCreatedAfter(new DateTime(2020, 1, 1)); 

Dentro das classes havia a lógica para trabalhar com o DBMS (na época era o ADO.NET).

Tudo parecia bom, mas com a expansão do projeto, essas classes também cresceram, transformando-se em objetos de difícil manutenção. Além disso, houve um sabor desagradável - parece ser uma regra de negócios, mas eles foram armazenados no nível da infraestrutura, porque estavam vinculados a uma implementação específica.

Essa abordagem foi substituída pelo repositório IQueryable <T> , que permitiu levar todas as regras diretamente para a camada de domínio.

 public interface IRepository<T> { T Get(object id); IQueryable<T> List(); void Delete(T obj); void Save(T obj); } 

que foi usado mais ou menos assim:

 var repository = new Repository(); var targetDate = new DateTime(2020, 1, 1); var createdUsers = await repository.List().Where(x => x.CreatedAd > targetDate).ToListAsync(); 

Um pouco melhor, mas o problema é que as regras se arrastam ao longo do código, e a mesma verificação pode ocorrer em centenas de lugares, e é fácil imaginar o que isso pode resultar na alteração de requisitos.

Essa abordagem oculta outro problema - se você não materializar a consulta, ou seja, uma chance de realizar várias consultas ao banco de dados, em vez de uma, o que, obviamente, afeta adversamente o desempenho do sistema.

E aqui em um dos projetos, um colega sugeriu o uso de uma biblioteca que sugerisse a implementação do padrão de "especificação" com base em árvores de expressão.

Em resumo, com base nesta biblioteca, filmamos especificações que nos permitiram criar filtros para entidades e criar filtros mais complexos com base em concatenações de regras simples. Por exemplo, temos uma especificação para caracteres criados após o novo ano e há uma especificação para escolher caracteres com um determinado item - então, combinando essas regras, podemos criar uma solicitação para uma lista de caracteres criados após o novo ano e com o item especificado. E se, no futuro, alterarmos a regra para determinar novos caracteres (por exemplo, usaremos a data do ano novo chinês), a corrigiremos apenas na própria especificação e não será necessário procurar todos os usos dessa lógica por código!

Este projeto foi concluído com êxito e a experiência de usar essa abordagem foi muito bem-sucedida. Mas eu não queria ficar parado e houve alguns problemas na implementação, a saber:

  • operador de colagem OU não funcionou;
  • a união funciona apenas para consultas que contêm filtros do tipo Where, mas eu queria regras mais ricas (consultas aninhadas, ignorar / obter, obter projeções);
  • o código de especificação dependia do ORM selecionado;
  • não foi possível usar os recursos do ORM, pois isso levou à inclusão de dependências na camada de lógica de negócios (por exemplo, era impossível buscar).

O resultado da solução desses problemas foi a mini-estrutura Singularis.Secification , que consiste em várias montagens:

  • Singularis.Specification.Definition - define o objeto de especificação e também contém a interface IQuery com a qual a regra é formada.
  • Singularis.Specification.Executor. * - implementa um repositório e um objeto para executar especificações para ORMs específicos (atualmente suportados pelo ef.core e NHibernate, como parte dos experimentos, também fiz uma implementação para o mongodb, mas esse código não foi produzido).

Vamos dar uma olhada na implementação.

A interface de especificação define a propriedade pública que a regra de especificação contém:

 public interface ISpecification { IQuery Query { get; } Type ResultType { get; } } public interface ISpefication<T>: ISpecification { } 

Além disso, a interface contém a propriedade ResultType , que retorna o tipo de entidade obtida como resultado da consulta.

Sua implementação está contida na classe Specification <T> , que implementa a propriedade ResultType , calculando-a com base na regra armazenada no Query, além de dois métodos: Source () e Source <TSource> () . Esses métodos servem para formar a fonte da regra. Source () cria uma regra com um tipo que corresponde ao argumento da classe de especificação, e Source <TSource> () permite criar uma regra para uma classe arbitrária (usada ao gerar consultas aninhadas).

Além disso, há também a classe SpecificationExtension , que contém métodos de extensão para encadear solicitações.

Dois tipos de junção são suportados: concatenação (pode ser considerada como junção pela condição "AND") e junção pela condição "OR".

Vamos voltar ao nosso exemplo e implementar nossas duas regras:

 public class CreatedAfter: Specification<Character> { public CreatedAfter(DateTime target) { Query = Source().Where(x => x.CreatedAt > target); } } public class CreatedBefore: Specification<Character> { public CreatedBefore(DateTime target) { Query = Source().Where(x => x.CreatedAt < target); } } 

e encontre todos os usuários que atendem às duas regras:

 var specification = new CreatedAfter(new DateTime(2019, 1, 1).Combine(new CreatedBefore(new DateTime(2020, 1, 1)); var users = repository.List(specification); 

A combinação com o método Combine suporta regras arbitrárias. O principal é que o tipo resultante do lado esquerdo coincide com o tipo de entrada do lado direito. Assim, você pode criar regras contendo projeções, pular / receber para paginação, regras de classificação, busca, etc.

A regra Ou é mais restritiva - suporta apenas cadeias que contêm condições de filtragem Where. Considere o uso de um exemplo: encontramos todos os personagens criados antes de 2000 ou depois de 2020:

 var specification = new CreatedAfter(new DateTime(2020, 1, 1).Or(new CreatedBefore(new DateTime(2000, 1, 1)); var users = repository.List(specification ); 

A interface IQuery repete amplamente a interface IQueryable ; portanto, não deve haver perguntas especiais. Vamos nos concentrar apenas em métodos específicos:

Buscar / ThenFetch - permite incluir dados relacionados na consulta gerada para fins de otimização. É claro que isso é um pouco torto quando temos características da implementação da infraestrutura que afetam as regras de negócios, mas, como eu disse, a realidade é abstração dura e pura - isso é algo teórico.

Onde - o IQuery declara duas sobrecargas desse método, uma utiliza apenas uma expressão lambda para filtrar na forma de Expressão <Func <T, bool >> , e a segunda também utiliza parâmetros adicionais IQueryContext , que permitem executar subconsultas aninhadas. Vejamos um exemplo.

Temos a classe ReadCharacter no modelo - suponha que nosso modelo seja apresentado como uma parte de leitura que contém dados desnormalizados e serve para feedback rápido e uma parte de gravação que contém links, dados normalizados etc. Queremos exibir todos os caracteres para os quais o usuário possui correio em um domínio específico.

 public class CharactersForUserWithEmailDomain: Specification<ReadCharacter> { public CharactersForUserWithEmailDomain(string domain) { var usersQuery = Source<User>(x => x.Email.Contains(domain)).Projection(x => x.Id); Query = Source().Where((x, ctx) => ctx.GetQueryResult<int>(usersQuery).Contains(x.Id)); } } 

Como resultado da execução, a seguinte consulta sql será gerada:

 select readcharac0_.id as id1_3_, readcharac0_.UserId as userid2_3_, readcharac0_.Name as name3_3_ from ReadCharacters readcharac0_ where readcharac0_.UserId in ( select user1_.Id from Users user1_ where user1_.Email like ('%'+@p0+'%') ); @p0 = '@inmagna.ca' [Type: String (4000:0:0)] 

Para cumprir todas essas regras maravilhosas, é definida a interface IRepository , que permite receber itens por identificador, receber um (o primeiro adequado) ou uma lista de objetos de acordo com a especificação e também salvar e excluir itens do repositório.
Com a definição de consultas, descobrimos, agora resta ensinar a nossa ORM a entender isso.
Para fazer isso, analisaremos a montagem do Singularis.Infrastructure.NHibernate (para ef.core tudo parece igual, apenas com as especificidades do ef.core).

O ponto de acesso a dados é o objeto Repository, que implementa a interface IRepository . No caso de receber um objeto pelo identificador, bem como de modificar o armazenamento (salvar / excluir), essa classe encerra uma sessão e oculta uma implementação específica da camada de negócios. No caso de trabalhar com especificações, ele forma um objeto IQueryable que reflete nossa consulta em termos de IQuery e depois a executa no objeto de sessão.

A principal mágica e o código mais feio estão na classe responsável pela conversão do IQuery para IQueryable - SpecificationExecutor. Essa classe contém muita reflexão, que chama métodos Queryable ou métodos de extensão de um ORM específico (EagerFetchingExtensionsMethods for NHiberante).

Essa biblioteca é usada ativamente em nossos projetos (para ser honesto, uma biblioteca já atualizada é usada para nossos projetos, mas gradualmente todas essas alterações serão definidas em domínio público) está constantemente passando por alterações. Apenas algumas semanas atrás, a próxima versão foi lançada, que passou para métodos assíncronos, os bugs foram corrigidos no executor'e para ef.core, testes e amostras foram adicionados. É provável que a biblioteca contenha erros e centenas de lugares para otimização - ela nasceu como um projeto paralelo no âmbito do trabalho nos principais projetos, por isso terei o prazer de sugerir sugestões de melhoria. Além disso, você não deve se apressar em usá-lo - é provável que, no seu caso particular, isso seja desnecessário ou inaplicável.

Quando vale a pena usar a solução descrita? Provavelmente é mais fácil começar com a pergunta "quando não deveria":

  • highload - se você precisar de alto desempenho, o uso do próprio ORM levanta uma questão. Embora, é claro, ninguém proíba a implementação de um executor que traduza consultas para SQL e as execute ...
  • projetos muito pequenos - isso é muito subjetivo, mas, você deve admitir, puxar o ORM e todo o zoológico que o acompanha para o projeto "lista de tarefas" parece disparar pardais de um canhão.

De qualquer forma, quem dominou a leitura até o fim - obrigado pelo seu tempo. Espero feedback para o desenvolvimento futuro!

Eu quase esqueci - o código do projeto está disponível no GitHub'e - https://github.com/SingularisLab/singularis.specification

Source: https://habr.com/ru/post/pt485328/


All Articles