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!
rodolphocastro / cell-cms
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.
As seguintes variáveis de ambiente…
Motivação para Abstrair o EF Core
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.
Caso você queira saber mais sobre os Enterprise Patterns: Dê uma olhada no livro Patterns of Enterprise Application Architecture do Martin Fowler
Vantagens
As principais vantagens são:
- 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 umGenericRepository<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>
public interface ICellCMSUnitOfWork
{
IRepository<Feed> Feeds { get; }
Task CommitChanges();
}
/// <summary>
/// Descreve métodos para um repositório.
/// </summary>
/// <typeparam name="T"></typeparam>
public interface IRepository<T> where T: class
{
Task<IEnumerable<T>> ListAll();
}
/// <summary>
/// Implementação genérica de um repository.
/// </summary>
/// <typeparam name="T"></typeparam>
public class GenericRepository<T> : IRepository<T> where T : class
{
private DbSet<T> _set;
public GenericRepository(CellContext context)
{
_set = context.Set<T>();
}
public async Task<IEnumerable<T>> ListAll() => await _set.ToListAsync();
}
/// <summary>
/// Simulação de uma Unit of Work para o CELL CMS.
/// </summary>
public class CellCMSUnitOfWork : ICellCMSUnitOfWork
{
private readonly CellContext _context;
public IRepository<Feed> Feeds { get; private set; }
public CellCMSUnitOfWork(CellContext context)
{
_context = context ?? throw new ArgumentNullException(nameof(context));
Feeds = new GenericRepository<Feed>(context);
}
public Task CommitChanges() => _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 akeyword
especialthis
.
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 umastring
e, nas últimas 3 linhas, eles são chamados como se fosse realmente parte do tipoFeed
estring
.
public static class MyExtensions
{
/// <summary>
/// Serializa um objeto para um JSON.
/// </summary>
/// <typeparam name="T"></typeparam>
/// <param name="obj"></param>
/// <returns></returns>
public static string Dump<T>(this T obj) where T : new()
{
return JsonSerializer.Serialize(obj);
}
/// <summary>
/// Restaura um objeto de a partir de um JSON.
/// </summary>
/// <typeparam name="T"></typeparam>
/// <param name="t"></param>
/// <returns></returns>
public static T Restore<T>(this string t) where T : new()
{
return JsonSerializer.Deserialize<T>(t);
}
/// <summary>
/// Clona um objeto.
/// </summary>
/// <typeparam name="T"></typeparam>
/// <param name="from"></param>
/// <returns></returns>
public static T Clone<T>(this T from) where T : new()
{
return from.
Dump().
Restore<T>();
}
}
// Usando os extension methods
var cobaia = new Feed();
var copy = 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 umaList<T>
-
Testável por integração: Novamente, como estendemos
IQueryable<T>
podemos testar através de umDbSet<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!
A própria documentação do EntityFrameworkCore explica:
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 umDbContext
é 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óprioextension method
-
Dependendo do
namespace
podem causar confusão: Extension methods são carregados pornamespace
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>
public static class FeedQueries
{
/// <summary>
/// Obtem todos os feeds.
/// </summary>
/// <param name="feeds"></param>
/// <returns></returns>
public static IQueryable<Feed> AllFeeds(this IQueryable<Feed> feeds) => feeds;
/// <summary>
/// Filtra feeds com base no ID.
/// </summary>
/// <param name="feeds"></param>
/// <param name="id"></param>
/// <returns></returns>
public static IQueryable<Feed> WithId(this IQueryable<Feed> feeds, int id)
{
return feeds
.Where(f => f.Id.Equals(id));
}
/// <summary>
/// Filtra feeds com base no Nome.
/// </summary>
/// <param name="feeds"></param>
/// <param name="name"></param>
/// <returns></returns>
public static IQueryable<Feed> FilterByNome(this IQueryable<Feed> feeds, string name)
{
if (string.IsNullOrWhiteSpace(name))
{
return feeds;
}
return feeds
.Where(f => f.Nome.Contains(name, System.StringComparison.InvariantCultureIgnoreCase));
}
}
// Testes
[Theory, CreateData]
[Trait(TraitsConstants.Label.Name, TraitsConstants.Label.Values.Domain)]
public void WithNome_EmptyData_ReturnsEmpty(string nome)
{
// Arrange
var subject = _emptyResult.AsQueryable();
// Act
var result = subject
.FilterByNome(nome);
// Assert
result.Should().BeEmpty();
}
[Theory, CreateData]
[Trait(TraitsConstants.Label.Name, TraitsConstants.Label.Values.Domain)]
public void WithNome_WithDataAndValidName_ReturnsData(IEnumerable<Feed> feeds, Feed extraFeed)
{
// Arrange
var nome = extraFeed.Nome;
var subject = feeds
.Concat(new List<Feed>() { extraFeed })
.AsQueryable();
// Act
var result = subject
.FilterByNome(nome);
// Assert
result.Should().NotBeEmpty();
}
[Theory, CreateData]
[Trait(TraitsConstants.Label.Name, TraitsConstants.Label.Values.Domain)]
public void WithNome_WithDataAndInvalidName_ReturnsData(IEnumerable<Feed> feeds, [Frozen] string fixedName)
{
// Arrange
var nome = $"{fixedName}{Guid.NewGuid()}";
var subject = feeds.AsQueryable();
// Act
var result = subject
.FilterByNome(nome);
// Assert
result.Should().BeEmpty();
}
[Theory, CreateData]
[Trait(TraitsConstants.Label.Name, TraitsConstants.Label.Values.Domain)]
public void WithIdAndNome_WithDataAndValidNameAndValidID_ReturnsData(IEnumerable<Feed> feeds, Feed extraFeed)
{
// Arrange
var nome = extraFeed.Nome;
var id = extraFeed.Id;
var subject = feeds
.Concat(new List<Feed>() { extraFeed })
.AsQueryable();
// Act
var result = subject
.WithId(id)
.FilterByNome(nome);
// Assert
result.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 overhead passei 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!
Top comments (0)