戏说领域驱动设计(二十)——值对象

值对象这个东西在DDD里算是比较抽象的,好多人学了半天也学不明白。我这种聪明人也费了好大劲,总算苦心人天不负,现在也能用个有模有样了。战术模式中不论是领域服务、

  值对象这个东西在DDD里算是比较抽象的,好多人学了半天也学不明白。我这种聪明人也费了好大劲,总算苦心人天不负,现在也能用个有模有样了。战术模式中不论是领域服务、对象工厂还是资源库,基本上您能听懂是什么意思,在BO层中所承担的角色也比较明确,唯独这个值对象有点坑爹。遥想当年我在使用C#的时候,里面有一个值类型,与别人讨论的时候经常会把这个东西搞混,就我现在写东西还下意识把“值对象”写成“值类型”呢。《实现领域驱动设计》书中针对值对象给了大概8类特性概括,如下图所示。不过要我说,也就那么有限几点值得注意的。如果从编程的角度来看,所谓的值对象其实也很普通,所以让我们以白话的形式盘盘它。

一、特性

  以我来看,最难的地方就是值对象与实体类型在建模时候的决择。有些对象可以是实体,也可以是值对象,反正你怎么想都有理。很多初学者包括我自己也时常搞迷惑,之所以出现这种问题以我现在的经历来看有三个原因:一是在建模的时候偏离了领域驱动的约束而以数据库为参照,毕竟我们在上学的时候学习编程都是从数据库驱动开始的。数据库要求每一个表都应该有个主键,而有的时候值对象需要单独存在一个表中,这种情况就容易造成把值对象看成实体。其实开始设计的时候是想着围绕着领域来搞的,不过做着做着就跑偏了,这是由于潜意识造成的偏差。第二、很多程序员您别看一说理论的时候都能够口吐芬芳,但并没有真正搞明白到底值对象是干什么的,它的本质到底是什么,为什么DDD先驱要特意提出这样一个概念,没有理论基础在实践中走弯路很正常;最后一个原因是程序员在设计的时候目光过于片面,只关注于眼前的需求,有些模型从当前的需求点上去看的确是值对象,如果转而从大业务流程的角度去观察,从全局的角度去考虑,事情就会有变化,这涉及到工作方式的变更,我感觉没有解只能靠自己去悟了。对了,经常有人问上面的图是怎么画的:PPT,还凑合吧?

二、详解

  废话不多说,让我们先从值对象的特征搞起。首先先说这东西的主要作用是什么,您不要一看上图那一堆的圆圈就头晕,是我在公司内部讲解PPT时用的,主要以唬人为主。在这里就不能这么玩儿了,咱们得是小胡同里赶猪——直来直去。“构成某物——我认为这是值对象最为重要的特性。这么说吧,90%的情况下是由于这个原因才把一个模型设计为值对象的。既然是构成,他就会依附于所构成的目标对象。比如电商购物网站的订单,一般会包含价格信息、付款信息、收货信息,如下代码所示。

public class Order extends EntityModel<Long> {
    private string 编号;
    private string 收件人姓名;
    private string 收件人电话;
    private string 收件人所在区;
    private string 收件人所在街道;
    private string 收件人所在街道;
    private BigDecimal 总价;
    private BigDecimal 优惠价;
    private Integer 支付方式;
    private BigDecimal 支付金额;
}

  上面的代码把所有的属性都定义为原始类型,这样设计实体其实也没有什么问题,还挺直观的。不过有些面向对象专家就感觉非常的不爽:这种设计缺少抽象能力和面向对象精神,他看着别扭。而且呢,这样的设计会造成其所属对象比如这里的“Order”所承受的责任有点重,假如我想知道一个订单的待支付价格(待支付价是在总价与优惠价中选择一个小的),就需要把这个方法写到“Order”里,如果抽象出一个价格信息对象呢?就可以让它来负责处理本条业务,那“Order”对象的责任也自然就减轻了。我性子急,既然专家认为上述代码不理想,就让我们跟随面向对象专家的思路,把一些相对内聚的元素组合起来成为单独的类,比如价格信息、支付信息和客户信息等。

public class Order extends EntityModel<Long> {
    private string 编号;
    private Customer 客户信息;
    private Price 价格信息;
    private Payment 支付信息;
}

public class Customer {
    private Contact 联系人信息;
    private Address 地址信息;
}

public class Contact {
    private string 收件人姓名;
    private string 收件人电话;
}

public class Address {
    private string 收件人所在区;
    private string 收件人所在街道;
    private string 收件人所在街道;
}

public class Payment {
    private Integer 支付方式;
    private BigDecimal 支付金额;
}

public class Price {
    private BigDecimal 总价;
    private BigDecimal 优惠价;
    
    public BigDecimal getFinalPrice() {
        return min(总价,优惠价);
    }
}

  这段代码已经足够OO了,如果想再追求极致可以把“价格”类中两个属性的类型从“BigDecimal”变为一个自定义类型比如“Money”。让我们再看看“Order”的现状,里面的大部分的属性的类型已经从基本类型变成了自定义的类,实例化后当然就是对象了。DDD给这些对象一个新的名字:值对象。回过头来再看本文最上面的图,您会发现好多东西全是废话。“构成某物”,这还用解释?没封装成值对象前也是“Order”的构成要素,多封装一层后本质也没什么变化啊;“无标识符”,废话!原来也没有啊,都是依附于订单的,只要订单有ID就行了,后面你完全可以根据订单找到那些值对象;“概念整体”,有点脑子都知道啊?第二个版本的代码就是通过把一些内聚的元素封装为一个个值对象的,对象不是整体是什么?所以说学习值对象的时候你得去做一个像我这样的推导,才会发现别看说得热闹,也不过如此。

  所谓的“修饰某物”,就是说值对象通常用于描述他所在的实体或值对象,一般会作目标对象的属性而存在,并不会孤立的存于世上。比如上面的“价格”值对象,是用来修饰订单的,脱离订单谈价格没有意义。这个特性可让帮助您在设计时决策一个对象是否应该被作为值对象看待。综合“构成某物”特性,我们就会发现值对象在其生命周期中需要依附于某物且只属于某物,不存在被共用的情况。抛开订单谈论“支付信息”或“客户信息”我感觉就是在搞笑;一个客户可能会下多个订单,但每个订单都包含一个独立的客户对象,它不会跨订单共享。其实,通过这两种特性,已经可以帮助您在99%的情况下决策一个对象到底是实体还是值对象了,说“决策”都夸张了点,应该是可以轻易的判别了。不过文章写到这份肯定不行,不够深入啊。我们还是要着重解释值对象的“概念整体”特征,这个涉及值对象的本质,只有了解这些您才知道为什么在使用值对象的时候有许多的约束比如“不可变性”,也可以帮助你理解值对象的内涵并推导出值对象的其它特征。

  通过前面的代码案例可知我们通过把一些概念内聚的属性组合在一起形成了值对象。这说明了什么?既然是有意的合并在一起,你就应该视值对象为一个整体!整体的意思就是不可分割(如果还能分割,您当初所做的合并就等于白干,工作自相矛盾),比如您去菜市场买龙虾,你跟老板说只想买肉不买皮,你说他会不会砍你?有了“整体”概念或约束作为前提,你在值对象上的任何操作都必须以整体为导向,必须始终把它当成一个整体来看待。比如你想修改值对象的某个属性值,就整另外一个值对象把原来的替换掉而不是单独修改那个属性,这叫整体替换。

  为什么要这样?值对象也是一种领域模型,也要始终保持其内部业务规则的一致性(这个叫对象的不变性)。为了达到这个目的,我们需要对对象的属性做各种验证,需要在执行某个方法时判断此操作是否会打破规则限制,实体对象我们是这样做的,非常麻烦。而引入值对象的一个初衷之是为了简化对象的使用,反正我所有的方法都不会修改属性,根本不需要做任何判断。如果我放开修改限制,非常容易造成对象的变质。比如联系人信息,姓名“张三”+电话“123321”在我构建这个值对象时已经验证过是合法的。如果允许修改单个的属性,您把电话变成了“ABC”,造成了人是张三但电话是李四的,小心人家投诉你打骚扰。假如只有少量的实体和值对象,您并不会从此受多大的益。一个系统中数以百计的对象,我都不多说比如100个,其中90个是值对象,这个比例比较合理。由于值对象先天的不可变性,您写代码时不用那么多的约束判断,这得省多大事儿?这么说吧,值对象越多,你受益越大。

  我们其实也可以把值对象想成一个Java中的Long类型的数据,您总不可能在修改它的时候只修改高32或低32位吧?要不就不修改,要修改就全部修改。好了,有了这样一个概念作为前提,那么我们就可以推导出以下四点。

  • 在设计值对象的时候不应该有“setter”方法来支持部分属性值的修改,只能通过构造函数进行全属性赋值;
  • 判等的时候,应该是每个属性值都相等才能算两个值对象是相等的,你可以把值对象想象成由几个原始类型属性组成的,判等肯定要比较每一个属性;
  • 值对象可以包含丰富的业务方法包括命令型的,但业务方法不应该修改值对象的某个属性值;
  • 修改属性时只能通过整体替换。

  上述四点正好可以对应开篇图中所说的“不可变性”、“属性判断”、“无负作用”和“替换性”。

  写到此,我们做一下总结:“构成某物”和“修饰某物”两个性质帮助我们决策一个对象是实体还是值对象;“概念整体”决定了值对象在设计和操作时所要遵循的规范(参考上一段所论述的四点)。至于无标识符,这个没什么可谈的。值对象需要依附于某个实体而不能独立存在,所以你给他标识符也没个卵用,要追踪某个值对象只要通过其所属的实体。在总结了值对象的特性后,我们来说一下使用值对象到底有哪些好处。

  好处一:简单;由于您不能单独修改值对象的某个属性,也就不用加上那么多的约束和验证,反正只有查询操作怎么玩也不会坏的。验证的责任在实体对象构造的时候都处理过了,我们就不用再费二次的力气处理这些;好处二:更符合面向对象精神。通过把业务方法拆到值对象中能减少实体设计的复杂度并让代码看起来更加符合单一责任原则 。下面我写了两段代码,您看看哪个更好。

//订单状态
public class Status {
    WAITING_PAY, PAIED, COMPLETED;
    
    public boolean canPay() {
        return this == Status.WAITING_PAY;
    } 
}

public class OrderA extends EntityModel<Long> {
    private Status status;
    
    public boolean canPay() {
        return status == Status.WAITING_PAY;
    }
}

public class OrderB extends EntityModel<Long> {
    private Status status;
    
    public boolean canPay() {
        return status.canPay();
    }
}

  我把“订单状态”封装为一个实体对象“Status”,方法“canPay”在实现时:类“OrderA”中实现了具体的逻辑;类“OrderB”中由“Status”来代理。如果逻辑很简单或状态枚举很少,这两种写法没有区别。就怕订单状态枚举特别多的时候,由值对象自已完成相应的逻辑更优雅,复用度也会更高。这叫“知识专家”原则,即哪个对象拥有完成一个业务逻辑所需的知识就把责任放在哪个对象上面。

  关于值对象的修改,这里有一个小技巧分享。当需要修改某个值对象的时候,最好别让客户直接以“New”的方式来构建值对象再传入到方法里;相反,您可以将这个值对象所需要的属性以参数的形式传入到方法中,在方法内部包装成值对象后再将原值对象替换掉,这样可以隐藏值对象的创建过程,如下代码片段所示。

public class Order extends EntityModel<Long> {
    private PaymentDetail paymentDetail;
    
    public void pay(String payer, BigDecimal payment) {
        this.paymentDetail = new PaymentDetail(payer, Money.of(payment));
    }
}

final public class PaymentDetail extends ValueModel {
    private String payer;
    private Money payment;
}

   文章写至这份儿上您应该明白了为什么有人说在DDD落地代码时,大部分的对象都最好设计成值对象了吧?也明白为什么在设计对象的时候能使用值对象就不使用实体了吧?最起码的好处是代码更清晰、量更少、维护性更高。看起来显得专业,人人都夸你心灵手儿巧。

三、存储

  值对象的存储其实也没什么可讲的,主要是太简单了。无怪乎是两种:1)把值对象的值和实体放在同一张表中;2)把值对象放在单独的表中。方式一相对简单,就是把值对象内的各个属性映射成和实体表在一起的字段,如下图所示。

 

  方式二,把值对象放到单独一个表中,如下示例所示。需要注意的是示例中“审批环节”对象在存储到数据库后有了一个ID属性。这其实不影响我们的设计,莫慌。在领域模型中值对象遵守了其设计规范,没有标识;落到了数据库中那就是数据层面的事儿了,在关系数据库中每个表都有个ID这是数据库本身的约束。再说了,有就有了呗,您又不用。不过说到这块,我突然想起了一个特别典型的案例。下面的案例您应该能看出来审批单和审批环节的关联关系。我们以审批单ID为“A0001”的数据为例,在经过某些业务后,需要把审批环节表中ID为“N0001”行的状态从“1”变成“2”。这怎么搞?您在加载审批单对象的时候肯定要把两个审批环节信息一同加载,加载后审批环节是个值对象,它都没有ID的属性,更别提“N0001”了。此种情况要怎么解?把值对象变成实体?

  两种思路:一是在更新审批环节前,把数据库中存在的数据拿出来和待持久化的信息做对比,有变化的就是要变更的,自然就可以获取到ID属性了。这种方法有点扯,麻烦不说有时候甚至是不好靠谱,也就是说你根本比不了,比如上面的案例就不行。再假如在审批环节表中再加一个字段“meta”,里面存储了JSON格式的审批元数据,现在我把JSON中的某个节点的值改变了,你怎么比?字符串比较,别闹……JOSN格式啊大哥,两个节点的顺序不同,但节点名和值都相同,您说他是不是相等的?什么什么?排序后再比较?我……算了吧,我们还是说方式二吧,有图有真像。

  直接把旧值对象对应的数据删除,再插入新的。感觉世界一下子清净了,还费那个劲比较每个属性,太不专业了。当然,上面例子仅出于演示作用,其实并不严谨,你还是需要提供一些关键属性(比如审批环节+审批人ID)来帮助实体识别出待修改的值对象,这个关键属性可不一定非得是ID,也就是完全不需要使用实体来替换原来的设计。

  存储的时候,你的系统中如果有NoSQL数据库也可以考虑去用一用。我在几年前做过一个业务,当时设计了一组关系挺复杂的对象。持久化的时候使用了MySQL,其实当时也没得选,按理放到MongoDB中最佳。这没办法,你也不能和运维去哭诉啊,加个中间件运维就需要投入更多的人力。结果,存储特别费劲,其实如果只是花费点精力也还好,查询才坑爹。这个案例不太好讲,总之呢如果以列的方式存储对象属性,不知道有多少列合适,因为对象属性的数量是动态的,即使能做也会有好多的列为空值;如果按行存,查询支撑不了。这个事情后来也没办法,只能是把复杂的搜索需求取消掉了。所以举这个案例其实是想强调我们在存储实体的时候要灵活一点,别只认识MySQL,那东西也不是万能的。平常多积累一些知识,关键时刻只有你应用的漂亮、得体,妥妥职场上最靓的仔。大家都崇拜强者,尤其是姑娘们,努力吧兄弟!

四、案例

  为了能把知识讲透了,我这颗不安的心强烈要求我再多举一些例子,它的建议我接受了,咱们不管干货稀货索性就多整点。以后园子里评什么敬业奖的时候,感觉得有一份归我。其实也不是给我,是给我那颗敬业的心。Let's go……

  1、订单与订单项:最常见的案例,必须安排。订单项肯定是值对象,你想看买了什么东西就必须通过订单进行导航。脱离了订单的订单项就是一个没妈的孤儿,找不到存在的理由,可怜呐!

 

   2、账户与实名信息:太简单了,不解释。

 

   3、电商系统中的地址管理:引功能用于管理登录账号的不同送货地址。地址对象在订单中属于值对象;在本场景中是实体,不买东西您还不让我管理我的送货地址?又没吃你家大米!

 

  4、微博:必须是实体啊,要不然别人怎么引用?

 

 

  5、论坛中的贴子与评论:你猜呢?我觉得评论算是一种特殊的帖子,他关联了被评论的对象而贴子则不需要,本质上都是一种“被发布的内容”。另外,修改贴子的时候不需要把评论一同编辑了;修改评论也不会影响贴子的状态,两者是一种弱关联。表面上看删除了贴子其对应的评论也不复存在(此外应该用被隐藏更为合适),但帖子并没有被一并删除。我们在看评论的时候其实都是通过贴子导航过去的,这个案例中仅仅是由于贴子被删除而失去了导航点,并不代表贴子与评论具有相同的生命周期,所以我猜这两个都是实体。再说了,我还能针对评论再给出新的评论呢,你不把它当成实体合适吗?

  6、角色与权限:两实体呗。权限可以属于多个角色,角色也包含多个权限,多对多的关系。多对多的时候,两边肯定都是实体。下面的图只画了一个方向的多重关联,因为那是设计模型,设计模型中不应该存在多对多的情况。

 

总结

  这章字写得有点多,本来一个没什么可讲的东西我都能给它整出花样来,你能不服?不管怎么着,有了这些知识,我相信您应该知道何为值对象以及怎么使用了吧?等你写面向对象的代码多了,就会发现其实值对象真的在所有的对象中占了大多数,9:1都不夸张。再提醒一句:务必把值对象当成整体来看,从这个角度理解您会通透很多。

  写完今天的这篇已经二十章了,可累坏了。不过好处也是大大的,能把自已所学与别人分享,这个过程让人快乐与满足。让我们继承往前行……对了,屏幕前的你也要多多努力啊,去成全自己的梦想。

附:

  在走查本文的时候,发现了一个细节没有重点说明:值对象除了不能有标识符(ID)和不能修改属性外,其实和实体是一样的,是可以有业务方法的,您可千万别把值对象当成DTO来用,这也是一个充血模型呢。