技术咨询、项目合作、广告投放、简历咨询、技术文档下载 点击这里 联系博主

# DDD 领域驱动设计

# 什么是 DDD

众所周知,领域驱动设计(DDD)的概念出自 Evic Evans 的《领域驱动设计:软件核心复杂性应对之道》。它是指通过统一语言、业务抽象、领域划分和领域建模等一系列手段来控制软件复杂度的方法论。

软件开发不是一蹴而就的事情,我们不可能在不了解产品(或行业领域)的前提下进行软件开发,在开发前,通常需要进行大量的业务知识梳理,而后到达软件设计的层面,最后才是开发。而在业务知识梳理的过程中,我们必然会形成某个领域知识,根据领域知识来一步步驱动软件设计,就是领域驱动设计的基本概念

领域驱动设计分为两个阶段:

  1. 以一种领域专家、设计人员、开发人员都能理解的通用语言作为相互交流的工具,在交流的过程中发现领域概念,然后将这些概念设计成一个领域模型
  2. 由领域模型驱动软件设计,用代码来实现该领域模型;

由此我们发现如何设计出领域模型在 DDD 中是重点,通过领域模型,我们可以:

  • 通过模型反映软件实现(Implementation)的结构;
  • 以模型为基础形成团队的统一语言(Ubiquitous Language);
  • 把模型作为精粹的知识,以用于传递

也就是我们常说的领域模型=代码=文档

# 如何建立领域知识

上面谈到了领域模型,那么到底什么是领域模型呢?下面以日常直播间内挂件开发为例:

现要以直播间挂件点击进入活动页面为例。根据 DDD 的思路,我们第一步是建立领域知识:作为平时工作的产品而言,他们自然就是这个领域的专家,我们第一个目标就是与他们沟通,也许我们并不能从中获取所有想要的知识,但至少可以筛选出主要的内容和元素。你可能会听到挂件,活动页,榜单,规则等领域名词;让们从一个简单的例子开始:

  • 挂件 -> 活动页

这个模型比较简单,我们无法知道挂件点击是如何进入活动页面的

  • 挂件 -> jsb -> webview -> 活动页

这个模型就较为丰富,其中涉及到的名字可能包含了产品、客户端、前端同学所了解的;对于不熟悉挂件,webview,活动页的同学则需要事先去了解并统一话题。

# 如何提取领域模型

在 DDD 中,Eric Evans 提倡了一种叫做知识消化(Knowledge Crunching)的方法帮助我们去提炼领域模型。

知识消化法具体来说有五个步骤,分别是:

  • 关联模型与软件实现: 是知识消化可以顺利进行的前提与基础。它将模型与代码统一在一起,使得对模型的修改,就等同于对代码的修改
  • 基于模型提取统一语言: 领域专家、开发团队、其他人员通过统一语言进行需求讨论,实际就是通过模型对需求进行讨论
  • 开发富含知识的模型;
  • 精炼模型;
  • 头脑风暴与试验

后面三步呢,构成了一个提炼知识的循环:通过统一语言讨论需求;发现模型中的缺失或者不恰当的概念,精炼模型以反映业务的实践情况;对模型的修改引发了统一语言的改变,再以试验和头脑风暴的态度,使用新的语言以验证模型的准确。

如此循环往复,不断完善模型与统一语言。因其整体流程与重构(Refactoring)类似,也有人称之为重构循环。示意图如下:

通过以上的分析,我们其实可以把“知识消化”这五步总结为两关联一循环

  • 两关联”即:模型与软件实现关联;统一语言与模型关联;
  • 一循环”即:提炼知识的循环。

# 关联模型与软件实现-贫血模型及充血模型

Eric Evans 在知识消化中并没有强调模型的好坏,反而非常强调模型与软件实现间的关联,其本质并非是要求一下子将所有的模型都设计好,当然这也是不现实的,其实更多提倡的是一种迭代的方式。

模型关联的实现方法又称之为充血模型,其是一种面向对象的变成风格。

#

贫血模型

传统的面向过程的设计流程为:

在传统模型中,对象是数据的载体,只有简单的 getter/setter 方法,没有行为。以数据为中心,以数据库 ER 设计作驱动。分层架构在这种开发模式下,可以理解为是对数据移动、处理和实现的过程。业务逻辑都是写在 Service 中的,对象充其量只是个数据载体,没有任何行为,是一种贫血模型。

传统架构的特点:

  1. 以数据库为中心
  2. 贫血模型: 是指仅用作数据载体,而没有行为和动作的领域对象
  3. 业务逻辑散落在大量的方法中
  4. 当系统越来越复杂时,开发时间指数增长,维护成本很高

我们以转账为例介绍一下传统编写方式:(以下代码参考自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
}

基于如上的修改,可以使用此领域模型和领域专家进行沟通,领域模型也较为直观的; 衡量一个领域模型的好坏取决于

  1. 领域模型是否包含了尽可能多的领域知识,能否反映领域专家脑海中的业务模型
  2. 领域模型能否成为文档,进而成为所有人沟通和共享知识的途径

# 领域模型的一些要素

# 实体(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 之外看不到其他对象;

  • 聚合内除根以外的其他实体的唯一标识都是本地标识,也就是只要在聚合内部保持唯一即可,因为它们总是从属于这个聚合的;

  • 聚合根负责与外部其他对象打交道并维护自己内部的业务规则;

  • 聚合内部的对象可以保持对其他聚合根的引用;

  • 删除一个聚合根时必须同时删除该聚合内的所有相关对象,因为他们都同属于一个聚合,是一个完整的概念;

# 参考

【未经作者允许禁止转载】 Last Updated: 2/4/2024, 6:06:40 AM