Part Ⅰ. Domain Primitive (DP)
原则一:将隐形概念显性化(Make Implicit Concepts Explicit)
案例:一个新应用在全国通过 地推业务员 做推广,需要做一个用户注册系统,同时希望在用户注册后能够通过用户电话(先假设仅限座机)的地域(区号)对业务员发奖金。
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");
}
// 此处省略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);
}
}
问题1-接口的清晰度
接口参数名因编译时丢失,一旦入参顺序不对,难以发现错误。
User register(String, String, String);
问题2-数据校验和错误处理
当存在多个类似的接口和类似的入参,这段逻辑在每个方法里都会被重复。更严重的是未来如果我们要扩展phone的校验逻辑,必须在所有方法中修改,维护成本极高。
if (phone == null || !isValidPhoneNumber(phone)) {
throw new ValidationException("phone");
}
问题3-业务代码清晰度
胶水代码:从一些入参里抽取一部分数据,然后调用一个外部依赖获取更多的数据,然后通常从新的数据中再抽取部分数据作其他的作用。
常见的办法是将这段代码抽离出来,变成独立的一个或多个方法。
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);
问题4-可测试性
一个方法中有N个参数,每个参数有M个校验逻辑,P个方法都需要对该字段进行测试,整体就需要P*M*N个TC
条件/入参 | name | phone | address |
---|---|---|---|
入参为null | √ | √ | √ |
入参为空 | √ | √ | √ |
入参不符合要求(可能多个) | √ | √ | √ |
Make Implicit Concepts Explicit(将隐形的概念显性化)
原来电话号仅仅是用户的一个参数,属于隐形概念,但实际上电话号的区号才是真正的业务逻辑,而我们需要将电话号的概念显性化,通过写一个Value Object:
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)这里将校验逻辑都放在了constructor里面,确保只要PhoneNumber类被创建出来后,一定是校验通过的;
2)之前的findAreaCode方法变成了PhoneNumber类里的getAreaCode,突出了areaCode是PhoneNumber的一个计算属性;
这样做完之后,我们发现把PhoneNumber显性化之后,其实是生成了一个Type和一个Class:
Type指我们在今后的代码里可以通过PhoneNumber去显性的标识电话号这个概念;
Class指我们可以把所有跟电话号相关的逻辑完整收集到一个文件里。
这两个概念加起来,构成了DP
全面使用DP后
public class User {
UserId userId;
Name name;
PhoneNumber phone;
Address address;
RepId repId;
}
public User register(
@NotNull Name name,
@NotNull PhoneNumber phone,
@NotNull Address address
) {
// 找到区域内的SalesRep
SalesRep rep = salesRepRepo.findRep(phone.getAreaCode());
// 最后创建用户,落盘,然后返回,这部分代码实际上也能用Builder解决
User user = new User();
user.name = name;
user.phone = phone;
user.address = address;
if (rep != null) {
user.repId = rep.repId;
}
return userRepo.saveUser(user);
}
评估1-接口的清晰度
方法签名很清晰
public User register(Name, PhoneNumber, Address)
评估2-数据验证和错误处理
因为DP的特性,只要能够带到入参里的一定是正确的或null(只需使用Bean Validation或lombok解决null),我们把数据验证的工作量前置到调用方;
另一个好处是遵循了DRY原则和单一性原则。
public User register(
@NotNull Name name,
@NotNull PhoneNumber phone,
@NotNull Address address
) // no throws
评估3-业务代码的清晰度
使用了DP后,变成了可复用和可测试的代码。刨除了数据验证代码、胶水代码之后,剩下的都是核心业务逻辑。
评估4-可测试性
多个方法的TC数量变成M+N+P
原则二:将隐形的上下文显性化(Make Implicit Context Explicit)
案例:转账-假设现在要实现一个功能,让A用户可以支付 x 元给用户 B。
public void pay(BigDecimal money, Long recipientId) {
BankService.transfer(money, "CNY", recipientId);
}
@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。
原则三:封装多对象行为(Encapsulate Multi-Object Behavior)
案例:前面的案例升级一下,假设用户可能要做跨境转账从 CNY 到 USD ,并且货币汇率随时在波动:
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);
}
}
ExchangeRate
汇率对象,通过封装金额计算逻辑以及各种校验逻辑,让原始代码变得极其简单。
Domain primitive是Value Object的进阶版,在VO的immutable基础上增加了Validity和行为。当然同样的要求是无副作用。
DTO | DP | |
---|---|---|
功能 | 数据传输 属于技术细节 |
代表业务域的概念 |
数据的关联 | 只是一堆数据放在一起 不一定有关联度 |
数据之间的高相关性 |
行为 | 无行为 | 丰富的行为和业务逻辑 |
常见的DP的使用场景:有格式限制的String、有限制的Integer、可枚举的int、Double和BigDecimal、复杂的数据结构。
第一步-创建Domain Primitive,收集所有DP行为;
第二步-替换数据校验和无状态逻辑;
第三步-创建新接口;
第四步-修改外部调用。