使用关系数据库(如 SQL Server、Oracle 或 PostgreSQL)时,建议的方法是基于实体框架(EF)实现持久性层。 EF 支持 LINQ,为您的模型提供强类型对象,并简化将数据持久化到数据库的过程。
实体框架作为 .NET Framework 的一部分有着悠久的历史。 使用 .NET 时,还应使用 Entity Framework Core,它以与 .NET 相同的方式在 Windows 或 Linux 上运行。 EF Core 是对 Entity Framework 的完整重写,实现了更小的占用空间和重要的性能改进。
Entity Framework Core 简介
Entity Framework (EF) Core 是一种轻型、可扩展且跨平台版本的常用 Entity Framework 数据访问技术。 它随 .NET Core 于 2016 年年中引入。
由于 EF Core 简介已在Microsoft文档中提供,此处我们只是提供指向该信息的链接。
其他资源
Entity Framework Core
https://learn.microsoft.com/ef/core/使用 Visual Studio 入门 ASP.NET Core 和 Entity Framework Core
https://learn.microsoft.com/aspnet/core/data/ef-mvc/DbContext 类
https://learn.microsoft.com/dotnet/api/microsoft.entityframeworkcore.dbcontext比较 EF Core 和 EF6.x
https://learn.microsoft.com/ef/efcore-and-ef6/index
从 DDD 的角度来看 Entity Framework Core 中的基础结构
从 DDD 的角度来看,EF 的重要功能是能够使用 POCO 域实体,在 EF 术语中也称为 POCO 代码优先实体。 如果使用 POCO 域实体,则域模型类是持久性-无知的,遵循 持久性忽略 和 基础结构忽略 原则。
根据 DDD 模式,应在实体类本身中封装域行为和规则,以便在访问任何集合时管理不变性、验证和规则。 因此,在 DDD 中允许公共访问子实体或值对象的集合并不是一个好的做法。 相反,您希望公开一些方法,以控制字段和属性集合的更新方式和时间,并规定在这种情况下应该发生什么行为和动作。
自 EF Core 1.1 以来,为满足这些 DDD 要求,您可以在实体中使用简单字段,而不是公共属性。 如果不希望可从外部访问实体字段,创建特性或字段(而非属性)即可。 还可使用专用属性资源库。
相同地,现可使用类型化为 IReadOnlyCollection<T>
(受依赖 EF 实现持久性的实体中集合(如 List<T>
)的专用字段成员的支持)的公共属性对集合进行只读访问。 以前版本的 Entity Framework 需要支持 ICollection<T>
集合属性,这意味着任何使用父实体类的开发人员都可以通过其属性集合添加或删除项。 这种可能性将违背 DDD 中建议的模式。
可以在公开只读 IReadOnlyCollection<T>
对象时使用专用集合,如以下代码示例所示:
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);
}
}
只能使用OrderItems
只读方式访问该IReadOnlyCollection<OrderItem>
属性。 此类型是只读的,因此免受常规外部更新的影响。
EF Core 提供了一种将域模型映射到物理数据库的方法,而无需“污染”域模型。 它是纯 .NET POCO 代码,因为映射操作是在持久层中实现的。 在该映射操作中,需要配置字段到数据库映射。 在来自 OnModelCreating
和 OrderingContext
类的 OrderEntityTypeConfiguration
方法的以下示例中,调用 SetPropertyAccessMode
来告诉 EF Core 通过其字段访问 OrderItems
属性。
// 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
}
}
使用字段而不是属性时,实体 OrderItem
将像具有属性 List<OrderItem>
一样持久保存。 但是,它公开单个访问器(AddOrderItem
方法),用于将新项添加到订单。 因此,行为和数据绑定在一起,并且在整个使用域模型的应用程序代码中保持一致。
使用 Entity Framework Core 实现自定义存储库
在实现层次上,存储库实际上是一个负责数据持久化的类,该类在执行更新时由工作单元(EF Core 中的 DBContext)进行协调,如以下类所示:
// 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;
}
}
}
IBuyerRepository
接口来自于域模型层(采用协定的形式)。 但是,存储库实现是在持久性层和基础结构层完成的。
EF DbContext 经由依赖关系注入而通过构造函数。 它在同一 HTTP 请求范围内的多个存储库之间共享,这要归功于 IoC 容器中的默认生存期(ServiceLifetime.Scoped
也可以显式设置)。services.AddDbContext<>
在存储库中实现的方法(更新或事务与查询的对比)
在每个存储库类中,应放置持久性方法来更新其相关聚合包含的实体的状态。 请记住,聚合与其相关存储库之间存在一对一关系。 请考虑聚合根实体对象在其 EF 图中可能嵌入了子实体。 例如,购买者可能有作为相关子实体的多个支付方法。
由于 eShopOnContainers 中订购微服务的方法也基于 CQS/CQRS,因此大多数查询都不会在自定义存储库中实现。 开发人员可以自由地创建他们在表现层所需的查询和连接,而不受聚合、每个聚合的自定义存储库以及DDD整体限制的束缚。 本指南建议的大多数自定义存储库具有多个更新或事务性方法,但只有查询方法需要更新数据。 例如,BuyerRepository 存储库实现 FindAsync 方法,因为应用程序需要知道特定买家是否存在,然后再创建与订单相关的新买家。
然而,正如所提到的,真实的查询方法是基于使用 Dapper 的灵活查询在 CQRS 查询中实现的,用于将数据发送到呈现层或客户端应用程序。
使用自定义存储库与直接使用EF DbContext相比
实体框架 DbContext 类基于工作单元和存储库模式,且可直接通过代码(如 ASP.NET Core MVC 控制器)进行使用。 工作单元和存储库模式会导致最简单的代码,就像 eShopOnContainers 中的 CRUD 目录微服务一样。 如果希望尽可能使用最简单的代码,可能需要像许多开发人员一样直接使用 DbContext 类。
但是,实现自定义存储库在实现更复杂的微服务或应用程序时提供了多种优势。 工作单元和存储库模式旨在封装基础结构持久性层,以便将其与应用程序和域模型层分离。 实现这些模式有助于使用模拟存储库来模拟对数据库的访问。
在图 7-18 中,可以看到不使用存储库(直接使用 EF DbContext)与使用存储库之间的差异,这使得模拟这些存储库更容易。
图 7-18. 使用自定义存储库与纯的DbContext进行比较
图 7-18 显示,使用自定义存储库会添加一个抽象层,该层可用于通过模拟存储库来简化测试。 模拟时有多个备选项。 可以模拟存储库,也可以模拟整个工作单元。 通常仅模拟存储库就足够了,抽象和模拟整个工作单元的复杂性通常不需要。
稍后,当我们专注于应用程序层时,你将看到依赖项注入在 ASP.NET Core 中的工作原理,以及如何使用存储库实现它。
简言之,自定义存储库允许使用不受数据层状态影响的单元测试更轻松地测试代码。 如果运行也通过实体框架访问实际数据库的测试,则它们不是单元测试,而是集成测试,这要慢得多。
如果直接使用 DbContext,你需要对其进行模拟,或者使用带有用于单元测试的可预测性数据的内存式 SQL Server 来运行单元测试。 但模拟 DbContext 或控制假数据所需完成的工作比在存储库级别进行模拟所需完成的工作多。 当然,你始终可以测试 MVC 控制器。
IoC 容器中的 EF DbContext 和 IUnitOfWork 实例生存期
DbContext
对象(作为 IUnitOfWork
对象公开)应在同一个 HTTP 请求范围内与多个存储库之间共享。 例如,当执行的操作必须处理多个聚合,或者仅仅因为你使用多个存储库实例时,这种情况就会出现。 同样重要的是, IUnitOfWork
接口是域层的一部分,而不是 EF Core 类型。
为此,对象的实例 DbContext
必须将其服务生存期设置为 ServiceLifetime.Scoped。 这是在您的 ASP.NET Core Web API 项目中从DbContext
文件向 IoC 容器注册 builder.Services.AddDbContext
时的默认生命周期。 以下代码演示了这一点。
// 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.
);
不应将 DbContext 实例化模式配置为 ServiceLifetime.Transient 或 ServiceLifetime.Singleton。
IoC 容器中的存储库实例生存期
同样地,通常应将存储库生存期设置为范围内(Autofac 中的 InstancePerLifetimeScope)。 也可设置为短暂(Autofac 中的 InstancePerDependency),但使用范围内生存期时服务在内存方面会更有效率。
// Registering a Repository in Autofac IoC container
builder.RegisterType<OrderRepository>()
.As<IOrderRepository>()
.InstancePerLifetimeScope();
DbContext 设置为范围内 (InstancePerLifetimeScope) 生存期(DBContext 的默认生存期)时,为存储库使用的单一生存期可能导致严重的并发问题。 只要存储库和 DbContext 的服务生存期都处于范围内,就可以避免这些问题。
其他资源
在 ASP.NET MVC 应用程序中实现存储库和工作单元模式
https://www.asp.net/mvc/overview/older-versions/getting-started-with-ef-5-using-mvc-4/implementing-the-repository-and-unit-of-work-patterns-in-an-asp-net-mvc-application乔纳森·艾伦 使用 Entity Framework、Dapper 和 Chain 的存储库模式实现策略
https://www.infoq.com/articles/repository-implementation-strategies塞萨尔·德拉托雷 比较 ASP.NET Core IoC 容器服务生存期与 Autofac IoC 容器实例范围
https://devblogs.microsoft.com/cesardelatorre/comparing-asp-net-core-ioc-service-life-times-and-autofac-ioc-instance-scopes/
表映射
表映射用于识别需要从数据库查询并保存至数据库的表数据。 以前,你了解了域实体(例如产品或订单域)如何用于生成相关的数据库架构。 EF 围绕 约定的概念进行强烈设计。 约定解决了“表的名称是什么?”或“主键是什么属性?”约定通常基于传统名称。 例如,主键通常是以 Id
结尾的属性。
按照约定,每个实体将设置为映射到名称与 DbSet<TEntity>
属性(公开派生上下文中的实体)相同的表中。 DbSet<TEntity>
如果未为给定实体提供任何值,则使用类名。
数据注释与 Fluent API
还有其他许多 EF Core 约定,大多数约定都可以通过使用数据注释或 Fluent API(在 OnModelCreating 方法中实现)进行更改。
数据注释必须用于实体模型类本身,这是从 DDD 的角度来看更具侵入性的方式。 这是因为你正在用与基础结构数据库相关的数据注释来污染模型。 另一方面,Fluent API 是一种在数据持久性基础结构层中更改大多数约定和映射的便捷方法,因此实体模型将与持久性基础结构分离。
Fluent API 和 OnModelCreating 方法
如前所述,若要更改约定和映射,可以在 DbContext 类中使用 OnModelCreating 方法。
eShopOnContainers 中的排序微服务在需要时实现显式映射和配置,如以下代码所示。
// 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");
}
}
可以在同一 OnModelCreating
方法中设置所有 Fluent API 映射,但建议对代码进行分区并具有多个配置类(每个实体一个),如示例中所示。 尤其是对于大型模型,建议使用单独的配置类来配置不同的实体类型。
示例中的代码显示了一些显式声明和映射。 但是,EF Core 约定会自动执行其中许多映射,因此在这种情况下所需的实际代码可能更小。
EF Core 中的 Hi/Lo 算法
前面示例中代码的一个有趣方面是,它使用 Hi/Lo 算法 作为关键生成策略。
在提交更改之前,需要唯一密钥时,Hi/Lo 算法非常有用。 总之,Hi-Lo 算法将唯一标识符分配给表行,而不依赖于立即将行存储在数据库中。 这样就可以立即开始使用标识符,就像常规顺序数据库 ID 一样。
Hi/Lo 算法描述从相关数据库序列获取一批唯一 ID 的机制。 这些 ID 是安全的,因为数据库保证唯一性,因此用户之间不会发生冲突。 出于以下原因,此算法很有趣:
它不会破坏事务单元模式。
它批量获取序列 ID,以最大程度减少与数据库之间的往返次数。
它生成人类可读标识符,这与使用 GUID 的技术不同。
EF Core 支持HiLo与UseHiLo
方法一起使用,如前面的示例所示。
映射字段(而非属性)
借助此功能,自 EF Core 1.1 起可用,可以直接将列映射到字段。 不能在实体类中使用属性,而只能将表中的列映射到字段。 一个常见的用途是为不需要从实体外部访问的任何内部状态设置私有字段。
您可以使用单个字段或集合,例如 List<>
字段,进行此操作。 我们在前面讨论域模型类建模的时候提到过这一点,但在这里,你可以看到如何通过前面的代码中突出显示的 PropertyAccessMode.Field
配置来执行映射。
在 EF Core 中使用隐藏在基础结构级别的阴影属性
EF Core 中的阴影属性是实体类模型中不存在的属性。 这些属性的值和状态纯粹保留在基础结构级别的 ChangeTracker 类中。
实现查询规范模式
如在设计部分前面介绍的那样,查询规范模式是一种 Domain-Driven 设计模式,旨在提供一个位置,用于定义具有可选排序和分页逻辑的查询。
查询规范模式定义对象中的查询。 例如,为了封装搜索某些产品的分页查询,可以创建一个 PagedProduct 规范,该规范采用必要的输入参数(pageNumber、pageSize、filter 等)。 然后,在任何存储库方法(通常为 List() 重载)内,它会接受 IQuerySpecification 并基于该规范运行预期的查询。
泛型规范接口的一个示例是以下代码,它类似于 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; }
}
然后,泛型规范基类的实现如下。
// 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
// for example, Basket.Items.Product
protected virtual void AddInclude(string includeString)
{
IncludeStrings.Add(includeString);
}
}
以下规范在给定购物篮 ID 或购物篮所属买家的 ID 情况下来加载单个购物篮实体。 它将立即加载购物篮的 Items
集合。
// 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);
}
}
最后,可以在下面查看泛型 EF 存储库如何使用此类规范来筛选和预先加载与给定实体类型 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();
}
除了封装筛选逻辑之外,规范还可以指定要返回的数据的形状,包括要填充的属性。
虽然不建议从存储库返回 IQueryable
,但最好在存储库中使用它们来生成一组结果。 可以在上面的 List 方法中看到此方法,该方法使用中间 IQueryable
表达式来构建查询的包含列表,然后再在最后一行中使用规范的条件执行查询。
了解如何 在 eShopOnWeb 示例中应用规范模式。
其他资源
表格映射
https://learn.microsoft.com/ef/core/modeling/relational/tables使用 HiLo 通过 Entity Framework Core 生成密钥
https://www.talkingdotnet.com/use-hilo-to-generate-keys-with-entity-framework-core/支持字段
https://learn.microsoft.com/ef/core/modeling/backing-field史蒂夫·史密斯 Entity Framework Core 中的封装集合
https://ardalis.com/encapsulated-collections-in-entity-framework-core阴影属性
https://learn.microsoft.com/ef/core/modeling/shadow-properties规范模式
https://deviq.com/specification-pattern/Ardalis.Specification NuGet 包 由 eShopOnWeb 使用。 \ https://www.nuget.org/packages/Ardalis.Specification