Cell CMS - Organizando, reutilizando e testando consultas do EntityFramework Core
Rodolpho Alves
Posted on February 4, 2021
Cell CMS - Organizando, reutilizando e testando consultas do EntityFramework Core
Intro
No último post falamos sobre testes unitários e como tornar a escrita e manutenção deles prática. Mas também tocamos em um assunto controverso para alguns no universo .NET:
Devo utilizar EFCore.InMemory nos meus testes unitários ou devo abstrair uma UnitOfWork e repositórios?
A ideia do post de hoje é apresentar, brevemente, os dois lados e uma solução que apesar de não ser aderente à Orientação a Objeto é uma solução prática, testável e escalável.
Lembrando que boa parte dos snippets aqui estão implementados no Cell CMS, disponível lá no GitHub!
CMS leve, self-contained e prático de utilizar! Feito por desenvolvedores e para desenvolvedores!
Cell CMS
Branch
Status
Descrição
Master
Ciclo estável, recomendado para produção
Develop
Ciclo em desenvolvimento, recomendado para entusiastas
Cell CMS é um content management system que visa ser:
Leve
Auto Contido (self-contained)
Prático de Utilizar
Nosso foco é em disponibilizar um CMS que desenvolvedores possam facilmente referenciar em seus aplicativos, sites e sistemas.
📚 Instruções
Utilizando uma Versão publicada
WIP, iremos suportar imagens Docker e executáveis
Compilando
Você precisará ter instalado em seu ambiente o SDK 5.0.101 do Dotnet.
Uma vez configurado basta executar dotnet build .\cell-cms.sln na raiz do repositório.
Testando
Execute dotnet test .\cell-cms.sln na raiz do repositório.
Caso queira capturar informações de cobertura de testes utilize:
dotnet test --no-restore --collect:"XPlat Code Coverage" .\cell-cms.sln
⚙ Configurações
Autenticação/Autorização
O CellCMS utiliza o Azure Active Directory como provider de identidade, então você terá de configurar sua instância do AAD conforme explicado neste post.
A principal motivação para abstrair o EFCore é simples:
É mais fácil fazer Mocks/Stubs de Interfaces, classes abstratas e métodos virtuais, portanto elas são mais testáveis
Devido à essa preocupação que o próprio projeto do EntityFramework Core criou o Provider InMemory! Afinal, como esperar que seu framework seja amplamente adotado por projetos de grande porte se tudo que utiliza ele deixa de ser testável?
Mas, vamos às abordagens práticas!
Abordagem "clássica" - UnitOfWork + Repositories
A UnitOfWork é um dos "Enterprise Patterns". A ideia é bem simples:
Você precisa de uma classe que faça a gestão centralizada do estado do banco de dados durante um processo de negócio.
Ou seja, é função da UnitOfWork:
Prover acesso aos objects que estão armazenados nas tabelas do seu banco
Prover mecanismos para salvar alterações (Commit)
Prover mecanismos para reverter alterações (Rollback)
Normalmente junto à UnitOfWork vemos um segundo padrão chamado de Repository. A ideia de um Repository é:
Centralizar as consultas (Queries) a algum object, de maneira que possamos utilizar lógicas adicionais (como um Cache) em um único lugar e minimizar a quantidade de código replicado dentro do sistema.
Soa familiar com o que um DbSet<T> e um DbContext faz, não? Pois é! Mas chega de foreshadowing.
Como são patterns eles normalmente serão introduzidos como dependências da sua classes através de suas interfaces, normalmente chamadas:
IUnitOfWork, IProjetoUnitOfWork
IMeuObjetoRepository, GenericRepository<T>
Agora vamos pensar em suas vantagens e desvantagens, sempre considerando que estamos utilizando o EntityFramework Core como nosso ORM.
Abstração: Permite que utilizemos Mocks e Stubs de maneira extremamente prática
Amplamente conhecido: Esse pattern é quase tão conhecido como Singletons
Mantém o padrão de Orientação a Objeto
As interfaces podem ser exportadas facilmente para outros projetos: Se você quiser fazer uma reutilização máxima de código você poderia declarar um repositório no seu projeto de Domínio e deixar que os diferentes runtimes façam a implementação.
Para mim as principais vantagens são #1 e #4. Especialmente se pensarmos em utilizar uma abordagem que preza pela reutilização: A mesma interface pode ser implementada no frontend com acesso a uma API REST e no backend com acesso a um banco relacional!
Desvantagens
Mais uma classe para Manutenção
Difícil de adicionar novas funcionalidade: Como você deve alterar a assinatura da Interface (ou criar uma nova) cada nova query é um novo método
Difícil de compor queries: Se você permite múltiplos filtros terá de ter métodos com inúmeros parâmetros!
Utilização de generics orientados a Copy-Paste: Essa acho que é o maior downside que vi para a abordagem até hoje! Inúmeras vezes você vê o mesmo código colado em diferentes projetos para criar um GenericRepository<T> que alivia alguns dos problemas acima
Quebra do próprio Pattern: Para tentar balancear com a nova dependência é comum ver Repositories que retornam DTOs, fazem alterações no banco, etc...
Para mim as principais desvantagens são as #1, #2 e #5. Eu mesmo já fiz repositories que fazem mais que consultar e já tive de quebrar interfaces por causa de um parâmetro a mais de filtro.
Exemplos
O seguinte snippet contém uma implementação (feita para exemplo apenas, bem incompleta!) de uma Unit of Work para o Cell CMS:
/// <summary>/// Descreve métodos para uma UnitOfWork do CellCMS./// </summary>publicinterfaceICellCMSUnitOfWork{IRepository<Feed>Feeds{get;}TaskCommitChanges();}/// <summary>/// Descreve métodos para um repositório./// </summary>/// <typeparam name="T"></typeparam>publicinterfaceIRepository<T>whereT:class{Task<IEnumerable<T>>ListAll();}/// <summary>/// Implementação genérica de um repository./// </summary>/// <typeparam name="T"></typeparam>publicclassGenericRepository<T>:IRepository<T>whereT:class{privateDbSet<T>_set;publicGenericRepository(CellContextcontext){_set=context.Set<T>();}publicasyncTask<IEnumerable<T>>ListAll()=>await_set.ToListAsync();}/// <summary>/// Simulação de uma Unit of Work para o CELL CMS./// </summary>publicclassCellCMSUnitOfWork:ICellCMSUnitOfWork{privatereadonlyCellContext_context;publicIRepository<Feed>Feeds{get;privateset;}publicCellCMSUnitOfWork(CellContextcontext){_context=context??thrownewArgumentNullException(nameof(context));Feeds=newGenericRepository<Feed>(context);}publicTaskCommitChanges()=>_context.SaveChangesAsync();}
Abordagem funcional - Extension Methods
Primeiramente um disclaimer: Essa solução é boa para o universo .NET, onde temos suporte a Extension Methods.
Um extension method é um método que adiciona funcionalidade a uma classe/interface existente sem precisar alterar o código original.
Boa parte das operações LINQ são extension methods, assim como maior parte das opções do próprio EntityFrameworkCore! Sem acesso ao código podemos confiar no Intellisense para mostrar quando estamos lidando com um método que foi adicionado a um tipo por extension method, note o ícone diferente nesta imagem:
Extension Methods 101
Mas Rodolpho, como posso fazer um extension method?
É mais simples do que parece! Você só precisa:
De uma static class onde seu método será armazenado
De acesso aos métodos do Tipo a ser estendido
Criar um método static onde o primeiro parâmetro recebe a keyword especial this.
Por exemplo, os seguintes extension method permitem que eu serialize um objeto para JSON, recupere o mesmo objeto a partir de um JSON e realize uma cópia do objeto através de serialização:
Note que consegui estender um generic T e uma string e, nas últimas 3 linhas, eles são chamados como se fosse realmente parte do tipo Feed e string.
publicstaticclassMyExtensions{/// <summary>/// Serializa um objeto para um JSON./// </summary>/// <typeparam name="T"></typeparam>/// <param name="obj"></param>/// <returns></returns>publicstaticstringDump<T>(thisTobj)whereT:new(){returnJsonSerializer.Serialize(obj);}/// <summary>/// Restaura um objeto de a partir de um JSON./// </summary>/// <typeparam name="T"></typeparam>/// <param name="t"></param>/// <returns></returns>publicstaticTRestore<T>(thisstringt)whereT:new(){returnJsonSerializer.Deserialize<T>(t);}/// <summary>/// Clona um objeto./// </summary>/// <typeparam name="T"></typeparam>/// <param name="from"></param>/// <returns></returns>publicstaticTClone<T>(thisTfrom)whereT:new(){returnfrom.Dump().Restore<T>();}}// Usando os extension methodsvarcobaia=newFeed();varcopy=cobaia.Clone();Debug.Assert(copy!=null);
Como o Visual Studio mostraria que estou usando uma extension:
Vantagens
Testável por unidade: Como fazemos extensão de um IQueryable<T> podemos testar através de uma List<T>
Testável por integração: Novamente, como estendemos IQueryable<T> podemos testar através de um DbSet<T> do EFCore
Armazenável próximo ao Model: Se você curte Clean Code, podemos deixar estas queries no mesmo arquivo que a classe do Model!
Permite composição: Podemos chamar filtros após filtros conforme nossa necessidade, sempre precisar de um novo método para cada nova combinação de filtros
Não duplica o pattern UoW + Repository em sua aplicação.
Todos são pontos bem legais, na minha opinião, mas vou dar uma ênfase no #5 e faço isso pois o próprio EntityFramework Core é uma implementação deste pattern!
O DbContext que herdamos é uma UnitOfWork de cara por nos permitir criar transações, realizar commits e rollbacks. E cada DbSet<T> também é repositório pois nos permite interagir com o objetos armazenados no banco!
Uma instância do DbContext representa uma sessão com o banco de dados e pode ser utilizado para consultar e salvar instâncias de suas entidades. O DbContext é uma combinação dos patterns Unit Of Work e Repository. (tradução livre pelo autor)
Com base nisso podemos concluir que:
Uma UnitOfWork sobre um DbContext é uma abstração feita sobre uma abstração!
Desvantagens
Menos reutilizável longe do EFCore: Como dependemos do IQueryable<T> fica mais complicado reutilizar essas queries em um mobile Xamarin, por exemplo
Pouca familiaridade com extension methods: Apesar de estarem ai a anos nem todo desenvolvedor já teve de escrever seu próprio extension method
Dependendo do namespace podem causar confusão: Extension methods são carregados por namespace de uma única vez. Você não consegue escolher quais métodos serão importados.
Exemplos
O seguinte snippet contém algumas das queries do Cell CMS que migrei para este padrão e alguns testes mostrando como elas podem ser utilizadas, como se comportam com os diferentes datasets e chains!
/// <summary>/// Queries relacionadas a Feeds./// </summary>publicstaticclassFeedQueries{/// <summary>/// Obtem todos os feeds./// </summary>/// <param name="feeds"></param>/// <returns></returns>publicstaticIQueryable<Feed>AllFeeds(thisIQueryable<Feed>feeds)=>feeds;/// <summary>/// Filtra feeds com base no ID./// </summary>/// <param name="feeds"></param>/// <param name="id"></param>/// <returns></returns>publicstaticIQueryable<Feed>WithId(thisIQueryable<Feed>feeds,intid){returnfeeds.Where(f=>f.Id.Equals(id));}/// <summary>/// Filtra feeds com base no Nome./// </summary>/// <param name="feeds"></param>/// <param name="name"></param>/// <returns></returns>publicstaticIQueryable<Feed>FilterByNome(thisIQueryable<Feed>feeds,stringname){if(string.IsNullOrWhiteSpace(name)){returnfeeds;}returnfeeds.Where(f=>f.Nome.Contains(name,System.StringComparison.InvariantCultureIgnoreCase));}}// Testes[Theory,CreateData][Trait(TraitsConstants.Label.Name,TraitsConstants.Label.Values.Domain)]publicvoidWithNome_EmptyData_ReturnsEmpty(stringnome){// Arrangevarsubject=_emptyResult.AsQueryable();// Actvarresult=subject.FilterByNome(nome);// Assertresult.Should().BeEmpty();}[Theory,CreateData][Trait(TraitsConstants.Label.Name,TraitsConstants.Label.Values.Domain)]publicvoidWithNome_WithDataAndValidName_ReturnsData(IEnumerable<Feed>feeds,FeedextraFeed){// Arrangevarnome=extraFeed.Nome;varsubject=feeds.Concat(newList<Feed>(){extraFeed}).AsQueryable();// Actvarresult=subject.FilterByNome(nome);// Assertresult.Should().NotBeEmpty();}[Theory,CreateData][Trait(TraitsConstants.Label.Name,TraitsConstants.Label.Values.Domain)]publicvoidWithNome_WithDataAndInvalidName_ReturnsData(IEnumerable<Feed>feeds,[Frozen]stringfixedName){// Arrangevarnome=$"{fixedName}{Guid.NewGuid()}";varsubject=feeds.AsQueryable();// Actvarresult=subject.FilterByNome(nome);// Assertresult.Should().BeEmpty();}[Theory,CreateData][Trait(TraitsConstants.Label.Name,TraitsConstants.Label.Values.Domain)]publicvoidWithIdAndNome_WithDataAndValidNameAndValidID_ReturnsData(IEnumerable<Feed>feeds,FeedextraFeed){// Arrangevarnome=extraFeed.Nome;varid=extraFeed.Id;varsubject=feeds.Concat(newList<Feed>(){extraFeed}).AsQueryable();// Actvarresult=subject.WithId(id).FilterByNome(nome);// Assertresult.Should().NotBeEmpty();}
Conclusão
Eu, pessoalmente, sempre utilizei o padrão UnitOfWork + Repository. Porém, ao descobrir que os extension methods poderiam me dar a mesma funcionalidade mas com um menor overheadpassei a preferir extension methods.
Caso ainda não esteja convencido dê uma olhada nos testes e refactors feitos no Cell CMS, que agora está seguindo este padrão, para basear sua decisão!
No fundo essa decisão é uma questão de gosto pessoal. Eu prefiro ser pragmático e minimizar a quantidade de coisas que preciso dar manutenção e testar. Mas para seu projeto/equipe pode ser que o padrão UnitOfWork seja melhor aceito!
O post de hoje vai ficando por aqui, espero que tenha dado o que pensar! O próximo post provavelmente será sobre Analyzers ou Configuração de um Ambiente Windows para desenvolvimento!
Fiquem ligados para o próximo, obrigado por lerem e até então!