一,还在用传统业务模型么?

1.从一个小需求开始

首先提一个小需求,我用传统的编码方式完成它。

做一个用户注册系统,同时希望在用户注册后能够通过用户电话(先假设仅限座机)的地域(区号)对业务员发奖金。

  1. public class User{
  2. Long UserId;
  3. String name;
  4. String phone;
  5. String address;
  6. Long repId;
  7. }
  8. public class UserService{
  9. private SalesRepRepository salesRepRepository;
  10. private User Repository;
  11. public User register(String name ,String phone , String address){
  12. //检验逻辑
  13. if(name == null || name.length == 0){
  14. throw new Exception("注册用户名不能为空!")
  15. }
  16. //此处省略校验电话号,地址逻辑
  17. //取电话号里面的区号,然后通过区号找到区域内的SalesRep
  18. String areaCode=null;
  19. String[] areas = new String[]{"0571", "021", "010"};
  20. for (int i = 0; i < phone.length(); i++) {
  21. String prefix = phone.substring(0, i);
  22. if (Arrays.asList(areas).contains(prefix)) {
  23. areaCode = prefix;
  24. break;
  25. }
  26. }
  27. SalesRep rep = salesRepRepository.findRep(areaCode);
  28. //最后创建用户,落盘,然后返回
  29. User user = new User();
  30. user.name=name;
  31. user.phone=phone;
  32. user.address=address;
  33. if(rep!=null){
  34. user.repId = rep.repId;
  35. return userRepo.save(user);
  36. }
  37. }

咋一看这段代码毫无问题,参数进行了检验,业务逻辑还算合理,最终落盘完成了需求。真的是这样么?转备好,接下来,我要开始挑刺儿了。

2.需求分析

我们日常大部分业务代码和模型其实都是跟这个是类似的,貌似我从接触代码的第一天起,就觉得代码应该这样写,直到我看了阿里技术专家团队的文章,只是感觉我的传统思想被打破了,为我打开了一扇新的大门。

从以下四个维度去分析这个代码:

1)接口清晰度

在Java代码中,对于一个方法来说所有的参数名在编译时丢失,留下的是一个参数类型的列表,所以,在运行时:

  1. User register(String,String,String);

所以以下的代码是一段编译器完全不会报错的,很难通过看代码就能发现的 bug :

  1. service.register("尹会东", "北京市昌平区天通苑本五区", "0571-12345678");

这段代码的问题,就算是普通的code review也很难发现错误。

还有一种情况:

  1. User findByName(String name);
  2. User findByPhone(String phone);
  3. User findByNameAndPhone(String name, String phone);

这里参数顺序错了只会返回null,并不会报错。

2)数据验证和错误处理

作为服务端,任何来源的参数对我们而讲都是不可信的,但是业务代码里面充满大量的参数校验,而且甚至每一个接口都会出现,,甚至可能还会重复,或者未来我们加一个字段,如果有很多地方都需要加这个字段,但是有一个地方我们忘了加,那将是毁灭性的影响。

可能大部分人会想到,是不是可以通过注解的方式校验,但是注解你就能保证,很多地方同时加,你不会有遗漏么?而且复杂的逻辑校验,是不是还是需要我们手动硬编码?

3)业务代码的清晰度

从一些入参里抽取一部分数据,然后调用一个外部依赖获取更多的数据,然后通常从新的数据中再抽取部分数据用作其他的作用。这种代码通常被称作“胶水代码”,其本质是由于外部依赖的服务的入参并不符合我们原始的入参导致的。

当然你可能会提出是否可以抽取出来一个静态工具类,但是这里要思考的是,静态工具类是否是最好的实现方式呢?当你的项目里充斥着大量的静态工具类,业务代码散在多个文件当中时,你是否还能找到核心的业务逻辑呢?

4)可测试性

单元测试的时候,如果我们突然加了一个入参,整个测试是不是会变化很大?需要多额外测试很多种情况,作为一个写完需求首先习惯本地测试的程序员,你应该了解,添加一个参数相当于多了很多种情况。

好,对上面的代码喷了那么久,我又想到了,或者知道了什么妙计呢?

3.引入DP

1)隐性的概念显性化

电话号仅仅是用户的一个参数,属于隐形概念,但实际上电话号的区号才是真正的业务逻辑,而我们需要将电话号的概念显性化,通过写一个Value Object:

  1. public class PhoneNumber{
  2. private final String number;
  3. public String getNumber(){
  4. return number;
  5. }
  6. public PhoneNumber(String number){
  7. if(number == null){
  8. throw new Exception("number is empty!");
  9. }else if(isValid(number)){
  10. throw new Exception("number format is error")
  11. }
  12. }
  13. public String getAreaCode() {
  14. for (int i = 0; i < number.length(); i++) {
  15. String prefix = number.substring(0, i);
  16. if (isAreaCode(prefix)) {
  17. return prefix;
  18. }
  19. }
  20. return null;
  21. }
  22. private static boolean isAreaCode(String prefix) {
  23. String[] areas = new String[]{"0571", "021", "010"};
  24. return Arrays.asList(areas).contains(prefix);
  25. }
  26. public static boolean isValid(String number) {
  27. String pattern = "^0?[1-9]{2,3}-?\\d{8}$";
  28. return number.matches(pattern);
  29. }
  30. }

这里面很重要的几点:

  • 通过private final String number 确保PhoneNumber是一个 Value Object;
  • 校验逻辑都放在了constructor里面,确保只要是phoneNumber类被创建出来,一定是校验通过的。
  • 之前的 findAreaCode 方法变成了 PhoneNumber 类里的 getAreaCode ,突出了 areaCode 是 PhoneNumber 的一个计算属性。

Type 指我们在今后的代码里可以通过 PhoneNumber 去显性的标识电话号这个概念 。

Class 指我们可以把所有跟电话号相关的逻辑完整的收集到一个文件里。

2)使用DP之后的效果

  1. public class User{
  2. UserId userId;
  3. Name name;
  4. PhoneNumber phone;
  5. Address address;
  6. RepId repId;
  7. }
  8. public User register(Name name,PhoneNumber phone,Address address){
  9. //根据手机号查找业务员
  10. SalesRep rep=salesRepRepository.findRep(phone.getAreaCode);
  11. User user=new User();
  12. user.name=name;
  13. user.phone=phone;
  14. user.address = address;
  15. if(rep!=null){
  16. user.repId=rep.repId;
  17. }
  18. return userRepo.saveUser(user);
  19. }

在使用了 DP 之后,所有的数据验证逻辑和非业务流程的逻辑都消失了,剩下都是核心业务逻辑,可以一目了然。

3)让隐性的上下文显性化

需求:转账 A给B转账x元。

  1. public void pay(BigDecimal money, Long recipientId) {
  2. BankService.transfer(money, "CNY", recipientId);
  3. }

如果是国内转账,此处毫无问题,但是如果是跨境呢?

此处抽象一个Money,来做支付功能。

  1. public class Money {
  2. private BigDecimal amount;
  3. private Currency currency;
  4. public Money(BigDecimal amount, Currency currency) {
  5. this.amount = amount;
  6. this.currency = currency;
  7. }
  8. }
  9. public void pay(Money money,Long recipientId){
  10. BankService.transfer(money,recipientId);
  11. }

通过将默认货币这个隐性的上下文概念显性化,并且和金额合并为 Money ,我们可以避免很多当前看不出来,但未来可能会暴雷的bug。

4)封装多对象行为

需求:跨境转账

  1. public void pay(Money money, Currency targetCurrency, Long recipientId) {
  2. if (money.getCurrency().equals(targetCurrency)) {
  3. BankService.transfer(money, recipientId);
  4. } else {
  5. BigDecimal rate = ExchangeService.getRate(money.getCurrency(), targetCurrency);
  6. BigDecimal targetAmount = money.getAmount().multiply(new BigDecimal(rate));
  7. Money targetMoney = new Money(targetAmount, targetCurrency);
  8. BankService.transfer(targetMoney, recipientId);
  9. }
  10. }

在这个case里,由于 targetCurrency 不一定和 money 的 Curreny 一致,需要调用一个服务去取汇率,然后做计算。最后用计算后的结果做转账。

这个case最大的问题在于,金额的计算被包含在了支付的服务中,涉及到的对象也有2个 Currency ,2 个 Money ,1 个 BigDecimal ,总共 5 个对象。这种涉及到多个对象的业务逻辑,需要用 DP 包装掉.

在这个 case 里,可以将转换汇率的功能,封装到一个叫做 ExchangeRate 的 DP 里:

  1. @Value
  2. public class ExchangeRate {
  3. private BigDecimal rate;
  4. private Currency from;
  5. private Currency to;
  6. public ExchangeRate(BigDecimal rate, Currency from, Currency to) {
  7. this.rate = rate;
  8. this.from = from;
  9. this.to = to;
  10. }
  11. public Money exchange(Money fromMoney) {
  12. notNull(fromMoney);
  13. isTrue(this.from.equals(fromMoney.getCurrency()));
  14. BigDecimal targetAmount = fromMoney.getAmount().multiply(rate);
  15. return new Money(targetAmount, to);
  16. }
  17. }

ExchangeRate 汇率对象,通过封装金额计算逻辑以及各种校验逻辑,让原始代码变得极其简单:

  1. public void pay(Money money, Currency targetCurrency, Long recipientId) {
  2. ExchangeRate rate = ExchangeService.getRate(money.getCurrency(), targetCurrency);
  3. Money targetMoney = rate.exchange(money);
  4. BankService.transfer(targetMoney, recipientId);
  5. }

在DDD里面,DP可以说是一切模型,方法,架构的基础。

总结:Domain Primitive 是一个在特定领域里,拥有精准定义的、可自我验证的、拥有行为的 Value Object 。

特点:

  • 隐性的概念显性化
  • 让隐性的上下文显性化
  • 封装多对象行为

二,GET到了,想操作一下?

老应用重构流程

在新应用中使用 DP 是比较简单的,但在老应用中使用 DP 是可以遵循以下流程按部就班的升级。

1.创建DP,收集所有DP行为

在真实的项目中,以前散落在各个服务或工具类里面的代码,可以都抽出来放在 DP 里,成为 DP 自己的行为或属性。这里面的原则是:所有抽离出来的方法要做到无状态,比如原来是 static 的方法。如果原来的方法有状态变更,需要将改变状态的部分和不改状态的部分分离,然后将无状态的部分融入 DP 。因为 DP 本身不能带状态,所以一切需要改变状态的代码都不属于 DP 的范畴。

2.替换数据校验和无状态逻辑

为了保障现有方法的兼容性,在第二步不会去修改接口的签名,而是通过代码替换原有的校验逻辑和根 DP 相关的业务逻辑。

  1. public User register(String name, String phone, String address)
  2. throws ValidationException {
  3. Name _name = new Name(name);
  4. PhoneNumber _phone = new PhoneNumber(phone);
  5. Address _address = new Address(address);
  6. SalesRep rep = salesRepRepo.findRep(_phone.getAreaCode());
  7. // 其他代码...
  8. }

3.创建新接口

创建新接口,将DP的代码提升到接口参数层:

  1. public User register(Name name, PhoneNumber phone, Address address) {
  2. SalesRep rep = salesRepRepo.findRep(phone.getAreaCode());
  3. }

4.修改外部调用

  1. service.register(new Name("尹会东"), new PhoneNumber("0571-12345678"), new Address("北京市昌平区天通苑本五区"));