Partilhar via


Implementar a camada de persistência de infraestrutura com o Entity Framework Core

Gorjeta

Este conteúdo é um trecho do eBook, .NET Microservices Architecture for Containerized .NET Applications, disponível no .NET Docs ou como um PDF para download gratuito que pode ser lido offline.

.NET Microservices Architecture for Containerized .NET Applications eBook cover thumbnail.

Quando você usa bancos de dados relacionais, como SQL Server, Oracle ou PostgreSQL, uma abordagem recomendada é implementar a camada de persistência baseada no Entity Framework (EF). O EF suporta LINQ e fornece objetos fortemente tipados para seu modelo, bem como persistência simplificada em seu banco de dados.

O Entity Framework tem uma longa história como parte do .NET Framework. Ao usar o .NET, você também deve usar o Entity Framework Core, que é executado no Windows ou Linux da mesma forma que o .NET. O EF Core é uma reescrita completa do Entity Framework que é implementada com uma pegada muito menor e melhorias importantes no desempenho.

Introdução ao Entity Framework Core

O Entity Framework (EF) Core é uma versão leve, extensível e multiplataforma da popular tecnologia de acesso a dados do Entity Framework. Ele foi introduzido com o .NET Core em meados de 2016.

Como uma introdução ao EF Core já está disponível na documentação da Microsoft, aqui nós simplesmente fornecemos links para essas informações.

Recursos adicionais

Infraestrutura no Entity Framework Core de uma perspetiva DDD

Do ponto de vista do DDD, uma capacidade importante do EF é a capacidade de usar entidades de domínio POCO, também conhecidas na terminologia do EF como entidades code-first POCO. Se você usar entidades de domínio POCO, suas classes de modelo de domínio serão ignorantes em persistência, seguindo os princípios Persistence Ignorance e Infrastructure Ignorance .

De acordo com os padrões DDD, você deve encapsular o comportamento e as regras do domínio dentro da própria classe de entidade, para que ela possa controlar invariantes, validações e regras ao acessar qualquer coleção. Portanto, não é uma boa prática no DDD permitir o acesso público a coleções de entidades filhas ou objetos de valor. Em vez disso, você deseja expor métodos que controlam como e quando seus campos e coleções de propriedades podem ser atualizados e quais comportamentos e ações devem ocorrer quando isso acontece.

Desde o EF Core 1.1, para satisfazer esses requisitos DDD, você pode ter campos simples em suas entidades em vez de propriedades públicas. Se você não quiser que um campo de entidade seja acessível externamente, basta criar o atributo ou campo em vez de uma propriedade. Você também pode usar setters de propriedade privada.

Da mesma forma, agora você pode ter acesso somente leitura a coleções usando uma propriedade pública digitada como IReadOnlyCollection<T>, que é apoiada por um membro de campo privado para a coleção (como um List<T>) em sua entidade que depende do EF para persistência. As versões anteriores do Entity Framework exigiam propriedades de coleção para oferecer suporte ICollection<T>ao , o que significava que qualquer desenvolvedor que usasse a classe de entidade pai poderia adicionar ou remover itens por meio de suas coleções de propriedades. Essa possibilidade seria contrária aos padrões recomendados no DDD.

Você pode usar uma coleção privada ao expor um objeto somente IReadOnlyCollection<T> leitura, conforme mostrado no exemplo de código a seguir:

public class Order : Entity
{
    // Using private fields, allowed since EF Core 1.1
    private DateTime _orderDate;
    // Other fields ...

    private readonly List<OrderItem> _orderItems;
    public IReadOnlyCollection<OrderItem> OrderItems => _orderItems;

    protected Order() { }

    public Order(int buyerId, int paymentMethodId, Address address)
    {
        // Initializations ...
    }

    public void AddOrderItem(int productId, string productName,
                             decimal unitPrice, decimal discount,
                             string pictureUrl, int units = 1)
    {
        // Validation logic...

        var orderItem = new OrderItem(productId, productName,
                                      unitPrice, discount,
                                      pictureUrl, units);
        _orderItems.Add(orderItem);
    }
}

A OrderItems propriedade só pode ser acessada como somente leitura usando IReadOnlyCollection<OrderItem>o . Esse tipo é somente leitura, portanto, está protegido contra atualizações externas regulares.

O EF Core fornece uma maneira de mapear o modelo de domínio para o banco de dados físico sem "contaminar" o modelo de domínio. É puro código .NET POCO, porque a ação de mapeamento é implementada na camada de persistência. Nessa ação de mapeamento, você precisa configurar o mapeamento de campos para banco de dados. No exemplo a seguir do OnModelCreating método de e da OrderEntityTypeConfiguration classe, a chamada para diz ao SetPropertyAccessMode EF Core para acessar a OrderItems propriedade por meio de OrderingContext seu campo.

// At OrderingContext.cs from eShopOnContainers
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
   // ...
   modelBuilder.ApplyConfiguration(new OrderEntityTypeConfiguration());
   // Other entities' configuration ...
}

// At OrderEntityTypeConfiguration.cs from eShopOnContainers
class OrderEntityTypeConfiguration : IEntityTypeConfiguration<Order>
{
    public void Configure(EntityTypeBuilder<Order> orderConfiguration)
    {
        orderConfiguration.ToTable("orders", OrderingContext.DEFAULT_SCHEMA);
        // Other configuration

        var navigation =
              orderConfiguration.Metadata.FindNavigation(nameof(Order.OrderItems));

        //EF access the OrderItem collection property through its backing field
        navigation.SetPropertyAccessMode(PropertyAccessMode.Field);

        // Other configuration
    }
}

Quando você usa campos em vez de propriedades, a OrderItem entidade é persistida como se tivesse uma List<OrderItem> propriedade. No entanto, ele expõe um único acessador, o AddOrderItem método, para adicionar novos itens à ordem. Como resultado, o comportamento e os dados são vinculados e serão consistentes em qualquer código de aplicativo que use o modelo de domínio.

Implementar repositórios personalizados com o Entity Framework Core

No nível de implementação, um repositório é simplesmente uma classe com código de persistência de dados coordenado por uma unidade de trabalho (DBContext no EF Core) ao executar atualizações, conforme mostrado na seguinte classe:

// using directives...
namespace Microsoft.eShopOnContainers.Services.Ordering.Infrastructure.Repositories
{
    public class BuyerRepository : IBuyerRepository
    {
        private readonly OrderingContext _context;
        public IUnitOfWork UnitOfWork
        {
            get
            {
                return _context;
            }
        }

        public BuyerRepository(OrderingContext context)
        {
            _context = context ?? throw new ArgumentNullException(nameof(context));
        }

        public Buyer Add(Buyer buyer)
        {
            return _context.Buyers.Add(buyer).Entity;
        }

        public async Task<Buyer> FindAsync(string buyerIdentityGuid)
        {
            var buyer = await _context.Buyers
                .Include(b => b.Payments)
                .Where(b => b.FullName == buyerIdentityGuid)
                .SingleOrDefaultAsync();

            return buyer;
        }
    }
}

A IBuyerRepository interface vem da camada de modelo de domínio como um contrato. No entanto, a implementação do repositório é feita na camada de persistência e infraestrutura.

O EF DbContext vem através do construtor através de Dependency Injection. Ele é compartilhado entre vários repositórios dentro do mesmo escopo de solicitação HTTP, graças ao seu tempo de vida padrão (ServiceLifetime.Scoped) no contêiner IoC (que também pode ser explicitamente definido com services.AddDbContext<>).

Métodos a implementar num repositório (atualizações ou transações versus consultas)

Dentro de cada classe de repositório, você deve colocar os métodos de persistência que atualizam o estado das entidades contidas por sua agregação relacionada. Lembre-se de que há uma relação um-para-um entre um agregado e seu repositório relacionado. Considere que um objeto de entidade raiz agregado pode ter incorporado entidades filhas em seu gráfico EF. Por exemplo, um comprador pode ter vários métodos de pagamento como entidades filhas relacionadas.

Como a abordagem para o microsserviço de pedidos no eShopOnContainers também é baseada em CQS/CQRS, a maioria das consultas não é implementada em repositórios personalizados. Os desenvolvedores têm a liberdade de criar as consultas e junções necessárias para a camada de apresentação sem as restrições impostas por agregados, repositórios personalizados por agregado e DDD em geral. A maioria dos repositórios personalizados sugeridos por este guia tem vários métodos de atualização ou transacionais, mas apenas os métodos de consulta necessários para obter dados a serem atualizados. Por exemplo, o repositório BuyerRepository implementa um método FindAsync, porque o aplicativo precisa saber se um comprador específico existe antes de criar um novo comprador relacionado ao pedido.

No entanto, os métodos de consulta reais para obter dados para enviar para a camada de apresentação ou aplicativos cliente são implementados, como mencionado, nas consultas CQRS baseadas em consultas flexíveis usando o Dapper.

Usando um repositório personalizado versus usando EF DbContext diretamente

A classe DbContext do Entity Framework é baseada nos padrões Unit of Work e Repository e pode ser usada diretamente do seu código, como de um controlador MVC ASP.NET Core. Os padrões de Unidade de Trabalho e Repositório resultam no código mais simples, como no microsserviço de catálogo CRUD em eShopOnContainers. Nos casos em que você deseja o código mais simples possível, você pode querer usar diretamente a classe DbContext, como muitos desenvolvedores fazem.

No entanto, a implementação de repositórios personalizados oferece vários benefícios ao implementar microsserviços ou aplicativos mais complexos. Os padrões de Unidade de Trabalho e Repositório destinam-se a encapsular a camada de persistência da infraestrutura para que ela seja dissociada das camadas de aplicativo e modelo de domínio. A implementação desses padrões pode facilitar o uso de repositórios fictícios simulando o acesso ao banco de dados.

Na Figura 7-18, você pode ver as diferenças entre não usar repositórios (usando diretamente o EF DbContext) versus usar repositórios, o que torna mais fácil simular esses repositórios.

Diagram showing the components and dataflow in the two repositories.

Figura 7-18. Usando repositórios personalizados versus um DbContext simples

A Figura 7-18 mostra que o uso de um repositório personalizado adiciona uma camada de abstração que pode ser usada para facilitar o teste simulando o repositório. Existem várias alternativas quando se zomba. Você pode zombar apenas de repositórios ou você pode zombar de toda uma unidade de trabalho. Normalmente, zombar apenas dos repositórios é suficiente, e a complexidade para abstrair e zombar de toda uma unidade de trabalho geralmente não é necessária.

Mais tarde, quando nos concentrarmos na camada de aplicativo, você verá como a injeção de dependência funciona no ASP.NET Core e como ela é implementada ao usar repositórios.

Em resumo, os repositórios personalizados permitem que você teste o código mais facilmente com testes de unidade que não são afetados pelo estado da camada de dados. Se você executar testes que também acessam o banco de dados real por meio do Entity Framework, eles não são testes de unidade, mas testes de integração, que são muito mais lentos.

Se você estivesse usando DbContext diretamente, teria que simulá-lo ou executar testes de unidade usando um SQL Server na memória com dados previsíveis para testes de unidade. Mas zombar do DbContext ou controlar dados falsos requer mais trabalho do que zombar no nível do repositório. Claro, você sempre pode testar os controladores MVC.

Tempo de vida da instância EF DbContext e IUnitOfWork em seu contêiner de IoC

O DbContext objeto (exposto como um IUnitOfWork objeto) deve ser compartilhado entre vários repositórios dentro do mesmo escopo de solicitação HTTP. Por exemplo, isso é verdadeiro quando a operação que está sendo executada deve lidar com várias agregações ou simplesmente porque você está usando várias instâncias de repositório. Também é importante mencionar que a IUnitOfWork interface faz parte da sua camada de domínio, não um tipo EF Core.

Para fazer isso, a instância do objeto deve ter seu tempo de DbContext vida de serviço definido como ServiceLifetime.Scoped. Este é o tempo de vida padrão ao registrar um DbContext com builder.Services.AddDbContext em seu contêiner IoC a partir do arquivo Program.cs em seu projeto de API Web principal ASP.NET. O seguinte código ilustra-o.

// Add framework services.
builder.Services.AddMvc(options =>
{
    options.Filters.Add(typeof(HttpGlobalExceptionFilter));
}).AddControllersAsServices();

builder.Services.AddEntityFrameworkSqlServer()
    .AddDbContext<OrderingContext>(options =>
    {
        options.UseSqlServer(Configuration["ConnectionString"],
                            sqlOptions => sqlOptions.MigrationsAssembly(typeof(Startup).GetTypeInfo().
                                                                                Assembly.GetName().Name));
    },
    ServiceLifetime.Scoped // Note that Scoped is the default choice
                            // in AddDbContext. It is shown here only for
                            // pedagogic purposes.
    );

O modo de instanciação DbContext não deve ser configurado como ServiceLifetime.Transient ou ServiceLifetime.Singleton.

O tempo de vida da instância do repositório em seu contêiner de IoC

Da mesma forma, o tempo de vida do repositório geralmente deve ser definido como escopo (InstancePerLifetimeScope no Autofac). Também pode ser transitório (InstancePerDependency no Autofac), mas seu serviço será mais eficiente em relação à memória ao usar o tempo de vida com escopo.

// Registering a Repository in Autofac IoC container
builder.RegisterType<OrderRepository>()
    .As<IOrderRepository>()
    .InstancePerLifetimeScope();

Usar o tempo de vida singleton para o repositório pode causar sérios problemas de simultaneidade quando seu DbContext está definido como escopo (InstancePerLifetimeScope) (os tempos de vida padrão para um DBContext). Contanto que os tempos de vida do serviço para seus repositórios e seu DbContext sejam ambos com escopo, você evitará esses problemas.

Recursos adicionais

Mapeamento da tabela

O mapeamento de tabela identifica os dados da tabela a serem consultados e salvos no banco de dados. Anteriormente, você viu como as entidades de domínio (por exemplo, um domínio de produto ou pedido) podem ser usadas para gerar um esquema de banco de dados relacionado. A EF é fortemente concebida em torno do conceito de convenções. As convenções abordam questões como "Qual será o nome de uma tabela?" ou "Qual propriedade é a chave primária?" As convenções são tipicamente baseadas em nomes convencionais. Por exemplo, é típico que a chave primária seja uma propriedade que termina com Id.

Por convenção, cada entidade será configurada para mapear para uma tabela com o mesmo nome da DbSet<TEntity> propriedade que expõe a entidade no contexto derivado. Se nenhum DbSet<TEntity> valor for fornecido para a entidade fornecida, o nome da classe será usado.

Anotações de dados versus API fluente

Existem muitas convenções EF Core adicionais, e a maioria delas pode ser alterada usando anotações de dados ou API Fluent, implementada dentro do método OnModelCreate.

As anotações de dados devem ser usadas nas próprias classes de modelo de entidade, o que é uma maneira mais intrusiva do ponto de vista do DDD. Isso ocorre porque você está contaminando seu modelo com anotações de dados relacionadas ao banco de dados de infraestrutura. Por outro lado, a API Fluent é uma maneira conveniente de alterar a maioria das convenções e mapeamentos dentro da camada de infraestrutura de persistência de dados, para que o modelo de entidade seja limpo e dissociado da infraestrutura de persistência.

API fluente e o método OnModelCreating

Como mencionado, para alterar convenções e mapeamentos, você pode usar o método OnModelCreating na classe DbContext.

O microsserviço de pedidos no eShopOnContainers implementa mapeamento e configuração explícitos, quando necessário, conforme mostrado no código a seguir.

// At OrderingContext.cs from eShopOnContainers
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
   // ...
   modelBuilder.ApplyConfiguration(new OrderEntityTypeConfiguration());
   // Other entities' configuration ...
}

// At OrderEntityTypeConfiguration.cs from eShopOnContainers
class OrderEntityTypeConfiguration : IEntityTypeConfiguration<Order>
{
    public void Configure(EntityTypeBuilder<Order> orderConfiguration)
    {
        orderConfiguration.ToTable("orders", OrderingContext.DEFAULT_SCHEMA);

        orderConfiguration.HasKey(o => o.Id);

        orderConfiguration.Ignore(b => b.DomainEvents);

        orderConfiguration.Property(o => o.Id)
            .UseHiLo("orderseq", OrderingContext.DEFAULT_SCHEMA);

        //Address value object persisted as owned entity type supported since EF Core 2.0
        orderConfiguration
            .OwnsOne(o => o.Address, a =>
            {
                a.WithOwner();
            });

        orderConfiguration
            .Property<int?>("_buyerId")
            .UsePropertyAccessMode(PropertyAccessMode.Field)
            .HasColumnName("BuyerId")
            .IsRequired(false);

        orderConfiguration
            .Property<DateTime>("_orderDate")
            .UsePropertyAccessMode(PropertyAccessMode.Field)
            .HasColumnName("OrderDate")
            .IsRequired();

        orderConfiguration
            .Property<int>("_orderStatusId")
            .UsePropertyAccessMode(PropertyAccessMode.Field)
            .HasColumnName("OrderStatusId")
            .IsRequired();

        orderConfiguration
            .Property<int?>("_paymentMethodId")
            .UsePropertyAccessMode(PropertyAccessMode.Field)
            .HasColumnName("PaymentMethodId")
            .IsRequired(false);

        orderConfiguration.Property<string>("Description").IsRequired(false);

        var navigation = orderConfiguration.Metadata.FindNavigation(nameof(Order.OrderItems));

        // DDD Patterns comment:
        //Set as field (New since EF 1.1) to access the OrderItem collection property through its field
        navigation.SetPropertyAccessMode(PropertyAccessMode.Field);

        orderConfiguration.HasOne<PaymentMethod>()
            .WithMany()
            .HasForeignKey("_paymentMethodId")
            .IsRequired(false)
            .OnDelete(DeleteBehavior.Restrict);

        orderConfiguration.HasOne<Buyer>()
            .WithMany()
            .IsRequired(false)
            .HasForeignKey("_buyerId");

        orderConfiguration.HasOne(o => o.OrderStatus)
            .WithMany()
            .HasForeignKey("_orderStatusId");
    }
}

Você pode definir todos os mapeamentos de API Fluent dentro do mesmo OnModelCreating método, mas é aconselhável particionar esse código e ter várias classes de configuração, uma por entidade, como mostrado no exemplo. Especialmente para modelos grandes, é aconselhável ter classes de configuração separadas para configurar diferentes tipos de entidade.

O código no exemplo mostra algumas declarações explícitas e mapeamento. No entanto, as convenções do EF Core fazem muitos desses mapeamentos automaticamente, portanto, o código real que você precisaria no seu caso pode ser menor.

O algoritmo Hi/Lo no EF Core

Um aspeto interessante do código no exemplo anterior é que ele usa o algoritmo Hi/Lo como a estratégia de geração de chaves.

O algoritmo Hi/Lo é útil quando você precisa de chaves exclusivas antes de confirmar alterações. Como resumo, o algoritmo Hi-Lo atribui identificadores exclusivos às linhas da tabela, sem depender do armazenamento imediato da linha no banco de dados. Isso permite que você comece a usar os identificadores imediatamente, como acontece com IDs de banco de dados sequenciais regulares.

O algoritmo Hi/Lo descreve um mecanismo para obter um lote de IDs exclusivos de uma sequência de banco de dados relacionada. Esses IDs são seguros de usar porque o banco de dados garante a exclusividade, portanto, não haverá colisões entre os usuários. Este algoritmo é interessante por estas razões:

  • Não quebra o padrão da Unidade de Trabalho.

  • Ele obtém IDs de sequência em lotes, para minimizar viagens de ida e volta ao banco de dados.

  • Ele gera um identificador legível por humanos, ao contrário das técnicas que usam GUIDs.

O EF Core suporta HiLo com o UseHiLo método, como mostrado no exemplo anterior.

Mapear campos em vez de propriedades

Com esse recurso, disponível desde o EF Core 1.1, você pode mapear colunas diretamente para campos. É possível não usar propriedades na classe de entidade e apenas mapear colunas de uma tabela para campos. Um uso comum para isso seriam campos privados para qualquer estado interno que não precisam ser acessados de fora da entidade.

Você pode fazer isso com campos únicos ou também com coleções, como um List<> campo. Este ponto foi mencionado anteriormente quando discutimos a modelagem das classes de modelo de domínio, mas aqui você pode ver como esse mapeamento é realizado com a configuração destacada PropertyAccessMode.Field no código anterior.

Usar propriedades de sombra no EF Core, ocultas no nível da infraestrutura

As propriedades de sombra no EF Core são propriedades que não existem no seu modelo de classe de entidade. Os valores e estados dessas propriedades são mantidos puramente na classe ChangeTracker no nível de infraestrutura.

Implementar o padrão de especificação de consulta

Conforme introduzido anteriormente na seção de design, o padrão Especificação de Consulta é um padrão de Design Controlado por Domínio projetado como o local onde você pode colocar a definição de uma consulta com lógica opcional de classificação e paginação.

O padrão Especificação de Consulta define uma consulta em um objeto. Por exemplo, para encapsular uma consulta paginada que procura alguns produtos, você pode criar uma especificação PagedProduct que usa os parâmetros de entrada necessários (pageNumber, pageSize, filter, etc.). Em seguida, dentro de qualquer método Repository (geralmente uma sobrecarga List()) ele aceitaria um IQuerySpecification e executaria a consulta esperada com base nessa especificação.

Um exemplo de uma interface de especificação genérica é o código a seguir, que é semelhante ao código usado no aplicativo de referência eShopOnWeb .

// GENERIC SPECIFICATION INTERFACE
// https://github.com/dotnet-architecture/eShopOnWeb

public interface ISpecification<T>
{
    Expression<Func<T, bool>> Criteria { get; }
    List<Expression<Func<T, object>>> Includes { get; }
    List<string> IncludeStrings { get; }
}

Em seguida, a implementação de uma classe base de especificação genérica é a seguinte.

// GENERIC SPECIFICATION IMPLEMENTATION (BASE CLASS)
// https://github.com/dotnet-architecture/eShopOnWeb

public abstract class BaseSpecification<T> : ISpecification<T>
{
    public BaseSpecification(Expression<Func<T, bool>> criteria)
    {
        Criteria = criteria;
    }
    public Expression<Func<T, bool>> Criteria { get; }

    public List<Expression<Func<T, object>>> Includes { get; } =
                                           new List<Expression<Func<T, object>>>();

    public List<string> IncludeStrings { get; } = new List<string>();

    protected virtual void AddInclude(Expression<Func<T, object>> includeExpression)
    {
        Includes.Add(includeExpression);
    }

    // string-based includes allow for including children of children
    // e.g. Basket.Items.Product
    protected virtual void AddInclude(string includeString)
    {
        IncludeStrings.Add(includeString);
    }
}

A especificação a seguir carrega uma única entidade da cesta com o ID da cesta ou a ID do comprador ao qual a cesta pertence. Vai carregar ansiosamente a coleção do Items cesto.

// SAMPLE QUERY SPECIFICATION IMPLEMENTATION

public class BasketWithItemsSpecification : BaseSpecification<Basket>
{
    public BasketWithItemsSpecification(int basketId)
        : base(b => b.Id == basketId)
    {
        AddInclude(b => b.Items);
    }

    public BasketWithItemsSpecification(string buyerId)
        : base(b => b.BuyerId == buyerId)
    {
        AddInclude(b => b.Items);
    }
}

E, finalmente, você pode ver abaixo como um repositório EF genérico pode usar essa especificação para filtrar e carregar dados relacionados a uma determinada entidade tipo T.

// GENERIC EF REPOSITORY WITH SPECIFICATION
// https://github.com/dotnet-architecture/eShopOnWeb

public IEnumerable<T> List(ISpecification<T> spec)
{
    // fetch a Queryable that includes all expression-based includes
    var queryableResultWithIncludes = spec.Includes
        .Aggregate(_dbContext.Set<T>().AsQueryable(),
            (current, include) => current.Include(include));

    // modify the IQueryable to include any string-based include statements
    var secondaryResult = spec.IncludeStrings
        .Aggregate(queryableResultWithIncludes,
            (current, include) => current.Include(include));

    // return the result of the query using the specification's criteria expression
    return secondaryResult
                    .Where(spec.Criteria)
                    .AsEnumerable();
}

Além de encapsular a lógica de filtragem, a especificação pode especificar a forma dos dados a serem retornados, incluindo quais propriedades preencher.

Embora não seja recomendável retornar IQueryable de um repositório, não há problema em usá-los dentro do repositório para construir um conjunto de resultados. Você pode ver essa abordagem usada no método List acima, que usa expressões intermediárias IQueryable para criar a lista de inclusões da consulta antes de executar a consulta com os critérios da especificação na última linha.

Saiba como o padrão de especificação é aplicado no exemplo eShopOnWeb.

Recursos adicionais