实施领域驱动设计(Implementing Domain Driven Design翻译)
引言
介绍
这是实现领域驱动的实用指南设计(DDD)。虽然实现细节依赖于ABP 框架基础设施,但是核心概念、原则和模式适用于任何类型的解决方案,即使它不是.NET 解决方案。
目标
本书的目标是:
●介绍和解释DDD 架构、概念、原则、模式和构建块。
●解释ABP框架提供的框架结构和解决方案结构
●引入显式规则来实现 DDD 模式和通过具体示例的给出最佳实践
●展示ABP 框架为您提供以适当的方式实施 DDD 的基础设施
●最后,基于软件开发经验提供最佳实践建议并创建可维护的代码库
简单的代码
踢足球很简单,但踢简单的足球是最困难的事情。—约翰克鲁伊夫
如果我们将这句名言用于编程,我们可以说:写代码很简单,但写简单的代码才是最重要的最难的事情。
在本文档中,我们将介绍一些简单的规则,它们是易于实施的。一旦您的应用程序增长,将很难遵循这些规则。有时你会发现打破规则会短期内节省你的时间。但是,短期内节省的时间将带来更多的中长期时间损失。你的代码库变得复杂且难以维护。大多数业务应用程序被重写只是因为你不能****再维护它。如果您遵循规则和最佳实践,您的代码库将更简单,更容易维护。您的应用程序更快应对变化。
什么是领域驱动设计?
领域驱动设计 (DDD) 是一种软件方法通过连接复杂的需求来开发实施到一个不断发展的模型;
DDD适用于复杂领域和大规模应用程序而不是简单的 CRUD 应用程序。它专注于在核心领域逻辑,而不是基础架构细节。它有助于构建灵活、模块化和可维护的代码。
面向对象和SOLID
实现 DDD 高度依赖面向对象编程 (OOP) 和SOLID原则。实际上,它实现和扩展这些原则。所以,对OOP 和 SOLID 的理解对你有很大帮助实施 DDD。
业务逻辑分为两层,领域层和应用层,而它们包含不同种类的业务逻辑;
●领域层实现核心用例域/系统的独立业务逻辑。
●应用层实现的用例基于领域。一个用例可以是被认为是用户界面上的用户交互(用户界面)。
●展示层包含 UI 元素(页面、组件)的应用程序。
●基础设施层支持其他层通过实现抽象或集成第三方库和系统。
相同的分层可以如下图所示被称为清洁架构,或者有时是洋葱结构
核心构建块
DDD 主要关注领域层和应用层而忽略表示层和基础设施层。他们被视为实现细节而业务层不应该依赖于它们。这并不意味着表示层和基础设施层不重要。它们非常重要。UI框架和数据库提供商有自己的规则和最佳实践,您需要了解并应用他们,然而这些都与DDD的话题无关。
本节介绍基本构建块:领域层和应用层。
领域层构建块
● 实体Entity: 一个实体是具有其自身属性的对象(状态、数据)和实现业务的方法在这些属性上执行的逻辑。一个实体是由其唯一标识符 (Id) 标识。两个实体对象具有不同 ID 的被视为不同的实体。
●值对象Value Object: 一个值对象是另一种领域对象由其属性而不是一个唯一Id标识身份。这意味着具有相同属性的两个值对象被视为同一个对象。值对象通常被实现为不可变的,大多数值对象都比实体简单得多。
●聚合及聚合根Aggregate & Aggregate Root: 一个聚合是一群绑定在一起的对象(实体和值对象)。一个聚合根是一个特定类型的实体以及一些额外的功能。
●存储库Repository (接口):一个存储库是领域层和应用层使用的一个类似集合的接口访问数据持久性系统的层(数据库)。它隐藏了 DBMS 业务代码的复杂性。领域层包含仓储接口。
●领域服务Domain Service: 一个领域服务是无状态的服务实现领域的核心业务规则。用于实现依赖于多个聚合(实体)类型或一些外部服务的领域逻辑。
●规约模式Specification: 规约用来定义命名的、可重用的、可组合的业务对象过滤器
●领域事件Domain Event: 领域事件是以一种松散耦合的方式当领域特定事件发生时通知其他服务的方式
应用层构建块
●应用服务Application Service: 一个应用服务是无状态的实现应用用例的服务。一个应用程序服务通常获取和返回 DTO,被展示层调用。它调用相关领域对象来实现用例。一个用例通常被视为一个工作单元。
●数据传输对象Data Transfer Object(DTO): DTO是一个在应用层和表示层(展示层)之间用于传输状态(数据)的没有任何业务逻辑的简单对象。
●工作单元Unit of Work(UOW): 工作单元是一个原子工作应该作为一个事务来完成。所有UOW 内的操作应该在成功时提交或在失败时回滚。
实现:概览
.NET 解决方案的分层
下图显示了使用ABP的应用启动模板创建的 Visual Studio 解决方案:
决方案名称是IssueTracking,它由多个项目组成。解决方案考虑DDD 原则以及开发和部署实践分层。
以下部分解释了解决方案中的项目:
您的解决方案结构可能略有不同,如果您选择不同的 UI 或数据库提供程序。然而领域层和应用层是相同的,这是DDD实践的要点。如果你想了解有关解决方案结构的更多信息参考应用程序启动模板文档
领域层Domain Layer 分为两个项目:
●IssueTracking.Domain是必不可少的领域层包含之前介绍过的所有构建块(实体、值对象、领域服务、规约、存储库接口等)。
●IssueTracking.Domain.Shared是很薄的一层包含一些属于领域层的类型,但与所有其他层共享。例如它可能包含一些相关的常量和枚举对象但需要被其他层重用。
应用层Application Layer也分为两个项目
●IssueTracking.Application.Contracts包含应用服务接口和这些接口使用的DTO。此项目可由客户应用程序(包括 UI)共享。
●IssueTracking.Application是必不可少的应用层,该层实现Application.Contracts层定义的接口。
展示层Presentation Layer
●IssueTracking.Web是一个 ASP.NET Core MVC/Razor Pages示例应用程序。这是唯一的可执行文件为应用层和 API 提供服务的应用程序。
ABP 框架还支持不同类型的 UI 框架包括Angular和Blazor. 在这些情况下,解决方案中不存在IssueTracking.Web 。相反,一个IssueTracking.HttpApi.Host应用程序将在解决方案中将 HTTP API 作为要使用的独立端点提供服务由 UI 应用程序通过 HTTP API 调用。
远程服务层Remote Service Layer
●IssueTracking.HttpApi项目包含通过解决方案定义的 HTTP API。它通常包含 MVC Controller和相关模型(如果有)。因此,您在这个项目中写 HTTP API。
大多数时候,API 控制器只是封装应用层服务并将它们公开给远程客户端。通过ABP 框架自动API控制器系统自动配置和公开您的应用层作为 API 控制器的服务,您通常不需要创建本项目中的控制器。但是,示例启动解决方案包含适用于您需要手动创建 API 控制器的情况。
●IssueTracking.HttpApi.Client项目在您需要使用 HTTP 的 C# 应用时特别有效。一旦客户端应用程序引用了这个项目,它可以直接注入和使用应用服务。这是通过 ABP 框架 动态 C#客户端 API 代理系统的帮助
解决方案的测试文件夹中有一个控制台应用程序,名为IssueTracking.HttpApi.Client.ConsoleTestApp。它使用IssueTracking.HttpApi.Client项目来使用应用程序公开的 API。这只是一个演示应用程序您可以安全地删除它。你甚至可以删除IssueTracking.HttpApi.Client项目,如果你认为不需要他们
基础设施层Infrastructure Layer
在 DDD 实现中,您可能只有一个基础设施项目来实现所有的抽象和集成,或者对于每个依赖项,您可能有不同的项目。
我们建议采取一种平衡的方法:为主要基础设施依赖项(如 Entity Framework Core)创建单独的项目和其他基础设施的通用基础设施项目。
ABP的启动方案对于Entity有两个项目框架核心集成:
●IssueTracking.EntityFrameworkCore是必不可少的EF Core 的集成包。应用程序的DbContext、数据库映射、存储库实现和其他 EF Core 相关内容位于这里。
●IssueTracking.EntityFrameworkCore.DbMigrations是一个管理 Code First 数据库迁移的特殊项目。这个项目中有一个单独的DbContext跟踪迁移。你通常不会总是接触这个项目,除了你需要创建一个新的数据库迁移或添加具有某些功能的应用程序模块数据库表,自然需要创建一个新的数据库迁移。
您可能想知道为什么 EF Core 有两个项目。主要由于模块化。每个模块都有自己的独立的DbContext并且您的应用程序也有一个数据库上下文。DbMigrations项目包含跟踪和应用单个迁移路径的模块。尽管大多数时候你不需要知道它,你可以查看 EF Core migrations文档以获取更多信息。
其他的项目Other Projects
还有一个项目IssueTracking.DbMigrator,这是一个迁移数据库架构的简单控制台应用程序并在您执行它时播种初始数据。这是一个有用的实用程序应用程序,您可以在开发以及在生产环境中使用它。
解决方案中项目的依赖关系
Dependencies of the Projects in the Solution
下图显示了解决方案中项目的基本的依赖关系:
●Domain.Shared是所有其他项目的直接或间接依赖。所以,这里的所有类型适用于所有项目。(Domain.Shared在最供其他层调用,定义枚举、自定义属性、异常和公共模型)
●Domain只依赖于Domain.Shared因为它已经是领域的(共享)一部分。例如,一个在Domain.Shared定义的IssueType enum类型可以在Domain Issue实体中引用。
●Application.Contracts依赖于Domain.Shared 。这样,您就可以在 DTO 中重用这些类型。例如,Domain.Shared中相同的IssueType enum类型可以被CreateIssueDto用作属性。(Application.Contracts调用Domain.Shared层,定义DTO)
●应用层Application依赖于Application.Contracts因为它实现应用服务接口并使用Application.Contracts里面的 DTO。Application也依赖Domain层,因为应用层使用Domain层中定义的领域对象。
●EntityFrameworkCore依赖于领域层Domain,因为它将领域对象(实体和值类型)映射到数据库表(因为它是一个 ORM)并实现Domain层中定义的存储库接口。
●HttpApi依赖于Application.Contracts,因为其中的控制器注入并使用如前所述应用层服务接口。
●HttpApi.Client依赖于Application.Contracts因为之前解释过它可以应用应用层服务。
●Web依赖于HttpApi,因为它应用HttpApi中定义的 API。而且,通过这种方式,它间接地消费Application.Contracts项目页面/组件中的应用层服务。
虚线依赖Dashed Dependencies
当您查看解决方案时,您将看到另外两个上图中虚线所示的依赖关系。Web项目依赖于应用层Application和EntityFrameworkCore项目,理论上不应该那样,但实际上是这样。
这是因为Web是运行和托管应用程序的最终项目,应用程序需要在运行时调用应用服务和存储库的实现。
此设计决策可能允许您使用实体和表示层中的EF Core对象但是应该严格避免这种情况。然而,我们发现替代设计比较复杂(However, we find the alternative designs over complicated.)。如果要删除依赖,有两种选择;
●将Web项目转换为 razor 类库并创建一个新项目,如Web.Host,依赖于Web、Application和EntityFrameworkCore项目托管应用程序。你不在这里写任何 UI 代码,而仅用于托管。
●移除Application和EntityFrameworkCore依赖项并在应用程序初始化程序集时加载它们。您可以使用 ABP插件模块 实现这个目标。
基于 DDD 的应用程序的执行流程
Execution Flow of a DDD Based Application
下图显示了一个典型的基于 DDD 模式开发的应用程序 Web 请求流。
●该请求通常始于用户在浏览器上的一个用例操作引发对服务器的 HTTP 请求。
●MVC 控制器或 Razor 页面处理程序表示层(或在分布式服务层)处理请求并执行一些横切这个阶段的过滤(授权,验证,异常处理等)。控制器/页面注入相关的应用服务接口并通过发送和接收DTO调用方法。
●应用服务Application Service
使用领域对象(实体、存储库接口、领域服务等
)实现用例。应用层Application Layer实现一些横切关注点(授权、验证、等等)。应用服务方法应该是Unit of Work。这意味着它应该是原子的。
大多数横切关注点是通常由 ABP 框架自动实现**,您通常不需要为它们编写代码。**
Common Principles
在详细介绍之前,让我们先看看一些总体的 DDD 原则:
数据库提供者/ORM 独立性Database Provider / ORM Independence
领域层和应用层应该是 ORM /数据库提供者不可知的。他们应该只依赖于Repository 接口,Repository 接口不使用任何 ORM 特定对象。
这个原则的主要原因:
1.使您的领域层/应用层基础架构独立,因为基础设施可能会在将来或者您可能需要之后支持第二种数据库类型。
2.使您的领域层/应用层专注于业务通过将基础结构细节隐藏在存储库代码背后。
3.使您的自动化测试更容易,因为您可以在这种情况下模拟存储库。
出于对这一原则的尊重,解决方案中的任何项目都没有引用EntityFrameworkCore项目,除了启动应用程序。
关于数据库独立性的讨论原则Discussion About the Database Independence Principle
特别地原因 1深深影响了您的领域对象设计(尤其是实体关系)和应用程序代码。假设您正在使用 Entity Framework Core与关系型数据库。如果您希望以后可以切换到MongoDB,你不能用一些非常有用的EF 核心功能
例如:
●您不能使用更改跟踪因为MongoDB提供程序做不到。所以,你总是需要明确地更新更改的实体。
●在您的实体中你不能用 导航属性(或集合)引用到其他聚合,因为这不能用于文档数据库。参见“规则:仅使用Id引用其他聚合”部分了解更多信息。
如果您认为这些功能对您很重要并且您永远不会偏离EF Core,我们相信它是值得的扩展这个原则。我们仍然建议使用存储库模式来隐藏基础设施细节。但你可以假设您在设计实体关系时正在使用 EF Core并编写您的应用程序代码。你甚至可以在应用层引用EF Core NuGet 包直接使用异步 LINQ 扩展方法,比如ToListAsync() (参见IQueryable & Async Operations部分有关更多信息,请参阅Repositories文档)。
演示技术不可知论Presentation Technology Agnostic
呈现技术(UI Framework)是最重要的技术之一,更改了现实世界应用程序的部分内容。将领域层和应用层设计成完全不知道演示技术/框架非常重要。这个原则比较容易实现而ABP的启动模板使它变得更加容易。
在某些情况下,你可能需要在应用层和表示层有重复的逻辑。例如,您可能需要在两者中重复验证和授权检查。UI层的检查主要是为了用户体验而检查应用层和领域层是为了安全性和数据完整性。这是完全正常并且必要的。
关注状态变化,而不是报表Focus on the State Changes, Not Reporting
DDD 关注领域对象如何变化和交互;如何创建实体并更改其属性通过保持数据完整性/有效性并实施业务规则。
DDD忽略报表和批量查询。这并不意味着它们并不重要。如果您的应用程序没有花哨的仪表板和报表,谁会使用它?然而,报表是另一个话题。您通常希望充分利用SQL Server 甚至使用单独的数据源(如ElasticSearch) 用于报表目的。你会写优化查询、创建索引甚至编写存储过程。只要这些实现没有污染业务逻辑,就可以自由地做所有这些事情。
Implementation: The Building Blocks
这是本指南的重要部分。我们将介绍和用示例解释一些明确的规则。你可以在你的领域驱动设计解决方案中遵循这些规则。
领域示例
The Example Domain
这些示例将使用 GitHub 使用的一些你已经很熟悉了的概念,像Issue、Repository、Label和User。下图显示了一些聚合aggregates,聚合根aggregate roots、实体entities,、值对象value object和它们之间关系:
问题聚合(Issue Aggregate)由一个问题聚合根(Issue Aggregate Root)组成:包含Comment和IssueLabel集合。其他聚合显示地比较简单,因为我们将关注问题聚合(Issue Aggregate)
聚合Aggregates
如前所述,聚合是一组对象(实体和值对象)由聚合根对象(Aggregate Root object)绑定在一起。本节将介绍聚合相关的原则和规则。
我们将术语聚合根和子集合实体称为实体,除非我们特别指明编写聚合根或子集合实体。
We refer the term Entity both for Aggregate Root and sub-collection entities unless we explicitly write Aggregate Root or sub-collection entity.
聚合/聚合根原则Aggregate / Aggregate Root Principles
业务规则Business Rules
实体(Entities)负责执行自己的属性相关的业务规则。聚合根实体(Aggregate Root Entities)还负责其子集合实体业务规则实现。
聚合应该通过以下方式保持其自我完整性和有效性:实现领域逻辑和约束。这意味着,与 DTO 不同,实体通过方法来实现一些业务逻辑。实际上我们应该尽可能在实体中实现业务逻辑。
单元Single Unit
一个聚合作为查询并保存的最小单元,包含所有子集合和属性。
例如,如果你想添加评论Comment到一个问题Issue,你需要:
●从数据库中获取问题Issue,包括所有子集合(Comments和IssueLabels)
●使用Issue类上的方法添加新评论,像Issue.AddComment(…)
●使用单个数据库操作(更新)将问题Issue(包括所有子集合)保存到数据库中。
对于过去使用EF Core和关系数据库 的开发人员来说,这似乎很奇怪。获取问题Issue与所有细节似乎没有必要而且效率低下。我们为什么不可以在不查询任何数据的情况下对数据库执行 SQL Insert命令?
答案是我们应该执行业务规则并保持代码中的数据一致性和完整性。如果我们有一个业务规则,比如“用户不能评论锁定问题”,我们如何在不检查问题的锁定状态的情况下从数据库中检索它?所以,我们可以执行业务逻辑仅当相关对象在应用程序代码中的时候。
另一方面,MongoDB开发人员会发现这条规则非常自然。在 MongoDB 中,一个聚合对象(带有子集合)保存在数据库中的单个集合中(虽然它在一个关系数据库中分布几个表中)。所以,当你得到一个聚合时,所有的子集合已经作为查询的一部分被检索,无需任何额外配置。
ABP 框架有助于在您的应用程序中实现这条原则。
示例:向问题添加评论Example: Add a comment to an issue
public class IssueAppService : ApplicationService,IIssueAppService
{
private readonly IRepository<Issue,Guid> _issueRepository;
public IssueAppService(TRepository<Issue,Guid> issueRepository)
{
_issueRepository = issueRepository;
}
[Authorize]
public async Task CreateCommentAsync(CreateCommentDto input)
{
var issue = await _issueRepository.GetAsync(input.IssueId);
issue.AddComment(CurrentUser.GetId(),input.Text);
await _issueRepository.UpdateAsync(issue);
}
}
_issueRepository.GetAsync方法在默认情况下检索问题Issue与所有详细信息(子集合)作为一个单元。虽然这为 MongoDB 开箱即用,但是对于EF Core您需要配置您的聚合详细信息。但是一旦你配置了,存储库就会自动处理它。_issueRepository.GetAsync方法获取一个可选参数includeDetails,您可以可以在需要时传递false以禁用此行为。
请参阅 EF Core 的加载相关实体部分配置和替代方案的文档
Issue.AddComment得到一个userId和comment text,实施必要的业务规则并添加到Issue的 Comments collection。
最后,我们使用__issueRepository.UpdateAsync来保存更改到数据库。_
EF Core 具有**更改跟踪(change tracking)**功能。所以,你实际上不需要调用_issueRepository.UpdateAsync。这将由 ABP 的工作单元系统在方法结束时自动调用DbContext.SaveChanges()自动保存。但是,对于 MongoDB,您需要显式更新改变的实体。所以,如果你想写你的代码 Database Provider独立,你应该总是调用UpdateAsync方法
事务边界Transaction Boundary
一个聚合通常被认为是一个事务边界。如果用例使用单个聚合,则读取并将其保存为一个单元,对聚合对象作为原子操作一起保存并且您不需要显式的数据库事务。
然而,在现实生活中,在单个用例中你可能需要改变不止一个聚合实例,您需要使用数据库事务以确保原子更新和数据一致性。因此,ABP 框架使用显式用例的数据库事务(应用程序服务方法边界an application service method boundary)。请参阅工作单元文档以了解更多信息。
可串行化Serializability
一个聚合(带有根实体和子集合)应该可以作为一个单元在网络上串行化和传输。例如,MongoDB 将聚合序列化为 JSON 文档在保存到数据库并从 JSON 反序列化时从数据库中读取。当您使用关系数据库和 ORM时,此要求不是必需的。然而,这是一个重要的领域驱动设计实践。
以下规则已经带来了可序列化性。
聚合/聚合根规则和最佳实践Aggregate / Aggregate Root Rules & Best Practices
以下规则确保实现以上介绍的原则:
仅通过 ID 引用其他聚合Reference Other Aggregates Only by ID
第一条规则说一个聚合应该仅使用ID引用其他聚合。这意味着你不能添加导航属性到其他聚合。
●这条规则使得实现可序列化成为可能。
●它还可以防止不同的聚合间相互操作或聚合间业务逻辑泄漏。
您在下面的例子会看到两个聚合根,GitRepository和Issue:
public class GitRepository : AggregateRoot<Guid>
{
public string Name { get; set; }
public int starcount { get; set; }
//错误做法
public collection<Issue> Issues{get; set;}
)
public class Issue : AggregateRoot<Guid>
{
public string Text { get; set; }
//错误做法
public GitRepository Repository { get; set;}
//正确做法
public Guid RepositoryId { get; set; }
}
GitRepository不应包含Issue的集合因为它们是不同的聚合。
●Issue不应具有GitRepository相关的导航属性,因为它是一个不同的聚合。
●Issue可以有RepositoryId (作为Guid)。
因此,当您遇到Issue并需要与此问题相关的GitRepository 时,您需要通过RepositoryId从数据库查询。
对于 EF Core 和关系数据库For EF Core & Relational Databases
在MongoDB中,自然不适合有这样的导航属性/集合。如果你这样做,你会找到一份数据库集合中的目标聚合对象源聚合,因为它在保存时被序列化为 JSON。
但是,EF Core 和关系数据库开发人员可能会发现这个限制性规则是不必要的,因为 EF Core在数据库读写时可以处理它。我们认为这是一个重要的规则有助于降低领域的复杂性以及潜在的问题,我们强烈建议实施此规则。但是,如果您认为忽略此规则是可行的,请参阅以上部分关于数据库独立原则的讨论。
保持小聚合Keep Aggregates Small
一个好的做法是保持聚合simple和small。这是因为聚合将被加载并保存为单个单元而读/写大对象有性能问题。
请参阅下面的示例:
public class Role : AggregateRoot<Guid>
{
public string Name { get; set; }
//错误的做法
public collection<UserRole> users { get; set;}
}
public class UserRole : valueobject
{
public Guid UserId { get; set;}
public Guid RoleId { get; set;}
}
public class User : AggregateRoot<Guid>
{
public string Name { get; set; }
//正确的做法
public collection<UserRole> Roles { get; set;}
}
角色聚合Role aggregate有一组UserRole值对象跟踪为此角色分配的用户。注意UserRole不是另一个聚合,它不是仅按 Id 引用其他聚合规则的问题。然而,它是一个实际中的问题。在现实生活场景中一个角色可能被分配给数千个(甚至数百万)用户,每当您从数据库中查询角色将加载数千个Item这是一个重要的性能问题(记住:聚合由它们的子集合作为一个单元被加载)。
另一方面,用户User可能有这样一个角色集合(Roles collection),因为用户实际上没有太多角色,当你在使用用户聚合时有一个角色列表它可能很有用。
如果仔细想想,在使用非关系数据库如MongoDB时如果 Role和User都有对方相关的集合。在这种情况下,相同的信息是在不同的集合中重复,将很难保持数据一致性(每当您向User.Roles添加角色,您也需要将其添加到Role.Users 中)。
因此,根据以下情况考虑聚合的边界和大小:
一起使用的对象。
查询(加载/保存)性能和内存消费
数据完整性、有效性和一致性。
在实践中;
大多数聚合根不会有子集合not have sub-collections
在大多数情况下一个子集合里面的项目不应超过100-150。如果你认为一个集合可能有更多的项目,不要定义集合作为聚合的一部分并将实体内集合提取为另一个聚合根。
聚合根/实体上的主键Primary Keys on the Aggregate Roots / Entities
聚合根通常具有单个Id属性,用于它的标识符(Primark Key:PK)。我们更喜欢Guid作为聚合根实体的 PK(参见 指南生成文档以了解原因)。
一个聚合内实体(不是聚合根)可以使用复合主键。
例如,请参阅下面的聚合根和实体:
//Aggregate Root
//Define a single Primary Key (ld)
public class organization
{
public Guid Id { get; set; }
public string Name { get; set; }
//
}
//Entity
//Can define a composite Primary Key
public class OrganizationUser
{
//OrganizationId and UserId as Primary Key
public Guid OrganizationId { get; set; }
public Guid UserId { get; set; }
public bool Isowner { get; set; }
}
organization有一个Guid标识符 ( Id )
OrganizationUser是的Organizationd的子集有一个由OrganizationId和UserId组成的复合主键
这并不意味着子集合实体应该始终具有复合 PK。当需要时,它们可能具有单个Id属性。
复合PK实际上是关系数据库的一个概念因为子集合实体有自己的表需要PK。另一方面,例如,在 MongoDB 中,您根本不需要为子集合实体定义 PK,因为它们作为聚合根的一部分存储。
聚合根/实体的构造函数Constructors of the Aggregate Roots / Entities
构造函数位于实体生命周期所在的位置开始。一个设计良好的构造函数:
获取所需的实体属性作为参数创建一个有效的实体。应该强制传递必需的参数,非必需的属性作为可选参数。
检查参数的有效性。
初始化子集合
示例问题(聚合根)构造函数Example Issue (Aggregate Root) constructor
using System;
using System.Collections. Generic;
using System.Collections.ObjectModel;using Volo.Abp;
using Volo.Abp. Domain.Entities;
namespace IssueTracking.Issues
{
public class Issue : AggregateRoot<Guid>
{
public Guid RepositoryId { get;set;}
public string Title { get; set;}
public string Text { get;set;}
public Guid? AssignedUserId { get; set;}
public bool IsClosed { get; set;}
public IssueCloseReason? CloseReason { get;set;} //enum
public ICollection<IssueLabel> Labels { get; set;}
public Issue(Guid id,Guid repositoryId,string title,string text = null,Guid? assignedUserId = null): base(id)
{
Repositoryld = repositoryId;
Title = Check.NotNul10rWhiteSpace(title,nameof(title));
Text = text;
AssignedUserId = assignedUserId;
Labels = new Collection<IssueLabel>();
}
private Issue(){/* for deserialization & ORMs*/}
}
}
Issue通过构造函数传入最少所需必须属性创建一个有效的实体
构造函数验证输入(Check.NotNullOrWhiteSpace(…)如果给定值为空,则抛出ArgumentException )
初始化子集合,所以创建Issue后你尝试使用Labels时不会得到一个空值的引用异常。
构造函数也接受id并传递给基类。我们不会在构造函数中生成Guid而把这个责任委托给另一个服务(Guid Generation)
ORM 需要私有的空构造函数。我们将其设为私有以防止在我们自己意外使用它代码。
见 实体文档以了解有关创建实体的更多信息
实体属性访问器和方法Entity Property Accessors & Methods
你可能觉得上面的例子很奇怪!例如,我们强制在构造函数中传递一个非空的Title。然而,开发人员可以将Title属性设置为null,而无需任何控制。这是因为上面的示例代码只关注构造函数。如果我们使用公共 setter 声明所有属性(例如例如上面的Issue类),我们不能在实体生命周期中强制有效性和完整性。
所以
当你需要在设置该属性时执行任何逻辑那么需要把为属性Setter设置为**私有 **
定义公共方法来操作这些属性
示例:在受控对象中更改属性的方法Example: Methods to change the properties in a controlled way
using System;
using Volo.Abp;
using Volo.Abp.Domain.Entities;
namespace IssueTracking. Issues
{
public class Issue : AggregateRoot<Guid>
{
public Guid RepositoryId { get; private set;}//Never changes
public string Title { get; private set;}//Needs validation
public string Text { get; set; }//No validation
public Guid? AssignedUserId { get; set;}//No validation
public bool IsClosed { get; private set; }//Should be changed with CloseReason
public IssueCloseReason? CloseReason { get; private set; }//Should be changed with IsC
public void SetTitle(string title)
{
Title = Check.NotNul10rWhiteSpace(title,nameof(title));
}
public void Close(IssueCloseReason reason)
{
IsClosed = true;
CloseReason = reason;
}
public void ReOpen()
{
IsClosed = false;
CloseReason = null;
}
}
}
RepositoryId setter 设为私有,Issue创建后没有办法更改它,这是我们想要的:一个问题不能转移到另一个仓储。
Title setter已设为私有且如果您想稍后以受控方式更改它,则创建SetTitle方法。
Text和AssignedUserId有public setters,因为有对他们没有限制。它们可以为 null 或任何其他值。我们认为没有必要单独定义方法来设置它们。如果我们以后需要,我们可以添加方法并将 setter 设为私有。领域层重大变化没有问题,因为领域层是一个内部项目,它不暴露给客户。
IsClosed和IssueCloseReason是成对属性。定义Close和ReOpen方法来一起改变它们。通过这种方式,我们可以防止issue在没有任何原因的情况下被关闭。
实体中的业务逻辑和异常Business Logic & Exceptions in the Entities
当您在实体中实现验证和业务逻辑时,您经常需要处理特殊情况。在这些情况下:
创建特定于领域的异常
必要时从实体方法中抛出这些异常
例如:
public class Issue : AggregateRoot<Guid>
{
//...
public bool IsLocked { get; private set;}
public bool IsClosed { get; private set;}
public IssueCloseReason? CloseReason { get;private set;}
public void Close(IssueCloseReason reason)
{
IsClosed = true;
CloseReason = reason;
}
public void Re0pen()
{
if (IsLocked)
{
throw new IssueStateException("Can not open a locked issue! Unlock it first.");
}
IsClosed = false;CloseReason = null;
}
public void Lock()
{
if(!IsClosed)
{
throw new IssueStateException("Can not open a locked issue! Unlock it first.");
}
IsLocked = true;
}
public void Unlock()
{
IsLocked = false;
}
}
这里有两个业务规则:
无法重新打开锁定的问题。
您无法锁定未解决的问题。
在这些情况下,问题类会抛出一个IssueStateException强制业务规则
using System;
namespace IssueTracking.Issues
{
public class IssueStateException : Exception
{
public IssueStateException(string message): base(message)
{
}
}
}
抛出这样的异常有两个潜在的问题:
如果出现此类异常,最终用户是否应该看到异常(错误)消息?如果是这样,你怎么定位的异常消息?你不能使用本地化系统,因为你不能注入和使用IStringLocalizer在实体中。
对于 Web 应用程序或 HTTP API,应该返回给客户端什么HTTP 状态码?
ABP 的异常处理系统解决了这些以及类似的问题。
示例:使用代码抛出业务异常Throwing a business exception with code
一个仓储是一个类似集合的接口被领域层和应用层用来访问持久化系统(例如数据库)读取和写入业务对象,通常是聚合。
常见的存储库原则是:
虽然这条规则一开始看起来很明显,但很容易将业务逻辑泄露到存储库中。
示例:从存储库中获取非活动问题Get inactive issues from a repository
**using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using Volo.Abp.Domain.Repositories;
namespace IssueTracking.Issues
{
public interface IIssueRepository : TRepository<Issue, Guid>
{
Task<List<Issue>>GetInActiveIssuesAsync();
}
}
**
IIssueRepository通过添加GetInActiveIssuesAsync方法扩展了标准的IRepository<…>接口。这个存储库使用以下Issue类:
public class Issue : AggregateRoot<Guid>,IHasCreationTime
{
public bool IsClosed { get; private set;}
public Guid? AssignedUserId{ get; private set;}
public DateTime CreationTime { get;private set;}
public DateTime? LastCommentTime { get; private set;}
//...代码只显示了我们在这个例子中需要的属性
}
规则说存储库不应该知道业务规则。这里的问题是“**什么是不活跃InActive的问题?**它是一项业务规则吗?"
让我们看一下实现来理解它:
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using IssueTracking.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore;
using Volo.Abp.Domain.Repositories.EntityFrameworkCore;
using Volo.Abp.EntityFrameworkCore;
namespace IssueTracking.Issues
{
public class EfCoreIssueRepository :EfCoreRepository<IssueTrackingDbContext,Issue,Guid>,IIssueRepository
{
public EfCoreIssueRepository(IDbContextProvider<IssueTrackingDbContext> dbContextProvider): base(dbContextProvider)
{
}
public async Task<List<Issue>> GetInActiveIssuesAsync()
{
var daysAgo30 = DateTime.Now.Subtract(TimeSpan.FromDays(30));
var dbSet = await GetDbSetAsync();
return await dbSet.Where(i=>
//0pen
!i.IsClosed&&
//Assigned to nobody
i.AssignedUserId == null &&
//Created 30+ days ago
i.CreationTime< daysAgo30 &&
//No comment or the last comment was30+days ago
(i.LastCommentTime == null || i.LastCommentTime < daysAgo30)
).ToListAsync();
}
}
}
(使用 EF Core 进行实现。请参阅EF Core integration document以了解如何通过 EF Core创建自定义存储库)
当我们检查GetInActiveIssuesAsync实现时,我们看到定义了一个in-active issue业务规则:The issue应该是open, assigned to nobody, created 30+ days
ago并且在过去 30 天内没有评论。
这是隐式定义在存储库方法中隐藏的业务规则。当我们需要重用这个业务逻辑的时候问题就出现了。
例如,假设我们要在实体上添加一个bool IsInActive()方法。这样,当我们有一个issue实体时我们可以检查issue活跃度。
让我们看看实现:
public class Issue : AggregateRoot<Guid>,IHasCreationTime
{
public bool IsClosed { get; private set;}
public Guid? AssignedUserId { get; private set;}
public DateTime CreationTime { get;private set;}
public DateTime? LastCommentTime { get; private set;}
//...
public bool IsInActive()
{
var daysAgo30 = DateTime.Now.Subtract(TimeSpan. FromDays(30));
return
//Open
!IsClosed &&
//Assigned to nobody
AssignedUserId==null&&
//Created 30+ days ago
CreationTime < daysAgo30 &&
//No comment or the last comment was 30+ days ago
(LastCommentTime ==null || LastCommentTime < daysAgo30);
}
}
我们不得不复制/粘贴/修改代码。如果定义活跃度规则发生变化?我们不应该忘记更新两个地方。这是重复的业务逻辑,这是很危险的。
这个问题的一个很好的解决方案是规约模式!
规约模式Specifications
一个规约是一个命名的,可重复使用,可组合和可测试的类以根据业务规则过滤领域对象。
ABP 框架提供了必要的基础设施来轻松地创建规约类并在您的应用程序内部使用它们。让我们将in-active issue过滤器实现为规约模式:
using System;
using System.Linq.Expressions;
using Volo.Abp.Specifications;
namespace IssueTracking.Issues
{
public class InActiveIssueSpecification : Specification<Issue>
{
public override Expression<Func<Issue,bool>> ToExpression()
{
var daysAgo30 = DateTime.Now.Subtract(TimeSpan.FromDays(30));
return i=>
//Open
!i.IsClosed &&
//Assigned to nobody
i.AssignedUserId == null &&
//Created 30+ days ago
i.CreationTime< daysAgo30 &&
//No comment or the last comment was 30+ days ago
(i.LastCommentTime == null || i.LastCommentTime < daysAgo30);
}
}
}
Specification基类简化了通过定义表达式来创建规约类。把仓储部分的表达式移动到规约类。现在,我们可以在Issue实体和EfCoreIssueRepository类中重用InActiveIssueSpecification
在实体内使用Using within the Entity
规约类提供了一个IsSatisfiedBy方法返回真如果给定对象(实体)满足规约。我们可以将Issue.IsInActive方法重写为如下图:
领域服务实现领域逻辑,其中:
依赖服务和存储库
逻辑实现需要使用多个聚合,所以不适合放到任何聚合内部。
领域服务与领域对象一起工作。他们的方法可以**获取和返回实体、值对象、原始类型…**等。但是,他们不会获取/返回 DTOs。DTO 是应用层的一部分。
示例:将问题分配给用户Assigning an issue to a user
记住Issue实体是如何实现问题分配的:
public class Issue : AggregateRoot<Guid>
{
//...
public Guid? AssignedUserld { get; private set;}
public async Task AssignToAsync(AppUser user,TUserIssueService userIssueService)
{
var openIssueCount = await userIssueService.Get0penissueCountAsync(user.Id);
if CopenIssueCount >- 3)
{
throw new BusinessException("IssueTracking:ConcurrentOpenIssueLimit");
}
AssignedUserId - user.Id;
}
public void CleanAssignment()
{
AssignedUserId = null;
}
}
在这里,我们将把这个逻辑移到领域服务中。
首先,更改Issue类:
public class Issue : AggregateRoot<Guid>
{
//...
public Guid? AssignedUserId { get; internal set;}
}
删除了与分配相关的方法
将AssignedUserId属性的 setter 从私有更改为internal ,允许从领域服务设置它。
下一步是创建一个领域服务,命名为IssueManager ,具有AssignToAsync来分配给定的问题给给定的用户。
public class IssueManager : DomainService
{
private readonly IRepository<Issue,Guid>_issueRepository;
public IssueManager(IRepository<Issue,Guid> issueRepository)
{
_issueRepository = issueRepository;
}
public asyne Task AssignToAsync(Issue issue,AppUser user)
{
var openIssueCount = await _issueRepository.CountAsync(
i =>i.AssignedUserId == user.Id && !i.IsClosed);
if (openIssueCount >= 3)
{
throw new BusinessException("IssueTracking:ConcurrentOpenIssueLimit");
}
issue.AssignedUserId = user.Id;
}
}
IssueManager可以注入任何服务依赖项并用于查询用户的未解决问题计数(open issue count)
我们更喜欢并建议对领域使用Manager后缀服务。
这种设计的唯一问题是Issue.AssignedUserId对类外修改开放的。但是,它不是public的,是internal的只能在同一个内部程序集修改它,如此示例解决方案的IssueTracking.Domain项目。我们认为这是合理的:
领域层开发人员已经意识到领域规则,他们使用IssueManager
应用层开发人员已经被迫使用IssueManager因为他们不直接设置它
虽然两种方法之间存在权衡,但当业务逻辑需要外部服务的时候我们更喜欢创建领域服务。
如果你没有充分的理由,我们认为没有必要为领域服务创建接口(为IssueManager创建IIssueManager接口)
应用服务是一个无状态的实现应用程序用例的服务。应用服务通常获取并返回 DTO 。它由展示层使用。它使用和协调领域对象(实体、存储库等)来实现用例。
应用服务的共同原则是:
实现特定于当前用例的应用程序逻辑。不实现核心域应用服务内部的逻辑。我们会回到应用程序领域逻辑之间的差异。
永远不要为应用服务方法获取或返回实体。这打破了领域层的封装。始终获取和返回 DTO。
示例:将问题分配给用户Assigning an issue to a user
using System;
using System.Threading.Tasks;
using IssueTracking.Users;
using Microsoft.AspNetCore.Authorization;
using Volo.Abp.Application.Services;
using Volo.Abp.Domain.Repositories;
namespace IssueTracking.Issues
{
public class IssueAppService : ApplicationService,IIssueAppService
{
private readonly IssueManager _issueManager;
private readonly IRepository<Issue, Guid> _issueRepository;
private readonly IRepository<AppUser,Guid> _userRepository;
public IssueAppService(IssueManager issueManager,IRepository<Issue,Guid>issueRepository,
IRepository<AppUser,Guid> userRepository)
{
_issueManager = issueManager;
_issueRepository = issueRepository;
_userRepository = userRepository;
}
[Authorize]
public async Task AssignAsync (IssueAssignDto input)
{
var issue = await _issueRepository.GetAsync(input. IssueId);
var user = await__userRepository.GetAsync(input.UserId);
await _issueManager.AssignToAsync(issue,user);
await _issueRepository.UpdateAsync(issue);
}
}
}
一个应用服务方法通常像这里实现的那样包含三个步骤:
从数据库中获取相关的领域对象来实现用例。
使用领域对象(领域服务、实体等)来执行实际操作。
更新已更改的实体到数据库中。
本例中的IssueAssignDto是一个简单的 DTO 类:
using System;
namespace IssueTracking.Issues
{
public class IssueAssignDto
{
public Guid IssueId { get;set;}
public Guid UserId { get;set;}
}
}
如果你使用的是 EF Core,则不需要最后一次更新(await _issueRepository.UpdateAsync(issue))因为它有一个变更跟踪系统。如果你想使用EF Core 变更跟踪功能的优势,请参阅上面关于数据库独立原则部分的讨论。
数据传输对象Data transfer Objects
DTO是应用层和表现层之间用于传送状态的简单对象(数据)。所以,应用服务Application Service方法获取和返回 DTO。
通用 DTO 原则和最佳实践
就其性质而言,DTO应该是可序列化的。因为,大多数时候它是通过网络传输的。所以应该有一个无参数(空)构造函数
不应包含任何业务逻辑
永远不要继承或引用实体。
输入 DTO(Input DTOs) (那些被传递到应用服务方法)与输出 DTO(Output DTOs)(那些从应用程序服务方法返回)是不同的。所以,他们应该被区别对待。
输入 DTO 最佳实践
不要为输入 DTO 定义未使用的属性
仅定义用例所需的属性!否则,客户在使用该应用服务时会感到困惑。您当然可以定义**可选属性,**但是当客户端提供它们时它们应该影响用例的工作方式。
首先这条规则似乎没有必要。谁会为一个方法定义未使用的参数(输入 DTO 属性)?但它会发生,尤其是当您尝试重用输入 DTO 时。
不要重复使用输入 DTO
为每个用例定义一个专门的输入 DTO(应用服务方法)。否则,在某些情况下某些属性没有被使用,这违反了上面定义的规则:不要为输入 DTO 定义未使用的属性。
有时,两个用例重用相同的 DTO 类似乎很有吸引力,因为它们几乎相同。即使现在他们是一样的,可能会随着时间变得不同你会遇到同样的问题。代码重复是一种比耦合用例更好的实践。重用输入的DTO的另一种方式是DTO彼此继承。虽然这在极少数情况下很有用,但大多数情况下它把你带到同样的问题。
示例:用户应用服务User Application Service
public interface IUserAppService : IApplicationService
{
Task CreateAsync(UserDto input);
Task UpdateAsync(UserDto input);
Task ChangePasswordAsync(UserDto input);
}
IUserAppService在所有方法中都使用UserDto作为输入 DTO(用例)。UserDto定义如下:
**public class UserDto
{
public Guid Id { get; set; }
public string UserName { get;set;}
public string Email { get;set;}
public string Password { get; set;}
public DateTime CreationTime { get; set;}
}
**
对于这个例子:
Create 方法中不使用ID属性由服务器确定
Update不使用Password属性,因为我们有另一个方法
CreationTime从未被使用,因为我们不能让客户发送创建时间。它应该在服务器中设置。
一个真正的实现可以是这样的:
public interface IUserAppService : IApplicationService
{
Task CreateAsync(UserCreationDto input);
Task UpdateAsync(UserUpdateDto input);
Task ChangePasswordAsync(UserChangePasswordDto input);
}
使用给定的输入 DTO 类:
public class UserCreationDto
{
public string UserName { get;set;]
public string Email { get; set;}
public string Password { get;set;}
}
public class UserUpdateDto
{
public Guid Id { get; set;}
public string UserName { get;set;}
public string Email { get; set;}
}
public class UserChangePasswordDto
{
public Guid Id { get; set;}
public string Password { get; set;}
}
虽然写了更多的代码但是这是更易于维护的方法。
例外情况:此规则可能有一些例外:如果你总是想并行开发两种方法,它们可能共享相同的输入 DTO(通过继承或直接重用)。例如,如果您的报表页面包含一些过滤器而你有多种应用服务方法(比如 screen报表、excel报表和csv报表方法)使用相同过滤但返回不同的结果,您可能需要重用相同的过滤器输入 DTO 来**耦合这些用例。**因为,在这个例子,每当你改变一个过滤器时,你必须使对所有方法进行必要的更改以保持报表系统的一致。(Notes:就是出现了这么一种情况:输入参数总是相同的只是输出不同格式的数据Excel、CSV等这时候可以考虑使用相同的DTO)
输入 DTO 验证逻辑
仅在 DTO 内实施格式验证。用数据注释验证(Data Annotation Validation)属性或实现IValidatableObject用于格式验证。
不要执行领域验证。例如,不要尝试检查 DTO 中的唯一用户名约束
示例:使用数据注释属性Using Data Annotation Attributes
using System.ComponentModel.DataAnnotations;
namespace IssueTracking.Users
{
public class UserCreationDto
{
[Required]
[StringLength(UserConsts.MaxUserNameLength)]
public string UserName { get; set; }
[Required]
[EmailAddress]
[StringLength(UserConsts.MaxEmailLength]
public string Email { get; set;}
[Required]
[StringLength(UserConsts.MaxEmailLength,MinimumLength = UserConsts. MinPasswordLength)]
public string Password { get; set;}
}
}
ABP 框架自动验证输入 DTO,抛出AbpValidationException并将 HTTP 状态400返回给客户端指示输入无效。
一些开发人员认为最好将验证规则和 DTO 类分开。我们认为声明式 (DataAnnotation) 方法实用且有用,不会导致任何设计问题。但是,ABP 也支持FluentValidation 如果您更喜欢其他方法,参考验证文件包含的所有验证选项。
输出 DTO 最佳实践
保持输出DTO总数最少。尽可能重复使用(例外:不要重用输入 DTO 作为输出 DTO)
DTO的输出可以包含比用于在客户端代码更多的属性
从Create和Update方法返回实体 DTO
这些建议的主要目标是:
使客户端代码易于开发和扩展
处理相似但不相同的DTO客户端有问题。
未来 UI/客户端需要其他属性是很常见的。返回所有属性(通过考虑实体的安全性和特权)使客户端代码易于改进,无需接触到后端代码。
如果你开放API给第三方客户端,你不知道每个客户的需求
使服务端代码易于开发和扩展
你可以了解和维护更少的类
你可以重用 Entity->DTO对象映射代码
从不同的方法返回相同的类型使创建新方法变得简单明了
示例:从不同的方法返回不同的 DTO(Returning Different DTOs from different methods)
public interface IUserAppService : IApplicationService
{
UserDto Get(Guid id);
List<UserNameAndEmailDto> GetUserNameAndEmail(Guid id);
List<string>GetRoles(Guid id);
List<UserListDto> GetList();
UserCreateResultDto Create(UserCreationDto input);
UserUpdateResultDto Update(UserUpdateDto input);
}
为了使示例更清晰我们没有使用异步方法,但在你的真实世界应用程序中请使用异步!)
上面的示例代码为每个方法返回不同的 DTO 类型。你可以想象到,会有很多重复的代码用于查询数据,将实体映射到 DTO。
上面的IUserAppService服务可以简化:
public interface TUserAppService : IApplicationService
{
UserDto Get(Guid id);
List<UserDto> GetList();
UserDto Create(UserCreationDto input);
UserDto Update(UserUpdateDto input);
}
使用单输出 DTO:
public class UserDto
{
public Guid Id { get;set; }
public string UserName { get;set;}
public string Email { get; set;}
public DateTime CreationTime { get; set;}
public List<string> Roles { get;set;}
}
移除了GetUserNameAndEmail和GetRoles方法因为Get方法已经返回了必要的信息
GetList现在返回与Get相同的DTO
Create和Update也返回相同的UserDto
如上所述,使用相同的 DTO 有很多优点。例如,考虑在UI上显示用户网格的场景。更新用户后,您可以获得返回值并在 UI 上更新它。所以,你不需要再调用GetList。这就是为什么我们建议返回实体 DTO(此处为UserDto)作为Create和Update 的返回值操作。
讨论Discussion
一些输出 DTO 的建议可能不适合所有场景。这些建议可能忽略了性能,尤其是当大数据集返回或当您为你自己的 UI 创建服务,你有太多并发请求。
在这些情况下,您可能需要创建专门的输出信息最少的 DTO。以上建议是对应用维护代码库比可忽略的性能损失更重要的情况**。**
对象到对象映射Object to Object Mapping
当两个对象具有相同或相似的属性时,自动对象到对象映射是一种有用的方法,将值从一个对象复制到另一个对象。
DTO 和实体Entity类通常具有相同/相似的属性并且您通常需要从实体创建 DTO 对象。ABP 的对象到对象映射系统集成了AutoMapper,使这些操作相对于手动映射来说更容易。
仅对实体到输出 DTO使用自动对象映射
不要使用DTO 输入到实体自动对象映射
不应该使用输入 DTO到实体自动映射的一些原因:
实体类通常有一个构造函数,它接受参数并确保有效的对象创建。自动对象映射操作一般需要一个空的构造函数
大多数实体属性具有私有 setter您应该使用受控的方式方法来更改这些属性
您通常需要仔细验证和处理的用户/客户端输入而不是盲目地映射到实体属性
虽然其中一些问题可以通过映射解决配置(例如,AutoMapper 允许定义自定义映射规则),它使您的业务代码隐式/隐藏并与基础设施紧密耦合。我们认为业务代码应该明确、清晰且易于理解。
请参阅下面的实体创建部分有关示例落实本节提出的建议。
用例示例
Example Use Cases
本节将演示一些示例用例和讨论替代方案。
从实体/聚合根类创建对象是实体生命周期的第一步。聚合/聚合根规则和最佳实践部分建议为 Entity 类创建一个主构造函数保证创建一个有效的实体。所以,每当我们需要创建该实体的实例,我们都应该始终使用它的构造函数。
请参阅下面的Issue聚合根(Aggregate Root)类:
public class Issue : AggregateRoot<Guid>
{
public Guid RepositoryId { get;private set;}
public string Title { get;private set;}
public string Text { get;set;}
public Guid? AssignedUserId { get;internal set; }
public Issue(Guid id,Guid repositoryId,string title,string text = null): base(id)
{
RepositoryId = repositoryId;
Title = Check.NotNu110rWhiteSpace(title,nameof(title));
Text = text;//Allow empty/null
}
private Issue()
{
/* Empty constructor is for ORMS */
}
public void SetTitle(string title)
{
Title = Check.NotNu110rWhiteSpace(title,nameof(title));
}
//...
}
public class IssueAppService : ApplicationService,IIssueAppService
{
private readonly IssueManager _issueManager;
private readonly IRepository<Issue, Guid> _issueRepository;
private readonly IRepository<AppUser,Guid> _userRepository;
public IssueAppService(IssueManager issueManager,
IRepository<Issue,Guid> issueRepository,
IRepository<AppUser,Guid>userRepository)
{
_issueManager = issueManager;
_issueRepository = issueRepository;
_userRepository = userRepository;
}
public async Task<IssueDto> CreateAsync(IssueCreationDto input)
{
//Create a valid entity
var issue = new Issue(GuidGenerator.Create(),input.RepositoryId,input.Title,input.Text);
//Apply additional domain actions
if (input.AssignedUserId.HasValue)
{
var user= await __userRepository.GetAsync(input.AssignedUserId.Value);
await _issueManager.AssignToAsync(issue,user);
}
// Save
await _issueRepository.InsertAsync(issue);
//Return a DT0 represents the new Issue
return 0bjectMapper.Map<Issue,IssueDto>(issue);
}
}
CreateAsync方法:
使用Issue构造函数来创建一个有效的问题。它使用IGuidGenerator服务创建Id。在这里它没有使用自动对象映射
如果客户端想在对象创建时将此问题issue分配给用户user,它使用IssueManager允许在assignment之前执行必要的检查
将实体保存到数据库
最后使用IObjectMapper自动从新Issue实体创建IssueDto返回
在实体创建中应用领域规则Applying Domain Rules on Entity Creation
示例Issue实体在实体创建时没有业务规则,除了构造函数中的一些格式验证。但是,实体创建时可能存在一些额外的业务规则需要检查。
例如,假设如果已经存在具有完全相同Title的issue您不允许再次创建。那么问题来了,在哪里执行这个规则?在应用服务中实现这个规则是不正确的,因为它是一个应始终检查的核心业务(领域)规则。
这个规则应该在领域服务中实现,当前示例就是IssueManager。所以,我们需要强制应用程序层总是使用IssueManager创建一个新的Issue。
首先,我们可以将Issue构造函数设置为internal,而不是public:
public class Issue : AggregateRoot<Guid>
{
internal Issue(Guid id,Guid repositoryId,string title,string text = null): base(id)
{
RepositoryId = repositoryId;
Title = Check.NotNu110rWhiteSpace(title,nameof(title));
Text = text;//Allow empty/null
}
//...
}
这可以防止应用服务直接使用构造函数,因此他们将使用IssueManager。接下来我们可以将CreateAsync方法添加到IssueManager:
using System;
using System.Threading.Tasks;
using Volo.Abp;
using Volo.Abp.Domain.Repositories;
using Volo.Abp. Domain.Services;
namespace IssueTracking.Issues
{
public class IssueManager : DomainService
{
private readonly IRepository<Issue,Guid> _issueRepository;
public IssueManager(IRepository<Issue,Guid issueRepository)
{
_issueRepository = issueRepository;
}
public async Task<Issues> CreateAsync(Guid repositoryId,string title,string text = null)
{
if (await_issueRepository.AnyAsync(i => i.Title == title)
{
throw new BusinessException("IssueTracking:IssueWithSameTitleExists");
}
return new Issue(GuidGenerator.Create(),repositoryld,title,text);
}
}
}
CreateAsync方法检查是否已经存在具有相同的标题的问题并抛出business exception
如果没有重复,它会创建并返回一个新的Issue
IssueAppService做出如下改变以使用IssueManager的CreateAsync方法:
**public class IssueAppService : ApplicationService,IIssueAppService
{
private readonly IssueManager _issueManager;
private readonly IRepository<Issue,Guid> _issueRepository;
private readonly IRepository<AppUser,Guid> _userRepository;
public IssueAppService(IssueManager issueManager,
IRepository<Issue,Guid> issueRepository,
IRepository<AppUser,Guid> userRepository)
{
_issueManager=issueManager;
_issueRepository = issueRepository;
_userRepository - userRepository;
}
public async Task<IssueDto> CreateAsync(IssueCreationDto input)
{
//Create a valid entity using the IssueManager
var issue = await _issueManager.CreateAsync(input.RepositoryId,input.Title,input.Text);
//Apply additional domain actions
if (input.AssignedUserId.HasValue)
{
var user = await _userRepository.GetAsync(input.AssignedUserId.value);
await _issueManager.AssignToAsync(issue,user);
}
//Save
await _issueRepository.InsertAsync(issue);
//Return a DTO represents the new issue
return 0bjectMapper.Map<Issue,IssueDto>(issue);
}
}
//IssueCreationDto class
public class IssueCreationDto
{
public Guid RepositoryId { get; set;}
[Required]
public string Title { get;set;}
public Guid? AssignedUserId { get; set;}
public string Text { get; set;}
}
**
讨论:为什么Issue没有在IssueManager 中保存到数据库?
您可能会问“为什么IssueManager没有将Issue保存到数据库?”。我们认为这是应用服务的责任。
因为,应用服务可能需要在保存之前对Issue对象进行额外的更改/操作。如果Domain Service 保存它,那么保存操作就重复了;
由于数据库往返翻倍导致性能下降
它需要涵盖两者操作的显式数据库事务
如果其他操作因为业务规则取消实体创建,数据库事务应该回滚
当你仔细查看IssueAppService代码 ,将看到IssueManager.CreateAsync不将Issue保存到数据库中的优点。否则,我们需要执行一次插入(在IssueManager 中)和一次更新(在Assignment之后)。
讨论:为什么重复标题检查不在应用服务中实现?
我们可以简单地说“因为它是一个核心领域逻辑,应该在领域层中实现”。但是,它带来了一个新问题“你是怎么决定它是一个核心的领域逻辑,而不是应用程序逻辑?”(我们将稍后讨论更多细节的区别)。
对于这个例子,一个简单的问题可以帮助我们做出决定:“如果我们有另一种方式(用例)来创建问题,我们还应该应用同样的规则吗?这条规则应该总是被被应用”。你可能会想“为什么我们有第二个创建问题的方式?”。然而,在现实生活中,你有:
应用程序的最终用户可能会在您的应用程序的标准 UI上创建issues
您可能有第二个后台应用程序由您自己的员工使用,您可能想要提供一种创建issues的方法(在这种情况下可能具有不同的授权规则)
您可能有一个对第三方开放的 HTTP API客户,他们会创建issues
您可能有一个后台服务如果它检测到一些问题,就会创建issues。这样,它将创建一个没有任何用户交互的issues(可能没有任何标准授权检查)
您可能在 UI 上有一个按钮可以转换某事(例如,讨论)到一个issue
我们可以举出更多的例子。所有这些都应该由不同的应用服务方法实现(见下面的多应用层部分),但它们始终遵循规则:新issue的标题不能与任何现有问题相同!这就是为什么这个逻辑是一个核心领域逻辑**,应该位于领域层,不应该在所有这些应用服务方法中**重复。
创建实体后,它会被用户更新/操作直到从系统中删除。可能有不同用例直接或间接地改变了一个实体。
在本节中,我们将讨论一个典型的更新操作更改Issue 的多个属性。
这一次,从UpdateDTO开始:
这一次,从UpdateDTO开始:
public class UpdateIssueDto
{
[Required]
public string Title { get; set;}
public string Text { get;set; }
public Guid? AssignedUserId { get; set;}
}
通过与IssueCreationDto进行比较,您看不到RepositoryId。因为,我们的系统不允许转移问题(考虑下 GitHub 仓储)。只有标题Title是必须的其他属性是可选的。
让我们看看IssueAppService 中的Update实现:
public class IssueAppService : ApplicationService,IIssueAppService
{
private readonly IssueManager _issueManager;
private readonly IRepository<Issue,Guid> _issueRepository;
private readonly IRepository<AppUser,Guid> _userRepository;
public IssueAppService(IssueManager issueManager,
IRepository<Issue, Guid> issueRepository,
IRepository<AppUser,Guid> userRepository)
{
_issueManager = issueManager;
_issueRepository = issueRepository;
_userRepository = userRepository;
}
public async Task<IssueDto> UpdateAsync(Guid id, UpdateIssueDto input)
{
//Get entity from database
var issue = await _issueRepository. GetAsync(id);
//Change Title
await _issueManager.ChangeTitleAsync(issue,input.Title);
// Change Assigned User
if (input.AssignedUserId.HasValue)
{
var user = await _userRepository.GetAsync(input.AssignedUserId.Value);
await _issueManager.AssignToAsync(issue, user);
}
//Change Text (no business rule,all values accepted)
issue.Text = input.Text;
// Update entity in the database
await issueRepository.UpdateAsync(issue);
//Return a DTo represents the new Issue
return 0bjectMapper.Map<Issue, IssueDto>(issue);
}
}
UpdateAsync方法把id作为单独的参数获取。它不包含在UpdateIssueDto 中。这个设计有助于 ABP 正确定义 HTTP 路由的决策当您将此服务自动公开为 HTTP API 端点时。所以,这与 DDD 无关。
它首先从数据库中获得了Issue实体
使用IssueManager的ChangeTitleAsync而不是直接调用Issue.SetTitle(…)。因为我们需要实现刚刚在实体创建中完成的重复Title检查。这需要Issue类和IssueManager类做些修改(将在下面解释)
使用IssueManager的AssignToAsync方法,如果此请求更改assigned user
直接设置Issue.Text因为设置Text有没有业务规则。如果以后需要,我们可以随时重构
保存更改到数据库。再次,保存改变实体协调业务对象和事务是应用服务的责任。如果IssueManager已在ChangeTitleAsync和AssignToAsync方法内部保存,那么将是双数据库操作(见以上讨论:为什么没有在IssueManager中保存Issue到数据库?)
最后使用IObjectMapper从updated Issue实体自动创建IssueDto返回
如前所述,我们需要对Issue类和IssueManager类 进行一些更改
首先,在Issue类中将 SetTitle 设为internal:
internal void SetTitle(string title)
{
Title = Check.NotNul10rWhiteSpace(title,nameof(title);
}
然后向IssueManager添加一个新方法来更改Title:
public async Task ChangeTitleAsync(Issue issue,string title)
{
if(issue.Title== title)
{
return;
}
if(await _issueRepository.AnyAsync(i =>i.Title==title))
{
throw new BusinessException("IssueTracking: IssueWithSameTitleExists");
}
issue.SetTitle(title);
}
如前所述,领域驱动中的业务逻辑设计分为两部分(层):领域逻辑和应用逻辑:
领域逻辑由系统的核心领域规则组成而 Application Logic 实现了特定于应用程序的用户用例。
虽然定义很明确,但实现起来可能并不简单。您可能不确定哪些代码应该放在应用层,哪些代码应该在领域层。本节试图解释这些差异。
当您的系统很大时,DDD 有助于处理复杂性。特别是,如果有多个应用程序正在在单个领域中开发,那么领域逻辑 vs 应用逻辑分离变得更加重要。
假设您正在构建一个具有多个应用程序的系统:
每个应用程序都会有不同的要求,不同的用户用例**(应用服务方法),不同的DTO,不同的验证和授权**规则…等
将所有这些逻辑混合到一个应用服务层中,服务包含太多if条件复杂的业务逻辑使您的代码更难开发、维护并测试并导致潜在的错误。
如果您在一个领域中有多个应用程序:
为了更清楚地区分实现,您可以为每个应用程序类型创建的不同项目 (. csproj )
例如:
示例:在领域服务中创建新组织Creating a new Organization in a Domain Service
public class OrganizationManager : DomainService
{
private readonly IRepository<Organization> _organizationRepository;
private readonly ICurrentUser _currentUser;
private readonly IAuthorizationService _authorizationService;
private readonly IEmailSender _emailSender;
public OrganizationManager(IRepository<Organization> organizationRepository,
ICurrentUser currentUser,
IAuthorizationService authorizationService,
IEmailSender emailSender)
{
_organizationRepository = organizationRepository;
_currentUser = currentUser;
_authorizationService= authorizationService;
_emailSender = emailSender;
}
public async Task<Organization> CreateAsync(string name)
{
if(await _organizationRepository.AnyAsync(x => x.Name==name))
{
throw new BusinessException("IssueTracking: DuplicateOrganizationName");
}
await _authorizationService.CheckAsync("OrganizationCreationPermission");
Logger.LogDebug($"Creating organization {name} by {_currentUser.UserName}");
var organization = new Organization();
await _emailSender.SendAsync("systemadmineissuetracking.com","New Organization",
"A new organization created with name: " +name);
return organization;
}
}
让我们一步一步看CreateAsync方法来讨论哪些代码应该或不应该在领域服务中
正确CORRECT:它首先检查组织名称是否重复在这种情况下,name重复会抛出异常。这是与核心领域规则相关的东西,我们从不允许重名
错误WRONG:领域服务不应该检查授权。授权应该在应用层
**错误WRONG:**它记录一条消息,包括当前用户的用户名。领域服务不应依赖在当前用户上。领域服务应该可用即使系统中没有用户。当前用户(Session) 应该是一个表示/应用(Presentation/Application)层相关概念
**错误WRONG:**它发送了一封关于这个新组织创建的电子邮件。我们认为这也是一个特定于用例的业务逻辑。您可能希望在不同的用例下发送不同的电子邮件或在某些情况下不需要发送电子邮件
示例:在应用服务中创建新组织Creating a new Organization in an Application Service
public class OrganizationAppService : ApplicationService
{
private readonly OrganizationManager _organizationManager;
private readonly IPaymentService _paymentService;
private readonly IEmailSender _emailSender;
public OrganizationAppService(OrganizationManager organizationManager,
IPaymentService paymentService,
IEmailSender emailSender)
{
_organizationManager = organizationManager;
_paymentService = paymentService;
_emailSender = emailSender;
}
[UnitOfWork]
[Authorize("OrganizationCreationPermission")]
public async Task<Organization> CreateAsync(CreateOrganizationDto input)
{
await paymentService.ChargeAsync(CurrentUser.Id,GetOrganizationPrice());
var organization = await_organizationManager.CreateAsync(input.Name);
awit _organizationManager.InsertAsync(organization);
await emailsender.SendAsync("[email protected]",
"New Organization",
"A new organization created with name: " + input.Name);
return organization;
}
private double GetOrganizationPrice()
{
return 42;//Gets from somewhere else...
}
}
让我们一步一步看CreateAsync方法来讨论哪些代码应该或不应该在应用服务中
**正确CORRECT:**应用程序服务方法应该是原子工作unit of work (transactional)。ABP工作单元系统自动实现(甚至不需要给应用服务添加[UnitOfWork]属性)
正确CORRECT:授权应该在应用层。在这里它使用[Authorize]]属性
**正确CORRECT:**Payment(一项基础设施服务)被调用到为此操作收费(创建一个组织是我们业务中的一项付费服务)
**正确CORRECT:**应用程序服务方法负责保存对数据库的更改
**正确CORRECT:**我们可以发送电子邮件通知系统管理员
**错误WRONG:**不要从应用服务返回实体,而是返回 DTO
讨论:我们为什么不把支付逻辑移到领域服务?
您可能想知道为什么付款代码不在OrganizationManager。这是一件重要的事情,我们从不想错过付款。
但是,重要并不足以将代码作为核心业务逻辑。我们可能还有其他用例,其中我们创建一个新组织不收取任何费用。
例如:
管理员用户可以使用后台应用程序来创建新组织无需支付任何费用
一个后台工作数据导入/集成/同步系统可能需要创建组织没有任何支付操作
如您所见,**支付不是创建一个有效的组织的必要操作。**它是一个特定于用例的应用程序逻辑。
示例:CRUD 操作
public class IssueAppService
{
private readonly IssueManager _issueManager;
public IssueAppService(IssueManager issueManager)
{
_issueManager=issueManager;
}
public async Task<IssueDto> GetAsync(Guid id)
{
return await _issueManager.GetAsync(id);
}
public async Task CreateAsync(IssueCreationDto input)
{
await _issueManager.CreateAsync(input);
}
public async Task UpdateAsync(UpdateIssueDto input)
{
await _issueManager.UpdateAsync(input);
}
public async Task DeleteAsync(Guid id)
{
await _issueManager.DeleteAsync(id);
}
}
此应用程序服务本身什么都不做,而是委托所有工作交给领域服务。它甚至将 DTO 传递给IssueManager
不要为没有任何域逻辑的简单CRUD操作创建领域服务方法
永远不要将DTO传递给领域服务或从领域服务返回DTO
应用服务可以直接与存储库查询、创建、更新或删除数据一起工作,除非在这些操作期间应该执行一些领域逻辑。在这种情况下,创建领域服务方法,除非真的有必要否则不要创建领域方法。
不要认为将来可能需要它们就创建这样的 CRUD 领域服务方法(雅格尼)!当你需要重构现有代码时由于应用层优雅地抽象了领域层,重构过程不会影响UI层和其他客户端。
领域驱动设计(Domain Driven Design)是 Eric Evans 在2003年首次引入的一种软件设计策略,旨在将复杂的领域(实际问题)简化为可扩展和可维护的软件解决方案。
1、该何时使用 DDD
DDD 非常适合业务(不仅仅是技术)非常复杂的大型应用程序。
这种应用程序需要借助领域专家的知识。 领域模型本身应包含有某种意义的行为,应体现出业务规则和交互,而不仅仅是存储和检索数据存储中各种记录的当前状态。
2、何时不该使用 DDD
DDD 需要在建模、体系结构和通信方面进行投资,这对于较小型的应用程序或本质只是 CRUD(创建/读取/更新/删除)的应用程序来说可能并不值得。
如果选择采用 DDD 处理应用程序,但发现域中有一个没有任何行为的贫乏性模型,则可能需要重新考虑处理方法。
可能是该应用程序不需要 DDD,也可能是你需要别人帮助你重构应用程序,将业务逻辑封装在域模型中,而不是数据库或用户界面中。
可以使用混合方法,只对应用程序中的事务性区域或比较复杂的区域使用 DDD,而不对应用程序中比较简单的 CRUD 或只读部分使用 DDD。
例如,如果是为显示报表或将仪表板数据可视化而查询数据,则无需具有聚合约束。 使用单独的、更简单的读取模型处理这类要求是完全可以接受的。