关于DDD

领域驱动设计(Domain Driven Design)是一种架构思想,目的是正确地划定一个业务的边界,令架构整体上更清晰。
这里记录下DDD的落地方案——从Bean的定义到架构如何构建。

Domain Primitive(DP)

DP的概念:

  • DP是Immutable的Value Object;
  • DP是业务的原始语言定义,比如我们要讨论用户中心这样的系统,就需要定义类似User、Name这样的DP;
  • DP可以是业务对象的复杂组合,比如User由Name、Phone等组成,它需要保证构造时使用的Name、Phone是足以构成一个User对象。

DP规定了业务代码中Bean的编写规范,DP包含3个原则:

  • Make Implicit Concepts Explicit(将隐性的概念显性化)
  • Make Implicit Context Explicit(将 隐性的 上下文 显性化)
  • Encapsulate Multi-Object Behavior(封装 多对象 行为)

Make Implicit Concepts Explicit(将隐性的概念显性化)

平时我们习惯写贫血模型的Bean:

1
2
3
4
5
6
public class User {
private Long userId;
private String userName;
private String phone;
private String address;
}

这么写的主要问题是对phone、address等字段的规定非常宽松,格式校验必须在业务代码中写:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
private boolean isInvalidName(String name) {
return null == name || name.length() == 0;
}

private boolean isInvalidPhone(String phone) {
String pattern = "^0[1-9]{2,3}-?\\d{8}$";
return !phone.matches(pattern);
}

public User register(String name, String phone, String address) {
// 格式校验
if(isInvalidName(name)) {
throw ValidationException("name");
}
if(isInvalidPhone(phone)) {
throw ValidationException("phone");
}
// 其他校验
...

// 创建用户

return user;
}

这么写的缺点是:
1、接口清晰度差

1
register("张三", "地球村", "0571-12345678");

像上面这样调用的话不会出现编译错误,但是运行时会报错。
2、数据校验和错误处理
register方法入口需要对参数格式进行校验,其实对每个传name、phone、address的地方都需要这么写一次校验,非常繁琐。

解决办法是对name、phone、address进行封装,即所谓的充血模型

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
public class PhoneNumber {

private final String number;

public String getNumber() {
return number;
}

public PhoneNumber(String number) {
if (number == null) {
throw new ValidationException("number 不能为空");
} else if (isValid(number)) {
throw new ValidationException("number 格式错误");
}
this.number = number;
}

public String getAreaCode() {
for (int i = 0; i < number.length(); i++) {
String prefix = number.substring(0, i);
if (isAreaCode(prefix)) {
return prefix;
}
}
return null;
}

private static boolean isAreaCode(String prefix) {
String[] areas = new String[]{"0571", "021", "010"};
return Arrays.asList(areas).contains(prefix);
}

public static boolean isValid(String number) {
String pattern = "^0?[1-9]{2,3}-?\\d{8}$";
return number.matches(pattern);
}
}
  • PhoneNumber是不可变(Immutable)的对象;
  • 校验逻辑都在构造方法里,只要PhoneNumber被创建出来后,一定是被校验通过的,只要能带入参数里的一定是正确的或null,数据校验的工作被前置到了调用方;

Make Implicit Context Explicit(将 隐性的 上下文 显性化)

多个传参之间紧密关联,可以组合成一个Bean,比如支付时需要给出支付金额和支付货币,我们可以将这两个参数组合成一个完整的概念Money:

1
2
3
4
5
6
7
8
public class Money {
private BigDecimal amount;
private Currency currency;
public Money(BigDecimal amount, Currency currency) {
this.amount = amount;
this.currency = currency;
}
}

这里货币<amount, currency>是一个隐性的上下文概念,合并成Money后完成了显性化,可以在Money中添加一些对货币的限制。

Encapsulate Multi-Object Behavior(封装 多对象 行为)

将涉及到多个对象的行为封装为一个对象,比如将转换汇率这个操作转换为一个ExchangeRate对象:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public class ExchangeRate {
private BigDecimal rate;
private Currency from;
private Currency to;

public ExchangeRate(BigDecimal rate, Currency from, Currency to) {
this.rate = rate;
this.from = from;
this.to = to;
}

public Money exchange(Money fromMoney) {
notNull(fromMoney);
isTrue(this.from.equals(fromMoney.getCurrency()));
BigDecimal targetAmount = fromMoney.getAmount().multiply(rate);
return new Money(targetAmount, to);
}
}

架构

事务脚本的写法

最普通写业务的思路,基本是MVC架构,并把核心业务逻辑统统写到一个方法内,顶多把一些能复用的拆到一些子方法内。
事务脚本的主要问题是:

  • 可维护性差
  • 可扩展性差
  • 可测试性差

改造思路 - 防腐层(ACL)

很多时候我们的系统会去依赖其他的系统,被依赖的系统可能会包含不合理的数据结构、API、协议或技术实现,如果对外部系统强依赖,会导致我们的系统被“腐蚀”。
解决办法是加入一个防腐层来隔离:

  • 接口适配
  • 缓存
  • 降级
  • 易测试
  • 功能开关

比如,为了屏蔽业务系统和消息队列服务,可以使用一个抽象的AuditMessageProducer、AuditMessage实现对底层消息队列服务的隔离。
系统中有很多与外部系统相关的计算,比如接收外部系统传入的金额,对账户金额的计算、账户余额的校验、转帐限制、金额增减等。解决办法是使用DP封装
对于跨多个域对象的行为,封装到业务Service不合适,但封装到单个的DP中也不合适,这种情况最好是单独为其创建一个Domain Service

六边形架构(Hexagonal Architecture)

传统架构是分层的。
而Hex中,架构变成内外关系,具体到项目的组成会有以下6个组件:
六边形架构的模块划分

  • Types
    保存对外暴露的Domain Primitives(DP),纯POJO,不依赖任何类库。
  • Domain
    核心业务逻辑,包含有状态的 Entity、领域服务 Domain Service、以及各种外部依赖的接口类(如 Repository、ACL、中间件等)。Domain 模块仅依赖 Types模块,也是纯 POJO 。
  • Application
    Application 模块主要包含 Application Ser vice 和一些相关的类。Application 模块依赖Domain 模块。还是不依赖任何框架,纯 POJO。
  • Infrastructure
    Infrastructure 模块包含了 Persistence、Messaging、External 等模块。比如:Persistence模块包含数据库 DAO 的实现,包含 Data Object、ORM Mapper、Entity 到 DO 的转化类等。Persistence 模块要依赖具体的 ORM 类库,比如 MyBatis。如果要用 Spring-Mybatis提供的注解方案,则需要依赖 Spring。
  • Web
    Web 模块包含 Controller 等相关代码。如果用 SpringMVC 则需要依赖 Spring。
  • Start
    Start模块是SpringBoot的启动类。

参考

  1. 《The Complete Works of Tao Technology 2020》 - 阿里技术专家详解 DDD 系列
  2. AnemicDomainModel - Martin Fowler