DDD之Domain Primitive(DP)

前言:

DDD是一种架构思想,而不是一套框架。

Domain Primitive:

何为DP,他是DDD中的“基础数据结构”,就像Java中的int,string一样,是我们学习的必经之路。这么说有点抽象,接下来通过一个案例来说明。

用户注册功能,需要输入用户的名字,电话(带区号的座机),地址。并且后台需要根据电话信息来统计哪个区域注册用户最多。那么就可以看出来电话需要自己的校验逻辑,来判断号码是否合法,以及拆分出区号。

传统的方式:

public class User {
    String name;
    String phone;
    String address;
}

public class RegistrationServiceImpl implements RegistrationService {

    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;
        return userRepo.save(user);
    }

    private boolean isValidPhoneNumber(String phone) {
        String pattern = "^0[1-9]{2,3}-?\\d{8}$";
        return phone.matches(pattern);
    }
}

看完以上的代码,可能有的人会问,有些判断是可以再DTO中使用@validation注解来做判断,这样说确实是没有问题,不过validation注解并不是万能的,遇到复杂情况还是需要在业务层里写代码来判断。

从上面这段代码,我们可以看到如下几个问题:

▍问题1 - 接口的清晰度:
从编译的角度来出发,方法在编译的时候是不存在参数名称的,只会留下参数类型,所以就如下

User register(String, String, String); 

所以如果参数在非法的情况下编译时是无法发现问题的,只有在执行的时候到了业务层,才会暴露出问题。

举一个更明显的例子,就是目前我看到项目里最多的情况

User findByName(String name);
User findByPhone(String phone);
User findByNameAndPhone(String name, String phone);

从上面的代码相信大家都不陌生,在查询功能中很常见,在这个场景下,由于入参都是 String 类型,不得不在方法名上面加上 ByXXX 来区分, 而 findByNameAndPhone 同样也会陷入前面的入参顺序错误的问题,而且和前面的入参不同,这里参数顺序如果输错了,方法不会报错只会返回 null,而这种 bug 更加难被发现。 这里的思考是,有没有办法让方法入参一目了然,避免入参错误导致的 bug ?

▍问题2 - 数据验证和错误处理

if (phone == null || !isValidPhoneNumber(phone)) {
            throw new ValidationException("phone");
        }

这段代码可以看出来是判断电话号码是否合法,现在的是带区号的电话号码,那么如果以后需求加入其他类型的号码,那岂不是需要在这里从新修改,并且如果其他地方也有判断,那岂不是要改很多地方。
这时肯定会有人提出使用ValidationUtils来进行判断,可是当大量的判断逻辑充斥在一个类中,就打破了Single Responsibility单一性原则。

▍问题3 - 业务代码的清晰度

// 取电话号里的区号,然后通过区号找到区域内的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;
            }
        }

很明显上面一段就是传说中的胶水代码,所谓胶水代码就是从一些入参里抽取一部分数据,然后调用一个外部依赖获取更多的数据,然后从新的数据中再抽取部分数据用作其他的作用。解决胶水代码最好的办法就是把它抽离出来做成一个方法:

//从号码中获取区号
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 。但是这里要思考的是,静态工具类是否是最好的实现方式呢?当你的项目里充斥着大量的静态工具类,业务代码散在多个文件当中时,你是否还能找到核心的业务逻辑呢?

问题的解决:

先来分析一下刚刚遇到问题,首先我们发现校验电话号码在业务层里让代码很,然后我们发现分离电话号码区号在代码里也很乱,判断区号是否存在也在代码里更乱,明明只是一个注册的业务,我确写了很多电话号码相关的东西,是不是有病?所以当你怀疑自己有病的时候,就可以考虑一下,这个电话号码,不就是tm的隐藏概念。

对没有错,DP的第一个原则就是发现隐藏概念,并且把它显性的展示出来。二话不说,直接把号码抽离出来成一个DP

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);
    }

}

可以看出来我们在构造方法里就给它加入了判断,并且把刚刚的方法作为了属性。
我们再看看实现了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;
    return userRepo.saveUser(user);
}

接口调用改为:

service.register(new Name("xx"), new Address("xxxxx"), new PhoneNumber("0571-12345678"));

可以看出,只要是传入的参数,就必定是满足条件的,并且代码比之前清晰了很多,号码相关的操作,就只用操作号码这个DP就可以。

总结:

▍Domain Primitive 的定义

让我们重新来定义一下 Domain Primitive :Domain Primitive 是一个在特定领域里,拥有精准定义的、可自我验证的、拥有行为的 Value Object 。

DP是一个传统意义上的Value Object,拥有Immutable的特性
DP是一个完整的概念整体,拥有精准定义
DP使用业务域中的原生语言
DP可以是业务域的最小组成部分、也可以构建复杂组合

(参考:阿里技术专家详解 DDD 系列- Domain Primitive)

你可能感兴趣的:(java,编程语言,spring)