# DDD 领域驱动设计
# 什么是 DDD
众所周知,领域驱动设计(DDD)的概念出自 Evic Evans 的《领域驱动设计:软件核心复杂性应对之道》。它是指通过统一语言、业务抽象、领域划分和领域建模等一系列手段来控制软件复杂度的方法论。
软件开发不是一蹴而就的事情,我们不可能在不了解产品(或行业领域)的前提下进行软件开发,在开发前,通常需要进行大量的业务知识梳理,而后到达软件设计的层面,最后才是开发。而在业务知识梳理的过程中,我们必然会形成某个领域知识,根据领域知识来一步步驱动软件设计,就是领域驱动设计的基本概念。
领域驱动设计分为两个阶段:
- 以一种领域专家、设计人员、开发人员都能理解的通用语言作为相互交流的工具,在交流的过程中发现领域概念,然后将这些概念设计成一个领域模型;
- 由领域模型驱动软件设计,用代码来实现该领域模型;
由此我们发现如何设计出领域模型在 DDD 中是重点,通过领域模型,我们可以:
- 通过模型反映软件实现(Implementation)的结构;
- 以模型为基础形成团队的统一语言(Ubiquitous Language);
- 把模型作为精粹的知识,以用于传递
也就是我们常说的领域模型=代码=文档
# 如何建立领域知识
上面谈到了领域模型,那么到底什么是领域模型呢?下面以日常直播间内挂件开发为例:
现要以直播间挂件点击进入活动页面为例。根据 DDD 的思路,我们第一步是建立领域知识:作为平时工作的产品而言,他们自然就是这个领域的专家,我们第一个目标就是与他们沟通,也许我们并不能从中获取所有想要的知识,但至少可以筛选出主要的内容和元素。你可能会听到挂件,活动页,榜单,规则等领域名词;让们从一个简单的例子开始:
- 挂件 -> 活动页
这个模型比较简单,我们无法知道挂件点击是如何进入活动页面的
- 挂件 -> jsb -> webview -> 活动页
这个模型就较为丰富,其中涉及到的名字可能包含了产品、客户端、前端同学所了解的;对于不熟悉挂件,webview,活动页的同学则需要事先去了解并统一话题。
# 如何提取领域模型
在 DDD 中,Eric Evans 提倡了一种叫做知识消化(Knowledge Crunching)的方法帮助我们去提炼领域模型。
知识消化法具体来说有五个步骤,分别是:
- 关联模型与软件实现: 是知识消化可以顺利进行的前提与基础。它将模型与代码统一在一起,使得对模型的修改,就等同于对代码的修改
- 基于模型提取统一语言: 领域专家、开发团队、其他人员通过统一语言进行需求讨论,实际就是通过模型对需求进行讨论
- 开发富含知识的模型;
- 精炼模型;
- 头脑风暴与试验
后面三步呢,构成了一个提炼知识的循环:通过统一语言讨论需求;发现模型中的缺失或者不恰当的概念,精炼模型以反映业务的实践情况;对模型的修改引发了统一语言的改变,再以试验和头脑风暴的态度,使用新的语言以验证模型的准确。
如此循环往复,不断完善模型与统一语言。因其整体流程与重构(Refactoring)类似,也有人称之为重构循环。示意图如下:
通过以上的分析,我们其实可以把“知识消化”这五步总结为两关联一循环:
- “两关联”即:模型与软件实现关联;统一语言与模型关联;
- “一循环”即:提炼知识的循环。
# 关联模型与软件实现-贫血模型及充血模型
Eric Evans 在知识消化中并没有强调模型的好坏,反而非常强调模型与软件实现间的关联,其本质并非是要求一下子将所有的模型都设计好,当然这也是不现实的,其实更多提倡的是一种迭代的方式。
模型关联的实现方法又称之为充血模型,其是一种面向对象的变成风格。
# 贫血模型
传统的面向过程的设计流程为:
在传统模型中,对象是数据的载体,只有简单的 getter/setter 方法,没有行为。以数据为中心,以数据库 ER 设计作驱动。分层架构在这种开发模式下,可以理解为是对数据移动、处理和实现的过程。业务逻辑都是写在 Service 中的,对象充其量只是个数据载体,没有任何行为,是一种贫血模型。
传统架构的特点:
- 以数据库为中心
- 贫血模型: 是指仅用作数据载体,而没有行为和动作的领域对象
- 业务逻辑散落在大量的方法中
- 当系统越来越复杂时,开发时间指数增长,维护成本很高
我们以转账为例介绍一下传统编写方式:(以下代码参考自Java 充血模型代码实例 (opens new window))
// 账户对象
public class AccountBO {
private String accountId;//账户ID
private Long balance; // 账户余额
private boolean isFrozen; // 是否冻结
public String getAccountId() {
return accountId;
}
public void setAccountId(String accountId) {
this.accountId = accountId;
}
public Long getBalance() {
return balance;
}
public void setBalance(Long balance) {
this.balance = balance;
}
// ...
}
Services 编写:转账业务服务实现
@Service
public class TransferServiceImpl implements TransferService {
@Autowired
private AccountService accountService;
@Override
public boolean transfer(String fromAccountId, String toAccountId, Long amount) {
AccountBO fromAccount = accountService.getAccountById(fromAccountId);
AccountBO toAccount = accountService.getAccountById(toAccountId);
/** 检查转出账户 **/
if (fromAccount.isFrozen()) {
throw new MyBizException(ErrorCodeBiz.ACCOUNT_FROZEN);
}
if (fromAccount.getBalance() < amount) {
throw new MyBizException(ErrorCodeBiz.INSUFFICIENT_BALANCE);
}
fromAccount.setBalance(fromAccount.getBalance() - amount);
/** 检查转入账户 **/
if (toAccount.isFrozen()) {
throw new MyBizException(ErrorCodeBiz.ACCOUNT_FROZEN);
}
toAccount.setBalance(toAccount.getBalance() + amount);
/** 更新数据库 **/
accountService.updateAccount(fromAccount);
accountService.updateAccount(toAccount);
return Boolean.TRUE;
}
}
这样的实现方式即:对象仅仅对简单的数据进行封装,而关联关系和业务计算都散落在对象的范围之外。这种方式实际上是在沿用过程式的风格组织逻辑,而没有发挥面向对象技术的优势。
而现如今我们在开发的时候,反而被因为使用了数据库而束缚了我们的思维,我们在接收到需求的第一时刻,在分析设计阶段,往往是以数据表为核心进行分析设计, 也就是根据需求首先得到数据表名和字段,然后培训程序员学会 SQL 语句如何操作这些数据表,那么程序员为实现数据表的前后顺序操作,必然会将代码写成过程式的风格,以数据表为核心进行分析设计,代码很难避免不演变成过程式的代码,因为我们的重点放在了操作某张表,以及相关的某个字段。 这里就是所谓的贫血模型了
# 充血模型
与“贫血模型”相对的则是“充血模型”,也就是与某个概念相关的主要行为与逻辑,都被封装到了对应的领域对象中。“充血模型”也就是 DDD 中强调的“富含知识的模型";
所以根据上面的转账业务,我们可以修改为充血类型的代码:
// 账户业务对象
public class AccountBO {
private String accountId; // 账户ID
private Long balance; // 账户余额
private boolean isFrozen; // 是否冻结
private DebitPolicy debitPolicy; // 出借策略
private CreditPolicy creditPolicy; // 入账策略
/**
* 出借方法
*
* @param amount 金额
*/
public void debit(Long amount) {
// 出借前检查
debitPolicy.preDebit(this, amount);
this.balance -= amount;
// 完成出借
debitPolicy.afterDebit(this, amount);
}
/**
* 转入方法
*
* @param amount 金额
*/
public void credit(Long amount) {
// 转入前检查
creditPolicy.preCredit(this, amount);
this.balance += amount;
// 完成转入
creditPolicy.afterCredit(this, amount);
}
// ...
/**
* BO和DO转换必须加set方法这是一种权衡
*/
public void setBalance(Long balance) {
this.balance = balance;
}
public DebitPolicy getDebitPolicy() {
return debitPolicy;
}
public void setDebitPolicy(DebitPolicy debitPolicy) {
this.debitPolicy = debitPolicy;
}
public CreditPolicy getCreditPolicy() {
return creditPolicy;
}
public void setCreditPolicy(CreditPolicy creditPolicy) {
this.creditPolicy = creditPolicy;
}
}
/**
* 入账策略实现
*/
@Service
public class CreditPolicyImpl implements CreditPolicy {
@Override
public void preCredit(AccountBO account, Long amount) {
if (account.isFrozen()) {
throw new MyBizException(ErrorCodeBiz.ACCOUNT_FROZEN);
}
}
@Override
public void afterCredit(AccountBO account, Long amount) {
System.out.println("afterCredit");
}
}
/**
* 出借策略实现
*
*/
@Service
public class DebitPolicyImpl implements DebitPolicy {
@Override
public void preDebit(AccountBO account, Long amount) {
if (account.isFrozen()) {
throw new MyBizException(ErrorCodeBiz.ACCOUNT_FROZEN);
}
if (account.getBalance() < amount) {
throw new MyBizException(ErrorCodeBiz.INSUFFICIENT_BALANCE);
}
}
@Override
public void afterDebit(AccountBO account, Long amount) {
System.out.println("afterDebit");
}
}
/**
* 转账业务服务实现
*/
@Service
public class TransferServiceImpl implements TransferService {
@Resource
private AccountService accountService;
@Resource
private CreditPolicy creditPolicy;
@Resource
private DebitPolicy debitPolicy;
@Override
public boolean transfer(String fromAccountId, String toAccountId, Long amount) {
// 获取账户的信息
AccountBO fromAccount = accountService.getAccountById(fromAccountId);
AccountBO toAccount = accountService.getAccountById(toAccountId);
// 设置出账及入账策略
fromAccount.setDebitPolicy(debitPolicy);
toAccount.setCreditPolicy(creditPolicy);
// 开始出账和入账
fromAccount.debit(amount);
toAccount.credit(amount);
// 更新账户信息
accountService.updateAccount(fromAccount);
accountService.updateAccount(toAccount);
return Boolean.TRUE;
}
}
AccountBO 包含了策略对象,策略对象是可以实现业务逻辑的,这样把业务逻辑实现在策略对象中,减少了 service 层面向过程的代码。
# 基于模型提取统一语言——模型更加语义化
以下部分参考自:使用函数式语言建立领域模型 (opens new window)
领域建模是整个 DDD 环节中最最考验开发人员功底的一环,不同于传统的数据库建模技术,开发人员需要有很好的抽象能力,通过恰如其分的编程技术,将领域知识映射到一个代码模型中。
以前端使用 TypeScript 为例,构造一个模型如何让其更加语义化呢?
一个简单的例子:
const timeToFly = 10;
你能一眼看出这句代码代表的领域知识吗?也许不能,fly 多久?查文档?No,你应该时刻告诉自己,代码等于文档。改进后的代码如下:
type Second = number;
const timeToFly: Second = 10;
一个信用卡的例子:
type CreditCard = {
cardNo: string
firstName: string
middleName: string
lastName: string
contactEmail: Email
contactPhone: Phone
}
从上面的代码能代表领域模型吗?也许不能;如果如果作为开发者,将这个发送给产品让其了解,他们一定有如下的疑惑:
- middleName 可以为空吗?
- 信用卡号 cardNo 长度是多少?可为任意字符串吗?
- 用户名称允许的长度是多少?
基于如上的疑问,很明显我们编写的领域模型并不 OK,基于如上的问题我们可以将其修改如下:
// 信用卡号
type CardNo = string
// 长度为50的名称
type Name50 = string
type CreditCard = {
cardNo: Option<CardNo>
firstName: Name50
middleName: Option<string> // 可选的名称
lastName: Name50
contactEmail: Email
contactPhone: Phone
}
现在的代码拥有跟多的领域知识,丰富的类型还充当了单元测试的角色,例如,你永远都不会把一个 email 赋值给 contactPhone,它们不是 string, 它们代表不同的领域知识。
但是这是否意味着上面的领域模型就 OK 了吗?
- 产品问:其他的场景也使用到 了用户名和联系方式的信息,你也需要修改一下哦
作为一个负责任的开发同学我们很容易就能把 Name 和 Contact 两个类型分离出来并加以组合,以供后续的场景使用:
type Name = {
firstName: Name50
middleName: Option<string>
lastName: Name50
}
type Contact = {
contactEmail: Email
contactPhone: Phone
}
type CreditCard = {
cardNo: Option<CardNo>
name: Name
contact: Contact
}
- 产品问:邮箱和电话号码都是必填的吗,只需要保留一个就行
type OnlyContactEmail = Email
type OnlyContactPhone = Phone
type BothContactEmailAndPhone = Email & Phone
type Contact =
| OnlyContactEmail
| OnlyContactPhone
| BothContactEmailAndPhone
type Name = {
firstName: Name50
middleName: Option<string>
lastName: Name50
}
type CreditCard = {
cardNo: Option<CardNo>
name: Name
contact: Contact
}
基于如上的修改,可以使用此领域模型和领域专家进行沟通,领域模型也较为直观的; 衡量一个领域模型的好坏取决于
- 领域模型是否包含了尽可能多的领域知识,能否反映领域专家脑海中的业务模型
- 领域模型能否成为文档,进而成为所有人沟通和共享知识的途径
# 领域模型的一些要素
# 实体(Entity)
在领域模型中,实体应该具有唯一的标识符。
例:最简单的,公安系统的身份信息录入,对于人的模拟,即认为是实体,因为每个人是独一无二的,且其具有唯一标识(如公安系统分发的身份证号码)。
从设计的一开始就应该考虑实体,决定是否建立一个实体也是十分重要的。另外,不应该给实体定义太多的属性或行为,而应该寻找关联,发现其他一些实体或值对象,将属性或行为转移到其他关联的实体或值对象上。
比如 Customer 实体,他有一些地址信息,由于地址信息是一个完整的有业务含义的概念,所以,我们可以定义一个 Address 对象,然后把 Customer 的地址相关的信息转移到 Address 对象上。如果没有 Address 对象,而把这些地址信息直接放在 Customer 对象上,并且如果对于一些其他的类似 Address 的信息也都直接放在 Customer 上,会导致 Customer 对象很混乱,结构不清晰,最终导致它难以维护和理解;
# 值对象(Value Object)
当一个对象用于对事务进行描述而没有唯一标识时,它被称作值对象(Value Object)。值对象在领域模型中是可以被共享的,他们应该是“不可变的”(只读的),当有其他地方需要用到值对象时,可以将它的副本作为参数传递。当共享值对象时,一般有复制和共享两种做法。
如果有两个 Customer 的地址信息是一样的,我们就会认为这两个 Customer 的地址是同一个。
值对象很重要,在习惯了使用数据库的数据建模后,很容易将所有对象看作实体。使用值对象,可以更好地做系统优化、精简设计。
它具有不变性、相等性和可替换性。
在实践中,需要保证值对象创建后就不能被修改,即不允许外部再修改其属性。在不同上下文集成时,会出现模型概念的公用,如商品模型会存在于电商的各个上下文中。在订单上下文中如果你只关注下单时商品信息快照,那么将商品对象视为值对象是很好的选择。
# 领域服务(Domain Service)
领域中的一些概念不太适合建模为对象,即归类到实体对象或值对象,因为它们本质上就是一些操作,一些动作,而不是事物。这些操作或动作往往会涉及到多个领域对象,并且需要协调这些领域对象共同完成这个操作或动作. 所以我们需要寻找一种新的模式来表示这种跨多个对象的操作,DDD 认为服务是一个很自然的范式用来对应这种跨多个对象的操作,所以就有了领域服务这个模式.
服务是无状态的,对象是有状态的。所谓状态,就是对象的基本属性:高矮胖瘦,年轻漂亮。服务本身也是对象,但它却没有属性(只有行为),因此说是无状态的。
服务存在的目的就是为领域提供简单的方法。为了提供大量便捷的方法,自然要关联许多领域模型,所以说,行为(Action)天生就应该存在于服务中。
服务具有以下特点:
- 服务中体现的行为一定是不属于任何实体和值对象的,但它属于领域模型的范围内;
- 服务的行为一定涉及其他多个对象;
- 服务的操作是无状态的;
领域服务存在的意义就是协调领域对象共完成某个操作,所有的状态还是都保存在相应的领域对象中。模型(实体)与服务(场景)是对领域的一种划分,模型关注领域的个体行为,场景关注领域的群体行为,模型关注领域的静态结构,场景关注领域的动态功能。
# 聚合及聚合根(Aggregate,Aggregate Root)
Aggregate(聚合)是一组相关对象的集合,作为一个整体被外界访问,聚合根(Aggregate Root)是这个聚合的根节点。
举个例子:人包括:手,眼睛、鼻子,嘴巴等;这一个组合就是一个聚合,而人就是这个组合的聚合根。
聚合有以下一些特点:
每个聚合有一个根和一个边界,边界定义了一个聚合内部有哪些实体或值对象,聚合根是聚合内的某个实体;
在聚合中,根是唯一允许外部对象保持对它的引用的元素,而边界内部的对象之间则可以互相引用。除根以外的其他 Entity 都有本地表示,但这些标识只有在聚合内部才需要加以区别,因为外部对象除了根 Entity 之外看不到其他对象;
聚合内除根以外的其他实体的唯一标识都是本地标识,也就是只要在聚合内部保持唯一即可,因为它们总是从属于这个聚合的;
聚合根负责与外部其他对象打交道并维护自己内部的业务规则;
聚合内部的对象可以保持对其他聚合根的引用;
删除一个聚合根时必须同时删除该聚合内的所有相关对象,因为他们都同属于一个聚合,是一个完整的概念;
# 参考
- 本文链接: https://mrgaogang.github.io/architecture/DDD%E9%A2%86%E5%9F%9F%E9%A9%B1%E5%8A%A8%E8%AE%BE%E8%AE%A1.html
- 版权声明: 本博客所有文章除特别声明外,均采用 CC BY-SA 4.0 许可协议。转载请注明出处!