作者/王晨
之前写过一篇关于DDD领域驱动设计的思考,是比较系统性的进行总结,在DDD的战术设计中,值对象相对来说是一个比较简单的概念,相对于实体、聚合根、事件处理等战术工具来说,简单很多。
但是使用好值对象却可以带来非常大的好处,对代码的可读性,内聚性,可测试性等方面都有很大帮助,个人觉得在DDD体系里值对象工具是一个学习投入产出比很高的工具。
那么今天结合一定的业务场景,针对DDD领域模型中的值对象,展开讨论,引申出的Domain Primitive概念,值对象的定义,如何设计值对象,以及值对象的好处。
DDD样例项目代码:
ddd-sample-code(https://github.com/citerus/dddsample-core)
会发现类似如下形式的一些值对象:
public final class TrackingId implements ValueObject<TrackingId> {
private String id;
public TrackingId(final String id){
Validate.notNull(id);
this.id = id;
}
}
这些值对象看起来什么也没做,只是简单地封装了一个String,乍看起来有点过度设计之嫌。
经过一番研究之后,发现这种写法还是很有好处的,我们以用户注册领域中的手机号字段为例,来分析下使用简单String类型和使用值对象包装类型的区别。
对于User类中的手机号字段,大部分人会用一个String phoneNumber 来表示,而如果模仿ddd-sample-code中的写法,手机号会封装成一个值对象,类似下面这样:
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;
}
public static boolean isValid(String number) {
String pattern = "^0?[1-9]{2,3}-?\\d{8}$";
return number.matches(pattern);
}
}
我们来想想将一个phone字段封装成一个PhoneNumber类后
我们获得了什么:
1、一个类。
我们获得了一个PhoneNumber Class,在这个Class中我们可以收敛部分代码,比如自身的校验,获取区号等方法。关于手机号处理相关的代码都收敛到了这个Class中。
2、一种类型。
我们获得了一个PhoneNumber类型,代码库里多了一种类型,代码之间的交互不再是String类型,而是PhoneNumber类型了。PhoneNumber的概念突显出来了。
这样做之后可以给代码带来如下好处:
1、接口更加清晰,代码可读性更高。
有了PhoneNumber类型之后,我们接口的定义不再是String phone,而是PhoneNumber phone。
比如发送短信接口的两种定义方式:
sendSms(String phone, String text)
sendSms(PhoneNumber phoneNumber, String text)
第一种形式我只能通过参数名才知道需要先传手机号再传信息,粗心的用户调用时可能会犯参数错位的错误,比如:
sendSms("myText","15088683360")
这样的调用,代码编译是完全合法的。第二种方式接口通过类型强制了调用方先传手机号,语意更明确,也杜绝了调用参数错位的问题。
2、校验收敛。
使用String 类型:
interfaces -> application -> domain
这里每一层接口中的手机号参数都需要做校验,因为String类型的手机号是不可信的。
使用PhoneNumber值对象作为参数后,校验逻辑只需要在最外层做,interfaces层传递的String 转成 PhoneNumber之后,后续 application、domain、infras层的接口入参都是 PhoneNumber,PhoneNumber类型只要创建出来就一定是合法可信的,不需要在做校验(当然判空还是需要的)。
3、测试收敛。
是由第2点带来的额外效果。如果每一层的接口定义的参数都是 String phone类型,则每一层的接口都需要测试不合法手机号的case。手机号校验收敛到PhoneNumber类之后,只需要针对PhoneNumber类做测试即可。
4、可以让业务代码和Entity类从细节中脱身。
也是得益于PhoneNumber类收敛了和手机号相关的逻辑,业务代码和Entity类不用再处理类似getAreaCode()之类的逻辑,这部分逻辑内聚到了PhoneNumber类中。
当然,
除了单个字段可以wrapper成一个值对象,更多情况下是多个紧密相关的字段组成一个值对象。
Dan Bergh Johnsson在他的书籍《Security by Design》中,把这些值对象称为 Domain Primitive, 直译过来就是领域原语。类似String、Integer等是Common Language Primitive(通用语言原语),相应地,UserId,PhoneNumber,Address等是DomainPrimitive,是用户账号领域的原语,他们和账号领域强相关。
经过对领域知识的不断消化和理解,可以沉淀出一套DomainPrimitive类,他们就是这个领域的最小构造块,是针对这个领域的一套API库,新需求来了基于这套API库编写代码,可以更快更安全。可以类比一下DSL,对于特定的领域,设计良好的DSL可以使编程大大简化,相应地,定义领域的DomainPrimitive就像设计一套领域的DSL一样,针对领域沉淀出一套合适的DomainPrimitive类可以使针对该领域编码工作大大简化。
最后,
再说一说哪些字段适合做成DomainPrimitive类:
1、有格式要求的string,有范围要求的数值类型,有参数格式校验的字段。
2、可以关联一些行为的字段或字段集,比如手机号等。
3、领域中的核心概念。比如用户注册领域中的Phone,IdCard等,特意将这些概念建模成一个值对象,是为了概念的显性化,可以使代码之间的交互更加清晰。
研究到最后会发现,其实值对象和DomainPrimitive也只不过是OOA/OOP的基本要求,本质就是封装、内聚、数据和行为一起放到值对象中。
而我们平时编码的时候,可能更关注业务逻辑的实现,直接用语言的基本类型+过程式的逻辑来完成大部分业务需求,没有认真去思考一下应该怎么设计一些对象出来,怎么把领域中的一些核心概念识别出来,显性化的表达出来,说白了是缺少OOA/OOP的一些基本素养,最终导致了面条式的代码。
通过有意识的构建一些值对象或领域原语,我们可以积累出这个领域对应的一套API,如果设计的合理,这一套领域API是非常有价值的,有助于领域知识的提炼,传承和表达,也有助于编写出更优雅的代码。
延展,
简单再聊下DDD分层架构在微服务架构的演进变化,因为架构设计是一个演进的过程,DDD设计深陷其中。
领域模型不是一成不变的,因为业务的变化会影响领域模型,而领域模型的变化则会影响微服务的功能和边界。领域模型中对象的层次从内到外依次是:值对象、实体、聚合和限界上下文。。
实体或值对象的简单变更,一般不会让领域模型和微服务发生大的变化。但聚合的重组或拆分却可以。这是因为聚合内业务功能内聚,能独立完成特定的业务逻辑。那聚合的重组或拆分,势必就会引起业务模块和系统功能的变化了。
以聚合为基础单元,完成领域模型和微服务架构的演进。聚合可以作为一个整体,在不同的领域模型之间重组或者拆分,或者直接将一个聚合独立为微服务。
我们结合上图,以微服务 1 为例,讲解下微服务架构的演进过程:
当你发现微服务 1 中聚合 a 的功能经常被高频访问,以致拖累整个微服务 1 的性能时,我们可以把聚合 a 的代码,从微服务 1 中剥离出来,独立为微服务 2。这样微服务 2 就可轻松应对高性能场景。
在业务发展到一定程度以后,你会发现微服务 3 的领域模型有了变化,聚合 d 会更适合放到微服务 1 的领域模型中。这时你就可以将聚合 d 的代码整体搬迁到微服务 1 中。如果你在设计时已经定义好了聚合之间的代码边界,这个过程不会太复杂,也不会花太多时间。
最后我们发现,在经历模型和架构演进后,微服务 1 已经从最初包含聚合 a、b、c,演进为包含聚合 b、c、d 的新领域模型和微服务了。你看,好的聚合和代码模型的边界设计,可以让你快速应对业务变化,轻松实现领域模型和微服务架构的演进。