DDD(domain driven design)领域驱动设计模型
- 一、DP(domain primitive)
- 1、什么是DP
- 2.为什么要用DP
- 2.1 API接口清晰度
- 2.2 数据验证和错误处理
- 2.3 业务代码的清晰度
- 3.DP原则
- 3.1 将隐性的概念显性化
- 3.2 将隐性的上下文显性化
- 3.3 封装多对象行为
- 4. DP与DTO
- 5.DP使用场景
- 二、DDD架构推演
- 传统三层架构(UI、业务层、基础设施层)
- 1.抽象数据存储层
- 1.1 将data access层做抽象,降低系统对数据库的直接依赖
- 方法
- 概念区别
- 2.抽象第三方服务
- 2.1 防腐层ACL
- 3.抽象中间件
- 4.封装业务逻辑
- 5.DDD架构图
- 6.依赖关系图
- 7.模型所在模块和转化器
- 三、Repository
- 1.change-tracking变更追踪
- 四、领域层设计规范
一、DP(domain primitive)
1、什么是DP
dp是一种基本类型,包括type(数据类型)和class(类)。
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);
}
}
如上例子,将于电话号相关的逻辑完整的收集到一个文件(class)中,形成了phoneNumber这个type
2.为什么要用DP
案例分析
一个新应用在全国通过 地推业务员做推广,需要做一个用户注册系统,同时希望在用户注册后能够通过用户电话(先假设仅限座机)的地域(区号)对业务员发奖金。
传统代码:
public class User {
Long userId;
String name;
String phone;
String address;
Long repId;
}
public class RegistrationServiceImpl implements RegistrationService {
private SalesRepRepository salesRepRepo;
private UserRepository userRepo;
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;
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);
}
}
DDD架构代码:
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);
}
}
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;
if (rep != null) {
user.repId = rep.repId;
}
return userRepo.saveUser(user);
}
2.1 API接口清晰度
调用时:
public User register(String, String, String)
传统代码可能会出现如下错误:
service.register("张三", "北京", "0731-88888888")
这个错误是编译无法发现的
DDD架构代码:
public User register(Name, PhoneNumber, Address);
service.register(new Name("张三"), new Address("北京"), new PhoneNumber("0731-88888888"))
2.2 数据验证和错误处理
public User register(
@NotNull Name name,
@NotNull PhoneNumber phone,
@NotNull Address address
)
DP参数一定是正确的或null, 将数据验证前置到了调用方
2.3 业务代码的清晰度
DDD只剩下核心业务逻辑
3.DP原则
3.1 将隐性的概念显性化
3.2 将隐性的上下文显性化
3.3 封装多对象行为
4. DP与DTO
5.DP使用场景
- 有格式限制的 String:比如Name,PhoneNumber,OrderNumber,ZipCode,Address等
- 有限制的Integer:比如OrderId(>0),Percentage(0-100%),Quantity(>=0)等
- 可枚举的 int :比如 Status(一般不用Enum因为反序列化问题)
- Double 或 BigDecimal:一般用到的 Double 或 BigDecimal 都是有业务含义的,比如 - Temperature、Money、Amount、ExchangeRate、Rating 等
- 复杂的数据结构:比如 Map<String, List> 等,尽量能把 Map 的所有操作包装掉,仅暴露必要行为
二、DDD架构推演
传统三层架构(UI、业务层、基础设施层)
缺点:高耦合、上层对下层强依赖
1.抽象数据存储层
1.1 将data access层做抽象,降低系统对数据库的直接依赖
方法
- 新建实体类entity
实体是拥有id的域对象,包括数据和行为。实体与数据存储格式无关,在设计中以该领域的通用严谨语言为依据。避免了其他业务逻辑和数据库的直接耦合,避免可当数据库字段变化时大量业务逻辑也跟着变的问题。 - 新建对象储存接口类repository
repository只负责entity的存储和读取,而repository的实现类实现数据库存储的细节。通过repository接口,底层数据库链接可以通过不同的实现类而替换。让业务逻辑不在面向数据库编程,而是面向领域模型编程。
概念区别
- DO和entity
DO是单纯的和数据库表的映射关系,每个字段对应数据库表的一个column,这种对象叫Data Object。DO只有数据,没有行为。AccountDO的作用是对数据库做快速映射,避免直接在代码里写SQL。无论你用的是MyBatis还是Hibernate这种ORM,从数据库来的都应该先直接映射到DO上,但是代码里应该完全避免直接操作 DO。
entity字段和数据库储存不需要有必然的联系。Entity包含数据,同时也应该包含行为。字段尽可能用 Domain Primitive 代替,可以避免大量的校验代码。 - DAO和repository
DAO对应的是一个特定的数据库类型的操作,相当于SQL的封装。所有操作的对象都是DO类,所有接口都可以根据数据库实现的不同而改变。比如,insert 和 update 属于数据库专属的操作。
Repository对应的是Entity对象读取储存的抽象,在接口层面做统一,不关注底层实现。比如,通过 save 保存一个Entity对象,但至于具体是 insert 还是 update 并不关心。Repository的具体实现类通过调用DAO来实现各种操作,通过Builder/Factory对象实现AccountDO 到 Account之间的转化
repositoryImpl实现类,由于其职责被单一拎出来,因此只需要关注entity对象到DO之间的映射关系以及repository方法到DAO方法之间的映射。
2.抽象第三方服务
解决第三方不可控、入参出参强耦合的问题,最常用的设计模式是防腐层
2.1 防腐层ACL
防腐层原则是:外部一切不可信
作用:
- 适配器:外部数据、接口和协议可能不符合内部规范,将数据转化逻辑封装到ACL内部。
- 缓存:对于频繁调用且数据变更不频繁的外部依赖,通过在ACL里嵌入缓存逻辑,能够有效的降低对于外部依赖的请求压力。同时,很多时候缓存逻辑是写在业务代码里的,通过将缓存逻辑嵌入ACL,能够降低业务代码的复杂度。
- 兜底:如果外部依赖的稳定性较差,一个能够有效提升我们系统稳定性的策略是通过ACL起到兜底的作用,比如当外部依赖出问题后,返回最近一次成功的缓存或业务兜底数据。这种兜底逻辑一般都比较复杂,如果散落在核心业务代码中会很难维护,通过集中在ACL中,更加容易被测试和修改。
- 易于测试:类似于之前的Repository,ACL的接口类能够很容易的实现Mock或Stub,以便于单元测试。
- 功能开关:有些时候我们希望能在某些场景下开放或关闭某个接口的功能,或者让某个接口返回一个特定的值,我们可以在ACL配置功能开关来实现,而不会对真实业务代码造成影响。同时,使用功能开关也能让我们容易的实现Monkey测试,而不需要真正物理性的关闭外部依赖。
3.抽象中间件
4.封装业务逻辑
用Domain Primitive封装跟实体无关的无状态计算逻辑
用Entity封装单对象的有状态的行为,包括业务校验
- 用Domain Service封装多对象逻辑
- 经过重构后的代码有以下几个特征:
业务逻辑清晰,数据存储和业务逻辑完全分隔。
Entity、Domain Primitive、Domain Service都是独立的对象,没有任何外部依赖,但是却包含了所有核心业务逻辑,可以单独完整测试。
- 原有的TransferService不再包括任何计算逻辑,仅仅作为组件编排,所有逻辑均delegate到其他组件。这种仅包含Orchestration(编排)的服务叫做Application Service(应用服务)。
整理后:
- 最底层不再是数据库,而是Entity、Domain Primitive和Domain Service。这些对象不依赖任何外部服务和框架,而是纯内存中的数据和操作。这些对象我们打包为Domain Layer(领域层)。领域层没有任何外部依赖关系。
- 再其次的是负责组件编排的Application Service,但是这些服务仅仅依赖了一些抽象出来的ACL类和Repository类,而其具体实现类是通过依赖注入注进来的。Application Service、Repository、ACL等我们统称为Application Layer(应用层)。应用层 依赖 领域层,但不依赖具体实现。
- 最后是ACL,Repository等的具体实现,这些实现通常依赖外部具体的技术实现和框架,所以统称为Infrastructure Layer(基础设施层)。Web框架里的对象如Controller之类的通常也属于基础设施层。
5.DDD架构图
6.依赖关系图
7.模型所在模块和转化器
三、Repository1.change-tracking变更追踪
repository是对聚合根进行操作,一个聚合根可能包括多个entity,当其中1个entity改变其他不改变时,需要找到这个改变的entity,即变更追踪。
有一下2个主流方法:
1.基于snapshot
当数据从DB里取出来后,在内存中保存一份snapshot,然后在数据写入时和snapshot比较。
2.基于proxy
传统OOP代码问题:
- 业务规则的归属到底是对象的“行为”还是独立的”规则对象“?
- 业务规则之间的关系如何处理?
- 通用“行为”应该如何复用和维护?