Domain Primitive
前言
首先,DDD不是一套结构体系,而是一种代码与业务统一的架构思想,所以很多人对DDD的看法都会有偏差。比如说,领域内贫血模型模型(实体类只有字段)得益于一些ORM的框架崛起,并且传统的三层架构(Business、Data Access、Database)在一定程度上与DDD概念混淆,导致很多实体内的字段满足了建模的思想,但是实体内是否存放业务逻辑,很多人得出看法不一。
本系列将讲述DDD的文章,通过一代比较合理的代码结构开约束DDD,降低DDD的事件门槛,从开发和测试来提升代码质量、可测性、安全性、
健壮性。
本系列覆盖的内容会包括
- 最佳架构实践:六边形应用架构 Clean架构的核心思想和落地方案
- 持续交付和发现:事件风暴-上下文-设计探索-建模
- 降低架构腐败速度:加入防腐层,集成模块化方案
- 标准的组件规范和边界:Entity, Aggregate, Repository, Domain Service, Application Service, Event, DTO Assembler 等
- 基于 Use Case 重定义应用服务的边界
- 基于 DDD 的微服务化改造及颗粒度控制
- CQRS 架构的改造和挑战基于事件驱动的架构的挑战
Domain Primitive
Primitive的定义是,不从其他事物发展而来,初级的形成后者生长的早期阶段,先通过以下案例,看看DP是个什么概念
案例1(注册)
一个新应用在全国通过地推业务员做推广,需要做一个用户注册系统,同时希望在用户注册后能够通过用户电话(先假设仅限座机)的地域(区号)对业务员发奖金。
实现方式一
package com.example.register.service.impl;
import com.example.register.model.SalesRep;
import com.example.register.model.User;
import com.example.register.repository.SalesRepository;
import com.example.register.repository.UserRepository;
import com.example.register.service.RegisterService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
import javax.validation.ValidationException;
import java.util.Arrays;
/**
*
* @Description:
* @Date: 2021/12/12 9:04 下午
*/
@Service
public class RegisterServiceImpl implements RegisterService {
@Autowired
private SalesRepository salesRepository;
@Autowired
private UserRepository userRepository;
/**
* @param name name
* @param phone phone
* @param address address
* @return User
*/
@Override
public User register(String name, String phone, String address) {
// 校验逻辑
this.checkParam(name, phone, address);
String areaCode = this.getAreaCode(phone);
SalesRep salesRep = salesRepository.findRep(areaCode);
// 最后创建用户,落盘,然后返回
User user = new User();
user.setName(name);
user.setPhone(phone);
user.setAddress(address);
if (salesRep != null) {
user.setRepId(salesRep.getId());
}
return userRepository.save(user);
}
private String getAreaCode(String phone) {
String areaCode = null;
String[] areas = new String[]{"0755", "0762", "010"};
for (int i = 0; i < phone.length(); i++) {
String prefix = phone.substring(0, i);
if (Arrays.asList(areas).contains(prefix)) {
areaCode = prefix;
}
}
return areaCode;
}
private void checkParam(String name, String phone, String address) {
if (StringUtils.isEmpty(name)) {
throw new ValidationException("name");
}
if (phone == null || !isValidPhoneNumber(phone)) {
throw new ValidationException("phone");
}
if (StringUtils.isEmpty(address)) {
throw new ValidationException("address");
}
}
private boolean isValidPhoneNumber(String phone) {
String pattern = "^0[1-9]{2,3}-?\\d{8}$";
return phone.matches(pattern);
}
}
这是我们日常写的代码, 我们可以通过以下几种维度测试分析
接口的清晰度
对Java来说,一个方法的所有参数名会在编译时丢失,仅仅留下的是一堆参数类型列表,所以上述接口编译情况如下
User register(String, String, String);
所以说,以下代码编译时不会犯错的,但是运行中就有可能爆异常
service.register("殷浩", "浙江省杭州市余杭区文三西路969号", "0571-12345678");
上述这种情况在查询服务中很常见,比如
User findByName(String name);
User findByPhone(String phone);
User findByNameAndPhone(String name, String phone);
如果方法名不区分,很难发现这个服务到底干啥的
数据验证和错误提示
可以看到,数据验证都是checkParam提供的,但是如果我们的入参很多,那就要写很多的if…else,
业务代码的清晰度
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);
上述代码被称为胶水代码
,原因就是先用自己简单的参数调用一个外部服务的新数据中作其他的作用
可测试性
为了保证代码质量,每个方法里的每个入参的每个可能出现的条件都要有 TC 覆盖(假设我们先不去测试内部业务逻辑),所以在我们这个方法里需要以下的 TC
假如一个方法有 N 个参数,每个参数有 M 个校验逻辑,至少要有 N * M 个 TC 。
如果这时候在该方法中加入一个新的入参字段 fax ,即使 fax 和 phone 的校验逻辑完全一致,为了保证 TC 覆盖率,也一样需要 M 个新的 TC 。
而假设有 P 个方法中都用到了 phone 这个字段,这 P 个方法都需要对该字段进行测试,也就是说整体需要:
P * N * M
个测试用例才能完全覆盖所有数据验证的问题,在日常项目中,这个测试的成本非常之高,导致大量的代码没被覆盖到。而没被测试覆盖到的代码才是最有可能出现问题的地方。
解决方案
一个新应用在全国通过 地推业务员 做推广,需要做一个用户的注册系统,在用户注册后能够通过用户电话号的区号对业务员发奖金。
注意这里的电话号码是有语意的,因为需要用户的电话号码找对应的区号,给业务员发奖金
我们可以将电话号码从用户中分离出来,通过写一个Value Object将这个隐性的概念显性化
public class PhoneNumber {
// 因为是值对象,字段值一旦确定了就不能修改了
private final String number;
// 校验逻辑全部放在constructor里面,确保PhoneNumber被创建出来以后就一定是合规的
public PhoneNumber(String number) {
if (number == null) {
throw new ValidationException("number不能为空");
} else if (isValid(number)) {
throw new ValidationException("number格式错误");
}
this.number = number;
}
// 之前的findAreaCode 变成了ValueObject的一个行为
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);
}
}
这样做以后,我们把用户的电话号码显性化,生成一个Type
,和一个Class
- Type 指的是在User中可以显性化电话号码这个概念
- Class 指我们可以把所有跟电话号相关的逻辑完整的收集到一个文件里
Type 和Class 就构成了Domain Primitive(DP)
public class UserDDD {
private Long userId;
private Name name;
private PhoneNumber phone;
private Address address;
private Long repId;
}
重构之后的效果
@Data
public class UserDDD {
private Long userId;
private Name name;
private PhoneNumber phone;
private Address address;
private Long repId;
}
@Validated
public interface RegisterDDDService {
User register(
@NotNull Name name,
@NotNull PhoneNumber phone,
@NotNull Address address);
}
public class RegisterDDDServiceImpl implements RegisterDDDService {
@Autowired
private SalesRepository salesRepository;
@Autowired
private UserRepository userRepository;
@Override
public User register(Name name, PhoneNumber phone, Address address) {
SalesRep salesRep = salesRepository.findRep(phone.getAreaCode());
// 最后创建用户,落盘,然后返回
UserDDD userDDD = new UserDDD();
userDDD.setName(name);
userDDD.setPhone(phone);
userDDD.setAddress(address);
if (salesRep != null) {
userDDD.setRepId(salesRep.getId());
}
User user = new User();
user.setName(userDDD.getName().getName());
user.setAddress(userDDD.getAddress().getAddress());
return userRepository.save(user);
}
}
接口的清晰度
public User register(Name, PhoneNumber, Address)
service.register(new Name("殷浩"), new Address("浙江省杭州市余杭区文三西路969号"), new PhoneNumber("0571-12345678"));
接口看上去就特别干净,由于每个参数的类型都不一样,所以不会出现相同参数概念混合的情况
数据验证和错误处理
@Validated
public interface RegisterDDDService {
User register(
@NotNull Name name,
@NotNull PhoneNumber phone,
@NotNull Address address);
}
业务的实现不在有任何校验数据的逻辑,由调用方提供合法的数据
其次,如果需要修改任意一个参数的校验逻辑,只需要修改对应的ValueObject
代码清晰度
SalesRep salesRep = salesRepository.findRep(phone.getAreaCode());
原来的一段胶水代码 findAreaCode
被改成了ValueObject的一个行为方式,可以被其他业务复用
可测试性
- 首先我们分离出来的
PhoneNumber
也是需要M个用例,但是我们针对对象进行测试,代码量会大大降低,维护成本也就降低 - 对于方法的每个入参,只需要覆盖为null的情况就行了
评估总结
维度 | 传统代码 | 使用DP |
---|---|---|
api清晰度 | 相同字段,含糊不清楚 | 接口清晰 |
数据校验,错误处理 | if-else太多 | 业务校验在内聚的对象的,在接口边界就完成了简单性校验 |
业务代码清晰度 | 存在胶水代码 | 无胶水代码,计算属性在ValueObject |
测试复杂度 | N * M * P | N+M+P |
案例2(转账)
我们来实现一个A转账给B的需求,假设A用户可以支付x元给用户B,可能的实现如下
public void pay(BigDecimal money, Long recipientId) {
BankService.transfer(money, "CNY", recipientId);
}
如果说这笔交易是境外交易的,那么写死的"CNY"将变得不再适用,所以这里我们发现了一个隐性的概念货币
再者,我们从需求上入手,转账的输入是x元加货币类型,所以可以合成一个概念Money
这也是DP的第二个概念
将 隐性的 上下文 显性化
上述代码修改成如下
public void pay(Money money, Long recipientId) {
BankService.transfer(money, recipientId);
}
现在如果需求准备升级,加入境外转账的汇率(此服务一般是外部返回的)
public void pay(Money money, Currency targetCurrency, Long recipientId) {
if (money.getCurrency().equals(targetCurrency)) {
this.transfer(money, recipientId);
} else {
BigDecimal rate = ExchangeService.getRate(money.getCurrency(), targetCurrency);
BigDecimal targetAmount = money.getAmount().multiply(new BigDecimal(String.valueOf(rate)));
Money targetMoney = new Money(targetAmount, targetCurrency);
this.transfer(targetMoney, recipientId);
}
}
在这个方法里边,我们把金额的计算放在了支付服务中,并且涉及了
2个Money,2个Currency,和1个BigDecimal, 共5个对象。这种涉及多个对象的业务逻辑,可以使用DP封装,DP的第三个概念为
封装多对象的行为
我们可以封装到一个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;
}
/**
* 封装多对象的行为
*
* @param fromMoney fromMoney
* @return Money
*/
public Money exchange(Money fromMoney) {
if (this.from.equals(fromMoney.getCurrency())) {
return fromMoney;
}
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);
}
总结
- 让我们重新来定义一下 Domain Primitive :Domain Primitive 是一个在特定领域里,拥有精准定义的、可自我验证的、拥有行为的 Value Object
- DP是一个传统意义上的Value Object,拥有Immutable的特性
- DP是一个完整的概念,拥有精准的定义
- DP使用业务中的原生语言
- DP可以使业务域最小组成部分,也可以使构建复杂组合
- DP的三个原则
将隐性的概念显性化
将隐性的上下文显性化
封装多对象行为
- Domain Primitive 和 Value Object的区别
- Domain Primitive是 Value Object 的进阶版,在原始VO的基础上要求每个DP拥有概念的整体,并不是简单的值对象,并且在此基础上加上了Validity和行为
- Value Object 是一种没有id的实体
-
Domain Primitive 和 DTO的区别
-
什么情况下使用 Domain Primitive
- 有格式限制的String,比如电话号码,区域,订单Id, 地址
- 有限制的Id,比如ID大于0,库存大于0
- 可以枚举的int,比如状态
- 计算字段,比如Double,BigDecimal都是含有业务语意的
- 复杂的数据结构 比如 Map<String, List< Integer >> 等,尽量能把 Map 的所有操作包装掉,仅暴露必要行为