DDD学习笔记

本文是博客学习笔记,可以说是缝合怪,原文在后面的参考链接中

什么是 DDD

与传统架构的差异

常见的业务三层架构:

再看 DDD 的架构:

来看两者的区别:

  • DDD 架构中,分为了接口层,应用层,领域层,基础设施层。相比于原来的三层模型,可以认为 DDD 讲业务层拆分为了应用层和领域层。其中,应用层主要是做业务逻辑的组装,它会调用基础设施和领域层的接口来组装业务。而领域层则是一些核心的业务操作,最关键的是,它是纯内存操作。于是我们可以这样理解,DDD 将一些核心业务的操作从业务层剥离出来了!
  • UI 层和接口层是类似的
  • 基础设施层包含了一切和外部应用交互的接口。可以说是包含了原来数据访问层的内容

理论解释

领域驱动设计(domain driven design)。首次出现在十几年前,却在微服务时代大放光彩。 DDD 是作为一种战略思想可用于指导微服务的拆分,DDD 本质不是一个软件架构, 而是一种指导思想。它涉及的术语很多,简单介绍几个我印象比较深刻的。

  • 领域对象(Entity):最重要的概念模型,可以理解和数据表有一种映射关系。然而又不会和数据表直接交互。它完全是内存操作,不会有外部依赖(ps:理解这一点很重要,这是整个 DDD 能做到高内聚的核心)
  • 值对象(Value Object):相比于 Entity,它不需要和数据表有映射关系,可以理解为一种更为基础的概念模型,同样的,它也完全内存操作,没有外部依赖
  • 领域服务:需要跨多个领域对象的业务操作,需要用领域服务来完成
  • 聚合根(Aggregate):对于那种比较复杂的业务,单一 Entity 无法完全表示它的含义,就需要多个 Entity 共同表示,就放在了一个聚合根中
  • 业务域:指的是具体的一块业务,比如订单业务域,报价业务域之类的
  • 充血模式:相比于平时写的比较多的只有 set/get 方法的 pojo 类,它还包含了一些行为方法,用于修改自身的状态值

只要记住,它主要分为战略设计和战术设计。

  • 战略设计:划分业务域。这里的划分和技术实现完全没有关系,只是业务上的划分。需要业务专家来做决断,这里做的好不好严重影响整个系统架构的质量。(ps:对于架构师来说,业务能力和技术能力一样重要)
  • 战术设计:具体的设计出一个 Entity,Value Object,Aggregate 等内容

DDD 有什么优点

一个 bad case 的改造

一个简单的案例需求如下:
用户可以通过银行网页转账给另一个账号,支持跨币种转账。
同时因为监管和对账需求,需要记录本次转账活动。
拿到这个需求之后,一个开发可能会经历一些技术选型,最终可能拆解需求如下:
1、从 MySql 数据库中找到转出和转入的账户,选择用 MyBatis 的 mapper 实现 DAO;
2、从 Yahoo(或其他渠道)提供的汇率服务获取转账的汇率信息(底层是 http 开放接口);
3、计算需要转出的金额,确保账户有足够余额,并且没超出每日转账上限;
4、实现转入和转出操作,扣除手续费,保存数据库;
5、发送 Kafka 审计消息,以便审计和对账用;
传统的写法会写出如下代码:

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
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
public class TransferController {

private TransferService transferService;

public Result<Boolean> transfer(String targetAccountNumber, BigDecimal amount, HttpSession session) {
Long userId = (Long) session.getAttribute("userId");
return transferService.transfer(userId, targetAccountNumber, amount, "CNY");
}
}

public class TransferServiceImpl implements TransferService {

private static final String TOPIC_AUDIT_LOG = "TOPIC_AUDIT_LOG";
private AccountMapper accountDAO;
private KafkaTemplate<String, String> kafkaTemplate;
private YahooForexService yahooForex;

@Override
public Result<Boolean> transfer(Long sourceUserId, String targetAccountNumber, BigDecimal targetAmount, String targetCurrency) {
// 1. 从数据库读取数据,忽略所有校验逻辑如账号是否存在等
AccountDO sourceAccountDO = accountDAO.selectByUserId(sourceUserId);
AccountDO targetAccountDO = accountDAO.selectByAccountNumber(targetAccountNumber);

// 2. 业务参数校验
if (!targetAccountDO.getCurrency().equals(targetCurrency)) {
throw new InvalidCurrencyException();
}

// 3. 获取外部数据,并且包含一定的业务逻辑
// exchange rate = 1 source currency = X target currency
BigDecimal exchangeRate = BigDecimal.ONE;
if (sourceAccountDO.getCurrency().equals(targetCurrency)) {
exchangeRate = yahooForex.getExchangeRate(sourceAccountDO.getCurrency(), targetCurrency);
}
BigDecimal sourceAmount = targetAmount.divide(exchangeRate, RoundingMode.DOWN);

// 4. 业务参数校验
if (sourceAccountDO.getAvailable().compareTo(sourceAmount) < 0) {
throw new InsufficientFundsException();
}

if (sourceAccountDO.getDailyLimit().compareTo(sourceAmount) < 0) {
throw new DailyLimitExceededException();
}

// 5. 计算新值,并且更新字段
BigDecimal newSource = sourceAccountDO.getAvailable().subtract(sourceAmount);
BigDecimal newTarget = targetAccountDO.getAvailable().add(targetAmount);
sourceAccountDO.setAvailable(newSource);
targetAccountDO.setAvailable(newTarget);

// 6. 更新到数据库
accountDAO.update(sourceAccountDO);
accountDAO.update(targetAccountDO);

// 7. 发送审计消息
String message = sourceUserId + "," + targetAccountNumber + "," + targetAmount + "," + targetCurrency;
kafkaTemplate.send(TOPIC_AUDIT_LOG, message);

return Result.success(true);
}

}

其实从可读性来说,上面的代码是没有什么问题的,注释也很详细。是一段很传统的业务层代码。
但是,我们可以看到这一段业务代码中包含了参数校验,数据读写存储,业务计算,调用外部服务,发送消息等操作。这种形式的代码,我们一般叫做“胶水式代码”,有点面向过程编程的意味了。
整个代码的调用链路:

这是按照原来传统三层架构拆解的。
“高内聚,低耦合”是对一个好的架构的评价的核心标准。如果对整体架构进行重构的话,就是降低各层之间的依赖。降低依赖的核心解决方案,就是再加一层抽象层!

抽象存储层

上面的结构是直接依赖了 mybatis+mysql 的实现,后面如果想更改数据源也很不方便。所以加个抽象层隔离开具体实现。具体做法是:

  • 新建 Account 实体对象:一个实体(Entity)是拥有 ID 的域对象,除了拥有数据之外,同时拥有行为。Entity 和数据库储存格式无关,因此是需要一个 DO 对象来和数据库映射的
  • 新建对象储存接口类 AccountRepository:Repository 只负责 Entity 对象的存储和读取,而 Repository 的实现类完成数据库存储的细节。通过加入 Repository 接口,底层的数据库连接可以通过不同的实现类而替换。
    更改后的架构:
抽象第三方服务

这里的第三方服务是指“YahooForexService”。虽然这个已经是一个接口了,但是这里其实调用的还是服务提供者的具体实现。如果要隔离服务未来发生变化的可能性,还是要加一层抽象层。这种一般会被称为“防腐层”。有了防腐层,外部逻辑再怎么变化,内部都可以尽可能不变!
除了隔离变化,防腐层还一般有如下功能:

  1. 适配器:基本功能,将外部数据转为内部数据对象
  2. 缓存:很适合在这里做调用的缓存层
  3. 监控日志:方便后面快速定位是外部系统还是内部系统的问题
  4. 快速降级:这里在这里做兜底逻辑
    添加后的架构:
抽象中间件

在对中间件的使用上加一层抽象,主要是中间件的返回值一般都是 byte[ ]结构,我们一般都会有序列化和反序列化的操作,这种操作是不属于业务逻辑的,不应该放在应用层。

封装业务逻辑

其实重构到现在,我们主要是把系统和外部数据交互的部分都加上了一层抽象层。剩余的就是一下业务规则计算了,这些是属于纯内存操作。而这些纯内存操作正是整个业务系统最核心的地方。也正是业务域的关键。
一般封装业务逻辑,我们第一步是将业务逻辑抽象概括出一些核心的 Entity 或者 Value Object。第二步是判断业务逻辑中有没有一些跨 Entity 的操作,如果有的话,就要新增“Domain Service”,用于处理多对象逻辑。
这里的话,就新增了 Account(Entity),ExchangeRate(Value Object) ,AccountTransferService (Domain Service)
最后就变成:

改造后效果

从代码上来看,其实区别不是很大:

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
public class TransferServiceImplNew implements TransferService {

private AccountRepository accountRepository;
private AuditMessageProducer auditMessageProducer;
private ExchangeRateService exchangeRateService;
private AccountTransferService accountTransferService;

@Override
public Result<Boolean> transfer(Long sourceUserId, String targetAccountNumber, BigDecimal targetAmount, String targetCurrency) {
// 参数校验
Money targetMoney = new Money(targetAmount, new Currency(targetCurrency));

// 读数据
Account sourceAccount = accountRepository.find(new UserId(sourceUserId));
Account targetAccount = accountRepository.find(new AccountNumber(targetAccountNumber));
ExchangeRate exchangeRate = exchangeRateService.getExchangeRate(sourceAccount.getCurrency(), targetMoney.getCurrency());

// 业务逻辑
accountTransferService.transfer(sourceAccount, targetAccount, targetMoney, exchangeRate);

// 保存数据
accountRepository.save(sourceAccount);
accountRepository.save(targetAccount);

// 发送审计消息
AuditMessage message = new AuditMessage(sourceAccount, targetAccount, targetMoney);
auditMessageProducer.send(message);

return Result.success(true);
}
}

而它的业务分层则按照 DDD 的方式来看:

  • 领域层纯粹是内存操作,不依赖任何外部依赖
  • 应用层则依赖抽象,而不依赖实现,有效隔离了变化。而应用层做的核心工作就是拼装逻辑
  • 其实对于 DDD 的四层架构来看,实际情况最容易发生变化的是领域层。业务规则最容易变化,而其他的基础设施层,接口层一般来说都不会做很大变动。这也符合领域驱动设计的思想。

应用到实际工作中

其实主要是有一些启发吧。DDD 本质是一种架构思想,为拆分微服务提供了指导方针。我阅读的博客大部分是一些偏向于能落地的,所以我对 DDD 的理解可能会有一些偏差。那几本著名的 DDD 的技术书都没有研究过。
不过,基于我目前的知识水平,还是能收获很多的。

  1. 业务开发的核心难点在于业务的复杂度,而业务的复杂度其实可以通过一些良好的架构设计来减少一些。后续的工作时,除了技术水平,业务水平也得一起练习。
  2. 高内聚,低耦合。这句话很重要,是架构设计的核心追求,我感觉我现在可能都理解的不够深刻。需要在平时的工作中多思考,如何做到这个
  3. 充血模型在一定场合可以用用,不过也不能说贫血模型就不合适
  4. DDD 不是瑞士军刀,不是每一个场景都需要像 DDD 这样,做这么多的抽象层的。对于一些工具类的需求,三层架构可能会带来更好的可读性。

参考链接

DDD领域驱动设计总结 - 知乎 (zhihu.com)
阿里技术专家详解DDD系列 第二弹 - 应用架构 (qq.com)


DDD学习笔记
http://ttoobbyyy.github.io/2024/01/27/DDD学习笔记/
作者
jianren.xiao
发布于
2024年1月27日
许可协议