概念
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"); }
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) { 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可以说是一切模型、方法、架构的基础。它是在特定领域拥有精准定义可以自我验证,并且拥有行为的对象,可以认为是领域的最小组成部分。