阿里技术专家详解 DDD 系列 第二弹 - 应用架构(二)

阅读数:23 2019 年 12 月 18 日 16:07

阿里技术专家详解DDD系列 第二弹 - 应用架构(二)

重构方案

在重构之前,我们先画一张流程图,描述当前代码在做的每个步骤:

阿里技术专家详解DDD系列 第二弹 - 应用架构(二)

image.png

这是一个传统的三层分层结构:UI 层、业务层、和基础设施层。上层对于下层有直接的依赖关系,导致耦合度过高。在业务层中对于下层的基础设施有强依赖,耦合度高。我们需要对这张图上的每个节点做抽象和整理,来降低对外部依赖的耦合度。

2.1 - 抽象数据存储层

第一步常见的操作是将 Data Access 层做抽象,降低系统对数据库的直接依赖。具体的方法如下:

  • 新建 Account 实体对象:一个实体(Entity)是拥有 ID 的域对象,除了拥有数据之外,同时拥有行为。Entity 和数据库储存格式无关,在设计中要以该领域的通用严谨语言(Ubiquitous Language)为依据。
  • 新建对象储存接口类 AccountRepository:Repository 只负责 Entity 对象的存储和读取,而 Repository 的实现类完成数据库存储的细节。通过加入 Repository 接口,底层的数据库连接可以通过不同的实现类而替换。

具体的简单代码实现如下:

Account 实体类:

复制代码
@Data
public class Account {
private AccountId id;
private AccountNumber accountNumber;
private UserId userId;
private Money available;
private Money dailyLimit;
public void withdraw(Money money) {
// 转出
}
public void deposit(Money money) {
// 转入
}
}
和 AccountRepository 及 MyBatis 实现类:
public interface AccountRepository {
Account find(AccountId id);
Account find(AccountNumber accountNumber);
Account find(UserId userId);
Account save(Account account);
}
public class AccountRepositoryImpl implements AccountRepository {
@Autowired
private AccountMapper accountDAO;
@Autowired
private AccountBuilder accountBuilder;
@Override
public Account find(AccountId id) {
AccountDO accountDO = accountDAO.selectById(id.getValue());
return accountBuilder.toAccount(accountDO);
}
@Override
public Account find(AccountNumber accountNumber) {
AccountDO accountDO = accountDAO.selectByAccountNumber(accountNumber.getValue());
return accountBuilder.toAccount(accountDO);
}
@Override
public Account find(UserId userId) {
AccountDO accountDO = accountDAO.selectByUserId(userId.getId());
return accountBuilder.toAccount(accountDO);
}
@Override
public Account save(Account account) {
AccountDO accountDO = accountBuilder.fromAccount(account);
if (accountDO.getId() == null) {
accountDAO.insert(accountDO);
} else {
accountDAO.update(accountDO);
}
return accountBuilder.toAccount(accountDO);
}
}

Account 实体类和 AccountDO 数据类的对比如下:

  • Data Object 数据类:AccountDO 是单纯的和数据库表的映射关系,每个字段对应数据库表的一个 column,这种对象叫 Data Object。DO 只有数据,没有行为。AccountDO 的作用是对数据库做快速映射,避免直接在代码里写 SQL。无论你用的是 MyBatis 还是 Hibernate 这种 ORM,从数据库来的都应该先直接映射到 DO 上,但是代码里应该完全避免直接操作 DO。
  • Entity 实体类:Account 是基于领域逻辑的实体类,它的字段和数据库储存不需要有必然的联系。Entity 包含数据,同时也应该包含行为。在 Account 里,字段也不仅仅是 String 等基础类型,而应该尽可能用上一讲的 Domain Primitive 代替,可以避免大量的校验代码。

DAO 和 Repository 类的对比如下:

  • DAO 对应的是一个特定的数据库类型的操作,相当于 SQL 的封装。所有操作的对象都是 DO 类,所有接口都可以根据数据库实现的不同而改变。比如,insert 和 update 属于数据库专属的操作。
  • Repository 对应的是 Entity 对象读取储存的抽象,在接口层面做统一,不关注底层实现。比如,通过 save 保存一个 Entity 对象,但至于具体是 insert 还是 update 并不关心。Repository 的具体实现类通过调用 DAO 来实现各种操作,通过 Builder/Factory 对象实现 AccountDO 到 Account 之间的转化

2.1.1 Repository 和 Entity

  • 通过 Account 对象,避免了其他业务逻辑代码和数据库的直接耦合,避免了当数据库字段变化时,大量业务逻辑也跟着变的问题。
  • 通过 Repository,改变业务代码的思维方式,让业务逻辑不再面向数据库编程,而是面向领域模型编程。
  • Account 属于一个完整的内存中对象,可以比较容易的做完整的测试覆盖,包含其行为。
    Repository 作为一个接口类,可以比较容易的实现 Mock 或 Stub,可以很容易测试。
  • AccountRepositoryImpl 实现类,由于其职责被单一出来,只需要关注 Account 到 AccountDO 的映射关系和 Repository 方法到 DAO 方法之间的映射关系,相对于来说更容易测试。

阿里技术专家详解DDD系列 第二弹 - 应用架构(二)
image.png

2.2 - 抽象第三方服务

类似对于数据库的抽象,所有第三方服务也需要通过抽象解决第三方服务不可控,入参出参强耦合的问题。在这个例子里我们抽象出 ExchangeRateService 的服务,和一个 ExchangeRate 的 Domain Primitive 类:

复制代码
public interface ExchangeRateService {
ExchangeRate getExchangeRate(Currency source, Currency target);
}
public class ExchangeRateServiceImpl implements ExchangeRateService {
@Autowired
private YahooForexService yahooForexService;
@Override
public ExchangeRate getExchangeRate(Currency source, Currency target) {
if (source.equals(target)) {
return new ExchangeRate(BigDecimal.ONE, source, target);
}
BigDecimal forex = yahooForexService.getExchangeRate(source.getValue(), target.getValue());
return new ExchangeRate(forex, source, target);
}

2.2.1 防腐层(ACL)

这种常见的设计模式叫做 Anti-Corruption Layer(防腐层或 ACL)。很多时候我们的系统会去依赖其他的系统,而被依赖的系统可能包含不合理的数据结构、API、协议或技术实现,如果对外部系统强依赖,会导致我们的系统被”腐蚀“。这个时候,通过在系统间加入一个防腐层,能够有效的隔离外部依赖和内部逻辑,无论外部如何变更,内部代码可以尽可能的保持不变。
阿里技术专家详解DDD系列 第二弹 - 应用架构(二)
image.png

ACL 不仅仅只是多了一层调用,在实际开发中 ACL 能够提供更多强大的功能:

适配器:很多时候外部依赖的数据、接口和协议并不符合内部规范,通过适配器模式,可以将数据转化逻辑封装到 ACL 内部,降低对业务代码的侵入。在这个案例里,我们通过封装了 ExchangeRate 和 Currency 对象,转化了对方的入参和出参,让入参出参更符合我们的标准。
缓存:对于频繁调用且数据变更不频繁的外部依赖,通过在 ACL 里嵌入缓存逻辑,能够有效的降低对于外部依赖的请求压力。同时,很多时候缓存逻辑是写在业务代码里的,通过将缓存逻辑嵌入 ACL,能够降低业务代码的复杂度。
兜底:如果外部依赖的稳定性较差,一个能够有效提升我们系统稳定性的策略是通过 ACL 起到兜底的作用,比如当外部依赖出问题后,返回最近一次成功的缓存或业务兜底数据。这种兜底逻辑一般都比较复杂,如果散落在核心业务代码中会很难维护,通过集中在 ACL 中,更加容易被测试和修改。
易于测试:类似于之前的 Repository,ACL 的接口类能够很容易的实现 Mock 或 Stub,以便于单元测试。
功能开关:有些时候我们希望能在某些场景下开放或关闭某个接口的功能,或者让某个接口返回一个特定的值,我们可以在 ACL 配置功能开关来实现,而不会对真实业务代码造成影响。同时,使用功能开关也能让我们容易的实现 Monkey 测试,而不需要真正物理性的关闭外部依赖。
阿里技术专家详解DDD系列 第二弹 - 应用架构(二)
image.png

2.3 - 抽象中间件

类似于 2.2 的第三方服务的抽象,对各种中间件的抽象的目的是让业务代码不再依赖中间件的实现逻辑。因为中间件通常需要有通用型,中间件的接口通常是 String 或 Byte[] 类型的,导致序列化 / 反序列化逻辑通常和业务逻辑混杂在一起,造成胶水代码。通过中间件的 ACL 抽象,减少重复胶水代码。

在这个案例里,我们通过封装一个抽象的 AuditMessageProducer 和 AuditMessage DP 对象,实现对底层 kafka 实现的隔离:

复制代码
@Value
@AllArgsConstructor
public class AuditMessage {
private UserId userId;
private AccountNumber source;
private AccountNumber target;
private Money money;
private Date date;
public String serialize() {
return userId + "," + source + "," + target + "," + money + "," + date;
}
public static AuditMessage deserialize(String value) {
// todo
return null;
}
}
public interface AuditMessageProducer {
SendResult send(AuditMessage message);
}
public class AuditMessageProducerImpl implements AuditMessageProducer {
private static final String TOPIC_AUDIT_LOG = "TOPIC_AUDIT_LOG";
@Autowired
private KafkaTemplate<String, String> kafkaTemplate;
@Override
public SendResult send(AuditMessage message) {
String messageBody = message.serialize();
kafkaTemplate.send(TOPIC_AUDIT_LOG, messageBody);
return SendResult.success();
}
}

具体的分析和 2.2 类似,在此略过。

阿里技术专家详解DDD系列 第二弹 - 应用架构(二)
image.png

2.4 - 封装业务逻辑

在这个案例里,有很多业务逻辑是跟外部依赖的代码混合的,包括金额计算、账户余额的校验、转账限制、金额增减等。这种逻辑混淆导致了核心计算逻辑无法被有效的测试和复用。在这里,我们的解法是通过 Entity、Domain Primitive 和 Domain Service 封装所有的业务逻辑:

2.4.1 - 用 Domain Primitive 封装跟实体无关的无状态计算逻辑

在这个案例里使用 ExchangeRate 来封装汇率计算逻辑:

复制代码
BigDecimal exchangeRate = BigDecimal.ONE;
if (sourceAccountDO.getCurrency().equals(targetCurrency)) {
exchangeRate = yahooForex.getExchangeRate(sourceAccountDO.getCurrency(), targetCurrency);
}
BigDecimal sourceAmount = targetAmount.divide(exchangeRate, RoundingMode.DOWN);
变为:
ExchangeRate exchangeRate = exchangeRateService.getExchangeRate(sourceAccount.getCurrency(), targetMoney.getCurrency());
Money sourceMoney = exchangeRate.exchangeTo(targetMoney);

2.4.2 - 用 Entity 封装单对象的有状态的行为,包括业务校验

用 Account 实体类封装所有 Account 的行为,包括业务校验如下:

复制代码
@Data
public class Account {
private AccountId id;
private AccountNumber accountNumber;
private UserId userId;
private Money available;
private Money dailyLimit;
public Currency getCurrency() {
return this.available.getCurrency();
}
// 转入
public void deposit(Money money) {
if (!this.getCurrency().equals(money.getCurrency())) {
throw new InvalidCurrencyException();
}
this.available = this.available.add(money);
}
// 转出
public void withdraw(Money money) {
if (this.available.compareTo(money) < 0) {
throw new InsufficientFundsException();
}
if (this.dailyLimit.compareTo(money) < 0) {
throw new DailyLimitExceededException();
}
this.available = this.available.subtract(money);
}
}

原有的业务代码则可以简化为:

复制代码
sourceAccount.deposit(sourceMoney);
targetAccount.withdraw(targetMoney);

2.4.3 - 用 Domain Service 封装多对象逻辑

在这个案例里,我们发现这两个账号的转出和转入实际上是一体的,也就是说这种行为应该被封装到一个对象中去。特别是考虑到未来这个逻辑可能会产生变化:比如增加一个扣手续费的逻辑。这个时候在原有的 TransferService 中做并不合适,在任何一个 Entity 或者 Domain Primitive 里也不合适,需要有一个新的类去包含跨域对象的行为。这种对象叫做 Domain Service。

我们创建一个 AccountTransferService 的类:

复制代码
public interface AccountTransferService {
void transfer(Account sourceAccount, Account targetAccount, Money targetMoney, ExchangeRate exchangeRate);
}
public class AccountTransferServiceImpl implements AccountTransferService {
private ExchangeRateService exchangeRateService;
@Override
public void transfer(Account sourceAccount, Account targetAccount, Money targetMoney, ExchangeRate exchangeRate) {
Money sourceMoney = exchangeRate.exchangeTo(targetMoney);
sourceAccount.deposit(sourceMoney);
targetAccount.withdraw(targetMoney);
}
}

而原始代码则简化为一行:

复制代码
accountTransferService.transfer(sourceAccount, targetAccount, targetMoney, exchangeRate);
image.png

2.5 - 重构后结果分析

这个案例重构后的代码如下:

复制代码
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);
}
}

可以看出来,经过重构后的代码有以下几个特征:

业务逻辑清晰,数据存储和业务逻辑完全分隔。
Entity、Domain Primitive、Domain Service 都是独立的对象,没有任何外部依赖,但是却包含了所有核心业务逻辑,可以单独完整测试。
原有的 TransferService 不再包括任何计算逻辑,仅仅作为组件编排,所有逻辑均 delegate 到其他组件。这种仅包含 Orchestration(编排)的服务叫做 Application Service(应用服务)。

我们可以根据新的结构重新画一张图:

阿里技术专家详解DDD系列 第二弹 - 应用架构(二)

image.png

然后通过重新编排后该图变为:

阿里技术专家详解DDD系列 第二弹 - 应用架构(二)

image.png

我们可以发现,通过对外部依赖的抽象和内部逻辑的封装重构,应用整体的依赖关系变了:

最底层不再是数据库,而是 Entity、Domain Primitive 和 Domain Service。这些对象不依赖任何外部服务和框架,而是纯内存中的数据和操作。这些对象我们打包为 Domain Layer(领域层)。领域层没有任何外部依赖关系。
再其次的是负责组件编排的 Application Service,但是这些服务仅仅依赖了一些抽象出来的 ACL 类和 Repository 类,而其具体实现类是通过依赖注入注进来的。Application Service、Repository、ACL 等我们统称为 Application Layer(应用层)。应用层 依赖 领域层,但不依赖具体实现。
最后是 ACL,Repository 等的具体实现,这些实现通常依赖外部具体的技术实现和框架,所以统称为 Infrastructure Layer(基础设施层)。Web 框架里的对象如 Controller 之类的通常也属于基础设施层。

如果今天能够重新写这段代码,考虑到最终的依赖关系,我们可能先写 Domain 层的业务逻辑,然后再写 Application 层的组件编排,最后才写每个外部依赖的具体实现。这种架构思路和代码组织结构就叫做 Domain-Driven Design(领域驱动设计,或 DDD)。所以 DDD 不是一个特殊的架构设计,而是所有 Transction Script 代码经过合理重构后一定会抵达的终点。

本文转载自淘系技术公众号。

原文链接: https://mp.weixin.qq.com/s/MU1rqpQ1aA1p7OtXqVVwxQ

评论

发布