引用
阿里的行文写的非常棒,笔者在这里列出好的行文脉络架构:
结论先行,思想优先:名词解释
问题驱动:案例分析(列举问题、分析问题、解决问题)
产出解决方案
方案分析
概念收尾
名词解释
可维护性=当代码的依赖进行变更时,有多少代码需要改变
可扩展性=当业务进行变更时,有多少代码需要改变
第一章——Domain Primitive
背景
在日常代码开发实践中,胶水代码(受限于隐式逻辑等原因)存在会导致代码的腐化、复用困难,因此我们寄希望通过一种代码架构解决该问题。
定义
Domain Primitive在笔者看来就是一个充血模型的Value Object,且是一个完整的整体、具有精确的定义,它仅仅包含『无状态』的业务行为。
构成
DP是拥有精准的定义(完善的属性) 、可自我验证 (完备的规约) 、拥有行为 (包含业务属性) 的Value Object。
Primitive 的定义是:不从任何其他事物发展而来,初级的形成或生长的早期阶段
注:Domain Primitive的概念和命名来自于Dan Bergh Johnsson & Daniel Deogun的书 Secure by Design。
目的
解决业务中的胶水代码,将隐式的业务显性化、将上下文显性化、对多对象行为进行封装。
例1
public class User {
Long userId;
String name;
String phone;
String address;
Long repId;
}
public class RegistrationServiceImpl implements RegistrationService {
private SalesRepRepository salesRepRepo;
private UserRepository userRepo;
public User register(String name, String phone, String address)
throws ValidationException {
// 校验逻辑
if (name == null || name.length() == 0) {
throw new ValidationException("name");
}
if (phone == null || !isValidPhoneNumber(phone)) {
throw new ValidationException("phone");
}
if (address == null || !isValidAddress(address)) {
throw new ValidationException("address");
}
// 胶水代码:取电话号里的区号,然后通过区号找到区域内的SalesRep
String areaCode = null;
String[] areas = new String[]{"0571", "021", "010"};
for (int i = 0; i < phone.length(); i++) {
String prefix = phone.substring(0, i);
if (Arrays.asList(areas).contains(prefix)) {
areaCode = prefix;
break;
}
}
SalesRep rep = salesRepRepo.findRep(areaCode);
// 最后创建用户,落盘,然后返回
User user = new User();
user.name = name;
user.phone = phone;
user.address = address;
if (rep != null) {
user.repId = rep.repId;
}
return userRepo.save(user);
}
private boolean isValidPhoneNumber(String phone) {
String pattern = "^0[1-9]{2,3}-?\d{8}$";
return phone.matches(pattern);
}
}
上述代码存在的主要问题:业务表达不明显
-
校验逻辑复用性低、扩展性差
- 这种写法下,校验逻辑属于register函数,而不是属于phone;如果我们重复使用phone这个入参,我们的检验就要进行多次复制(同理修改时一样);
- 如果后续,phone的校验过多,会导致代码迅速膨胀、维护成本增加
-
胶水代码导致业务和操作混杂,导致业务逻辑不清晰
- 如果我们细看注释标注的胶水代码块段(在实践过程中,大多数时候我们的确也是这么去组织和书写代码),这段代码的逻辑十分简单:对一个变量进行加工、抽取一部分用于其他作用,其本质是由于外部依赖的服务的入参并不符合我们原始的入参导致的。
- 大部分胶水代码一个典型的特点就是:将业务逻辑和操作放在一起,从而弱化了真实的业务逻辑
-
可测试性下降
- 由于参数校验附属于函数,从而导致测试用例变成M*N的笛卡尔积
解决方案
将隐性的概念显性化:通过引入Value Obj的方式,解决已上问题。
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);
}
}
这里面有几个很重要的元素:
1、通过 private final String number 确保 PhoneNumber 是一个(Immutable)Value Object。(一般来说 VO 都是 Immutable 的,这里只是重点强调一下)
2、校验逻辑都放在了 constructor 里面,确保只要 PhoneNumber 类被创建出来后,一定是校验通过的。
3、之前的 findAreaCode 方法变成了 PhoneNumber 类里的 getAreaCode ,突出了 areaCode 是 PhoneNumber 的一个计算属性。
修改后的代码:
public class User {
Long userId;
String name;
String phone;
String address;
Long repId;
}
public class RegistrationServiceImpl implements RegistrationService {
private SalesRepRepository salesRepRepo;
private UserRepository userRepo;
public User register(String name, String phone, String address)
throws ValidationException {
// 校验逻辑
...
SalesRep rep = salesRepRepo.findRep(phone.getAreaCode());
//持久化
....
}
例子1仅仅只涉及到将隐式逻辑显性化,同时它还存在两种特性:将隐式上下文显性化、封装多对象行为。
文中给出转账的例子:
public void pay(BigDecimal money, Long recipientId) {
BankService.transfer(money, "CNY", recipientId);
}
存在的问题:转账默认的业务上下文是:出入账货币相同,一旦是境外转账就会存在bug。
从这个角度出发,转账的金额应该包含两个属性:金额和货币。
@Value
public class Money {
private BigDecimal amount;
private Currency currency;
public Money(BigDecimal amount, Currency currency) {
this.amount = amount;
this.currency = currency;
}
}
原有的代码变为:
public void pay(Money money, Long recipientId) {
BankService.transfer(money, recipientId);
}
通过将默认货币这个隐性的上下文概念显性化,并且和金额合并为 Money ,我们可以避免很多当前看不出来,但未来可能会暴雷的bug。
但是,如果我们再演进一步:转账时的汇率是不停在波动的,这个代码应该如何组织:
public void pay(Money money, Currency targetCurrency, Long recipientId) {
if (money.getCurrency().equals(targetCurrency)) {
BankService.transfer(money, recipientId);
} else {
BigDecimal rate = ExchangeService.getRate(money.getCurrency(), targetCurrency);
BigDecimal targetAmount = money.getAmount().multiply(new BigDecimal(rate));
Money targetMoney = new Money(targetAmount, targetCurrency);
BankService.transfer(targetMoney, recipientId);
}
}
这个case最大的问题在于:转账这个业务又引入了金额转换的业务逻辑,并且这个业务逻辑是隐式,代码中仅仅表达操作;如果我们从未来的扩展性出发,我们将转换汇率的功能,封装到一个叫做 ExchangeRate 的 DP 里:
@Value
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);
}
}
最终的代码如下:
public void pay(Money money, Currency targetCurrency, Long recipientId) {
ExchangeRate rate = ExchangeService.getRate(money.getCurrency(), targetCurrency);
Money targetMoney = rate.exchange(money);
BankService.transfer(targetMoney, recipientId);
}
DP和VO的区别
总结
我们关注从来不是这些名词和概念,而是应该不忘初心:我们应该让业务被收敛、体现,DP是一种不错的解决方案。因为,他可以提升代码的可测试性、降低胶水代码、提升代码的复用性。
我们应该在什么时候去考虑使用DP
- 当我们发现一些属性集合属于VO时,使用DP
- 当存在多个对象进行互相操作时,我们可以通过DP的方式暴露业务、降低胶水代码的存在。
第二章——第二弹 - 应用架构
引言
架构这个词源于英文里的“Architecture“,源头是土木工程里的“建筑”和“结构”,而架构里的”架“同时又包含了”架子“(scaffolding)的含义,意指能快速搭建起来的固定结构;而在今天代码的语境下,是指软件系统中固定不变的代码结构、设计模式、规范和组件间的通信方式。
什么是好的代码架构——from 阿里的同学
独立于框架:业务实现和技术组件分离,架构不应该依赖某些外部的库、框架
独立UI
独立于底层数据源:
独立于外部依赖:无论外部依赖如何变更、升级,业务的核心逻辑不应该随之而大幅变化。
可测试性
当然笔者不是完全认同上述观点,笔者认为好的代码架构应该具备以下特点:
1.代码的可扩展性:好的代码架构帮助代码开发人员将业务与技术解耦,增加代码的扩展性
2.代码的可维护性:好的代码架构能将业务和依赖进行解耦,增加代码的可维护性;同时好的代码架构可以降低代码和文档的腐化程度
3.代码复用性高、可测试性强:代码架构有助于提升代码的复用性、可测试性
锦上添花:
4.系统稳定性高:好的代码架构有助于提升系统的稳定性
案例
需求:用户可以通过银行网页转账给另一个账号,支持跨币种转账。同时,因为监管和对账需求,需要记录本次转账活动。
拿到这个需求之后,一个开发可能会经历一些技术选型,最终可能拆解需求如下:
1、从MySql数据库中找到转出和转入的账户,选择用 MyBatis 的 mapper 实现 DAO;
2、从 Yahoo(或其他渠道)提供的汇率服务获取转账的汇率信息(底层是 http 开放接口);
3、计算需要转出的金额,确保账户有足够余额,并且没超出每日转账上限;
4、实现转入和转出操作,扣除手续费,保存数据库;
5、发送 Kafka 审计消息,以便审计和对账用;
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);
}
}
问题分析
我们根据上文进行问题分析:
可维护性
可维护性不高的原因可能是因为以下几点:
-
- 数据结构的不稳定:AccountDO类是一个纯数据结构,映射了数据库中的一个表。这里的问题是数据库的表结构和设计是应用的外部依赖,长远来看都有可能会改变
- 依赖库的升级:AccountMapper依赖MyBatis的实现,如果MyBatis未来升级版本,可能会造成用法的不同(可以参考iBatis升级到基于注解的MyBatis的迁移成本)。同样的,如果未来换一个ORM体系,迁移成本也是巨大的。
- 第三方服务的不确定性:第三方服务如果出现变更,导致迁移成本增加
- 第三方服务API的接口变化:YahooForexService.getExchangeRate返回的结果是小数点还是百分比?入参是(source, target)还是(target, source)?谁能保证未来接口不会改变?如果改变了,核心的金额计算逻辑必须跟着改,否则会造成资损。
- 中间件的变更:如果将中间件进行变更,或者信息序列化方式进行改变均会导致迁移成本的增加
上述的这些原因均会导致代码的可维护性降低,这是因为代码把外部的第三方依赖和业务进行耦合。
可扩展性
参考上述代码,如果我们将需求变同时支持跨行转账(数据来源可能来自mysql,也可能来自第三方),你会发现基本上需要重新开发,基本上没有任何的可复用性:
-
- 数据来源固定、数据格式不兼容:原有的AccountDO是从本地获取的,而跨行转账的数据可能需要从一个第三方服务获取,而服务之间数据格式不太可能是兼容的,导致从数据校验、数据读写、到异常处理、金额计算等逻辑都要重写。
- 业务逻辑无法复用:由于数据格式不兼容,导致核心业务逻辑无法复用。每个用例都是特殊逻辑的后果就是代码中大量的if else语句,增加代码的圈复杂度
- 逻辑和数据存储的互相依赖: 当业务逻辑增加变得越来越复杂时,新加入的逻辑很有可能需要对数据库schema或消息格式做变更。而变更了数据格式后会导致原有的其他逻辑需要一起跟着动。在最极端的场景下,一个新功能的增加会导致所有原有功能的重构,成本巨大。
在事务脚本式的架构下,一般做第一个需求都非常的快,但是做第N个需求时需要的时间很有可能是呈指数级上升的,绝大部分时间花费在老功能的重构和兼容上,最终你的创新速度会跌为0,促使老应用被推翻重构。
可测试性
上述代码可测性低:
- 外部依赖多, 设施搭建困难:当代码中强依赖了数据库、第三方服务、中间件等外部依赖之后,想要完整跑通一个测试用例需要确保所有依赖都能跑起来,这个在项目早期是及其困难的。在项目后期也会由于各种系统的不稳定性而导致测试无法通过。
- 运行时间长
- 耦合度高::假如一段脚本中有A、B、C三个子步骤,而每个步骤有N个可能的状态,当多个子步骤耦合度高时,为了完整覆盖所有用例,最多需要有N * N * N个测试用例。当耦合的子步骤越多时,需要的测试用例呈指数级增长。
总结分析
上述代码为什么会触犯上述几个问题,因为违背了几个软件设计原则:
- 单一性原则:单一性原则要求一个对象/类应该只有一个变更的原因。但是在这个案例里,代码可能会因为任意一个外部依赖或计算逻辑的改变而改变。
- 依赖反转原则(Dependency Inversion Principle) :依赖反转原则要求在代码中依赖抽象,而不是具体的实现。在这个案例里外部依赖都是具体的实现,比如YahooForexService虽然是一个接口类,但是它对应的是依赖了Yahoo提供的具体服务,所以也算是依赖了实现。同样的KafkaTemplate、MyBatis的DAO实现都属于具体实现。
- 开闭原则(Open Closed Principle) :开闭原则指开放扩展,修改封闭。在这个案例里的金额计算属于可能会被修改的代码,这个时候该逻辑应该需要被包装成为不可修改的计算类,新功能通过计算类的拓展实现
重构方案
在重构之前,我们先画一张流程图,描述当前代码在做的每个步骤:
这是一个传统的三层分层结构:UI层、业务层、和基础设施层。上层对于下层有直接的依赖关系,导致耦合度过高。
第一步:抽象存储层
第一步常见的操作是将Data Access层做抽象,降低系统对数据库的直接依赖。具体的方法如下:
1、新建Account实体对象:一个实体(Entity)是拥有ID的域对象,除了拥有数据之外,同时拥有行为。 Entity和数据库储存格式无关,在设计中要以该领域的通用严谨语言(Ubiquitous Language)为依据。
2、新建对象储存接口类AccountRepository:Repository只负责Entity对象的存储和读取,而Repository的实现类完成数据库存储的细节。通过加入Repository接口,底层的数据库连接可以通过不同的实现类而替换。
@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) {
// 转入
}
}
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);
}
}
这里罗列一些比较:DO和Entity的区别:充血模型和贫血模型的区别
DAO 和 Repository 类的对比如下:
- DAO对应的是一个特定的数据库类型的操作,相当于SQL的封装。所有操作的对象都是DO类,所有接口都可以根据数据库实现的不同而改变。比如,insert 和 update 属于数据库专属的操作
- Repository对应的是Entity对象读取储存的抽象,在接口层面做统一,不关注底层实现。 比如,通过 save 保存一个Entity对象,但至于具体是 insert 还是 update 并不关心。Repository的具体实现类通过调用DAO来实现各种操作,通过Builder/Factory对象实现AccountDO 到 Account之间的转化
通过Repository和Entity结合的方式实践,优点如下:
- 业务逻辑收敛到Entity中, 避免了其他业务逻辑代码和数据库的直接耦合。 避免了当数据库字段变化时,大量业务逻辑也跟着变的问题。
- 通过Repository的方式, 改变业务代码的思维方式,让业务逻辑不再面向数据库编程,而是面向领域模型编程。
Account属于一个完整的内存中对象,可以比较容易的做完整的测试覆盖,包含其行为。
Repository作为一个接口类,可以比较容易的实现Mock或Stub,可以很容易测试。
AccountRepositoryImpl实现类,由于其职责被单一出来,只需要关注Account到AccountDO的映射关系和Repository方法到DAO方法之间的映射关系,相对于来说更容易测试。
修改后的组织结构如下:
第二步:抽象第三方服务
所有第三方服务也需要通过抽象解决第三方服务不可控,入参出参强耦合的问题。在这个例子里我们抽象出 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);
}
防腐层(ACL)
这种常见的设计模式叫做Anti-Corruption Layer(防腐层或ACL)。很多时候我们的系统会去依赖其他的系统,而被依赖的系统可能包含不合理的数据结构、API、协议或技术实现,如果对外部系统强依赖,会导致我们的系统被”腐蚀“。这个时候,通过在系统间加入一个防腐层,能够有效的隔离外部依赖和内部逻辑,无论外部如何变更,内部代码可以尽可能的保持不变。
修改后代码结构层次:
第三步:抽象中间件
对各种中间件的抽象的目的是让业务代码不再依赖中间件的实现逻辑 。因为中间件通常需要有通用型,中间件的接口通常是String或Byte[] 类型的,导致序列化/反序列化逻辑通常和业务逻辑混杂在一起,造成胶水代码。 通过中间件的ACL抽象,减少重复胶水代码。
我们通过对Message的构建成一个DP对象,从而实现对底层的Kafka实现隔离:
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();
}
}
代码架构优化如下:
第四步封装业务逻辑
通过DP封装跟实体无关的无状态计算逻辑
在这个案例里使用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);
用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);
用Domain Service封装多实体的逻辑
在这个案例里,我们发现这两个账号的转出和转入实际上是一体的,也就是说这种行为应该被封装到一个对象中去。特别是考虑到未来这个逻辑可能会产生变化:比如增加一个扣手续费的逻辑。 这个时候在原有的TransferService中做并不合适,在任何一个Entity或者Domain Primitive里也不合适,需要有一个新的类去包含跨域对象的行为。这种对象叫做Domain Service。
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);
最终的业务构成图
然后通过重新编排后该图变为:
我们可以发现,通过对外部依赖的抽象和内部逻辑的封装重构,应用整体的依赖关系变了:
1、最底层不再是数据库,而是Entity、Domain Primitive和Domain Service。这些对象不依赖任何外部服务和框架,而是纯内存中的数据和操作。这些对象我们打包为Domain Layer(领域层)。领域层没有任何外部依赖关系。
2、再其次的是负责组件编排的Application Service,但是这些服务仅仅依赖了一些抽象出来的ACL类和Repository类,而其具体实现类是通过依赖注入注进来的。Application Service、Repository、ACL等我们统称为Application Layer(应用层)。应用层依赖领域层,但不依赖具体实现。
3、最后是ACL,Repository等的具体实现,这些实现通常依赖外部具体的技术实现和框架,所以统称为Infrastructure Layer(基础设施层)。
通过上述的思路和演进,我们最终达到了一个当前情况下看起来合理的代码组织和实现结构,这种结构就是DDD。 所以DDD不是一个特殊的架构设计,而是所有Transction Script代码经过合理重构后一定会抵达的终点。
总结
从上述案例中,我们至少有以下经验:
-
好的代码应该只做业务编排,但具体的业务实现需要区分有无状态;
- 有状态的交由实体Entity
- 无状态的交由DP
-
依赖注入、控制反转:业务的实现不应该依赖具体的实现类,而是应该转为对接口的依赖
- 实现依赖注入:实现依赖注入至少要通过一个接口类+一个实现类的方式实现
DDD的六边形架构
分析一下,上图的表达:越靠近外层的代码越稳定,越内层的代码演进越快。
从代码演进的角度来看/需求变更的速度来看:
- Domain层属于核心业务逻辑,属于经常被修改的地方;这部分的需求经常随着产品的迭代进行变更
- Application层属于Use Case(业务用例)。业务用例一般都是描述比较大方向的需求,接口相对稳定,特别是对外的接口一般不会频繁变更。添加业务用例可以通过新增Application Service或者新增接口实现功能的扩展。
- Infrastructure层属于最低频变更的。一般这个层的模块只有在外部依赖变更了之后才会跟着升级,而外部依赖的变更频率一般远低于业务逻辑的变更频率。
DDD不是一个特殊的架构,而是传统的代码经过合理迭代后,最终会抵达的合理的软件架构。他能够解决传统软件架构中的以下问题:
- 可维护性
- 可扩展性
- 可测试性
- 代码结构清晰
第三章——Repository代码规范
接口规范
- 接口名词不应该使用底层实现的语法:我们常见的insert、select、update、delete都属于SQL语法,使用这几个词相当于和DB底层实现做了绑定。相反,我们应该把 Repository 当成一个中性的类似Collection 的接口,使用语法如 find、save、remove;这样的做法是因为部分缓存系统不存在insert和update的差异
- 出参入参不应该使用底层数据格式:需要记得的是 Repository 操作的是 Entity 对象(实际上应该是Aggregate Root),而不应该直接操作底层的 DO。这样可以避免底层实现逻辑渗透到业务代码中,Repository接口的调用一般在Domain Service层,不去查看DO的实现。
- 应该避免所谓的“通用”Repository模式:很多 ORM 框架都提供一个“通用”的Repository接口,然后框架通过注解自动实现接口,比较典型的例子是Spring Data、Entity Framework等,这种框架的好处是在简单场景下很容易通过配置实现,但是坏处是基本上无扩展的可能性(比如加定制缓存逻辑),在未来有可能还是会被推翻重做。
具体可见:juejin.cn/post/684516…
第四章——领域层设计规范
需求
如何用代码实现一个龙与魔法的游戏世界的(极简)规则?
基础配置如下:
- 玩家(Player)可以是战士(Fighter)、法师(Mage)、龙骑(Dragoon)
- 怪物(Monster)可以是兽人(Orc)、精灵(Elf)、龙(Dragon),怪物有血量
- 武器(Weapon)可以是剑(Sword)、法杖(Staff),武器有攻击力
- 玩家可以装备一个武器,武器攻击可以是物理类型(0),火(1),冰(2)等,武器类型决定伤害类型
攻击规则如下:
- 兽人对物理攻击伤害减半
- 精灵对魔法攻击伤害减半
- 龙对物理和魔法攻击免疫,除非玩家是龙骑,则伤害加倍
OOP实现
对于熟悉Object-Oriented Programming的同学,一个比较简单的实现是通过类的继承关系(此处省略部分非核心代码):
public abstract class Player {
Weapon weapon
}
public class Fighter extends Player {}
public class Mage extends Player {}
public class Dragoon extends Player {}
public abstract class Monster {
Long health;
}
public Orc extends Monster {}
public Elf extends Monster {}
public Dragoon extends Monster {}
public abstract class Weapon {
int damage;
int damageType; // 0 - physical, 1 - fire, 2 - ice etc.
}
public Sword extends Weapon {}
public Staff extends Weapon {}
规则逻辑实现代码
public class Player {
public void attack(Monster monster) {
monster.receiveDamageBy(weapon, this);
}
}
public class Monster {
public void receiveDamageBy(Weapon weapon, Player player) {
this.health -= weapon.getDamage(); // 基础规则
}
}
public class Orc extends Monster {
@Override
public void receiveDamageBy(Weapon weapon, Player player) {
if (weapon.getDamageType() == 0) {
this.setHealth(this.getHealth() - weapon.getDamage() / 2); // Orc的物理防御规则
} else {
super.receiveDamageBy(weapon, player);
}
}
}
public class Dragon extends Monster {
@Override
public void receiveDamageBy(Weapon weapon, Player player) {
if (player instanceof Dragoon) {
this.setHealth(this.getHealth() - weapon.getDamage() * 2); // 龙骑伤害规则
}
// else no damage, 龙免疫力规则
}
}
单测:
public class BattleTest {
@Test
@DisplayName("Dragon is immune to attacks")
public void testDragonImmunity() {
// Given
Fighter fighter = new Fighter("Hero");
Sword sword = new Sword("Excalibur", 10);
fighter.setWeapon(sword);
Dragon dragon = new Dragon("Dragon", 100L);
// When
fighter.attack(dragon);
// Then
assertThat(dragon.getHealth()).isEqualTo(100);
}
@Test
@DisplayName("Dragoon attack dragon doubles damage")
public void testDragoonSpecial() {
// Given
Dragoon dragoon = new Dragoon("Dragoon");
Sword sword = new Sword("Excalibur", 10);
dragoon.setWeapon(sword);
Dragon dragon = new Dragon("Dragon", 100L);
// When
dragoon.attack(dragon);
// Then
assertThat(dragon.getHealth()).isEqualTo(100 - 10 * 2);
}
@Test
@DisplayName("Orc should receive half damage from physical weapons")
public void testFighterOrc() {
// Given
Fighter fighter = new Fighter("Hero");
Sword sword = new Sword("Excalibur", 10);
fighter.setWeapon(sword);
Orc orc = new Orc("Orc", 100L);
// When
fighter.attack(orc);
// Then
assertThat(orc.getHealth()).isEqualTo(100 - 10 / 2);
}
@Test
@DisplayName("Orc receive full damage from magic attacks")
public void testMageOrc() {
// Given
Mage mage = new Mage("Mage");
Staff staff = new Staff("Fire Staff", 10);
mage.setWeapon(staff);
Orc orc = new Orc("Orc", 100L);
// When
mage.attack(orc);
// Then
assertThat(orc.getHealth()).isEqualTo(100 - 10);
}
}
代码缺陷分析
当代码不进行变更时,这个代码目前来看没什么问题,但是如果我们加一个限制条件:
- 战士只能装备剑
- 法师只能装备法杖
我们有两种的方式对代码进行修改:
- 通过override,修改Fighter中的代码
- 在父类上进行修改
从代码开闭的思路上,我们摈弃1中的方法,转而在2的基础上进行优化和修改。
@Data
public abstract class Player {
@Setter(AccessLevel.PROTECTED)
private Weapon weapon;
}
@Test
public void testCastEquip() {
Fighter fighter = new Fighter("Hero");
Sword sword = new Sword("Sword", 10);
fighter.setWeapon(sword);
Player player = fighter;
Staff staff = new Staff("Staff", 10);
player.setWeapon(staff); // 编译不过,但从API层面上应该开放可用
}
如果我们继续提出新的诉求:
- 战士和法师都能装备匕首(dagger)
上述方法实现上会比较复杂,这个本质的原因继承虽然可以通过子类扩展新的行为 ,但因为子类可能直接依赖父类的实现,导致一个变更可能会影响所有对象。在这个例子里,如果增加任意一种类型的玩家、怪物或武器,或增加一种规则,都有可能需要修改从父类到子类的所有方法。
比如,如果要增加一个武器类型:狙击枪,能够无视所有防御一击必杀,需要修改的代码包括:
- Weapon
- Player和所有的子类(是否能装备某个武器的判断)
- Monster和所有的子类(伤害计算逻辑)
public class Monster {
public void receiveDamageBy(Weapon weapon, Player player) {
this.health -= weapon.getDamage(); // 老的基础规则
if (Weapon instanceof Gun) { // 新的逻辑
this.setHealth(0);
}
}
}
public class Dragon extends Monster {
public void receiveDamageBy(Weapon weapon, Player player) {
if (Weapon instanceof Gun) { // 新的逻辑
super.receiveDamageBy(weapon, player);
}
// 老的逻辑省略
}
}
在一个复杂的软件中我们建议最好不要违反OCR原则,最核心的原因是一个现有的逻辑可能会影响到原有的代码,从而导致无法预见的风险,且这种风险只能通过单测来保障。OCP的原则能尽可能的规避这种风险,当新的行为只能通过新的字段/方法来实现时,老代码的行为自然不会变。
继承虽然对于扩展开放(open for extension),但是很难做到Closed for modification。所以,今天的解决OCP的方案主要是通过组合实现扩展性,而不是继承。
这也解释了go语言为什么不支持继承
一个好的问题:Player.attack(monster) 还是 util.Attack(Monster, Player)?
作者在文档中的分析实在太过精彩,因此在这里提供原文:
在这个例子里,其实业务规则的逻辑到底应该写在哪里是有异议的:当我们去看一个对象和另一个对象之间的交互时,到底是Player去攻击Monster,还是Monster被Player攻击?目前的代码主要将逻辑写在Monster的类中,主要考虑是Monster会受伤降低Health,但如果是Player拿着一把双刃剑会同时伤害自己呢?是不是发现写在Monster类里也有问题?代码写在哪里的原则是什么?
在此先抛开这个问题,先分析这样一种场景:如果我们要在这个案例中增加一个可移动的行为,如何修改?
public abstract class Player {
int x;
int y;
void move(int targetX, int targetY) {
// logic
}
}
public abstract class Monster {
int x;
int y;
void move(int targetX, int targetY) {
// logic
}
}
分析这段代码,move代码逻辑是重复的,我们应该如何去化简?
ok,遵从oop原则,我们再抽象出一个新的父类,代码如下:
public abstract class Movable {
int x;
int y;
void move(int targetX, int targetY) {
// logic
}
}
public abstract class Player extends Movable;
public abstract class Monster extends Movable;
看起来,问题似乎解决了,但是当代码继续进行迭代,再增加:跑、跳等行为?在这些行为上再绑定一定的规则,我们如何设计继承关系?
最终我们只能走向接口+继承的复合代码中、或者通过重复代码实现,这对于代码质量来说就是灾难!
问题总结
在这个案例里虽然从直觉来看OOP的逻辑很简单,但如果你的业务比较复杂,未来会有大量的业务规则变更时,简单的OOP代码会在后期变成复杂的一团浆糊,逻辑分散在各地,缺少全局视角,各种规则的叠加会触发bug。有没有感觉似曾相识?对的,电商体系里的优惠、交易等链路经常会碰到类似的坑。而这类问题的核心本质在于:
- 业务规则的归属到底是对象的“行为”还是独立的”规则对象“?
- 业务规则之间的关系如何处理?
- 通用“行为”应该如何复用和维护?
基于DDD代码架构的解法
领域对象
实体类
在DDD里,实体类包含ID和内部状态,在这个案例里实体类包含Player、Monster和Weapon。Weapon被设计成实体类是因为两把同名的Weapon应该可以同时存在,所以必须要有ID来区分,同时未来也可以预期Weapon会包含一些状态,比如升级、临时的buff、耐久等。
public class Player implements Movable {
private PlayerId id;
private String name;
private PlayerClass playerClass; // enum
private WeaponId weaponId; // (Note 1)
private Transform position = Transform.ORIGIN;
private Vector velocity = Vector.ZERO;
}
public class Monster implements Movable {
private MonsterId id;
private MonsterClass monsterClass; // enum
private Health health;
private Transform position = Transform.ORIGIN;
private Vector velocity = Vector.ZERO;
}
public class Weapon {
private WeaponId id;
private String name;
private WeaponType weaponType; // enum
private int damage;
private int damageType; // 0 - physical, 1 - fire, 2 - ice
}
Note 1: 因为 Weapon 是实体类,但是Weapon能独立存在,Player不是聚合根,所以Player只能保存WeaponId,而不能直接指向Weapon。
笔者认为作者在这个地方之所以不选择palyer作为aggregate,主要考量是因为场景简单
Q:移动需求应该如何实现?
An:DP——值对象构建
public interface Movable {
// 相当于组件
Transform getPosition();
Vector getVelocity();
// 行为
void moveTo(long x, long y);
void startMove(long velX, long velY);
void stopMove();
boolean isMoving();
}
// 具体实现
public class Player implements Movable {
public void moveTo(long x, long y) {
this.position = new Transform(x, y);
}
public void startMove(long velocityX, long velocityY) {
this.velocity = new Vector(velocityX, velocityY);
}
public void stopMove() {
this.velocity = Vector.ZERO;
}
@Override
public boolean isMoving() {
return this.velocity.getX() != 0 || this.velocity.getY() != 0;
}
}
@Value
public class Transform {
public static final Transform ORIGIN = new Transform(0, 0);
long x;
long y;
}
@Value
public class Vector {
public static final Vector ZERO = new Vector(0, 0);
long x;
long y;
}
注意:Moveable的接口没有Setter。一个Entity的规则是不能直接变更其属性,必须通过Entity的方法去对内部状态做变更。这样能保证数据的一致性。
Q:装备规则的实现
因为我们已经不会用Player的子类来决定什么样的Weapon可以装备,所以这段逻辑应该被拆分到一个单独的类里。这种类在DDD里被叫做领域服务(Domain Service)。
public interface EquipmentService {
boolean canEquip(Player player, Weapon weapon);
}
在DDD里,一个Entity不应该直接依赖另一个Entity或服务,也就是说以下的代码是错误的:
public class Player {
@Autowired
EquipmentService equipmentService; // BAD: 不可以直接依赖
public void equip(Weapon weapon) {
// ...
}
}
这里的问题是Entity只能自己来修改自己的状态(或非聚合根的对象)。任何其他的对象,无论是否通过依赖注入的方式弄进来,都会破坏Entity的Invariance,并且还难以单测。
正确的引用方式是通过方法参数引入(Double Dispatch):
public class Player {
public void equip(Weapon weapon, EquipmentService equipmentService) {
if (equipmentService.canEquip(this, weapon)) {
this.weaponId = weapon.getId();
} else {
throw new IllegalArgumentException("Cannot Equip: " + weapon);
}
}
}
在这里,无论是Weapon还是EquipmentService都是通过方法参数传入,确保不会污染Player的自有状态。
Double Dispatch是一个使用Domain Service经常会用到的方法,类似于调用反转。
然后在EquipmentService里实现相关的逻辑判断,这里我们用了另一个常用的Strategy(或者叫Policy)设计模式
public class EquipmentServiceImpl implements EquipmentService {
private EquipmentManager equipmentManager;
@Override
public boolean canEquip(Player player, Weapon weapon) {
return equipmentManager.canEquip(player, weapon);
}
}
// 策略优先级管理
public class EquipmentManager {
private static final List<EquipmentPolicy> POLICIES = new ArrayList<>();
static {
POLICIES.add(new FighterEquipmentPolicy());
POLICIES.add(new MageEquipmentPolicy());
POLICIES.add(new DragoonEquipmentPolicy());
POLICIES.add(new DefaultEquipmentPolicy());
}
public boolean canEquip(Player player, Weapon weapon) {
for (EquipmentPolicy policy : POLICIES) {
if (!policy.canApply(player, weapon)) {
continue;
}
return policy.canEquip(player, weapon);
}
return false;
}
}
// 策略案例
public class FighterEquipmentPolicy implements EquipmentPolicy {
@Override
public boolean canApply(Player player, Weapon weapon) {
return player.getPlayerClass() == PlayerClass.Fighter;
}
/**
* Fighter能装备Sword和Dagger
*/
@Override
public boolean canEquip(Player player, Weapon weapon) {
return weapon.getWeaponType() == WeaponType.Sword
|| weapon.getWeaponType() == WeaponType.Dagger;
}
}
Q:如何实现攻击行为
在上文中曾经有提起过,到底应该是Player.attack(Monster)还是Monster.receiveDamage(Weapon, Player)?在DDD里,因为这个行为可能会影响到Player、Monster和Weapon,所以属于跨实体的业务逻辑。在这种情况下需要通过一个第三方的领域服务(Domain Service)来完成。
public interface CombatService {
void performAttack(Player player, Monster monster);
}
public class CombatServiceImpl implements CombatService {
private WeaponRepository weaponRepository;
private DamageManager damageManager;
@Override
public void performAttack(Player player, Monster monster) {
Weapon weapon = weaponRepository.find(player.getWeaponId());
int damage = damageManager.calculateDamage(player, weapon, monster);
if (damage > 0) {
monster.takeDamage(damage); // (Note 1)在领域服务里变更Monster
// note2 具体的实体的属性变更由实体内部实现
}
// 省略掉Player和Weapon可能受到的影响
}
}
同样的在这个案例里,可以通过Strategy设计模式来解决damage的计算问题:
// 策略优先级管理
public class DamageManager {
private static final List<DamagePolicy> POLICIES = new ArrayList<>();
static {
POLICIES.add(new DragoonPolicy());
POLICIES.add(new DragonImmunityPolicy());
POLICIES.add(new OrcResistancePolicy());
POLICIES.add(new ElfResistancePolicy());
POLICIES.add(new PhysicalDamagePolicy());
POLICIES.add(new DefaultDamagePolicy());
}
public int calculateDamage(Player player, Weapon weapon, Monster monster) {
for (DamagePolicy policy : POLICIES) {
if (!policy.canApply(player, weapon, monster)) {
continue;
}
return policy.calculateDamage(player, weapon, monster);
}
return 0;
}
}
// 策略案例
public class DragoonPolicy implements DamagePolicy {
public int calculateDamage(Player player, Weapon weapon, Monster monster) {
return weapon.getDamage() * 2;
}
@Override
public boolean canApply(Player player, Weapon weapon, Monster monster) {
return player.getPlayerClass() == PlayerClass.Dragoon &&
monster.getMonsterClass() == MonsterClass.Dragon;
}
}
特别需要注意的是这里的CombatService领域服务和3.2的EquipmentService领域服务,虽然都是领域服务,但实质上有很大的差异。上文的EquipmentService更多的是提供只读策略,且只会影响单个对象,所以可以在Player.equip方法上通过参数注入。但是CombatService有可能会影响多个对象,所以不能直接通过参数注入的方式调用。
单元测试
@Test
@DisplayName("Dragoon attack dragon doubles damage")
public void testDragoonSpecial() {
// Given
Player dragoon = playerFactory.createPlayer(PlayerClass.Dragoon, "Dart");
Weapon sword = weaponFactory.createWeaponFromPrototype(swordProto, "Soul Eater", 60);
((WeaponRepositoryMock)weaponRepository).cache(sword);
dragoon.equip(sword, equipmentService);
Monster dragon = monsterFactory.createMonster(MonsterClass.Dragon, 100);
// When
combatService.performAttack(dragoon, dragon);
// Then
assertThat(dragon.getHealth()).isEqualTo(Health.ZERO);
assertThat(dragon.isAlive()).isFalse();
}
@Test
@DisplayName("Orc should receive half damage from physical weapons")
public void testFighterOrc() {
// Given
Player fighter = playerFactory.createPlayer(PlayerClass.Fighter, "MyFighter");
Weapon sword = weaponFactory.createWeaponFromPrototype(swordProto, "My Sword");
((WeaponRepositoryMock)weaponRepository).cache(sword);
fighter.equip(sword, equipmentService);
Monster orc = monsterFactory.createMonster(MonsterClass.Orc, 100);
// When
combatService.performAttack(fighter, orc);
// Then
assertThat(orc.getHealth()).isEqualTo(Health.of(100 - 10 / 2));
}
移动系统
最后还有一种Domain Service,通过组件化,来降低一些重复性的代码:
public class MovementSystem {
private static final long X_FENCE_MIN = -100;
private static final long X_FENCE_MAX = 100;
private static final long Y_FENCE_MIN = -100;
private static final long Y_FENCE_MAX = 100;
private List<Movable> entities = new ArrayList<>();
public void register(Movable movable) {
entities.add(movable);
}
public void update() {
for (Movable entity : entities) {
if (!entity.isMoving()) {
continue;
}
Transform old = entity.getPosition();
Vector vel = entity.getVelocity();
long newX = Math.max(Math.min(old.getX() + vel.getX(), X_FENCE_MAX), X_FENCE_MIN);
long newY = Math.max(Math.min(old.getY() + vel.getY(), Y_FENCE_MAX), Y_FENCE_MIN);
entity.moveTo(newX, newY);
}
}
}
单测
@Test
@DisplayName("Moving player and monster at the same time")
public void testMovement() {
// Given
Player fighter = playerFactory.createPlayer(PlayerClass.Fighter, "MyFighter");
fighter.moveTo(2, 5);
fighter.startMove(1, 0);
Monster orc = monsterFactory.createMonster(MonsterClass.Orc, 100);
orc.moveTo(10, 5);
orc.startMove(-1, 0);
movementSystem.register(fighter);
movementSystem.register(orc);
// When
movementSystem.update();
// Then
assertThat(fighter.getPosition().getX()).isEqualTo(2 + 1);
assertThat(orc.getPosition().getX()).isEqualTo(10 - 1);
}
总结
代码架构分析
上文比较了两种代码架构:OOP和DDD
- 基于OOP的代码架构:OOP的代码方便理解,写起来最快。但是由于所有的规则都在对象内部,一旦代码复杂,不方便定位是某个字段是被哪个逻辑修改;同时,当业务逻辑 变更时,其结构会限制它的发展,新的规则有可能会导致代码的整体重构。
- DDD代码架构::DDD的规则其实最复杂,同时要考虑到实体类的内聚和保证不变性(Invariants),也要考虑跨对象规则代码的归属,甚至要考虑到具体领域服务的调用方式,理解成本比较高
DDD领域层的一些设计规范
实体类(Entity)
实体类是业务聚合的最小单元,Entity最重要的设计原则是保证实体的不变性(Invariants) ,也就是说要确保无论外部怎么操作,一个实体内部的属性都不能出现相互冲突,状态不一致的情况。因此,存在一些创建原则:
- 创建一致性
在贫血模型里,通常见到的代码是一个模型通过手动new出来之后,由调用方一个参数一个参数的赋值,这就很容易产生遗漏,导致实体状态不一致。所以DDD里实体创建的方法有两种:
-
-
constructor参数要包含所有必要属性,或者在constructor里有合理的默认值。
- 这部分通过强校验的constructor来保证
-
另一种方法是通过Factory模式来创建对象,降低一些重复性的入参。
-
public class WeaponFactory {
public Weapon createWeaponFromPrototype(WeaponPrototype proto, String newName) {
Weapon weapon = new Weapon(null, newName, proto.getWeaponType(), proto.getDamage(), proto.getDamageType());
return weapon;
}
}
- 避免public setter
一个最容易导致不一致性的原因是实体暴露了public的setter方法,特别是set单一参数会导致状态不一致的情况。 比如,一个订单可能包含订单状态(下单、已支付、已发货、已收货)、支付单、物流单等子实体,如果一个调用方能随意去set订单状态,就有可能导致订单状态和子实体匹配不上,导致业务流程走不通的情况。所以在实体里,需要通过行为方法来修改内部状态:
@Data @Setter(AccessLevel.PRIVATE) // 确保不生成public setter
public class Order {
private int status; // 0 - 创建,1 - 支付,2 - 发货,3 - 收货
private Payment payment; // 支付单
private Shipping shipping; // 物流单
public void pay(Long userId, Long amount) {
if (status != 0) {
throw new IllegalStateException();
}
this.status = 1;
this.payment = new Payment(userId, amount);
}
public void ship(String trackingNumber) {
if (status != 1) {
throw new IllegalStateException();
}
this.status = 2;
this.shipping = new Shipping(trackingNumber);
}
}
- 通过聚合根保证主子实体的一致性
在稍微复杂一点的领域里,通常主实体会包含子实体,这时候主实体就需要起到聚合根的作用,即:
- 子实体不能单独存在,只能通过聚合根的方法获取到。任何外部的对象都不能直接保留子实体的引用
- 子实体没有独立的Repository,不可以单独保存和取出,必须要通过聚合根的Repository实例化
- 子实体可以单独修改自身状态,但是多个子实体之间的状态一致性需要聚合根来保障
常见的电商域中聚合的案例如主子订单模型、商品/SKU模型、跨子订单优惠、跨店优惠模型等。
- 不可以强依赖其他聚合根实体或领域服务
一个实体的原则是高内聚、低耦合,即一个实体类不能直接在内部直接依赖一个外部的实体或服务。 这个原则和绝大多数ORM框架都有比较严重的冲突,所以是一个在开发过程中需要特别注意的。这个原则的必要原因包括:对外部对象的依赖性会直接导致实体无法被单测;以及一个实体无法保证外部实体变更后不会影响本实体的一致性和正确性。
所以,正确的对于外部的依赖有三种方式:
-
-
只保存外部实体的ID: 推荐使用强类型的ID对象,而不是Long型ID。强类型的ID对象不单单能自我包含验证代码,保证ID值的正确性,同时还能确保各种入参不会因为参数顺序变化而出bug。
-
如果不存在『副作用』,通过方法参数的方式引入外部依赖, 可以最大程度上保证实体的完整性,比如上文的equip(weapon,EquipmentService)的方法。
- 所谓的副作用:传入的实体与管理的实体均需要被修改:比如player装备一把双刃剑
-
如果存在副作用、或者实体过多,通过Domain Service的方式去解决外部依赖
-
- 任何实体的行为只能直接影响到本实体(和其子实体)
每个实体只能直接的修改自己的属性,而不能修改其他实体,这么做的好处是代码读下来不会产生意外~
一个遵守的原因是可以降低未知的变更的风险。在一个系统里一个实体对象的所有变更操作应该都是预期内的,如果一个实体能随意被外部直接修改的话,会增加代码bug的风险。
领域服务(Domain Service)
领域服务其实也分很多种,在这里根据上文总结出来三种常见的:
- 单对象策略领域服务
这种领域服务主要面向单个实体对象的变更,但会涉及到多个领域实体或外部依赖。在上文中,EquipmentService即为此类:
- 变更的对象是Player的参数
- 读取的是Player和Weapon的数据,可能还包括从外部读取一些数据
在这种类型下,实体应该通过方法入参的方式传入这种领域服务,然后通过Double Dispatch来反转调用领域服务的方法,比如:
Player.equip(Weapon, EquipmentService) {
if (EquipmentService.canEquip(this, Weapon)){
palyer.weaponID = weapon.GetID()
}
}
为什么这种情况下不能先调用领域服务,再调用实体对象的方法,从而减少实体对领域服务的入参型依赖呢?比如,下面这个方法是错误的:
boolean canEquip = EquipmentService.canEquip(Player, Weapon);
if (canEquip) {
Player.equip(Weapon); // ❌,这种方法不可行,因为这个方法有不一致的可能性
}
其错误的主要原因是缺少了领域服务入参会导致方法有可能产生不一致的情况——翻译一下:equip本身就代表了一种业务,通过检查的weapon,player才可以装备。下面的写法,其实缺失了业务,可能会导致不一致。
- 跨对象领域服务
当一个行为会直接修改多个实体时,即我们所说的副作用,不能再通过单一实体的方法作处理,而必须直接使用领域服务的方法来做操作。在这里,领域服务更多的起到了跨对象事务的作用,确保多个实体的变更之间是有一致性的。
不推荐的写法:
public class Player {
void attack(Monster, CombatService) {
CombatService.performAttack(this, Monster); // ❌,不要这么写,会导致副作用
}
}
推荐的写法:
public void test() {
//...
combatService.performAttack(mage, orc);
}
这个原则也映射了4.1.5 的原则,即Player.attack会直接影响到Monster,但这个调用Monster又没有感知。
这里笔者也不推荐第一种写法,一个基本的原因:代码应该尽可能避免隐式的注册、修改、读取。
3.通用组件型服务
略
策略对象(Domain Policy)
Policy或者strategy策略模式是一个通用的设计模式,但在DDD中经常用来封装领域规则。
一个policy是一个无状态的单例对象,通常有两个方法:canApply和业务方法。其中,canApply方法用来判断一个Policy是否适用于当前的上下文,如果适用则调用方会去触发业务方法。通常,为了降低一个Policy的可测试性和复杂度,Policy不应该直接操作对象,而是通过返回计算后的值,在Domain Service里对对象进行操作。
在上文案例里,DamagePolicy只负责计算应该受到的伤害,而不是直接对Monster造成伤害。这样除了可测试外,还为未来的多Policy叠加计算做了准备。
这里提供一种简单的实现:调用链
副作用的处理方法 - 领域事件
在上文中,有一种类型的领域规则被我刻意忽略了,那就是”副作用“。一般的副作用发生在核心领域模型状态变更后,同步或者异步对另一个对象的影响或行为。
比如在这个业务形态下,我们增加一个副作用:
- 当Monster的生命值降为0后,给Player奖励经验值
我们的解法如下,引入领域服务层:
public class CombatService {
public void performAttack(Player player, Monster monster) {
// ...
monster.takeDamage(damage);
if (!monster.isAlive()) {
player.receiveExp(10); // 收到经验
}
}
}
但是这样写的问题是:很快CombatService的代码就会变得很复杂,比如我们再加一个副作用:
- 当Player的exp达到100时,升一级
这时我们的代码就会变成:
public class CombatService {
public void performAttack(Player player, Monster monster) {
// ...
monster.takeDamage(damage);
if (!monster.isAlive()) {
player.receiveExp(10); // 收到经验
if (player.getExp()==100){
player.Upgrade();
}
}
}
}
如果再加上“升级后奖励XXX”呢?“更新XXX排行”呢?依此类推,后续这种代码将无法维护。所以我们需要介绍一下领域层最后一个概念:领域事件(Domain Event)。
领域事件介绍
领域事件是一个在领域里发生了某些事后,希望领域里其他对象能够感知到的通知机制。在上面的案例里,代码之所以会越来越复杂,其根本的原因是反应代码(比如升级)直接和上面的事件触发条件(比如收到经验)直接耦合,而且这种耦合性是隐性的。领域事件的好处就是将这种隐性的副作用“显性化”,通过一个显性的事件,将事件触发和事件处理解耦,最终起到代码更清晰、扩展性更好的目的。
所以,领域事件是在DDD里,比较推荐使用的跨实体“副作用”传播机制。
领域事件的实现
领域事件通常是立即执行的、在同一个进程内、可能是同步或异步。我们可以通过一个EventBus来实现进程内的通知机制,简单实现如下:
和消息中间件不同的是,领域的事件通常是立即执行的、在同一个进程内、可以同步/异步。因此,需要通过一个EventBus实现进程内部的通知机制,简单实现如下:
public class EventBus {
// 注册器
@Getter
private final EventRegistry invokerRegistry = new EventRegistry(this);
// 事件分发器
private final EventDispatcher dispatcher = new EventDispatcher(ExecutorFactory.getDirectExecutor());
// 异步事件分发器
private final EventDispatcher asyncDispatcher = new EventDispatcher(ExecutorFactory.getThreadPoolExecutor());
// 事件分发
public boolean dispatch(Event event) {
return dispatch(event, dispatcher);
}
// 异步事件分发
public boolean dispatchAsync(Event event) {
return dispatch(event, asyncDispatcher);
}
// 内部事件分发
private boolean dispatch(Event event, EventDispatcher dispatcher) {
checkEvent(event);
// 1.获取事件数组
Set<Invoker> invokers = invokerRegistry.getInvokers(event);
// 2.一个事件可以被监听N次,不关心调用结果
dispatcher.dispatch(event, invokers);
return true;
}
// 事件总线注册
public void register(Object listener) {
if (listener == null) {
throw new IllegalArgumentException("listener can not be null!");
}
invokerRegistry.register(listener);
}
private void checkEvent(Event event) {
if (event == null) {
throw new IllegalArgumentException("event");
}
if (!(event instanceof Event)) {
throw new IllegalArgumentException("Event type must by " + Event.class);
}
}
}
调用方式:
public class LevelUpEvent implements Event {
private Player player;
}
public class LevelUpHandler {
public void handle(Player player);
}
public class Player {
public void receiveExp(int value) {
this.exp += value;
if (this.exp >= 100) {
LevelUpEvent event = new LevelUpEvent(this);
EventBus.dispatch(event);
this.exp = 0;
}
}
}
@Test
public void test() {
EventBus.register(new LevelUpHandler());
player.setLevel(1);
player.receiveExp(100);
assertThat(player.getLevel()).equals(2);
}
目前领域事件的缺陷和展望
- 隐式代码调度:由于代码触发变成了事件监听,逻辑流会从显式变成隐式。
- 对框架要求过高:领域事件的很好的实施依赖EventBus、Dispatcher、Invoker这些属于框架级别的支持
- 进程内一致性表达成本增加:进程内的事务一致性的实现成本很高
- 单测成本增加:EventBus使用单例模式,导致单测成本增加
还有一个方式:侵入式的代码
另一种解法是侵入Entity,对每个Entity增加一个List:
public class Player {
List<Event> events;
public void receiveExp(int value) {
this.exp += value;
if (this.exp >= 100) {
LevelUpEvent event = new LevelUpEvent(this);
events.add(event); // 把event加进去
this.exp = 0;
}
}
}
@Test
public void test() {
EventBus.register(new LevelUpHandler());
player.setLevel(1);
player.receiveExp(100);
for(Event event: player.getEvents()) { // 在这里显性的dispatch事件
EventBus.dispatch(event);
}
assertThat(player.getLevel()).equals(2);
}
但是能看出来这种解法不但会侵入实体本身,同时也需要比较啰嗦的显性在调用方dispatch事件,也不是一个好的解决方案。
也许未来会有一个框架能让我们既不依赖全局Singleton,也不需要显性去处理事件,但目前的方案基本都有或多或少的缺陷,大家在使用中可以注意。
总而言之
在真实的业务逻辑中,让业务完美符合DDD的规范是非常困难的事情,所以最主要的是梳理一个对象行为的影响面,然后作出设计决策,即: 是仅影响单一对象还是多个对象,同时还要从以下几个点出发:
- 扩展性和灵活性
- 性能
- 副作用等
笔者认为无论使用什么框架或者代码结构,最重要的是不忘初心——写一份好的业务代码
第五章——阿里的落地实践
案例简介
这里举一个简单的常见案例:下单链路。假设我们在做一个checkout接口,需要做各种校验、查询商品信息、调用库存服务扣库存、然后生成订单:
典型的代码如下:
@RestController
@RequestMapping("/")
public class CheckoutController {
@Resource
private ItemService itemService;
@Resource
private InventoryService inventoryService;
@Resource
private OrderRepository orderRepository;
@PostMapping("checkout")
public Result<OrderDO> checkout(Long itemId, Integer quantity) {
// 1) Session管理
Long userId = SessionUtils.getLoggedInUserId();
if (userId <= 0) {
return Result.fail("Not Logged In");
}
// 2)参数校验
if (itemId <= 0 || quantity <= 0 || quantity >= 1000) {
return Result.fail("Invalid Args");
}
// 3)外部数据补全
ItemDO item = itemService.getItem(itemId);
if (item == null) {
return Result.fail("Item Not Found");
}
// 4)调用外部服务
boolean withholdSuccess = inventoryService.withhold(itemId, quantity);
if (!withholdSuccess) {
return Result.fail("Inventory not enough");
}
// 5)领域计算
Long cost = item.getPriceInCents() * quantity;
// 6)领域对象操作
OrderDO order = new OrderDO();
order.setItemId(itemId);
order.setBuyerId(userId);
order.setSellerId(item.getSellerId());
order.setCount(quantity);
order.setTotalCost(cost);
// 7)数据持久化
orderRepository.createOrder(order);
// 8)返回
return Result.success(order);
}
}
上述代码的问题:为什么这种典型的流水账代码在实际应用中会有问题呢?
他违反了SRP(Single Responsbility Principle)单一职责原则。这段代码中混杂了业务计算、校验逻辑、基础设施和通信协议,在迭代的过程中无论是哪个部分逻辑的变更都可能会影响这段代码,长期当后人不断的在上面叠加新的逻辑时,会造成代码复杂度增加、逻辑分支越来越多,最终造成bug或者没人敢重构的历史包袱。
一种简单解决方式,抽离出各种层级,每个层级专注于解决自己的问题:
-
分离出独立的Interface接口层,负责处理网络协议相关的逻辑
- Interface层同时可以负责异常处理、规范对外输出信息、日志落地等
-
从真实业务场景中,找出具体用例(Use Cases) ,然后将具体用例通过专用的Command指令、Query查询、和Event事件对象来承接
-
分离出独立的Application应用层,负责业务流程的编排,响应Command、Query和Event。每个应用层的方法应该代表整个业务流程中的一个节点
-
处理一些跨层的横切关注点,如鉴权、异常处理、校验、缓存、日志等
Interface接口层
Interface的核心功能:
- 网络协议的转化:这个尽量交给框架处理,我们主要的工作是关注如何将参数反序列化或者序列化
- 统一的鉴权:比如在一些需要AppKey+Secret的场景,需要针对某个租户做鉴权的,包括一些加密串的校验
- Session的管理:一般在面向用户的接口或者有登陆态的,通过Session或者RPC上下文可以拿到当前调用的用户,以便传递给下游服务。
- 限流配置:对接口进行限流
- 异常处理:通常在接口层要避免将异常直接暴露给调用端,所以需要在接口层做统一的异常捕获,转化为调用端可以理解的数据格式
- 日志打印:在该层进行日志的打印
- 可选:部分代码会在interface层引入缓存
如果有一个独立的网关设施/应用,则可以抽离出鉴权、Session、限流、日志等逻辑,但是目前来看API网关也只能解决一部分的功能,即使在有API网关的场景下,应用里独立的接口层还是有必要的。
在interface层,鉴权、Session、限流、缓存、日志等都比较直接,只有一个异常处理的点需要重点说下
Interface层的异常处理
规范1:Interface层的HTTP和RPC接口,返回值为Result,捕捉所有异常
规范2:Application层的所有接口返回值为DTO,不负责处理异常
如下例子:
@PostMapping("checkout")
public Result<OrderDTO> checkout(Long itemId, Integer quantity) {
try {
CheckoutCommand cmd = new CheckoutCommand();
OrderDTO orderDTO = checkoutService.checkout(cmd);
return Result.success(orderDTO);
} catch (ConstraintViolationException cve) {
// 捕捉一些特殊异常,比如Validation异常
return Result.fail(cve.getMessage());
} catch (Exception e) {
// 兜底异常捕获
return Result.fail(e.getMessage());
}
}
规范3:接口应该保证业务单一性原则,需要尽量避免用同一个类承接不同类型业务的需求。
应该尽量避免下面这种情况,通过type区分:
GetMasterRechargeHistoryReq struct {
g.Meta `path:"/v1/masterwallet/recharge" tags:"MasterWallet" method:"get" summary:"主账户充值记录type=1充值,type=2授信,type=3充值coupon"`
AccountID string `v:"required" dc:"主账号ID"`
Type int32 `v:"required|in:1,2,3" dc:"充值类型"`
StartDate string `dc:"开始时间,yyyy-mm-dd" p:"date-format:Y-m-d#请输入正确的起始时间"`
EndDate string `dc:"结束时间,yyyy-mm-dd" p:"date-format:Y-m-d#请输入正确的结束时间"`
api.BasePageReq
}
GetMasterRechargeHistoryRes struct {
api.ListResp
}
在可以预期的某个未来时刻,这些需求会随着业务的发展越走越远,将来一定会进行拆分。
这种实现方式,大大限制了代码的扩展性,导致代码过度的膨胀;同时,这种方式带来的便利性是非常少的,原因如下,interface依赖相对稳定的application,通过多对多的方式降低代码冗余:
因为业务需求是快速变化的,所以接口层也要跟着快速变化,通过独立的接口层可以避免业务间相互影响,但我们希望相对稳定的是Application层的逻辑。
Application层
Application的核心功能:
- Application层负责业务的编排,应该将业务拆分成多个服务(Domain),而不是负责业务的具体实现
- DTO到DO的互相转换,将外部输入和内部数据对象相统一
- CQE(Command、Query、Evenet)对象:作为ApplicationService的入参
笔者在这里给出自己的认知:
应用层的工作:
1.负责服务的编排:负责将业务拆分成多个领域的任务,将一个大任务拆分给若干领域
2.负责领域服务的聚合:将多个领域的结果汇总聚合,最后进行修改并输出给外部应用
3.负责事件通知、发布订阅、权限校验(可选)、安全认证等
4.负责基本输入的获取、处理,Application层负责将Interface层的DTO转化成DO、PO交由domain层、interface层
5.Application的出参为DTO,入参为CQE对象
应用层对于数据的处理工作:
1.负责定义DTO,只负责最基本的DTO属性和DO转换
2.负责Assembler:负责实体到DTO的互相转换,如果应用层直接调用infra层,应该还包括PO到DTO的互相转换
Application层最核心的对象是ApplicationService(对于go来说,只有ApplicationService文件),它的核心功能是承接“业务流程“。
CQE模型
从本质上来看,这几种对象都是Value Object,但是从语义上来看有比较大的差异:
- Command指令:指调用方明确想让系统执行的指令,他的预期是对一个系统进行影响,即写操作。通常来说,需要一个明确的返回值(如:同步的操作结构、异步的指令被接受)
- Query指令:指调用方明确想查询的东西,包括查询参数、过滤、分页等条件,其预期是对一个系统的数据完全不影响的,也就是只读操作
- Event指令:指一件已经发生过的事情,需要系统根据这个事实进行相应,通常都会伴随一个写操作。事件处理器不会有返回值。补充一下,Application层的Event概念和Domain层的DomainEvent是类似的概念,但不一定是同一回事,这里的Event更多是外部一种通知机制而已。
| | Command | Query | Event |
|---|---|---|---|
| 语意 | ”希望“能触发的操作 | 各种条件的查询 | 已经发生过的事情 |
| 读/写 | 写 | 只读 | 通常是写 |
| 返回值 | DTO 或 Boolean | DTO 或 Collection | Void或DTO |
为什么使用CQE对象?
这是一个好的问题,从笔者的经验来看,DDD在于解决复杂的业务;从某种意义上来说,笔者认为读不算真正的业务,读往往可以理解成数据的组装。因此,对问题进行拆分,分而治之,从工程学上来说是一种简单可行的方案。
从完美的角度上来说,如果能有一种可以全治理的方案一定是最好的!
从真实的场景来分析:
通常在很多代码里,能看到接口上有多个参数,或者,根据多个条件进行查询:
List<OrderDO> queryByItemId(Long itemId);
List<OrderDO> queryBySellerId(Long sellerId);
List<OrderDO> queryBySellerIdWithPage(Long sellerId, int currentPage, int pageSize);
可以看出来,传统的接口写法有几个问题:
- 接口膨胀:一个查询条件一个方法
- 难以扩展:每新增一个参数都有可能需要调用方升级
- 难以测试:接口一多,职责随之变得繁杂,业务场景各异,测试用例难以维护但是另外一个最重要的问题是:这种类型的参数罗列,本身没有任何业务上的”语意“,只是一堆参数而已,无法明确的表达出来意图。
CQE的规范
所以在Application层的接口里,强力建议的一个规范是:
规范4:ApplicationService的接口入参只能是一个Command、Query或Event对象,CQE对象需要能代表当前方法的语意。唯一可以的例外是根据单一ID查询的情况,可以省略掉一个Query对象的创建
按照上面的规范,实现案例是:
public interface CheckoutService {
OrderDTO checkout(@Valid CheckoutCommand cmd);
List<OrderDTO> query(OrderQuery query);
OrderDTO getOrder(Long orderId); // 注意单一ID查询可以不用Query
}
@Data
public class CheckoutCommand {
private Long userId;
private Long itemId;
private Integer quantity;
}
@Data
public class OrderQuery {
private Long sellerId;
private Long itemId;
private int currentPage;
private int pageSize;
}
CQE vs DTO
从上面的代码可以看出来,ApplicationService的入参是CQE对象,但是出参却是一个DTO,从代码格式上来看都是简单的POJO对象,那么他们之间有什么区别呢?
DTO对象:是负责承接数据的容器,不负责具体的业务,不包含任何逻辑,只是贫血对象
CQE对象:CQE对象是ApplicationService层的输入,有明确的意图,这个对象必须保证输入的正确性
但可能最重要的一点:因为CQE是”意图“,所以CQE对象在理论上可以有”无限“个,每个代表不同的意图;但是DTO作为模型数据容器,和模型一一对应,所以是有限的。
这样,CQE对象会带来额外的成本。
CQE检验
这样带来的问题,CQE对象需要大量的校验逻辑,因此为了避免校验逻辑下沉;可以使用框架自带的Validation进行解决,而不是在Application层进行代码判断。
规范5:CQE对象的校验应该前置,避免在ApplicationService里做参数的校验。可以通过框架自带的Validation来实现,如果实在没有引入,可以在Interface层进行。
避免复用CQE对象
规范6:因为CQE对象是存在语意的,因此,尽量避免对CQE对象的复用。如果
❌ 反例:一个常见的场景是“Create创建”和“Update更新”,一般来说这两种类型的对象唯一的区别是一个ID,创建没有ID,而更新则有。所以经常能看见有的同学用同一个对象来作为两个方法的入参,唯一区别是ID是否赋值。这个是错误的用法,因为这两个操作的语意完全不一样,他们的校验条件可能也完全不一样,所以不应该复用同一个对象。
正确的做法是产出两个对象:
public interface CheckoutService {
OrderDTO checkout(@Valid CheckoutCommand cmd);
OrderDTO updateOrder(@Valid UpdateOrderCommand cmd);
}
@Data
public class CheckoutCommand {
private Long userId;
private Long itemId;
private Integer quantity;
}
@Data
public class UpdateOrderCommand {
@NotNull(message = "用户未登陆")
private Long userId;
@NotNull(message = "必须要有OrderID")
private Long orderId;
@NotNull
@Positive(message = "需要是合法的itemId")
private Long itemId;
@NotNull
@Min(value = 1, message = "最少1件")
@Max(value = 1000, message = "最多不能超过1000件")
private Integer quantity;
}
CQE带来的痛苦事情
上面我描述了CQE的优势,但是最悲伤的事情还是存:每当你新增一个CQE时,你的Assembler层就需要增加一个transfer的方法用于互相转化,这也是CQE带来的额外成本!
Application层的重头戏
Application层则负责业务的编排,将原本的业务流水账剥离了校验逻辑、领域计算、持久化等逻辑之后剩余的流程,是“胶水层”代码。
参考一个简易的交易流程:
在这个案例里可以看出来,交易这个领域一共有5个用例:下单、支付成功、支付失败关单、物流信息更新、关闭订单。这5个用例可以用5个Command/Event对象代替,也就是对应了5个方法。
下面列出3种AS的组织形态:
- 一个AS类包含一个完整的业务流,每个方法对应一个Use Case。这种好处就是显而易见的,每个接口收敛一种业务,从接口就可以明白所有的业务,适合简单的业务流程。坏处:负责的业务就会导致一个类的方法过多,代码量过大。
代码示例如下:
从笔者实践的角度出发,可以将查询拆分成一个新的Service,保证代码的纯净。
public interface CheckoutService {
// 下单
OrderDTO checkout(@Valid CheckoutCommand cmd);
// 支付成功
OrderDTO payReceived(@Valid PaymentReceivedEvent event);
// 支付取消
OrderDTO payCanceled(@Valid PaymentCanceledEvent event);
// 发货
OrderDTO packageSent(@Valid PackageSentEvent event);
// 收货
OrderDTO delivered(@Valid DeliveredEvent event);
// 批量查询
List<OrderDTO> query(OrderQuery query);
// 单个查询
OrderDTO getOrder(Long orderId);
}
- 针对于比较复杂的业务流程,可以通过增加独立的CommandHandler、EventHandler来降低一个类中的代码量:
笔者推荐使用调用链,但是由于CQE对象不同,这部分在落地时候可以自行取舍
@Component
public class CheckoutCommandHandler implements CommandHandler<CheckoutCommand, OrderDTO> {
@Override
public OrderDTO handle(CheckoutCommand cmd) {
//
}
}
public class CheckoutServiceImpl implements CheckoutService {
@Resource
private CheckoutCommandHandler checkoutCommandHandler;
@Override
public OrderDTO checkout(@Valid CheckoutCommand cmd) {
return checkoutCommandHandler.handle(cmd);
}
}
- 比较激进一点,通过CommandBus、EventBus,直接将指令或事件抛给对应的Handler,EventBus比较常见。具体案例代码如下,通过消息队列收到MQ消息后,生成Event,然后由EventBus做路由到对应的Handler:
// Application层
// 在这里框架通常可以根据接口识别到这个负责处理PaymentReceivedEvent
// 也可以通过增加注解识别
@Component
public class PaymentReceivedHandler implements EventHandler<PaymentReceivedEvent> {
@Override
public void process(PaymentReceivedEvent event) {
//
}
}
// Interface层,这个是RocketMQ的Listener
public class OrderMessageListener implements MessageListenerOrderly {
@Resource
private EventBus eventBus;
@Override
public ConsumeOrderlyStatus consumeMessage(List<MessageExt> msgs, ConsumeOrderlyContext context) {
PaymentReceivedEvent event = new PaymentReceivedEvent();
eventBus.dispatch(event); // 不需要指定消费者
return ConsumeOrderlyStatus.SUCCESS;
}
}
方案3是非常不推荐的! 显而易见,方案3是业务调用非常隐式,只有在动态的时候才存在关联关系,静态的情况下,无法从代码明白关联性。虽然看起来很便利,但是根据我们自己业务的实践和踩坑发现,当代码中的CQE对象越来越多,handler越来越复杂时,运行时的dispatch缺乏了静态代码间的关联关系,导致代码很难读懂,特别是当你需要trace一个复杂调用链路时,
规范7:ApplicationService尽量少使用事件驱动,当业务简单时,应当尽可能用:一个接口对应一个use case的方式实现
Application Service 是业务流程的封装,不处理业务逻辑
虽然之前曾经无数次重复ApplicationService只负责业务流程串联,不负责业务逻辑,但如何判断一段代码到底是业务流程还是逻辑呢? 举个之前的例子,最初的代码重构后: 判断是否业务流程的几个点:
- 不要有if/else分支逻辑:也就是说代码的Cyclomatic Complexity(循环复杂度)应该尽量等于1 通常有分支逻辑的,都代表一些业务判断,应该将逻辑封装到DomainService或者Entity里。但这不代表完全不能有if逻辑,如:
boolean withholdSuccess = inventoryService.withhold(cmd.getItemId(), cmd.getQuantity());
if (!withholdSuccess) {
throw new IllegalArgumentException("Inventory not enough");
}
这段代码仅仅代表中断条件,具体的业务逻辑没有受到影响,可以把他仅仅看做是Prediction,因此不受影响。
样例代码如下:
@Service
@Validated
public class CheckoutServiceImpl implements CheckoutService {
private final OrderDtoAssembler orderDtoAssembler = OrderDtoAssembler.INSTANCE;
@Resource
private ItemService itemService;
@Resource
private InventoryService inventoryService;
@Resource
private OrderRepository orderRepository;
@Override
public OrderDTO checkout(@Valid CheckoutCommand cmd) {
ItemDO item = itemService.getItem(cmd.getItemId());
if (item == null) {
throw new IllegalArgumentException("Item not found");
}
boolean withholdSuccess = inventoryService.withhold(cmd.getItemId(), cmd.getQuantity());
if (!withholdSuccess) {
throw new IllegalArgumentException("Inventory not enough");
}
Order order = new Order();
order.setBuyerId(cmd.getUserId());
order.setSellerId(item.getSellerId());
order.setItemId(item.getItemId());
order.setItemTitle(item.getTitle());
order.setItemUnitPrice(item.getPriceInCents());
order.setCount(cmd.getQuantity());
Order savedOrder = orderRepository.save(order);
return orderDtoAssembler.orderToDTO(savedOrder);
}
}
- 不要有任何的修改、计算
原本存在计算如下:
// 5)领域计算
Long cost = item.getPriceInCents() * quantity;
order.setTotalCost(cost);
通过将这个计算逻辑封装到实体里,避免在ApplicationService里做计算
@Data
public class Order {
private Long itemUnitPrice;
private Integer count;
// 把原来一个在ApplicationService的计算迁移到Entity里
public Long getTotalCost() {
return itemUnitPrice * count;
}
}
order.setItemUnitPrice(item.getPriceInCents());
order.setCount(cmd.getQuantity());
- 一些数据的转化可以交给其他对象来做: 比如DTO Assembler,将对象间转化的逻辑沉淀在单独的类中,降低ApplicationService的复杂度。
常用的ApplicationService“套路”
我们可以看出来,ApplicationService的代码通常有类似的结构:AppService通常不做任何决策(Precondition除外),仅仅是把所有决策交给DomainService或Entity,把跟外部交互的交给Infrastructure接口,如Repository或防腐层。
一般的“套路”如下:
- 准备数据:包括从外部服务或持久化源取出相对应的Entity、VO以及外部服务返回的DTO。
- 执行操作:包括新对象的创建、赋值,以及调用领域对象的方法对其进行操作。需要注意的是这个时候通常都是纯内存操作,非持久化。
- 持久化:将操作结果持久化,或操作外部系统产生相应的影响,包括发消息等异步操作。
如果涉及到对多个外部系统(包括自身的DB)都有变更的情况,这个时候通常处在“分布式事务”的场景里,无论是用分布式TX、TCC、还是Saga模式,取决于具体场景的设计,在此处暂时略过
DTO Assembler层
一个经常被忽视的问题是 ApplicationService应该返回 Entity 还是 DTO?这里提出一个规范,在DDD分层架构中:
规范8:ApplicationService应该永远返回DTO而不是Entity
为什么呢?
- 构建领域边界:ApplicationService的入参是CQE对象,出参是DTO,这些基本上都属于简单的POJO,来确保Application层的内外互相不影响。
- 降低规则依赖:Entity里面通常会包含业务规则,如果ApplicationService返回Entity,则会导致调用方直接依赖业务规则。如果内部规则变更可能直接影响到外部。
- 通过DTO组合降低成本:Entity是有限的,DTO可以是多个Entity、VO的自由组合,一次性封装成复杂DTO,或者有选择的抽取部分参数封装成DTO可以降低对外的成本。
因为我们操作的对象是Entity,但是输出的对象是DTO,这里就需要一个专属类型的对象叫DTO Assembler 。DTO Assembler的唯一职责是将一个或多个Entity/VO,转化为DTO。注意:DTO Assembler通常不建议有反操作,也就是不会从DTO到Entity,因为通常一个DTO转化为Entity时是无法保证Entity的准确性的。
规范9:DTO Assembler应该只负责entity向DTO的转化,而不负责DTO转化到Entity。
阿里给出的show case:通过Mapstruct的方式实现互转,简单化代码。
结合上文,数据层之间的关系应该如下:
规范10:Application层只返回DTO,可以直接抛异常,不用统一处理。所有调用到的服务也都可以直接抛异常,除非需要特殊处理,否则不需要刻意捕捉异常
原因:关注点分离,将错误和返回分离;将错误的管理统一交给interface层来实现。
防腐层Anti-Corruption Layer实现
在ApplicationService中,经常会依赖外部服务,从代码层面对外部系统产生了依赖。比如上文中的:
ItemDO item = itemService.getItem(cmd.getItemId());
boolean withholdSuccess = inventoryService.withhold(cmd.getItemId(), cmd.getQuantity());
会发现我们的ApplicationService会强依赖ItemService、InventoryService以及ItemDO这个对象。如果任何一个服务的方法变更,或者ItemDO字段变更,都会有可能影响到ApplicationService的代码。也就是说,我们自己的代码会因为强依赖了外部系统的变化而变更,这个在复杂系统中应该是尽量避免的。那么如何做到对外部系统的隔离呢?需要加入ACL防腐层.
ACL防腐层的简单原理如下:
- 对于依赖的外部对象,我们抽取出所需要的字段,生成一个内部所需的VO或DTO类
- 构建一个新的Facade,在Facade中封装调用链路,将外部类转化为内部类
- 针对外部系统调用,同样的用Facade方法封装外部调用链路
实现方案:通过接口的方式实现,使用接口替代具体的实现类
@Service
public class CheckoutServiceImpl implements CheckoutService {
@Resource
private ItemFacade itemFacade;
@Resource
private InventoryFacade inventoryFacade;
@Override
public OrderDTO checkout(@Valid CheckoutCommand cmd) {
ItemDTO item = itemFacade.getItem(cmd.getItemId());
if (item == null) {
throw new IllegalArgumentException("Item not found");
}
boolean withholdSuccess = inventoryFacade.withhold(cmd.getItemId(), cmd.getQuantity());
if (!withholdSuccess) {
throw new IllegalArgumentException("Inventory not enough");
}
// ...
}
}
实践总结
本章重点描述了两个层(Interface、Application层)对DDD落地实践阐述了作者自己的理解。
具体而言:
Interface层的核心
- 日志处理、异常处理
- 熔断、限流处理
- 网络协议和session的处理
- 入参CQE对象的校验、创建;出参统一Result
- 决定O&C的架构选型
Application层的核心
-
编排业务的胶水代码(将具体的业务下放Domain层)
- 圈复杂度低的代码:最好不要有业务逻辑的if else
- 不要有任何的修改、计算
- 一些数据的转化可以交给其他对象来做:DTO Assembler
-
入参为CQE对象,出参DTO
总结:Orchestration vs Choreography
模式简介
Orchestration:通常出现在脑海里的是一个交响乐团(Orchestra,注意这两个词的相似性),如下图。交响乐团的核心是一个唯一的指挥家Conductor,在一个交响乐中,所有的音乐家必须听从Conductor的指挥做操作,不可以独自发挥。所以在Orchestration模式中,所有的流程都是由一个节点或服务触发的。我们常见的业务流程代码,包括调用外部服务,就是Orchestration,由我们的服务统一触发。
Choreography:通常会出现在脑海的场景是一个舞剧(来自于希腊文的舞蹈,Choros),如下图。其中每个不同的舞蹈家都在做自己的事,但是没有一个中心化的指挥。通过协作配合,每个人做好自己的事,整个舞蹈可以展现出一个完整的、和谐的画面。所以在Choreography模式中,每个服务都是独立的个体,可能会响应外部的一些事件,但整个系统是一个整体。
核心区别:Orchestration中会有一个Conductor,而Choreography并没有,Choreography更依赖于参与者自身的自驱力。
案例
用一个常见的例子:下单后支付并发货
如果这个案例是Orchestration,则业务逻辑为:下单时从一个预存的账户里扣取资金,并且生成物流单发货,从图上看是这样的:
如果这个案例是Choreography,则业务逻辑为:下单,然后等支付成功事件,然后再发货,类似这样
模式的区别和选择
从代码依赖关系来看:
- Orchestration:显式依赖——涉及到一个服务调用到另外的服务,对于调用方来说,是强依赖的服务提供方。
- Choreography:隐式依赖——每一个服务只是做好自己的事,然后通过事件触发其他的服务,服务之间没有直接调用上的依赖。但要注意的是下游还是会依赖上游的代码(比如事件类),所以可以认为是下游对上游有依赖。
从代码灵活性上来看:
- Orchestration:灵活性因为显式依赖导致下降
- Choreography:灵活性最强
从调用链路来看:
- Orchestration:是从一个服务主动调用另一个服务,所以是Command-Driven指令驱动的。
- Choreography:事件驱动,Event-Driven驱动
从业务职责来看:
- Orchestration:有主动的调用方(比如:下单服务)。无论下游的依赖是谁,主动的调用方都需要为整个业务流程和结果负责。
- Choreography:没有主动调用方,每个服务只关心自己的触发条件和结果,没有任何一个服务会为整个业务链路负责
总结下来一个比较:
| | Orchestration | Choreography |
|---|---|---|
| 驱动力 | 指令驱动Command-Driven | 事件驱动Event-Driven |
| 调用依赖 | 上游强依赖下游 | 无直接调用依赖 但是有代码依赖 可以认为是下游依赖上游 |
| 灵活性 | 较差 | 较高 |
| 业务职责 | 上游为业务负责 | 无全局责任人 |
另外需要重点明确的:“指令驱动”和“事件驱动”的区别不是“同步”和“异步”。指令可以是同步调用,也可以是异步消息触发(但异步指令不是事件);反过来事件可以是异步消息,但也完全可以是进程内的同步调用。所以指令驱动和事件驱动差异的本质不在于调用方式,而是一件事情是否“已经”发生。
所以在日常业务中当你碰到一个需求时,该如何选择是用Orchestration还是Choreography?
这里给出两个判断方法:
- 明确依赖的方向:
在代码中的依赖是比较明确的:如果你是下游,上游对你无感知,则只能走事件驱动;如果上游必须要对你有感知,则可以走指令驱动。反过来,如果你是上游,需要对下游强依赖,则是指令驱动;如果下游是谁无所谓,则可以走事件驱动。
- 找出业务中的“负责人”:
第二种方法是根据业务场景找出其中的“负责人”。比如,如果业务需要通知卖家,下单系统的单一职责不应该为消息通知负责,但订单管理系统需要根据订单状态的推进主动触发消息,所以是这个功能的负责人。
在一个复杂业务流程里,通常两个模式都要有,但也很容易设计错误。如果出现依赖关系很奇怪,或者代码里调用链路/负责人梳理不清楚的情况,可以尝试转换一下模式,可能会好很多。
Which One is Better?
no silver bullet! 最近几年比较流行的Event-Driven Architecture(EDA)事件驱动架构,以及Reactive-Programming响应式编程(比如RxJava),虽然有很多创新,但在一定程度上是“当你有把锤子,所有问题都是钉子”的典型案例。他们对一些基于事件的、流处理的问题有奇效,但如果拿这些框架硬套指令驱动的业务,就会感到代码极其“不协调”,认知成本提高。所以在日常选型中,还是要先根据业务场景梳理出来是哪些流程中的部分是Orchestration,哪些是Choreography,然后再选择相对应的框架。
跟DDD分层架构的关系
O&C和DDD的关系是什么?
- O&C其实是Interface层的关注点,Orchestration = 对外的API,而Choreography = 消息或事件。当你决策了O还是C之后,需要在interface层承接这些“驱动力”。
- 无论O&C如何设计,Application层都“无感知”,因为ApplicationService天生就可以处理Command、Query和Event,至于这些对象怎么来,是Interface层的决策。
所以,虽然Orchestration 和 Choreography是两种完全不同的业务设计模式,但最终落到Application层的代码应该是一致的,这也是为什么Application层是“用例”而不是“接口”,是相对稳定的存在。