Domain-Driven Design入门
# Domain-Driven Design入门
学习视频:【领域驱动设计】DDD入门五板 (opens new window)
# 1. 介绍
Domain-Driven Design,可以基于领域的工程设计
领域:业务问题的范畴,领域可大可小,对应着大小业务问题的边界。
领域驱动设计:就是将业务上要做的一件大事,通过推演和抽象,拆分成多个内聚的领域。
有点像模块化、微服务。都是为了降低软件开发维护复杂度,遵顼解耦原则。但他们属于软件开发中不同层面的实现方式。
# 2. 举例说明
假设现在在做一个简单的数据统计系统,地推员输入客户的姓名和手机号。根据客户手机号的归属地和所属运营商,将客户群体分组,分配给相应销售组,由销售组跟进后续的业务。
根据需求,需要提供一个注册服务,注册服务的入参是客户的姓名和手机号。流程如下:
代码实现如下:
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) {
//...
}
}
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
如果是一个小工程,或者迭代低频甚至短期可能下线的系统,这样写没有任何问题。但是,如果在一个迭代频繁的大工程内,存在一些隐患:
# 2.1 接口语义与参数校验
问题一:入参为两个 String 类型,编译后方法只保留了参数类型,不会保留参数名。在被调用时,很有可能 name 和 phone 写反,导致错误发生。
问题二:当前只通过 name 和 phone 进行注册,如果想通过 name 和 id 进行注册,或者通过 name、id 和 phone 进行注册,那么接口需要频繁修改,说明原来的接口定义不完善。
所以接口定义的目标:
- 语义明确无歧义,拓展性强,具有自检性
- 参数校验逻辑复用,内聚
- 参数校验异常和业务逻辑异常解耦
如果使用工具类在业务逻辑中进行参数校验,那么就业务将与工具类耦合起来,业务的参数异常和业务的逻辑异常也混合起来了。当参数类型越来越多,工具类中的校验逻辑也会不断膨胀,后续不利于维护。
解决方案:自定义类,包含属性与行为。PhoneNumber 中包含了属性和校验逻辑,
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);
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
此时方法中使用 PhoneNumber phone
,语义更加清晰,在调用该方法时会进行强类型校验。
改造后的代码:
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) {
//...
}
}
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
# 2.2 核心业务逻辑清晰度
经过上一步的改造,代码优雅了一些,但是其功能不是很纯粹。该方法的功能是用户注册,所以它所承担的职责应该仅仅是注册,但是方法内包含了其他的业务逻辑,比如”获取手机号归属地编码“和”获取运营商编号“。
什么逻辑归属于哪个业务域,这就是对领域的理解,
”获取手机号归属地编码“和”获取运营商编号“不属于注册领域,应该属于 phoneNumber 领域,对代码再次优化
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);
}
public String getAreaCode() {
//...
}
public String getOperatorCode(PhoneNumber phone) {
//...
}
}
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
此时 ,注册方法就变得很清晰了
public class RegistrationServiceImpl implements RegistrationService {
private SalesRepRepository salesRepRepo;
private UserRepository userRepo;
public User register(String name, PhoneNumber phone) {
// 获取用户信息
SalesRep rep = salesRepRepo.findRep(phone.getAreaCode(), phone.getOperatorCode());
// 存储用户信息
User user = new User();
user.name = name;
user.phone = phone;
if (rep != null) {
user.repId = rep.repId;
}
return userRepo.save(user);
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
# 2.3 单元测试可行性
通过对 PhoneNumber 逻辑的内聚,对业务方法内逻辑的简化,写单元测试的效率能极大提高。而且 PhoneNumber 类型的改动频率比较小,一旦写了完善的测试用例,复用性会很高。
随着业务方法越来越多,业务方法内部的逻辑越来越复杂,单元测试的维护成本只会越来越低。
# 3. Domain Primitive
DP,Domain Primitive
在传统的 POJO 中,类中只包含属性和 getter/setter 方法。这里的 PhoneNumber 却包含了初始化、校验、属性处理等多种逻辑。这就是 DDD 和传统 MVC 开发的重要差异之一。
POJO 只包含属性和 getter/setter 方法,属于贫血模型;PhoneNumber 拥有属性和属性相关的职责,属于充血模型。
PhoneNumber 这种类型成为 DP,Domain Primitive。
在 DDD 中,DP 可以说是一切模型、方法、架构的基础。它在特定领域精准定义、可以自我验证、拥有行为的对象。可以认为是领域的最小组成部分。
DP 三条原则:
- 让隐性的概念显性化
- 归属地编号、运营商编号就属于电话号码的隐形属性
- 让隐性的上下文显性化
- 比如手机号所采用的协议
- 封装多对象行为
- 一个 DP 可以封装其他多个 DP 的行为
# 4. Entity & Repository
对上面的业务进行扩展 :
- 对手机号进行实名校验,实名信息通过调用外部服务获得。(假设目前由中国电信提供该服务)
- 根据外部服务返回的实名信息,按照一定逻辑计算出用户标签,记录在用户账号中。
- 根据用户标签为该用户开通相应等级的新客福利。
具体的处理流程:
业务方法的入参是客户姓名和手机号,首先使用手机号去调用外部服务查询实名信息,校对是否和入参中的姓名-致,如果一致,则通过。
然后,然后根据得到的实名信息,按照定逻辑计算得出该用户的标签,该标签将作为用户的一个属性。
接着,根据手机号的归属地和所属运营商,查询得到关联的销售组信息,该销售组ID将作为用户的一个属性。
此时,根据用户信息,构建用户对象和福利对象,并查询风控是否通过。
若通过,用户失去新客身份,且可以查询到福利信息,数据落库。若不通过,用户保持新客身份,但查询不到福利信息,数据落库。
上述逻辑默认在同一个事务中处理。
普通写法:
public class RegistrationServiceImpl implements RegistrationService {
private SalesRepMapper salesRepDAO;
private UserMapper userDAO;
private RewardMapper rewardDAO;
private TelecomRealnameService telecomService;
private RiskControlService riskControlService;
public UserDO register(String name, PhoneNumber phone) {
// 参数合法性校验已在PhoneNumber中处理
// 参数一致性校验
TelecomInfoDTO rnInfoDTO = telecomService.getRealnameInfo(phone.getNumber());
if (!name.equals(rnInfoDTO.getName())) {
throw new InvalidRealnameException();
}
// 计算用户标签
String label = getLabel(rnInfoDTO);
// 计算销售组
String salesRepId = getSalesRepId(phone);
// 构造User对象和Reward对象
String idCard = rnInfoDTO.getIdCard();
UserDO userDO = new UserDO(idCard, name, phone.getNumber(), label, salesRepId);
RewardDO rewardDO = RewardDO(idCard, label);
// 检查风控
if(!riskControlService.check(idCard, label)) {
userDO.setNew(true);
rewardDO.setAvailable(false);
}else {
userDO.setNew(false);
rewardDO.setAvailable(true);
}
// 存储信息
rewardDAO.insert(rewardDO);
return userDAO.insert(userDO);
}
private String getLabel(TelecomInfoDTO dto) {
// 本地逻辑处理
}
private String getSalesRepId(PhoneNumber phone) {
SalesRepDO repDO = salesRepDAO.select(phone.getAreaCode(), phone.getOperatorCode());
if (repDO != null) {
return repDO.getRepId();
}
return null;
}
}
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
# 4.1 对外部依赖耦合
- 一切不属于当前域内的设施和服务都说外部依赖,如数据库、RPC 服务、ORM 框架、中间件等 ,并且这些依赖都可替换。
- 要保证即使外部依赖发生变化,也能将自己系统所产生变化的波动降到最小。
- 由外部依赖变化导致系统内部系统的改动程度,可以侠义的理解为系统的可维护性。
- 外部依赖 - 数据库 Scheme:上面的代码包含多个操作外部数据库的行为,强依赖于 DO 类。
- 外部依赖 - ORM 框架:包含多个 Mybatis 的操作,如果 API 变化并且没有向下兼容,那么这些代码都无法使用;替换 ORM 也将无法使用。
- 外部依赖 - RPM 服务:依赖于中国电信提供的外部方法,如果该方法修改了,这里的代码也要修改。
- 耦合的具体原因:面向具体的实现编程;所以要改为,面向抽象接口编程。领域驱动作为一种指导思想。
- 抽象接口编程可以理解为一种协议,依赖方和被依赖方都只要对该协议负责
- 接口将软件进行隔离,任何一方的改动都被限制在当前范围内
下面针对外部依赖 - RPC 调用抽象
public interface RealnameService {
RealnameInfo get(PhoneNumber phone);
}
public class TelecomRealnameService implements RealnameService {
@Override
public RealnameInfo get(PhoneNumber phone){
// RPC调用,并将返回结果封装为RealnameInfo
// RealnameInfo是DP
}
}
2
3
4
5
6
7
8
9
10
11
12
使用 realnameService 替代 telecomService,具体实现对象是通过配置来注入的,达到了依赖倒置的目的。
使用 realnameInfo 代理 TelecomInfoDTO,将外部变动范围控制在具体实现类和配置文件内部,保证核心业务逻辑稳定。
public class RegistrationServiceImpl implements RegistrationService {
private SalesRepMapper salesRepDAO;
private UserMapper userDAO;
private RewardMapper rewardDAO;
private RealnameService realnameService;
private RiskControlService riskControlService;
public UserDO register(String name, PhoneNumber phone) {
// 一致性校验
RealnameInfo realnameInfo = realnameService.get(phone); // realnameService 为防腐层
realnameInfo.check(name);
// 计算标签信息
String label = getLabel(realnameInfo);
// 计算销售组
String salesRepId = getSalesRepId(phone);
// 构造对象
String idCard = realnameInfo.getIdCard();
UserDO userDO = new UserDO(idCard, name, phone.getNumber(), label, salesRepId);
RewardDO rewardDO = RewardDO(idCard, label);
// 检查风控
if(!riskControlService.check(idCard, label)) {
userDO.setFresh(true);
rewardDO.setAvailable(false);
}else {
userDO.setFresh(false);
rewardDO.setAvailable(true);
}
// 存储信息
rewardDAO.insert(rewardDO);
return userDAO.insert(userDO);
}
private String getLabel(RealnameInfo info) {
// 本地逻辑处理
}
private String getSalesRepId(PhoneNumber phone) {
SalesRepDO repDO = salesRepDAO.select(phone.getAreaCode(), phone.getOperatorCode());
if (repDO != null) {
return repDO.getRepId();
}
return null;
}
}
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
下面针对外部依赖 - 数据库
DO 作为数据表的直接映射,属于具体实现,不应该直接暴露给业务逻辑;DAO 作为访问数据库的具体实现,不应该暴露给业务逻辑。这里引入领域驱动设计中的 Entity 和 Repository。
老代码属于面向数据表编程,业务逻辑直接依赖于 DO 和 DAO。事实上,我们的业务逻辑应该只面向领域实体,而并不需要关系这个对象背后是否用到了数据库。
领域实体类 User,里面的属性用于描述在这个系统内,客户应该含有的信息,可以使用更多的 DP 将自检和隐性属性内聚起来。定义 User 的时候不需要关心下层数据库怎么写,哪一部分数据存储在内存中,哪一部分数据存储在数据库中也不需要关心。在定义领域实体时,只需要关注如何去描述这个领域实体。
Entity 与 DP 的区别,本质差异就是在语义上是否拥有数据状态,比如,PhoneNumber 是无状态的,User 是有状态的。
- Entity:有状态,领域实体
- DP:无状态,组成实体的基础类型
// User Entity
public class User {
// 用户id,DP
private UserId userId;
// 用户手机号,DP
private PhoneNumber phone;
// 用户标签,DP
private Label label;
// 绑定销售组ID,DP
private SalesRepId salesRepId;
private Boolean fresh = false;
private SalesRepRepository salesRepRepository;
// 构造方法
public User(RealnameInfo info, name, PhoneNumber phone) {
// 参数一致性校验,若校验失败,则check内抛出异常(DP的优点)
info.check(name);
initId(info);
labelledAs(info);
// 查询
SalesRep salesRep = salesRepRepository.find(phone);
this.salesRepId = salesRep.getRepId();
}
// 对this.userId赋值
private void initId(RealnameInfo info) {
}
// 对this.label赋值
private void labelledAs(RealnameInfo info) {
// 本地处理逻辑
}
public void fresh() {
this.fresh = true;
}
}
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
下面针对外部依赖 - 数据访问的抽象
业务逻辑不应该耦合数据访问的具体实现的,Respository 就是数据访问的抽象,在抽象层只定义动作,在具体实现类中依赖数据库相关的各种具体实现,比如可以直接依赖 MyBatis。
public interface UserRepository {
User find(UserId id);
User find(PhoneNumber phone);
User save(User user);
}
public class UserRepositoryImpl implements UserRepository {
private UserMapper userDAO;
private UserBuilder userBuilder;
@Override
public User find(UserId id) {
UserDO userDO = userDAO.selectById(id.value());
return userBuilder.parseUser(userDO);
}
@Override
public User find(PhoneNumber phone) {
UserDO userDO = userDAO.selectByPhone(phone.getNumber());
return userBuilder.parseUser(userDO);
}
@Override
public User save(User user) {
UserDO userDO = userBuilder.fromUser(user);
if (userDO.getId() == null) {
userDAO.insert(userDO);
} else {
userDAO.update(userDO);
}
return userBuilder.parseUser(userDO);
}
}
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
改造之后的代码,逻辑更加清晰,可读性增强了不少。核心业务逻辑不再依赖于任何具体实现,无论什么外部依赖产生变动,我们只需要修改响应的具体实现类。
public class RegistrationServiceImpl implements RegistrationService {
private UserRepository userRepository;
private RewardRepository rewardRepository;
private RealnameService realnameService;
private RiskControlService riskControlService;
public UserDO register(String name, PhoneNumber phone) {
// 查询实名信息
RealnameInfo realnameInfo = realnameService.get(phone);
// 构造对象
User user = new User(realnameInfo, phone);
Reward reward = Reward(user);
// 检查风控
if(!riskControlService.check(user)) {
user.fresh();
reward.inavailable();
}
// 存储信息
rewardRepository.save(reward);
return UserRepository.save(user);
}
}
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
# 4.2 对内部逻辑耦合
由内部逻辑的变化所导致内部系统的改造程度,可以侠义地理解为系统的可拓展性。
上面的代码,发奖逻辑和风控耦合在了注册当中,这里的注册就不够纯粹了。发奖逻辑主要是针对于新用户,可以把他们分为获取用户,检查并更新用、存储用户信息。检查并更新用户的逻辑中,存在发奖这种衍生行为,与其他可能的行为。
public class RegistrationServiceImpl implements RegistrationService {
private UserRepository userRepository;
private RealnameService realnameService;
private CheckUserService checkUserService;
public UserDO register(String name, PhoneNumber phone) {
// 查询信息
RealnameInfo realnameInfo = realnameService.get(phone);
// 构造对象
User user = new User(realnameInfo, phone);
// 检查并更新对象
checkUserService.check(user);
// 存储信息
return userRepository.save(user);
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
可维护和可扩展性大大提高。
# 4.3 单元测试
改造之前的代码,多个业务逻辑耦合在一起,耦合的代码中有 3 个逻辑,假设每段逻辑修改了 n 次。最多需要构造 n * n * n 个测试用例。如果把多个逻辑解耦只会,只需要 n + n + n 个测试用例。
# 4.4 概念辨析
DP:抽象并封装自检和一些隐性属性的计算逻辑,且这些属性是无状态的。
Entity:抽象并封装单对象有状态的逻辑。
Domain Service:抽象并封装多对象的有状态逻辑。
Repository:抽象并封装外部数据访问逻辑。
流程:
- 对需要处理的业务问题进行总览
- 对领域对象(Entity)进行划分,明确每个领域对象的包含信息和职责边界。并进行跨对象,多对象的逻辑组织(Domain Service)
- 在上层应用中根据业务描述去编排 Entity 和 Domain Service
- 最后做一些下水道逻辑,对下层数据访问,RPC调用的具体实现。
DP 和 Entity 的区别
DP(VO) | Entity | |
---|---|---|
是否充血 | 是 | 是 |
有无状态 | 无 | 有 |
充血模型:rich domain model,赋予对象除了属性和属性的读写方法外,包含业务逻辑的领域行为。好处是,模型本身高度内聚,表达力强。而不是每当涉及一个业务操作,就交由其他的对象处理,有效避免代码写成高度耦合的,且难以维护的事物脚本。
状态:该对象是否存在生命周期,程序是否需要追踪该对象的变化事件。
# 4.5 统一模型
统一语言(Ubiquitous Language),简称 UL,黑话拉通、对齐。
举一个 UL 的例子,假如你在做互联网金融相关领域的业务,金融背景的同事往往认为一些概念是常识,但技术背景的人对它的理解却很容易产生偏差。甚至理解成两种不同的事物,一旦这种信息差产生且没有及时消除,那么在项目后续的迭代中,建模将会越来越困难。
财务人眼中 | 技术人眼中 | |
---|---|---|
含义 | 资产=负载+所有者权益 | 借贷?贷款? |
为了形成可靠的 UL,不同角色的同事在沟通时,一旦产生新的术语,需要双方不断确认,形成一致的理解,一旦对术语产生了歧义就需要及时修正大家对它的理解。
在不断强化 UL 的过程中,也是对业务领域知识进行消化。为后续建设丰富可靠的模型打基础。所以领域驱动设计提倡的是业务领域专家和技术人员一起去进行螺旋式的建模,
为了建设有价值的模型,我们需要在形成 UL 的基础上,消化知识,并向模型中提炼知识。
# 5. DDD 初步的理解
DDD 是一种更高级的设计思想,目的也是降低系统内部的耦合度,更加方便系统的维护和扩展。DDD 将业务上要做的一件大事,通过推演和抽象,拆分成多个内聚的领域。
对于领域这个概念的理解,就是业务问题的范畴,领域可大可小,对应着大小业务问题的边界。在项目的初期,据需要大家进行足够的讨论,对相关知识有一致的认识,并规定好领域,难点就是在于大家对某一个领域的认知是相同的,并且领域边界的制定。
在普通的系统中,POJO 是贫血模型,只具有属性、getter/setter 方法;在 DDD 中,DP 是充血模型,它不仅包含属性、getter/setter,还包含了属性相关的职责、拥有行为和自检能力,它可以认为领域的最小组成部分。
在 DDD 中,领域实体 Entity,不需要关心下层数据库怎么写,哪一部分数据存储在内存中,哪一部分数据存储在数据库中也不需要关心。在定义领域实体时,只需要关注如何去描述这个领域实体。
在 DDD 模型的业务中,也要保证该业务只做该领域内的事情。核心业务逻辑不依赖于任何具体实现,无论什么外部依赖产生变动,我们只需要修改相应的具体实现类。
简单的流程:
- 对需要处理的业务问题进行总览
- 对领域对象(Entity)进行划分,明确每个领域对象的包含信息和职责边界。并进行跨对象,多对象的逻辑组织(Domain Service)
- 在上层应用中根据业务描述去编排 Entity 和 Domain Service
- 最后做一些下水道逻辑,对下层数据访问,RPC调用的具体实现。