使用 Entity Framework Core 实现基础设施持久化层

When you use relational databases such as SQL Server, Oracle, or PostgreSQL, a recommended approach is to implement the persistence layer based on Entity Framework (EF). EF supports LINQ and provides strongly typed objects for your model, as well as simplified persistence into your database.

当你使用 SQL Server、Oracle 或 PostgreSQL 等关系型数据库时,一个推荐的做法是基于 Entity Framework (EF) 来实现持久化层。EF 支持 LINQ,能为你的模型提供强类型对象,并简化向数据库的持久化操作。

Entity Framework has a long history as part of the .NET Framework. When you use .NET, you should also use Entity Framework Core, which runs on Windows or Linux in the same way as .NET. EF Core is a complete rewrite of Entity Framework that's implemented with a much smaller footprint and important improvements in performance.

Entity Framework 作为 .NET Framework 的一部分,有着悠久的历史。当你使用 .NET 时,你应该同时使用 Entity Framework Core,它可以像 .NET 一样在 Windows 或 Linux 上运行。EF Core 是对 Entity Framework 的完全重写,它的占用空间更小,并且在性能上有了显著的提升。

Introduction to Entity Framework Core    Entity Framework Core 简介

Entity Framework (EF) Core is a lightweight, extensible, and cross-platform version of the popular Entity Framework data access technology. It was introduced with .NET Core in mid-2016.

Entity Framework (EF) Core 是广受欢迎的数据访问技术 Entity Framework 的一个轻量级、可扩展且跨平台的版本。它于 2016 年中期随 .NET Core 一同推出。

Since an introduction to EF Core is already available in Microsoft documentation, here we simply provide links to that information.

由于微软的官方文档中已经提供了关于 EF Core 的详细介绍,我们这里就不再赘述,仅提供相关信息的链接:

Infrastructure in Entity Framework Core from a DDD perspective    从 DDD 视角看 Entity Framework Core 的基础设施

From a DDD point of view, an important capability of EF is the ability to use POCO domain entities, also known in EF terminology as POCO code-first entities. If you use POCO domain entities, your domain model classes are persistence-ignorant, following the Persistence Ignorance and the Infrastructure Ignorance principles.

从 DDD 的角度来看,EF 有一个非常重要的能力,那就是支持使用 POCO 领域实体,在 EF 的术语中也称为 POCO 代码优先(code-first)实体。如果你使用 POCO 领域实体,你的领域模型类就可以做到持久化无关(persistence-ignorant),这遵循了“持久化无关”和“基础设施无关”的原则。

Per DDD patterns, you should encapsulate domain behavior and rules within the entity class itself, so it can control invariants, validations, and rules when accessing any collection. Therefore, it is not a good practice in DDD to allow public access to collections of child entities or value objects. Instead, you want to expose methods that control how and when your fields and property collections can be updated, and what behavior and actions should occur when that happens.

按照 DDD 模式,你应该将领域行为和规则封装在实体类本身内部,这样当访问任何集合时,实体就能控制自身的不变量、验证和规则。因此,在 DDD 中,允许公开访问子实体或值对象的集合并不是一个好的实践。相反,你希望通过暴露方法来控制字段和属性集合的更新时机与方式,以及当更新发生时应该触发什么样的行为和动作。

Since EF Core 1.1, to satisfy those DDD requirements, you can have plain fields in your entities instead of public properties. If you do not want an entity field to be externally accessible, you can just create the attribute or field instead of a property. You can also use private property setters.

从 EF Core 1.1 开始,为了满足这些 DDD 需求,你可以在实体中使用普通的字段(fields)来代替公开的属性(properties)。如果你不希望某个实体字段被外部访问,只需直接创建该属性或字段,而不必将其写成一个属性。你也可以使用私有的属性设置器(private property setters)。

In a similar way, you can now have read-only access to collections by using a public property typed as IReadOnlyCollection<T>, which is backed by a private field member for the collection (like a List<T>) in your entity that relies on EF for persistence. Previous versions of Entity Framework required collection properties to support ICollection<T>, which meant that any developer using the parent entity class could add or remove items through its property collections. That possibility would be against the recommended patterns in DDD.

同样地,你现在也可以通过使用 IReadOnlyCollection<T> 类型的公开属性来实现对集合的只读访问,该属性在内部由实体中的一个私有集合字段成员(比如 List<T>)作为支撑,并依赖 EF 进行持久化。早期版本的 Entity Framework 要求集合属性必须支持 ICollection<T>,这意味着任何使用父实体类的开发人员都可以通过其属性集合来添加或删除项目。这种可能性是与 DDD 的推荐模式相违背的。

You can use a private collection while exposing a read-only IReadOnlyCollection<T> object, as shown in the following code example:

你可以像下面这段代码示例展示的那样,在内部使用私有集合,同时对外暴露一个只读的 IReadOnlyCollection<T> 对象:

/// <summary>
/// 订单实体类,继承自基础实体基类 Entity(采用 DDD 风格设计)
/// </summary>
public class Order : Entity
{
    // 使用私有字段来存储数据,这在 EF Core 1.1 及更高版本中是被允许的
    private DateTime _orderDate;

    // 其他私有字段...

    /// <summary>
    /// 订单项的私有只读列表。使用 readonly 确保集合引用本身不会被替换,
    /// 从而防止外部代码绕过业务逻辑直接篡改集合内容
    /// </summary>
    private readonly List<OrderItem> _orderItems;

    /// <summary>
    /// 以 IReadOnlyCollection 形式暴露订单项集合,仅允许外部进行安全的读取操作
    /// </summary>
    public IReadOnlyCollection<OrderItem> OrderItems => _orderItems;

    /// <summary>
    /// EF Core 所需的受保护无参构造函数。用于框架在查询数据库时实例化对象,
    /// protected 修饰符既满足了 EF Core 的需求,又阻止了类外部的非法默认实例化
    /// </summary>
    protected Order() { }

    /// <summary>
    /// 创建新订单的公共构造函数。通过此方法初始化订单的基础信息,
    /// 确保订单在被创建时就满足所有必要的业务规则
    /// </summary>
    /// <param name="buyerId">购买者的唯一标识</param>
    /// <param name="paymentMethodId">支付方式的唯一标识</param>
    /// <param name="address">收货地址信息</param>
    public Order(int buyerId, int paymentMethodId, Address address)
    {
        // 在此处执行基础属性的初始化逻辑...
    }

    /// <summary>
    /// 向当前订单中添加一个新的订单项。该方法封装了添加商品的业务逻辑,
    /// 确保外部只能通过此受控方法来修改订单内容
    /// </summary>
    /// <param name="productId">商品 ID</param>
    /// <param name="productName">商品名称</param>
    /// <param name="unitPrice">单价</param>
    /// <param name="discount">折扣金额</param>
    /// <param name="pictureUrl">商品图片链接</param>
    /// <param name="units">购买数量,默认为 1</param>
    public void AddOrderItem(int productId, string productName,
                             decimal unitPrice, decimal discount,
                             string pictureUrl, int units = 1)
    {
        // 在此处执行参数校验与业务验证逻辑...

        // 根据传入的参数创建一个有效的订单项实体
        var orderItem = new OrderItem(productId, productName,
                                      unitPrice, discount,
                                      pictureUrl, units);

        // 将新建的订单项添加到私有的集合中
        _orderItems.Add(orderItem);
    }
}

The OrderItems property can only be accessed as read-only using IReadOnlyCollection<OrderItem>. This type is read-only so it is protected against regular external updates.

OrderItems 属性只能通过 IReadOnlyCollection<OrderItem> 以只读的方式进行访问。这种类型是只读的,因此可以防止常规的外部更新操作。

EF Core provides a way to map the domain model to the physical database without "contaminating" the domain model. It is pure .NET POCO code, because the mapping action is implemented in the persistence layer. In that mapping action, you need to configure the fields-to-database mapping. In the following example of the OnModelCreating method from OrderingContext and the OrderEntityTypeConfiguration class, the call to SetPropertyAccessMode tells EF Core to access the OrderItems property through its field.

EF Core 提供了一种方法,可以在不“污染”领域模型的前提下,将其映射到物理数据库。它完全是纯粹的 .NET POCO 代码,因为映射动作是在持久化层中实现的。在这个映射动作中,你需要配置字段与数据库的映射关系。在下面来自 OrderingContext OnModelCreating 方法以及 OrderEntityTypeConfiguration 类的示例中,调用 SetPropertyAccessMode 就是为了告诉 EF Core 通过其字段(field)来访问 OrderItems 属性。

/// <summary>
/// 订单上下文类(继承自 DbContext),用于配置实体模型与数据库的映射关系。
/// (注:此代码片段提取自 eShopOnContainers 项目中的 OrderingContext.cs)
/// </summary>
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    // ...

    // 将 Order 实体的配置逻辑委托给独立的 OrderEntityTypeConfiguration 类进行处理,保持代码整洁
    modelBuilder.ApplyConfiguration(new OrderEntityTypeConfiguration());

    // 应用其他实体的配置...
}


/// <summary>
/// Order 实体的类型配置类,实现了 EF Core 的 IEntityTypeConfiguration 接口,
/// 用于集中定义 Order 实体到数据库表的具体映射规则。
/// (注:此代码片段提取自 eShopOnContainers 项目中的 OrderEntityTypeConfiguration.cs)
/// </summary>
class OrderEntityTypeConfiguration : IEntityTypeConfiguration<Order>
{
    /// <summary>
    /// 配置 Order 实体的属性、表名及导航属性的访问模式等。
    /// </summary>
    /// <param name="orderConfiguration">Order 实体的构建器</param>
    public void Configure(EntityTypeBuilder<Order> orderConfiguration)
    {
        // 指定该实体映射到的数据库表名为 "orders",并设置其所属的默认架构(Schema)
        orderConfiguration.ToTable("orders", OrderingContext.DEFAULT_SCHEMA);

        // 其他常规属性配置...

        // 查找 Order 实体中名为 "OrderItems" 的导航属性元数据
        var navigation =
              orderConfiguration.Metadata.FindNavigation(nameof(Order.OrderItems));

        // 核心配置:指示 EF Core 在读写 OrderItem 集合时,直接通过底层的私有后备字段(backing field)进行访问。
        // 这样配合 DDD 风格的实体设计,可以防止外部代码绕过业务逻辑直接操作集合,确保数据的封装性。
        navigation.SetPropertyAccessMode(PropertyAccessMode.Field);

        // 其他配置...
    }
}

When you use fields instead of properties, the OrderItem entity is persisted as if it had a List<OrderItem> property. However, it exposes a single accessor, the AddOrderItem method, for adding new items to the order. As a result, behavior and data are tied together and will be consistent throughout any application code that uses the domain model.

当你使用字段(fields)来代替属性(properties)时,OrderItem 实体的持久化效果,就好像它拥有一个 List<OrderItem> 属性一样。然而,它只对外暴露了一个访问器(accessor),也就是 AddOrderItem 方法,用于向订单中添加新条目。这样一来,行为和(数据)就紧密绑定在了一起,并且在使用该领域模型的任何应用程序代码中,都能始终保持一致性。

Implement custom repositories with Entity Framework Core    使用 Entity Framework Core 实现自定义仓储

At the implementation level, a repository is simply a class with data persistence code coordinated by a unit of work (DBContext in EF Core) when performing updates, as shown in the following class:

从实现层面来看,仓储其实就是一个简单的类。当执行更新操作时,它内部包含的数据持久化代码会由一个工作单元(在 EF Core 中就是 DBContext)来进行协调,如下面的类所示:

// using 指令...

namespace Microsoft.eShopOnContainers.Services.Ordering.Infrastructure.Repositories
{
    /// <summary>
    /// 买家仓储类,负责处理与 Buyer(买家)相关的数据访问操作。
    /// 该类实现了 IBuyerRepository 接口,遵循仓储模式(Repository Pattern)。
    /// </summary>
    public class BuyerRepository : IBuyerRepository
    {
        /// <summary>
        /// EF Core 数据库上下文,用于与底层数据库进行交互
        /// </summary>
        private readonly OrderingContext _context;

        /// <summary>
        /// 获取当前仓储关联的工作单元(Unit of Work)。
        /// 在 DDD 和 EF Core 中,通常将 DbContext 作为工作单元的实现,以管理事务和变更跟踪。
        /// </summary>
        public IUnitOfWork UnitOfWork
        {
            get
            {
                // 直接返回当前的数据库上下文实例
                return _context;
            }
        }

        /// <summary>
        /// 构造函数,通过依赖注入获取数据库上下文。
        /// </summary>
        /// <param name="context">订单服务的数据库上下文</param>
        /// <exception cref="ArgumentNullException">当传入的 context 为 null 时抛出异常</exception>
        public BuyerRepository(OrderingContext context)
        {
            // 确保注入的上下文不为空,否则抛出参数为空异常
            _context = context ?? throw new ArgumentNullException(nameof(context));
        }

        /// <summary>
        /// 添加一个新的买家实体到数据库中。
        /// </summary>
        /// <param name="buyer">要添加的买家实体对象</param>
        /// <returns>返回被 EF Core 追踪的买家实体</returns>
        public Buyer Add(Buyer buyer)
        {
            // 将买家实体添加到上下文的 Buyers 集合中,并返回被追踪的实体引用
            return _context.Buyers.Add(buyer).Entity;
        }

        /// <summary>
        /// 根据买家的身份标识 GUID 异步查找买家信息。
        /// </summary>
        /// <param name="buyerIdentityGuid">买家的身份标识 GUID(对应 FullName 字段)</param>
        /// <returns>如果找到则返回包含支付信息的买家实体,否则返回 null</returns>
        public async Task<Buyer> FindAsync(string buyerIdentityGuid)
        {
            // 从数据库上下文中查询买家:
            // 1. Include: 贪婪加载该买家关联的 Payments(支付记录)导航属性
            // 2. Where: 根据传入的身份标识 GUID 过滤匹配 FullName 字段的记录
            // 3. SingleOrDefaultAsync: 异步执行查询,期望结果最多只有一条,若无则返回默认值(null)
            var buyer = await _context.Buyers
                .Include(b => b.Payments)
                .Where(b => b.FullName == buyerIdentityGuid)
                .SingleOrDefaultAsync();

            // 返回查询到的买家实体
            return buyer;
        }
    }
}

The IBuyerRepository interface comes from the domain model layer as a contract. However, the repository implementation is done at the persistence and infrastructure layer.

IBuyerRepository 接口源自领域模型层,作为一种契约。然而,仓储的具体实现是在持久化层和基础设施层完成的。

The EF DbContext comes through the constructor through Dependency Injection. It is shared between multiple repositories within the same HTTP request scope, thanks to its default lifetime (ServiceLifetime.Scoped) in the IoC container (which can also be explicitly set with services.AddDbContext<>).

EF DbContext 通过构造函数经由依赖注入(Dependency Injection)传入。 得益于其在 IoC 容器中的默认生命周期(ServiceLifetime.Scoped)(也可以通过 services.AddDbContext<> 显式设置),它可以在同一个 HTTP 请求作用域内的多个仓储之间共享。

Methods to implement in a repository (updates or transactions versus queries)    在仓储中需要实现的方法(更新/事务与查询的区分)

Within each repository class, you should put the persistence methods that update the state of entities contained by its related aggregate. Remember there is one-to-one relationship between an aggregate and its related repository. Consider that an aggregate root entity object might have embedded child entities within its EF graph. For example, a buyer might have multiple payment methods as related child entities.

在每个仓储类中,你应该放入那些用于更新其相关聚合所包含实体状态的持久化方法。请记住,聚合与它的相关仓储之间是一对一的关系。请注意,一个聚合根实体对象在其 EF 图形中可能包含嵌入的子实体。例如,一个买家(buyer)可能拥有多个支付方式(payment methods)作为相关的子实体。

Since the approach for the ordering microservice in eShopOnContainers is also based on CQS/CQRS, most of the queries are not implemented in custom repositories. Developers have the freedom to create the queries and joins they need for the presentation layer without the restrictions imposed by aggregates, custom repositories per aggregate, and DDD in general. Most of the custom repositories suggested by this guide have several update or transactional methods but just the query methods needed to get data to be updated. For example, the BuyerRepository repository implements a FindAsync method, because the application needs to know whether a particular buyer exists before creating a new buyer related to the order.

由于 eShopOnContainers 中订单微服务(ordering microservice)的方法也是基于 CQS/CQRS(命令查询职责分离)的,因此大多数查询并没有在自定义仓储中实现。开发者可以自由地为表现层创建所需的查询和连接(joins),而不受聚合、每个聚合的自定义仓储以及 DDD 通用规则的限制。本指南建议的大多数自定义仓储包含多个更新或事务方法,但仅保留一个获取数据以进行更新所需的查询方法(即查询方法只有一个,更新或事务方法有多个)。例如,BuyerRepository 仓储实现了一个 FindAsync 方法,因为应用程序在创建与订单相关的新买家之前,需要知道该特定买家是否已经存在。

However, the real query methods to get data to send to the presentation layer or client apps are implemented, as mentioned, in the CQRS queries based on flexible queries using Dapper.

然而,正如前面提到的,用于获取发送到表现层或客户端应用程序数据的真正查询方法,是基于灵活的查询使用 Dapper 在 CQRS 查询中实现的。

Using a custom repository versus using EF DbContext directly    使用自定义仓储与直接使用 EF DbContext 的对比

The Entity Framework DbContext class is based on the Unit of Work and Repository patterns and can be used directly from your code, such as from an ASP.NET Core MVC controller. The Unit of Work and Repository patterns result in the simplest code, as in the CRUD catalog microservice in eShopOnContainers. In cases where you want the simplest code possible, you might want to directly use the DbContext class, as many developers do.

Entity Framework 的 DbContext 类基于工作单元(Unit of Work)和仓储模式,可以直接在代码中使用,例如从 ASP.NET Core MVC 控制器中直接调用。工作单元和仓储模式能产生最简单的代码,就像 eShopOnContainers 中的 CRUD 目录微服务(catalog microservice)那样。在你希望代码尽可能简单的情况下,你可能希望直接使用 DbContext 类,就像许多开发者所做的那样。

However, implementing custom repositories provides several benefits when implementing more complex microservices or applications. The Unit of Work and Repository patterns are intended to encapsulate the infrastructure persistence layer so it is decoupled from the application and domain-model layers. Implementing these patterns can facilitate the use of mock repositories simulating access to the database.

然而,在实现更复杂的微服务或应用程序时,实现自定义仓储提供了几个优势。工作单元和仓储模式旨在封装基础设施持久化层,使其与应用程序层和领域模型层解耦。实现这些模式有助于使用模拟仓储(mock repositories)来模拟对数据库的访问。

In Figure 7-18, you can see the differences between not using repositories (directly using the EF DbContext) versus using repositories, which makes it easier to mock those repositories.

在图 7-18 中,你可以看到不使用仓储(直接使用 EF DbContext)与使用仓储之间的区别,后者使得模拟这些仓储变得更加容易。

image

Figure 7-18. Using custom repositories versus a plain DbContext

图 7-18。使用自定义仓储与直接使用原生 DbContext 的对比

Figure 7-18 shows that using a custom repository adds an abstraction layer that can be used to ease testing by mocking the repository. There are multiple alternatives when mocking. You could mock just repositories or you could mock a whole unit of work. Usually mocking just the repositories is enough, and the complexity to abstract and mock a whole unit of work is usually not needed.

图 7-18 展示了使用自定义仓储如何增加了一个抽象层,该抽象层可以通过模拟(Mocking)仓储来简化测试工作。在进行模拟时有多种选择:你可以仅模拟仓储,也可以模拟整个工作单元(Unit of Work)。通常情况下,仅模拟仓储就足够了,而要抽象并模拟整个工作单元往往过于复杂,并非必需。

Later, when we focus on the application layer, you will see how Dependency Injection works in ASP.NET Core and how it is implemented when using repositories.

稍后,当我们聚焦于应用层时,你将看到依赖注入在 ASP.NET Core 中是如何工作的,以及在使用仓储时它又是如何被实现的。

In short, custom repositories allow you to test code more easily with unit tests that are not impacted by the data tier state. If you run tests that also access the actual database through the Entity Framework, they are not unit tests but integration tests, which are a lot slower.

简而言之,自定义仓储允许你通过单元测试更轻松地测试代码,而这些单元测试不会受到数据层状态的影响。如果你运行的测试通过 Entity Framework 访问了实际数据库,那么它们就不是单元测试,而是集成测试,速度要慢得多。

If you were using DbContext directly, you would have to mock it or to run unit tests by using an in-memory SQL Server with predictable data for unit tests. But mocking the DbContext or controlling fake data requires more work than mocking at the repository level. Of course, you could always test the MVC controllers.

如果你直接使用 DbContext,你将不得不对其进行模拟,或者通过使用内存中的 SQL Server(为单元测试提供可预测的数据)来运行单元测试。但模拟 DbContext 或管理模拟数据所需的工作量,要大于在仓储级别进行模拟的工作量。当然,你始终可以直接测试 MVC 控制器。

EF DbContext and IUnitOfWork instance lifetime in your IoC container    在你的 IoC 容器中,EF DbContext 和 IUnitOfWork 实例的生命周期

The DbContext object (exposed as an IUnitOfWork object) should be shared among multiple repositories within the same HTTP request scope. For example, this is true when the operation being executed must deal with multiple aggregates, or simply because you are using multiple repository instances. It is also important to mention that the IUnitOfWork interface is part of your domain layer, not an EF Core type.

DbContext 对象(作为 IUnitOfWork 对象暴露出来)应该在同一个 HTTP 请求作用域内的多个仓储之间共享。例如,当正在执行的操作必须处理多个聚合,或者仅仅因为你正在使用多个仓储实例时,就需要这样做。同样重要的是要提到,IUnitOfWork 接口属于你的领域层,而不是 EF Core 的类型。

In order to do that, the instance of the DbContext object has to have its service lifetime set to ServiceLifetime.Scoped. This is the default lifetime when registering a DbContext with builder.Services.AddDbContext in your IoC container from the Program.cs file in your ASP.NET Core Web API project. The following code illustrates this.

为了实现这一点,DbContext 对象的实例必须将其服务生命周期(Service Lifetime)设置为 ServiceLifetime.Scoped。这是在 ASP.NET Core Web API 项目的 Program.cs 文件中,通过 builder.Services.AddDbContextDbContext 注册到 IoC 容器时的默认生命周期。以下代码说明了这一点。

// 添加框架级别的 MVC 服务
builder.Services.AddMvc(options =>
{
    // 注册全局异常过滤器,用于统一捕获和处理 HTTP 请求中的未处理异常
    options.Filters.Add(typeof(HttpGlobalExceptionFilter));
})
// 将控制器作为服务注入到依赖注入容器中,以支持在控制器中通过构造函数注入其他服务
.AddControllersAsServices();

// 添加 Entity Framework Core 的 SQL Server 提供程序服务,并配置数据库上下文
builder.Services.AddEntityFrameworkSqlServer()
    .AddDbContext<OrderingContext>(options =>
    {
        // 配置使用 SQL Server 数据库,连接字符串从 Configuration(如 appsettings.json)中读取
        options.UseSqlServer(Configuration["ConnectionString"],
            sqlOptions => 
                // 指定 EF Core 迁移文件所在的程序集名称,确保运行时能正确找到和应用数据库迁移
                sqlOptions.MigrationsAssembly(typeof(Startup).GetTypeInfo().
                                                Assembly.GetName().Name));
    },
    // 设置 DbContext 的生命周期为 Scoped(每次 HTTP 请求创建一个新实例)。
    // 注意:Scoped 是 AddDbContext 的默认选择,这里显式声明仅出于教学演示目的。
    ServiceLifetime.Scoped 
);

The DbContext instantiation mode should not be configured as ServiceLifetime.Transient or ServiceLifetime.Singleton.

DbContext 的实例化模式不应该被配置为 ServiceLifetime.Transient 或 ServiceLifetime.Singleton

The repository instance lifetime in your IoC container    在你的 IoC 容器中仓储实例的生命周期

In a similar way, repository's lifetime should usually be set as scoped (InstancePerLifetimeScope in Autofac). It could also be transient (InstancePerDependency in Autofac), but your service will be more efficient in regards to memory when using the scoped lifetime.

同样地,仓储的生命周期通常也应该设置为 Scoped(在 Autofac 中对应 InstancePerLifetimeScope)。当然,它也可以设置为 Transient(在 Autofac 中对应 InstancePerDependency),但如果使用 Scoped 生命周期,你的服务在内存使用方面会更加高效。

// 在 Autofac IoC(控制反转)容器中注册仓储(Repository)

// 1. 指定要注册的具体实现类为 OrderRepository
builder.RegisterType<OrderRepository>()
    // 2. 将其映射到 IOrderRepository 接口,以便后续可以通过该接口进行依赖注入
    .As<IOrderRepository>()
    // 3. 设置生命周期作用域为“每个生命周期范围一个实例”(类似于 Scoped 模式),
    //    这意味着在同一个请求或生命周期内共享同一个实例,有助于提高性能并保证状态一致性
    .InstancePerLifetimeScope();

Using the singleton lifetime for the repository could cause you serious concurrency problems when your DbContext is set to scoped (InstancePerLifetimeScope) lifetime (the default lifetimes for a DBContext). As long as your service lifetimes for your repositories and your DbContext are both Scoped, you'll avoid these issues.

如果你把仓储(Repository)的生命周期设置为单例(Singleton),而你的 DbContext 又是 Scoped(InstancePerLifetimeScope)生命周期(这是 DbContext 的默认生命周期),那么可能会引发严重的并发问题。只要确保你的仓储服务和 DbContext 的生命周期都是 Scoped,就能避免这些问题。

Table mapping    表映射

Table mapping identifies the table data to be queried from and saved to the database. Previously you saw how domain entities (for example, a product or order domain) can be used to generate a related database schema. EF is strongly designed around the concept of conventions. Conventions address questions like "What will the name of a table be?" or "What property is the primary key?" Conventions are typically based on conventional names. For example, it is typical for the primary key to be a property that ends with Id.

表映射用于确定要从哪些表中查询数据,以及将数据保存到哪些表中。之前你已经了解到,领域实体(例如产品或订单领域)可以用来生成相关的数据库架构。EF 的设计非常依赖于“约定(conventions)”的概念。这些约定解决了一些类似“表应该叫什么名字?”或者“哪个属性是主键?”的问题。这些约定通常基于常规的命名习惯。例如,主键通常是一个以 Id 结尾的属性。

By convention, each entity will be set up to map to a table with the same name as the DbSet<TEntity> property that exposes the entity on the derived context. If no DbSet<TEntity> value is provided for the given entity, the class name is used.

按照约定,每个实体都会被映射到一个表,该表的名称与派生上下文(derived context)中用于暴露该实体的 DbSet<TEntity> 属性名称相同。如果没有为该实体提供 DbSet<TEntity> 值,则会直接使用类名。

Data Annotations versus Fluent API    数据注解(Data Annotations)与 Fluent API

There are many additional EF Core conventions, and most of them can be changed by using either data annotations or Fluent API, implemented within the OnModelCreating method.

EF Core 还有许多其他的约定,而其中大多数都可以通过“数据注解”或在 OnModelCreating 方法中实现的“Fluent API”来进行更改。

Data annotations must be used on the entity model classes themselves, which is a more intrusive way from a DDD point of view. This is because you are contaminating your model with data annotations related to the infrastructure database. On the other hand, Fluent API is a convenient way to change most conventions and mappings within your data persistence infrastructure layer, so the entity model will be clean and decoupled from the persistence infrastructure.

数据注解必须直接用在实体模型类上,从 DDD 的角度来看,这是一种侵入性更强的方式。这是因为你正在用与基础设施数据库相关的注解来“污染”你的领域模型。相比之下,Fluent API 是一种更便捷的方式,可以在数据持久化基础设施层内更改大多数约定和映射,从而保证实体模型的整洁,使其与持久化基础设施解耦。

Fluent API and the OnModelCreating method    Fluent API 与 OnModelCreating 方法

As mentioned, in order to change conventions and mappings, you can use the OnModelCreating method in the DbContext class.

正如前面提到的,为了更改约定和映射,你可以在 DbContext 类中使用 OnModelCreating 方法。

The ordering microservice in eShopOnContainers implements explicit mapping and configuration, when needed, as shown in the following code.

eShopOnContainers 中的订单微服务(ordering microservice)在需要时实现了显式的映射和配置,如下面的代码所示。

/// <summary>
/// 订单上下文配置类(节选自 eShopOnContainers 的 OrderingContext.cs)
/// </summary>
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    // ...

    // 应用 Order 实体的 EF Core 映射配置
    modelBuilder.ApplyConfiguration(new OrderEntityTypeConfiguration());

    // 其他实体的映射配置 ...
}

/// <summary>
/// Order 实体类型配置类,实现了 IEntityTypeConfiguration 接口以分离映射逻辑
/// (节选自 eShopOnContainers 的 OrderEntityTypeConfiguration.cs)
/// </summary>
class OrderEntityTypeConfiguration : IEntityTypeConfiguration<Order>
{
    /// <summary>
    /// 配置 Order 实体的数据库表结构、属性映射及关系
    /// </summary>
    public void Configure(EntityTypeBuilder<Order> orderConfiguration)
    {
        // 指定该实体映射到数据库中的 "orders" 表,并使用默认的 Schema
        orderConfiguration.ToTable("orders", OrderingContext.DEFAULT_SCHEMA);

        // 设置 Id 属性为主键
        orderConfiguration.HasKey(o => o.Id);

        // 忽略 DomainEvents 属性,因为领域事件属于内存中的行为,不需要持久化到数据库
        orderConfiguration.Ignore(b => b.DomainEvents);

        // 配置 Id 字段使用 HiLo(高低位)算法生成主键值,避免频繁访问数据库自增序列
        orderConfiguration.Property(o => o.Id)
            .UseHiLo("orderseq", OrderingContext.DEFAULT_SCHEMA);

        // Address 是一个值对象(Value Object),自 EF Core 2.0 起支持将其作为 Owned Entity(从属实体类型)进行持久化
        orderConfiguration
            .OwnsOne(o => o.Address, a =>
            {
                // 建立与所有者(Order)的关联
                a.WithOwner();
            });

        // 配置私有字段 _buyerId 映射到 BuyerId 列。
        // 使用 PropertyAccessMode.Field 允许 EF Core 直接通过字段而非属性来读写数据,从而绕过 DDD 风格实体中的私有 setter 限制
        orderConfiguration
            .Property<int?>("_buyerId")
            .UsePropertyAccessMode(PropertyAccessMode.Field)
            .HasColumnName("BuyerId")
            .IsRequired(false); // 买家 ID 非必填

        // 同样地,将私有字段 _orderDate 映射到 OrderDate 列,并设置为必填项
        orderConfiguration
            .Property<DateTime>("_orderDate")
            .UsePropertyAccessMode(PropertyAccessMode.Field)
            .HasColumnName("OrderDate")
            .IsRequired();

        // 将私有字段 _orderStatusId 映射到 OrderStatusId 列,并设置为必填项
        orderConfiguration
            .Property<int>("_orderStatusId")
            .UsePropertyAccessMode(PropertyAccessMode.Field)
            .HasColumnName("OrderStatusId")
            .IsRequired();

        // 将私有字段 _paymentMethodId 映射到 PaymentMethodId 列,支付方式非必填
        orderConfiguration
            .Property<int?>("_paymentMethodId")
            .UsePropertyAccessMode(PropertyAccessMode.Field)
            .HasColumnName("PaymentMethodId")
            .IsRequired(false);

        // Description 属性为非必填字符串字段
        orderConfiguration.Property<string>("Description").IsRequired(false);

        // 获取 OrderItems 集合导航属性的元数据信息
        var navigation = orderConfiguration.Metadata.FindNavigation(nameof(Order.OrderItems));

        // DDD 模式注释:
        // 将导航属性的访问模式设置为 Field(EF 1.1 引入的新特性)。
        // 这样 EF Core 就可以直接通过底层字段(如 HashSet)来操作集合,而不是调用公开的只读属性,确保外部无法绕过业务逻辑修改集合
        navigation.SetPropertyAccessMode(PropertyAccessMode.Field);

        // 配置 Order 与 PaymentMethod 之间的一对多关系(一个支付方式对应多个订单)
        orderConfiguration.HasOne<PaymentMethod>()
            .WithMany()
            .HasForeignKey("_paymentMethodId") // 指定外键为私有字段 _paymentMethodId
            .IsRequired(false)                 // 该关系非强制
            .OnDelete(DeleteBehavior.Restrict); // 删除时采取 Restrict 策略,防止级联删除引发意外数据丢失

        // 配置 Order 与 Buyer 之间的一对多关系
        orderConfiguration.HasOne<Buyer>()
            .WithMany()
            .IsRequired(false)           // 买家信息非强制
            .HasForeignKey("_buyerId");  // 指定外键为私有字段 _buyerId

        // 配置 Order 与 OrderStatus 之间的一对多关系
        orderConfiguration.HasOne(o => o.OrderStatus)
            .WithMany()
            .HasForeignKey("_orderStatusId"); // 指定外键为私有字段 _orderStatusId
    }
}

You could set all the Fluent API mappings within the same OnModelCreating method, but it's advisable to partition that code and have multiple configuration classes, one per entity, as shown in the example. Especially for large models, it is advisable to have separate configuration classes for configuring different entity types.

你可以将所有的 Fluent API 映射都写在同一个 OnModelCreating 方法中,但更建议对代码进行分区,并为每个实体设置独立的配置类,就像示例中展示的那样。特别是对于大型模型而言,为不同的实体类型使用独立的配置类是更明智的做法。

The code in the example shows a few explicit declarations and mapping. However, EF Core conventions do many of those mappings automatically, so the actual code you would need in your case might be smaller.

示例中的代码展示了一些显式的声明和映射。然而,EF Core 的约定(conventions)其实会自动完成其中的许多映射工作,因此在你的实际场景中,需要编写的代码量可能会更少。

The Hi/Lo algorithm in EF Core    EF Core 中的 Hi/Lo 算法

An interesting aspect of code in the preceding example is that it uses the Hi/Lo algorithm as the key generation strategy.

上述代码中一个有趣的方面是,它使用了 Hi/Lo 算法作为主键生成策略。

The Hi/Lo algorithm is useful when you need unique keys before committing changes. As a summary, the Hi-Lo algorithm assigns unique identifiers to table rows while not depending on storing the row in the database immediately. This lets you start using the identifiers right away, as happens with regular sequential database IDs.

Hi/Lo 算法在你需要在提交更改之前就获取唯一键时非常有用。简而言之,Hi/Lo 算法能够在不立即将行存储到数据库的情况下,为表中的行分配唯一标识符。这让你能够立即开始使用这些标识符,就像使用常规的自增数据库 ID 一样。

The Hi/Lo algorithm describes a mechanism for getting a batch of unique IDs from a related database sequence. These IDs are safe to use because the database guarantees the uniqueness, so there will be no collisions between users. This algorithm is interesting for these reasons:

Hi/Lo 算法描述了一种从相关的数据库序列中获取一批唯一 ID 的机制。这些 ID 的使用是安全的,因为数据库保证了其唯一性,所以用户之间不会发生冲突。该算法之所以有趣,主要有以下几个原因:

  • It does not break the Unit of Work pattern.

    它不会破坏工作单元(Unit of Work)模式。

  • It gets sequence IDs in batches, to minimize round trips to the database.

    它按批次获取序列 ID,从而最大限度地减少了与数据库的往返交互次数。

  • It generates a human readable identifier, unlike techniques that use GUIDs.

    它生成的是人类可读的标识符,不像使用 GUID 那样。

EF Core supports HiLo with the UseHiLo method, as shown in the preceding example.

EF Core 通过 UseHiLo 方法支持 Hi/Lo 算法,正如前面的示例所示。

Map fields instead of properties     映射字段而非属性

With this feature, available since EF Core 1.1, you can directly map columns to fields. It is possible to not use properties in the entity class, and just to map columns from a table to fields. A common use for that would be private fields for any internal state that do not need to be accessed from outside the entity.

从 EF Core 1.1 开始提供此功能,你可以直接将数据库列映射到字段。这意味着在实体类中可以不使用属性,而直接将表中的列映射到字段。一个常见的用法是用于实体内部不需要被外部访问的私有字段(比如内部状态)。

You can do this with single fields or also with collections, like a List<> field. This point was mentioned earlier when we discussed modeling the domain model classes, but here you can see how that mapping is performed with the PropertyAccessMode.Field configuration highlighted in the previous code.

你可以对单个字段这样做,也可以对集合(比如 List<> 字段)这样做。这一点我们在之前讨论领域模型类建模时提到过,但在这里你可以看到,该映射是如何通过前面代码中高亮显示的 PropertyAccessMode.Field 配置来完成的。

Use shadow properties in EF Core, hidden at the infrastructure level    在 EF Core 中使用在基础设施层隐藏的“影子属性”

Shadow properties in EF Core are properties that do not exist in your entity class model. The values and states of these properties are maintained purely in the ChangeTracker class at the infrastructure level.

EF Core 中的影子属性(Shadow properties)是指那些不存在于你的实体类模型中的属性。这些属性的值和状态纯粹是在基础设施层的 ChangeTracker 类中进行维护的。

Implement the Query Specification pattern    实现查询规约(Query Specification)模式

As introduced earlier in the design section, the Query Specification pattern is a Domain-Driven Design pattern designed as the place where you can put the definition of a query with optional sorting and paging logic.

正如前文在设计部分所介绍的,查询规约(Query Specification)模式是一种领域驱动设计(DDD)模式,它被设计为一个可以放置带有可选排序和分页逻辑的查询定义的地方。

The Query Specification pattern defines a query in an object. For example, in order to encapsulate a paged query that searches for some products you can create a PagedProduct specification that takes the necessary input parameters (pageNumber, pageSize, filter, etc.). Then, within any Repository method (usually a List() overload) it would accept an IQuerySpecification and run the expected query based on that specification.

查询规约模式将查询定义在一个对象中。例如,为了封装一个搜索某些产品的分页查询,你可以创建一个 PagedProduct 规约(Specification),它接收必要的输入参数(如 pageNumberpageSizefilter 等)。然后,在任何仓储方法(通常是一个 List() 的重载)中,它都会接收一个 IQuerySpecification,并基于该规约运行预期的查询。

An example of a generic Specification interface is the following code, which is similar to code used in the eShopOnWeb reference application.

一个通用的规约(Specification)接口示例如下面的代码所示,这与 eShopOnWeb 参考应用程序中使用的代码类似。

// GENERIC SPECIFICATION INTERFACE
// 通用规约模式接口
// 参考来源: https://github.com/dotnet-architecture/eShopOnWeb

/// <summary>
/// 定义通用的规约模式(Specification Pattern)接口,用于构建可复用的数据查询条件。
/// 该接口封装了实体查询的筛选条件(Criteria)、关联加载(Includes)等逻辑,
/// 常用于仓储模式(Repository Pattern)中以实现灵活的数据访问。
/// </summary>
/// <typeparam name="T">需要查询的实体类型</typeparam>
public interface ISpecification<T>
{
    /// <summary>
    /// 获取用于筛选数据的 LINQ 表达式条件。
    /// 例如:x => x.Price > 100
    /// </summary>
    Expression<Func<T, bool>> Criteria { get; }

    /// <summary>
    /// 获取需要通过 LINQ 强类型方式显式加载的关联实体列表(导航属性)。
    /// 用于避免懒加载(Lazy Loading)带来的性能问题,确保相关数据被一次性加载。
    /// 例如:x => x.Category
    /// </summary>
    List<Expression<Func<T, object>>> Includes { get; }

    /// <summary>
    /// 获取需要通过字符串方式显式加载的关联实体列表(导航属性)。
    /// 主要用于处理复杂或动态的关联路径,或者在无法使用强类型表达式时使用。
    /// 例如:"Brand" 或 "Order.Items"
    /// </summary>
    List<string> IncludeStrings { get; }
}

Then, the implementation of a generic specification base class is the following.

接下来,是一个通用规约(Specification)基类的具体实现。

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

/// <summary>
/// 规约模式的基类实现(泛型)
/// 用于定义查询实体时的筛选条件(Criteria)、关联加载(Includes)等规范
/// </summary>
/// <typeparam name="T">需要查询的实体类型</typeparam>
public abstract class BaseSpecification<T> : ISpecification<T>
{
    /// <summary>
    /// 构造函数,用于初始化查询的基本筛选条件
    /// </summary>
    /// <param name="criteria">表达式树,表示查询的 WHERE 条件</param>
    public BaseSpecification(Expression<Func<T, bool>> criteria)
    {
        Criteria = criteria;
    }

    /// <summary>
    /// 获取当前规约的筛选条件表达式
    /// 例如:x => x.Status == "Active"
    /// </summary>
    public Expression<Func<T, bool>> Criteria { get; }

    /// <summary>
    /// 强类型关联加载列表
    /// 用于存储需要在查询时通过强类型表达式加载的导航属性
    /// 例如:Include(x => x.Basket)
    /// </summary>
    public List<Expression<Func<T, object>>> Includes { get; } =
                                           new List<Expression<Func<T, object>>>();

    /// <summary>
    /// 字符串形式的关联加载列表
    /// 用于存储需要通过字符串路径加载的深层导航属性
    /// 例如:"Basket.Items.Product"
    /// </summary>
    public List<string> IncludeStrings { get; } = new List<string>();

    /// <summary>
    /// 添加强类型关联加载项
    /// 子类可通过此方法指定需要包含的导航属性
    /// </summary>
    /// <param name="includeExpression">关联加载的表达式,如 x => x.Items</param>
    protected virtual void AddInclude(Expression<Func<T, object>> includeExpression)
    {
        Includes.Add(includeExpression);
    }

    /// <summary>
    /// 添加字符串形式的关联加载项
    /// 用于处理复杂的层级关系,例如子级的子级
    /// </summary>
    /// <param name="includeString">关联路径字符串,如 "Items.Product.Category"</param>
    protected virtual void AddInclude(string includeString)
    {
        IncludeStrings.Add(includeString);
    }
}

The following specification loads a single basket entity given either the basket's ID or the ID of the buyer to whom the basket belongs. It will eagerly load the basket's Items collection.

接下来的这个规约(Specification)会根据购物车的 ID 或者所属买家的 ID,来加载单个购物车实体。它还会预先加载(eagerly load)购物车里的 Items(商品条目)集合。

// 示例:查询规约(Query Specification)的实现

/// <summary>
/// 包含订单项的购物车查询规约类。
/// 继承自 BaseSpecification,用于封装针对 Basket 实体的复杂查询逻辑和关联数据的加载规则
/// </summary>
public class BasketWithItemsSpecification : BaseSpecification<Basket>
{
    /// <summary>
    /// 通过购物车 ID 初始化查询规约,并配置需要加载其关联的 Items 集合
    /// </summary>
    /// <param name="basketId">目标购物车的唯一标识符</param>
    public BasketWithItemsSpecification(int basketId)
        // 调用基类构造函数,设置根据购物车 ID 进行精确匹配的过滤条件
        : base(b => b.Id == basketId)
    {
        // 添加 Include 规则,指示 EF Core 在查询时同步加载当前购物车下的所有 Items(订单项)
        AddInclude(b => b.Items);
    }

    /// <summary>
    /// 通过买家 ID 初始化查询规约,并配置需要加载其关联的 Items 集合
    /// </summary>
    /// <param name="buyerId">目标买家的唯一标识符</param>
    public BasketWithItemsSpecification(string buyerId)
        // 调用基类构造函数,设置根据买家 ID 进行精确匹配的过滤条件
        : base(b => b.BuyerId == buyerId)
    {
        // 添加 Include 规则,指示 EF Core 在查询时同步加载该买家购物车下的所有 Items(订单项)
        AddInclude(b => b.Items);
    }
}

And finally, you can see below how a generic EF Repository can use such a specification to filter and eager-load data related to a given entity type T.

最后,你可以在下面看到,一个通用的 EF 仓储(Repository)是如何利用这样的规约(Specification),来过滤并预先加载与给定实体类型 T 相关的数据的。

// GENERIC EF REPOSITORY WITH SPECIFICATION
// 参考项目: https://github.com/dotnet-architecture/eShopOnWeb

/// <summary>
/// 根据规约(Specification)从数据库中获取实体列表。
/// 该方法会自动处理规约中定义的关联数据加载(Includes)和查询条件(Criteria)。
/// </summary>
/// <typeparam name="T">要查询的实体类型</typeparam>
/// <param name="spec">包含查询条件和关联加载配置的规约对象</param>
/// <returns>满足条件的实体集合</returns>
public IEnumerable<T> List(ISpecification<T> spec)
{
    // 1. 处理表达式类型的关联加载 (Expression-based Includes)
    // 例如:x => x.Brand
    // 使用 Aggregate 方法将多个 Include 表达式累积应用到查询中
    var queryableResultWithIncludes = spec.Includes
        .Aggregate(_dbContext.Set<T>().AsQueryable(),
            (current, include) => current.Include(include));

    // 2. 处理字符串类型的关联加载 (String-based Includes)
    // 例如:"Brand" 或 "Items.Product"
    // 这种方式灵活性高,但失去了编译时检查(强类型优势)
    var secondaryResult = spec.IncludeStrings
        .Aggregate(queryableResultWithIncludes,
            (current, include) => current.Include(include));

    // 3. 应用查询条件并执行查询
    // Where: 应用规约中定义的过滤条件 (spec.Criteria)
    // AsEnumerable: 立即执行查询,将结果从数据库拉取到应用程序内存中
    return secondaryResult
                    .Where(spec.Criteria)
                    .AsEnumerable();
}

In addition to encapsulating filtering logic, the specification can specify the shape of the data to be returned, including which properties to populate.

除了封装过滤逻辑之外,规约(Specification)还可以指定要返回的数据形态,包括需要填充(populate)哪些属性。

Although we don't recommend returning IQueryable from a repository, it's perfectly fine to use them within the repository to build up a set of results. You can see this approach used in the List method above, which uses intermediate IQueryable expressions to build up the query's list of includes before executing the query with the specification's criteria on the last line.

虽然我们不建议直接从仓储(Repository)中返回 IQueryable,但在仓储内部使用它来构建结果集是完全没问题的。你可以在上面的 List 方法中看到这种做法:它使用了中间的 IQueryable 表达式来构建查询的 Include(预先加载)列表,然后在最后一行才真正执行带有规约条件的查询。

Learn how the specification pattern is applied in the eShopOnWeb sample.

了解规约(Specification)模式在 eShopOnWeb 示例中是如何应用的。

posted @ 2026-05-19 10:35  菜鸟吊思  阅读(10)  评论(0)    收藏  举报