聊一聊DDD中的值对象


    在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类后,我们获得了什么:

  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是非常有价值的,有助于领域知识的提炼,传承和表达,也有助于编写出更优雅的代码。

 

参考:

1. https://www.infoq.com/presentations/Value-Objects-Dan-Bergh-Johnsson/ 《Power User Of Value Object》 强烈推荐。
2.《Secure By Design》 第5章


免责声明!

本站转载的文章为个人学习借鉴使用,本站对版权不负任何法律责任。如果侵犯了您的隐私权益,请联系本站邮箱yoyou2525@163.com删除。



 
粤ICP备18138465号  © 2018-2025 CODEPRJ.COM