DDD五板斧之一:Domain Primitive

概念

DDD(Domain-Driven Design)首先我们从名字上来进行简单的理解,“领域驱动设计”的驱动是“促进”的意思,可以理解为“基于领域的工程设计”,那么什么是“领域”呢?我们暂且可以将其理解为“业务问题的范畴”,领域可大可小,对应着大小业务问题的边界,简单来说,领域驱动设计就是将业务上要做的一件大事通过推演和抽象,拆分成多个内聚的领域。

案例

1
假设现在在做一个简单的数据统计系统,市场专员输入客户的姓名和手机号,根据客户手机号的归属地和所属运营商,将客户群体分组,分配给相应销售专员,由销售专员跟进后续的业务。

简单分析一下,根据这段需求,我们需要提供一个注册服务,这个服务的入参应该是客户的姓名和手机号,服务内部根据这个手机号查询对应的归属地编号和运营商编号,再根据这两个值获取分组号,最后将客户的姓名、手机号、分组号封装成一个对象,再存入数据表。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
public class User {
Long userId;
String name;
String phone;
Long repId;
}

public class RegistrationServiceImpl implements RegistrationService {

private SalesRepRepository salesRepRepo;
private UserRepository userRepo;

public User register(String name, String phone)
throws ValidationException {
// 参数校验
if (name == null || name.length() == 0) {
throw new ValidationException("name");
}
if (phone == null || !isValidPhoneNumber(phone)) {
throw new ValidationException("phone");
}

// 获取手机号归属地编号和运营商编号 然后通过编号找到区域内的SalesRep
String areaCode = getAreaCode(phone);
String operatorCode = getOperatorCode(phone);
SalesRep rep = salesRepRepo.findRep(areaCode, operatorCode);

// 最后创建用户,落盘,然后返回
User user = new User();
user.name = name;
user.phone = phone;

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

private String getAreaCode(String phone) {
//...
}

private String getOperatorCode(String phone) {
//...
}
}

我们定义了一个User类,一个注册接口的具体实现类,注册方法中先对参数进行校验,然后通过手机号分别获得姐属地编号和运营商编号,再通过这两个编号去查询数据表获取分组编号,最后构造用户对象来存入数据表。这样写看上去没什么问题,大部分人也都是这么写的,如果是一个小工程或者说迭代低频,甚至短期内有可能下线的系统,这样写可以说又快又好,但如果将这样的代码写在一个迭代频繁的大工程内,其实存在一些隐患

代码审视

接口语义和参数校验

register方法存在两个类型为string的参数,第一个为用户名,第二个为手机号,方法内部一开始对其合法性进行了校验,当这段代码被编译后,方法只会保留参数类型而不会保留参数名,如果这段代码存在于近端包内被其他项目集成时,其他程序员并不了解这个方法的肉部逻辑,或者说仅仅因为失误他很有可能的会颠倒参数的顺序

并且在代码编译器是无法检查出来的,此外,假设在未来系统开始支持通过用户名和身份证号进行注册,身份证号也是一个string,这是register可能就要被改造成registerByPhone和 registerByIDCard两个方法,假设后面又要通过手机号和身份证号注册呢?接口又得频繁修改,这么看来,原来的接口并不完善。

接口定义修改目标:

1.语义明确无歧义

2.拓展性强一些

3.最好带一定的自检性

再来看方法一开始对参数的校验逻辑,如果存在多个类似的方法,都要在开头进行校验,这里一定会存在大量的重复代码,而且一旦某种类型的参数校验逻辑需要修改,那么每个地方修还要意义监测修改,这显然不符合“开闭原则”。那么我们是否可以使用自定义类型来代替呢?自定义类型肯定有基本属性,此外,我们可以将对属性的校验逻辑封装到这个自定义类型中,这样接口就只会接收到通过校验的参数,并且还可以将参数校验异常和业务逻辑异常区分开来。

自定义PhoneNumber类,这样我们在构PhoneNumber对象时,就会执行校验逻辑,确保被构建出来的对象一定是合法的,不再需要业务方法内部去做校验逻辑,将不同类型的校验逻辑内聚到了它自身之内,而且方法中使用了自定义类型,语义更加清晰,在编译器就会进行强类型校验,避免传参乱序

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
public User register(String name, PhoneNumber phone)

public class PhoneNumber {
private final String number;
private final String pattern = "^0?[1-9]{2,3}-?\\d{8}$";

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

private boolean isValid(String number) {
return number.matches(pattern);
}

}

此时代码就被我们改造成下面这样

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
public class User {
Long userId;
String name;
PhoneNumber phone;
Long repId;
}

public class RegistrationServiceImpl implements RegistrationService {

private SalesRepRepository salesRepRepo;
private UserRepository userRepo;

public User register(String name, PhoneNumber phone) {

// 获取手机号归属地编号和运营商编号,然后通过编号找到区域内的SalesRep
String areaCode = getAreaCode(phone);
String operatorCode = getOperatorCode(phone);
SalesRep rep = salesRepRepo.findRep(areaCode, operatorCode);

// 最后创建用户,落盘,然后返回
User user = new User();
user.name = name;
user.phone = phone;

if (rep != null) {
user.repId = rep.repId;
}

return userRepo.save(user);
}

private String getAreaCode(PhoneNumber phone) {
//...
}

private String getOperatorCode(PhoneNumber phone) {
//...
}
}

可以看出接口语义更加清晰,拥有了一定的可拓展性,对参数的校验更加内聚了。

核心业务逻辑清晰度

虽然上面的代码优化后优雅了一些,不过并不纯粹,RegfistrationService是用于对用户进行注册的服务,那么他所承担的职责仅仅限定为“注册”,而这段代码存在两个行为,一个是“获取手机号的归属地编码”,一个是“获取运营商编码” ,把他们放在“注册”这个业务域里其实并不合适,什么逻辑应该归属哪个业务域,这就是对“领域”的理解。

那我们为什么要在register方法里面写这些逻辑?仅仅是为了适配findRep这个函数,来对原始的参数进行拼接,就像是拿胶水来进行缝缝补补,这杯形象地称为是“胶水逻辑”

那么如何改造我们的“胶水逻辑”?

第一张方式,改造这个接口地入参

假设方法地入参为PhoneNumber,就不必在register方法内进行胶水操作了。

第二种方法,假如这个findRep是外部接口,我们没有办法去修改入参类型,那怎么办?“获取手机号地归属地编码”、“获取手机号地运营商编码”、这两个都是获取手机号相关的属性,应该内聚在手机号这个类型中,因此进一步优化

优化之后:

业务逻辑变得非常清晰,只留下“注册”这个领域内最本质的两个操作,获取用户信息,并存入数据表。

思考题

总结

在传统的pojo中,类中只包含属性和getter/setter方法,这里的PhoneNumber却包含初始化、校验、属性处理等多种逻辑,这其实就是DDD和传统MVC开发的重要差异点之一,上面说的pojo只包含属性值,属于贫血模型,而像PhoneNumber不仅拥有属性,而且包含了与其属性相关的职责,是充血模型的一种,充血模型也有强弱程度之分,如果使用充血模型,那么把握它的强弱程度就需要丰富的经验了,这里我们将PhoneNumber这种类型称为DP(Domain Primitive),就像Integer、String是所有语言的primitive意义,在DDD里,DP可以说是一切模型、方法、架构的基础。它是在特定领域拥有精准定义可以自我验证,并且拥有行为的对象,可以认为是领域的最小组成部分。


DDD五板斧之一:Domain Primitive
http://example.com/2024/08/25/DDD五板斧之一/
Author
Posted on
August 25, 2024
Licensed under