Domain Primitive
Primitive 定义:不从任何其他事物发展而来,初级的形成或生长的早期阶段
案例分析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");
}
// 此处省略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);
// 常用查询服务
User findByName(String name);
User findByPhone(String phone);
User findByNameAndPhone(String name, String phone);
由于入参都是 String 类型,不得不在方法名上面加ByXXX
区分,这里参数顺序如果输错了,方法不会报错只会返回 null,而这种 bug 更加难被发现。
有没有办法让方法入参一目了然,避免入参错误导致的 bug ?
2. 数据验证和错误处理
if (phone == null || !isValidPhoneNumber(phone)) {
throw new ValidationException("phone");
}
在日常编码中经常会出现,一般来说这种代码需要出现在方法的最前端
,确保能够 fail-fast
,每个方法这段逻辑都会被重复
。
如果未来需要要拓展电话号码时:
if (phone == null || !isValidPhoneNumber(phone) || !isValidCellNumber(phone)) {
throw new ValidationException("phone");
}
最后,在这个业务方法里,会(隐性或显性的)抛 ValidationException
,所以需要外部调用方去try/catch,而业务逻辑异常
和数据校验异常
被混在了一起,是否是合理的?
所以,有没有一种方法,能够一劳永逸的解决所有校验的问题以及降低后续的维护成本和异常处理成本呢?
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);
除开最后一行业务代码之外其他都为胶水代码,本质是由于外部依赖的服务
的入参
并不符合我们原始的入参
导致的。
常见处理:将胶水代码抽离出来变成一个或者多个方法
private static String findAreaCode(String phone) {
for (int i = 0; i < phone.length(); i++) {
String prefix = phone.substring(0, i);
if (isAreaCode(prefix)) {
return prefix;
}
}
return null;
}
private static boolean isAreaCode(String prefix) {
String[] areas = new String[]{"0571", "021"};
return Arrays.asList(areas).contains(prefix);
}
此时方法调用变为:
String areaCode = findAreaCode(phone);
SalesRep rep = salesRepRepo.findRep(areaCode);
为了复用以上的方法,可能会抽离出一个静态工具类 PhoneUtils
。但是这里要思考的是,静态工具类是否是最好的实现方式呢?当你的项目里充斥着大量的静态工具类,业务代码散在多个文件当中时,你是否还能找到核心的业务逻辑呢?
4. 可测试性
为了保证代码质量,每个方法里的每个入参的每个可能出现的条件都要有 TC 覆盖(假设我们先不去测试内部业务逻辑),所以在我们这个方法里需要以下的 TC :
这 P 个方法都需要对该字段进行测试,也就是说整体需要:
P * N * M
在这个情况下,降低测试成本 == 提升代码质量,如何能够降低测试的成本呢?
5. 解决方案
回头先重新看一下原始的 use case,并且标注其中可能重要的概念:
一个新应用在全国通过 地推业务员 做推广,需要做一个用户的注册系统,在用户注册后能够通过用户电话号的区号对业务> 员发奖金。
发现其中地推业务员、用户本身自带 ID 属性,属于Entity(实体),
而注册系统属于 Application Service(应用服务)
电话号码:属于用户的属性?属于地推员的属性?属于注册系统?
电话号码应该属于一个独立的概念
。
将隐性的概念显性化
public class PhoneNumber {
private final String number;
public String getNumber() {
return number;
}
// 确保只要 PhoneNumber 类被创建出来后,一定是校验通过
public PhoneNumber(String number) {
if (number == null) {
throw new ValidationException("number不能为空");
} else if (isValid(number)) {
throw new ValidationException("number格式错误");
}
this.number = number;
}
// AreaCode只是PhoneNumber的一个属性
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);
}
}
这样做完之后,我们发现把 PhoneNumber 显性化之后,其实是生成了一个 **Type(数据类型)**和一个 Class(类):
- Type 指我们在今后的代码里可以通过 PhoneNumber 去显性的标识电话号这个概念
- Class 指我们可以把所有跟电话号相关的逻辑完整的收集到一个文件里
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);
}
进阶使用
转账
假设现在要实现一个功能,让A用户可以支付 x 元给用户 B ,可能的实现如下:
public void pay(BigDecimal money, Long recipientId) {
BankService.transfer(money, "CNY", recipientId);
}
该方法是明显的 bug ,因为 money 对应的货币不一定是 CNY 。写代码时,需要把所有隐性的条件显性化,而这些条件整体组成当前的上下文。所以 DP 的第二个原则是:
将隐形的上下文显性化
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);
}
跨境转账
前面的案例升级一下,假设用户可能要做跨境转账从 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);
}
}
这种涉及到多个对象的业务逻辑,需要用 DP 包装掉,所以这里引出 DP 的第三个原则:
封装多对象行为
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 的使用场景包括:
- 有格式限制的 String:比如Name,PhoneNumber,OrderNumber,ZipCode,Address等
- 有限制的Integer:比如OrderId(>0),Percentage(0-100%),Quantity(>=0)等
- 可枚举的 int :比如 Status(一般不用Enum因为反序列化问题)
- Double 或 BigDecimal:一般用到的 Double 或 BigDecimal 都是有业务含义的,比如 Temperature、Money、Amount、ExchangeRate、Rating 等
- 复杂的数据结构:比如 Map<String, List> 等,尽量能把 Map 的所有操作包装掉,仅暴露必要行为