设计基础结构持久性层

小窍门

此内容摘自电子书《适用于容器化 .NET 应用程序的 .NET 微服务体系结构》,可以在 .NET Docs 上获取,也可以下载免费的 PDF 以供离线阅读。

适用于容器化 .NET 应用程序的 .NET 微服务体系结构电子书封面缩略图。

数据持久性组件提供对微服务边界内托管的数据(即微服务的数据库)的访问。 它们包含组件的实际实现,例如存储库和 工作单元 类,如自定义实体框架 (EF) DbContext 对象。 EF DbContext 同时实现存储库和工作单元模式。

存储库模式

存储库模式是一种 Domain-Driven 设计模式,旨在将持久性问题保留在系统的域模型之外。 一个或多个持久性抽象(接口)在域模型中定义,这些抽象采用在应用程序中其他地方定义的特定于持久性的适配器的形式实现。

存储库实现是封装访问数据源所需的逻辑的类。 它们集中通用数据访问功能,提供更好的可维护性,并将用于从域模型访问数据库的基础结构或技术分离。 如果使用像实体框架这样的对象关系映射 (ORM),则可通过 LINQ 和强类型化来简化必须实现的代码。 这使你可以专注于数据持久性逻辑,而不是数据访问管道。

存储库模式是一种记录良好的数据源处理方式。 在 《企业应用程序体系结构模式》一书中,Martin Fowler 将存储库描述如下:

存储库执行域模型层和数据映射之间的中介任务,其作用方式类似于内存中的一组域对象。 客户端对象以声明方式生成查询,并将其发送到存储库以获取答案。 从概念上讲,存储库封装了一组存储在数据库中的对象及其可执行的操作,从而提供了一种更接近持久层的方法。 存储库还支持清晰地以一个方向分离工作域与数据分配或映射之间的依赖关系的功能。

为每个聚合定义一个存储库

对于每个聚合或聚合根,应创建一个存储库类。 你也许能够利用 C# 泛型来减少需要维护的具体类总数(如本章稍后所示)。 在基于 Domain-Driven 设计(DDD)模式的微服务中,更新数据库的唯一通道应该是存储库。 这是因为它们与聚合根具有一对一关系,该根控制聚合的固定性和事务一致性。 可以通过其他通道查询数据库(就像可以遵循 CQRS 方法一样),因为查询不会更改数据库的状态。 但是,事务区域(即更新)必须始终由存储库和聚合根控制。

基本上,存储库允许您在内存中填充来自数据库的域实体数据。 一旦实体存储在内存中之后,可以更改这些实体,然后通过事务将其持久化回数据库。

如前所述,如果使用 CQS/CQRS 体系结构模式,则初始查询由域模型外并行查询执行,由使用 Dapper 的简单 SQL 语句执行。 此方法比存储库更灵活,因为可以查询和联接所需的任何表,并且这些查询不受聚合规则的限制。 该数据将转到呈现层或客户端应用。

如果用户进行更改,则要更新的数据来自客户端应用或呈现层到应用程序层(例如 Web API 服务)。 在命令处理程序中收到命令时,可以使用存储库从数据库获取要更新的数据。 可使用通过命令传递的数据在内存中对其进行更新,然后通过事务在数据库中添加或更新数据(域实体)。

请务必再次强调,应仅为每个聚合根定义一个存储库,如图 7-17 所示。 若要实现聚合根目标,以保持聚合中所有对象之间的事务一致性,不应为数据库中的每个表创建存储库。

显示域和其他基础结构关系的关系图。

图 7-17. 存储库、聚合和数据库表之间的关系

上图显示了域层和基础结构层之间的关系:购买者聚合依赖于 IBuyerRepository 接口,订单聚合依赖于 IOrderRepository 接口。这些接口由基础结构层中依赖于工作单元 (UnitOfWork) 的相应存储库实现,工作单元也在那里实现,并且它们访问数据层中的表。

为每个存储库强制实施一个聚合根

以这样一种方式实现存储库设计可能很有价值,即实施仅聚合根具有存储库的规则。 可以创建一个泛型或基本存储库类型,该类型约束其使用的实体类型,以确保它们具有 IAggregateRoot 标记接口。

因此,在基础结构层实现的每个存储库类实现自己的协定或接口,如以下代码所示:

namespace Microsoft.eShopOnContainers.Services.Ordering.Infrastructure.Repositories
{
    public class OrderRepository : IOrderRepository
    {
      // ...
    }
}

每个特定的存储库接口实现泛型 IRepository 接口:

public interface IOrderRepository : IRepository<Order>
{
    Order Add(Order order);
    // ...
}

但是,最好让代码强制实施每个存储库与单个聚合相关的约定,即实现泛型存储库类型。 这样,就明确表示你正在使用存储库以特定聚合为目标。 这可以通过实现泛型 IRepository 基接口轻松完成,如以下代码所示:

public interface IRepository<T> where T : IAggregateRoot
{
    //....
}

使用存储库模式可以更轻松地测试应用程序逻辑

使用存储库模式,可以使用单元测试轻松测试应用程序。 请记住,单元测试仅测试代码,而不是基础结构,因此存储库抽象使实现该目标更容易。

如前面的部分所述,建议在域模型层中定义和放置存储库接口,以便应用程序层(如 Web API 微服务)不直接依赖于已实现实际存储库类的基础结构层。 通过执行此作并在 Web API 的控制器中使用依赖关系注入,可以实现返回假数据的模拟存储库,而不是从数据库返回数据。 通过这种分离方法,可以创建并运行单元测试,以集中应用程序的逻辑,而无需连接到数据库。

与数据库的连接可能会失败,更重要的是,针对数据库运行数百次测试是错误的,原因有两个原因。 首先,由于大量测试,可能需要很长时间。 其次,由于数据库记录可能会发生更改并影响测试结果,尤其是当测试并行运行时,它们可能会导致结果不一致。 单元测试通常可以并行运行;集成测试可能不支持并行执行,具体取决于它们的实现。 针对数据库进行测试不是单元测试,而是集成测试。 应该有许多单元测试运行得很快,但针对数据库的集成测试更少。

在单元测试的关注点分离方面,您的逻辑对内存中的域实体进行操作。 它假定存储库类已交付这些内容。 逻辑修改域实体后,它假定存储库类将正确存储它们。 此处的要点是针对域模型及其域逻辑创建单元测试。 聚合根是 DDD 中的主要一致性边界。

在 eShopOnContainers 中实现的存储库依赖于 EF Core 的 DbContext 实现,使用更改跟踪器实现存储库和工作单元模式,因此它们不会复制此功能。

存储库模式与旧版数据访问类(DAL 类)模式之间的差异

典型的 DAL 对象直接对存储执行数据访问和持久性操作,通常是在单个表和行的级别进行。 通过一组 DAL 类实现的简单 CRUD 操作往往不支持事务(尽管情况并非总是如此)。 大多数 DAL 类方法极少使用抽象,导致调用 DAL 对象的应用程序或业务逻辑层(BLL)类之间出现紧密耦合。

使用存储库时,持久性的实现详细信息会封装在域模型之外。 抽象的使用可通过修饰器或代理等模式轻松扩展行为。 例如,可以使用这些模式(而不是在数据访问代码本身中硬编码)应用跨切关注点(如 缓存、日志记录和错误处理)。 支持多个存储库适配器(可用于不同环境,从本地开发到共享过渡环境到生产环境)也十分简单。

实现工作单元

工作单元是指涉及多个插入、更新或删除作的单个事务。 简单而言,这意味着对于特定用户动作(如网站上的注册),所有插入、更新和删除操作都在单个事务中处理。 这比以更繁琐的方式处理多个数据库操作更有效。

当应用层的代码发出命令时,这些多个持久性操作稍后会合并为一个单个操作来执行。 将内存中更改应用于实际数据库存储的决定通常基于工作单元模式。 在 EF 中,工作单元模式由 a DbContext 实现,并在调用 SaveChanges时执行。

在许多情况下,针对存储应用作的这种模式或方法可以提高应用程序性能,并减少不一致的可能性。 它还降低了数据库表中的事务阻塞,因为所有预期操作都作为单个事务的一部分进行提交。 与对数据库执行许多独立操作相比,这种方法更高效。 因此,所选 ORM 可以通过在同一事务中对多个更新操作进行分组来优化对数据库的执行,而非许多小型且独立的事务执行。

可以使用或不使用存储库模式实现工作单元模式。

存储库不应是必需的

自定义存储库适用于前面引用的原因,这是 eShopOnContainers 中订购微服务的方法。 但是,在 DDD 设计中,甚至在一般的 .NET 开发中,实现这一模式并不是必不可少的。

例如,Jimmy Bogard 在为本指南提供直接反馈时说:

这可能是我最重要的一次反馈。 我真的不是存储库的粉丝,主要是因为它们隐藏了基础持久性机制的重要细节。 这也是我选择使用 MediatR 来处理命令的原因。 我可以使用持久性层的全部功能,并将所有域行为推送到聚合根。 我通常不想模拟存储库 - 我仍然需要对真实内容进行集成测试。 使用 CQRS 意味着我们不再需要存储库。

存储库可能很有用,但它们对于 DDD 设计来说并不像聚合模式和丰富的域模型那样关键。 因此,你可以根据需要使用或不使用存储库模式。

其他资源

存储库模式

工作单元模式