领域驱动设计
领域驱动设计
领域驱动设计(DDD) 是一种通过将实现连接到持续进化的模型来满足复杂需求的软件开发方法. 领域驱动设计的前提是:
- 把项目的主要重点放在核心领域和领域逻辑上
- 把复杂的设计放在领域模型上
- 发起技术专家和领域专家之间的创造性协作,以迭代方式完善解决特定领域问题的概念模型
分层
ABP框架遵循DDD原则和模式去实现分层应用程序模型,该模型由四个基本层组成:
- 表示层: 为用户提供接口. 使用应用层实现与用户交互.
- 应用层: 表示层与领域层的中介,编排业务对象执行特定的应用程序任务. 使用应用程序逻辑实现用例.
- 领域层: 包含业务对象以及业务规则. 是应用程序的核心.
- 基础设施层: 提供通用的技术功能,支持更高的层,主要使用第三方类库.
领域层
实体
实体通常映射到关系型数据库的表中.
实体都继承自Entity
1 | public class Book : Entity<Guid> |
如果你不想继承基类
Entity<TKey>
,也可以直接实现IEntity<TKey>
接口
Entity<TKey>
类只是用给定的主 键类型定义了一个Id属性,在上面的示例中是Guid类型.可以是其他类型如string, int, long
或其他你需要的类型.
Guid主键的实体
- 创建一个构造函数,获取ID作为参数传递给基类.如果没有为
GUID Id
赋值,ABP框架会在保存时设置它,但是在将实体保存到数据库之前最好在实体上有一个有效的Id
. - 如果使用带参数的构造函数创建实体,那么还要创建一个
private
或protected
构造函数. 当数据库提供程序从数据库读取你的实体时(反序列化时)将使用它. - 不要使用
Guid.NewGuid()
来设置Id
! 在创建实体的代码中使用IGuidGenerator
服务 传递Id参数.IGuidGenerator
经过优化可以产生连续的GUID
.这对于关系数据库中的聚集索引非常重要.
具有复合键的实体
有些实体可能需要 复合键 .在这种情况下,可以从非泛型Entity
类派生实体.如:
1 | public class UserRole : Entity |
上面的例子中,复合键由UserId和RoleId
组成.在关系数据库中,它是相关表的复合主键. 具有复合键的实体应当实现上面代码中所示的GetKeys()
方法.
需要注意,复合主键实体不可以使用
IRepository<TEntity, TKey>
接口,因为它需要一个唯一的Id
属性. 但你可以使用IRepository<TEntity>
.
聚合根
AggregateRoot<TKey>
类继承自Entity<TKey>
类,所以默认有Id
这个属性
值得注意的是 ABP 会默认为聚合根创建仓储,当然,ABP也可以为所有的实体创建仓储
聚合根例子
这是一个具有子实体集合的聚合根例子:
1 | public class Order : AggregateRoot<Guid> |
如果你不想你的聚合根继承
AggregateRoot<TKey>
类,你可以直接实现IAggregateRoot<TKey>
接口
Order
是一个具有Guid
类型Id
属性的 聚合根.它有一个OrderLine
实体集合.OrderLine
是一个具有组合键(OrderId和 ProductId
)的实体.
虽然这个示例可能无法实现聚合根的所有最佳实践,但它仍然遵循良好的实践:
Order
有一个公共的构造函数,它需要 minimal requirements 来构造一个”订单”实例.因此,在没有Id
和referenceNo
的时候是无法创建订单的.protected/private
的构造函数只有从数据库读取对象时 反序列化 才需要.OrderLine
的构造函数是internal
的,所以它只能由领域层来创建.在Order.AddProduct
这个方法的内部被使用.Order.AddProduct
实现了业务规则将商品添加到订单中
所有属性都有protected的set
.这是为了防止实体在实体外部任意改变.因此,在没有向订单中添加新产品的情况下设置TotalItemCount
将是危险的.它的值由AddProduct
方法维护.
带有组合键的聚合根
虽然这种聚合根并不常见(也不建议使用),但实际上可以按照与上面提到的跟实体相同的方式定义复合键.在这种情况下,要使用非泛型的AggregateRoot
基类.
BasicAggregateRoot类AggregateRoot
类实现了 IHasExtraProperties 和 IHasConcurrencyStamp
接口,这为派生类带来了两个属性. IHasExtraProperties
使实体可扩展(请参见下面的 额外的属性部分) 和 IHasConcurrencyStamp
添加了由ABP框架管理的 ConcurrencyStamp
属性实现乐观并发. 在大多数情况下,这些是聚合根需要的功能.
但是,如果你不需要这些功能,你的聚合根可以继承 BasicAggregateRoot<TKey>(或BasicAggregateRoot)
.
值对象
值对象类必须实现 GetAtomicValues()
方法来返回原始值
仓储
仓储用于领域对象在数据库中的操作, 通常每个 聚合根 或不同的实体创建对应的仓储.
通用(泛型)仓储
ABP为每个聚合根或实体提供了 默认的通用(泛型)仓储 . 你可以在服务中注入 IRepository<TEntity, TKey>
使用标准的CRUD操作.
默认通用仓储用法示例:
1 | public class PersonAppService : ApplicationService |
在这个例子中
PersonAppService
在它的构造函数中注入了IRepository<Person, Guid>
.Create
方法使用了InsertAsync
创建并保存新的实体.GetList
方法使用标准LINQ Where 和 ToList
方法在数据源中过滤并获取People
集合.
通用仓储提供了一些开箱即用的标准CRUD功能:
- 提供
Insert
方法用于保存新实体. - 提供
Update 和 Delete
方法通过实体或实体id
更新或删除实体. - 提供
Delete
方法使用条件表达式过滤删除多个实体. - 实现了
IQueryable<TEntity>
, 所以你可以使用LINQ
和扩展方法FirstOrDefault, Where, OrderBy, ToList
等… - 所有方法都具有
sync(同步) 和 async(异步)
版本
只读仓储
对于想要使用只读仓储的开发者,我们提供了IReadOnlyRepository<TEntity, TKey> 与 IReadOnlyBasicRepository<Tentity, TKey>
接口.
无主键的通用(泛型)仓储
如果你的实体没有id
主键 (例如, 它可能具有复合主键) 那么你不能使用上面定义的 IRepository<TEntity, TKey>
, 在这种情况下你可以仅使用实体(类型)注入 IRepository<TEntity>
.
IRepository<TEntity>
有一些缺失的方法, 通常与实体的Id
属性一起使用. 由于实体在这种情况下没有Id
属性, 因此这些方法不可用. 比如Get
方法通过id
获取具有指定id
的实体. 不过, 你仍然可以使用IQueryable<TEntity>
的功能通过标准LINQ
方法查询实体.
自定义仓储
对于大多数情况, 默认通用仓储就足够了. 但是, 你可能会需要为实体创建自定义仓储类.
自定义仓储接口,首先在领域层定义一个仓储接口:
1 | public interface IPersonRepository : IRepository<Person, Guid> |
此接口扩展了 IRepository<Person, Guid>
以使用已有的通用仓储功能.
自定义存储库依赖于你使用的数据访问工具. 在此示例中, 我们将使用Entity Framework Core
:
1 | public class PersonRepository : EfCoreRepository<MyDbContext, Person, Guid>, IPersonRepository |
你可以直接使用数据库访问提供程序 (本例中是 DbContext ) 来执行操作.
IAsyncQueryableExecuterIAsyncQueryableExecuter
是一个用于异步执行 IQueryable<T>
对象的服务,不依赖于实际的数据库提供程序.
示例:
注入并使用IAsyncQueryableExecuter.ToListAsync()
方法
1 | using System; |
ABP框架使用实际数据库提供程序的API异步执行查询.虽然这不是执行查询的常见方式,但它是使用异步API而不依赖于数据库提供者的最佳方式.
领域服务
在领域驱动设计(DDD) 解决方案中,核心业务逻辑通常在聚合(实体)和领域服务中实现.
在以下情况下特别需要创建领域服务
- 你实现了依赖于某些服务(如存储库或其他外部服务)的核心域逻辑.
- 你需要实现的逻辑与多个聚合/实体相关,因此它不适合任何聚合.
领域服务是简单的无状态类. 虽然你不必从任何服务或接口派生,但ABP
框架提供了一些有用的基类和约定.
DomainService
和 IDomainService
从DomainService
基类派生领域服务或直接实现 IDomainService
接口.
示例:
创建从DomainService
基类派生的领域服务.
1 | using Volo.Abp.Domain.Services; |
当你这样做时:ABP
框架自动将类注册为瞬态生命周期到依赖注入系统.
你可以直接使用一些常用服务作为基础属性,而无需手动注入 (例如ILogger and IGuidGenerator
).
建议使用
Manager
或Service
后缀命名领域服务. 我们通常使用如上面示例中的Manager
后缀.
示例:
实现将问题分配给用户的领域逻辑
1 | public class IssueManager : DomainService |
问题是定义如下所示的 聚合根:
1 | public class Issue : AggregateRoot<Guid> |
使用internal
的set
确保外层调用者不能直接在调用 set
,并强制始终使用IssueManager
为User
分配 Issue
.
应用程序服务与领域服务
虽然应用服务
和领域服务
都实现了业务规则,但存在根本的逻辑和形式差异:
- 应用程序服务实现应用程序的用例(典型Web应用程序中的用户交互),而领域服务实现核心的、用例独立的领域逻辑.
- 应用程序服务获取/返回 数据传输对象,领域服务方法通常获取和返回领域对象(实体,值对象).
- 领域服务通常由应用程序服务或其他领域服务使用,而应用程序服务由表示层或客户端应用程序使用.
生命周期
领域服务的生命周期是瞬态
的,它们会自动注册到依赖注入服务.
规约
规约模式用于为实体和其他业务对象定义 命名、可复用、可组合和可测试的过滤器 .
你可以创建一个由Specification<Customer>
派生的新规约类.
1 | using System; |
你也可以直接实现
ISpecification<T>
接口,但是基类Specification<T>
做了大量简化.
虽然规约模式通常与C#
的lambda
表达式相比较,算是一种更老的方式.一些开发人员可能认为不再需要它,我们可以直接将表达式传入到仓储或领域服务中,如下所示:
1 | var count = await _customerRepository.CountAsync(c => c.Balance > 100000 && c.Age => 18); |
自从ABP
的仓储支持表达式,这是一个完全有效的用法.你不必在应用程序中定义或使用任何规约,可以直接使用表达式.
所以,规约的意义是什么?为什么或者应该在什么时候考虑去使用它?
何时使用?
使用规约的一些好处:
- 可复用:假设你在代码库的许多地方都需要用到优质顾客过滤器.如果使用表达式而不创建规约,那么如果以后更改“优质顾客”的定义会发生什么?假设你想将最低余额从100000美元更改为250000美元,并添加另一个条件,成为顾客超过3年.如果使用了规约,只需修改一个类.如果在任何其他地方重复(复制/粘贴)相同的表达式,则需要更改所有的表达式.
- 可组合:可以组合多个规约来创建新规约.这是另一种可复用性.
命名:PremiumCustomerSpecification
更好地解释了为什么使用规约,而不是复杂的表达式.因此,如果在你的业务中使用了一个有意义的表达式,请考虑使用规约. - 可测试:规约是一个单独(且易于)测试的对象.
什么时侯不要使用?
- 没有业务含义的表达式:不要对与业务无关的表达式和操作使用规约.
- 报表:如果只是创建报表,不要创建规约,而是直接使用
IQueryable 和LINQ
表达式.你甚至可以使用普通SQL
、视图或其他工具生成报表.DDD不关心报表,因此从性能角度来看,查询底层数据存储的方式可能很重要.
应用服务层
应用服务
应用服务实现应用程序的用例, 将领域层逻辑公开给表示层.从表示层调用应用服务,DTO作为参数. 返回DTO给表示层.