在DDD的战术设计中,值对象相对来说是一个比较简单的概念,相对于实体、聚合根、事件处理等战术工具来说,简单很多。但是使用好值对象却可以带来非常大的好处,对代码的可读性,内聚性,可测试性等方面都有很大帮助,个人觉得在DDD体系里值对象工具是一个学习投入产出比很高的工具。 这篇文章我们就来聊一聊值对象,以及引申出的Domain Primitive概念。
在查看ddd-sample-code(https://github.com/citerus/dddsample-core)时,会发现类似如下形式的一些值对象
这些值对象看起来什么也没做,只是简单地封装了一个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类后,我们获得了什么:
- 一个类。我们获得了一个PhoneNumber Class,在这个Class中我们可以收敛部分代码,比如自身的校验,获取区号等方法。关于手机号处理相关的代码都收敛到了这个Class中。
- 一种类型。 我们获得了一个PhoneNumber类型,代码库里多了一种类型,代码之间的交互不再是String类型,而是PhoneNumber类型了。PhoneNumber的概念突显出来了。
这样做之后可以给代码带来如下好处
-
接口更加清晰,代码可读性更高。有了PhoneNumber类型之后,我们接口的定义不再是String phone,而是PhoneNumber phone。 比如发送短信接口的两种定义方式
sendSms(String phone,String text)
sendSms(PhoneNumber phoneNumber,String text)
第一种形式我只能通过参数名才知道需要先传手机号再传信息,粗心的用户调用时可能会犯参数错位的错误,比如sendSms("myText","15088683360")这样的调用,代码编译是完全合法的。第二种方式接口通过类型强制了调用方先传手机号,语意更明确,也杜绝了调用参数错位的问题。
-
-
测试收敛。是由第2点带来的额外效果。如果每一层的接口定义的参数都是 String phone类型,则每一层的接口都需要测试不合法手机号的case。手机号校验收敛到PhoneNumber类之后,只需要针对PhoneNumber类做测试即可。
-
可以让业务代码和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类:
- 有格式要求的string,有范围要求的数值类型,有参数格式校验的字段。
-
可以关联一些行为的字段或字段集,比如手机号等。
-
领域中的核心概念。比如用户注册领域中的Phone,IdCard等,特意将这些概念建模成一个值对象,是为了概念的显性化,可以使代码之间的交互更加清晰。
研究到最后会发现,其实值对象和DomainPrimitive也只不过是OOA/OOP的基本要求,本质就是封装、内聚、数据和行为一起放到值对象中。而我们平时编码的时候,可能更关注业务逻辑的实现,直接用语言的基本类型+过程式的逻辑来完成大部分业务需求,没有认真去思考一下应该怎么设计一些对象出来,怎么把领域中的一些核心概念识别出来,显性化的表达出来,说白了是缺少OOA/OOP的一些基本素养,最终导致了面条式的代码。 通过有意识的构建一些值对象或领域原语,我们可以积累出这个领域对应的一套API,如果设计的合理,这一套领域API是非常有价值的,有助于领域知识的提炼,传承和表达,也有助于编写出更优雅的代码。
参考:
1. https://www.infoq.com/presentations/Value-Objects-Dan-Bergh-Johnsson/ 《Power User Of Value Object》 强烈推荐。
2.《Secure By Design》 第5章