值对象的威力

值对象是DDD中非常重要的一种技术,掌握这种技术让你写代码事半功倍,体会到OO的精妙。如果你是一名Java程序员,我相信你或多或少地见过值对象了,只是你没有意识到而已。

引用维基百科的解释

In computer science, a value object is a small object that represents a simple entity whose equality is not based on identity.

字面意思就是,值对象是一个小对象,它代表着一个简单的实体,而实体的相等性不取决于它的ID。

刚刚接触OO编程的新手看完上面的解释相信直接是懵逼的,跟我接触这一概念时一样。如何理解值对象了,我还是举一个栗子。比如我们在做一个短信推送的服务,需要根据目标用户的手机号推送到相应的短信网关。我们定义了一个根据手机号推送短信的interface,很有可能我们是这么设计:

void sendMessage(String phone, String message) {
    if(StringUtils.isBlank(phone) && phone.length()!=13) {
        throw new IllegalArgumentException("phone format error:"+phone);
    }
    if(phone.starts("134")) {
        sendMessageToChinaMobileGateway(phone,message);
    }else if(phone.starts("130"){
        sendMessageToChinaUnionGateway(phone,message);
    }else if(phone.starts("189") {
        sendMessageToChinaTelecomGateway(phone,message);
    }else {
        throw new RuntimeException("unknown phone range");
    }
}

上面的过程我们只考虑3个号码段,134(移动)\130(联通)\189(电信),其他的号码短我们暂不处理。上面的处理方式有什么问题?

如果我们的工程里面只有一个地方用的phone的概念,也只有一个地方对phone所属的号码短进行判断,那么没问题。上面的写法没有任何问题,因为它是一个简单问题。但是如果你在做一个短信推送的应用,在你的工程里面会只有一个地方会使用phone这个概念吗,也之有一个地方需要判断号码短吗? 显然不可能。

有人可能会争论说,不就是判断号码归属吗?我可以搞一个类似PhoneQueryService之类的查询类,再提供一个 Operator queryBelong(String phone)的interface不就搞定了吗? 当然,这么做也没有问题。但是当你的问题域逐渐变得复杂的时候,你就会开始有些不舒服了。因为每一个出现phone的地方,你发现基本上都会需要PhoneQueryService,但是他们在代码上又是两个东西。这种做饭的滥用最终会导致Fat Service的出现,代码的复用性会急剧降低。

究其原因,是因为我们把phone这个概念和phone的行为给拆开了。你可以用String代表任何字符类型,可以是phone,也可以是name,基本上这种类型可以代表任何东西。使用你API的人无法从中得到任何信息,除了你把变量名称叫做phone以外。同时,判断手机号网段这个动作是和phone本身强相关的,为什么不把这个动作加到phone里面了?! 现在,我们重构一下代码,得到类似下面的代码结构:

class Phone {
   private String phoneNumber;
   public Phone(String phoneNumber) {
      if(!validate(phoneNumber)) {
        throw new IllegalArgumentException("phone format error:"+phone);
      }
      this.phoneNumber = phoneNumber;
   }

   public static boolean validate(String phoneNmber) {
      //验证逻辑
   }

   public boolean isMobile() {
        return phoneNumber.starts("134");
   }

   public boolean isUnion() {
         return phoneNumber.starts("130");
    }

    public boolean isTelecom() {
        return phone.starts("189");
    }

    public String getRawPhone() {
        return this.phoneNumber;
    }

    public boolean isSameWith(Phone other) {
        return other!=null&&this.phoneNumber.equals(other.getRawPhone());
    }
}

我们新增了一个叫Phone的类,并加入了判断网段归属的逻辑。引入这个类以后sendMessage()发生了什么变化呢?

void sendMessage(Phone phone, String message) {
    checkNotNull(phone);

    if(phone.isMobile()) {
        sendMessageToChinaMobileGateway(phone,message);
    }else if(phone.isUnion()){
        sendMessageToChinaUnionGateway(phone,message);
    }else if(phone.isChinaTelecom()) {
        sendMessageToChinaTelecomGateway(phone,message);
    }
}

咋一看,代码好像没有怎么减少啊。对于这个interface来说代码确实没有减少,反而我们还新加一个类。但是现在看看我们获得了什么:

  • 首先,方法签名变了。不在用String了,取而代之的是Phone类型。这对使用者的约束更强了,我们也再也不用判断phone是否合法了。
  • 其次,判断网段归属和phone合在一起了,这样我需要判断归属运营商的时候直接调用phone的方法就行了。

现在,我们已经得到了一个值对象了,那就是Phone。它是一个小对象,代表了手机号这个概念,它的相等性是基于其业务属性的,而不是ID,而且值对象根本就没有ID这个概念。

值对象最大的好处在于增加了代码复用,同时它也是类型安全的(这一点和我之前提到了enum类似)。如果你只在一个地方使用值对象,那么你是不会体会到值对象带来的好处的。但是,每当你的代码应用一次值对象,你就会收获值对象带来的好处。用的越多,收益越大,这一点和单元测试比较类似。使用值对象的另外一个好处就是前置的安全校验,尤其是你在编写SDK或者开放接口的时候。因为你无法知道使用者会如何使用你的API,那么通过值对象来获得一个前置的安全校验有着非常大的好处。

值对象用在什么地方呢? 我个人的经验就是,如果在你的工程中反复出现一个具体的概念(往往跟现实生活有关),而且这个概念中涉及的行为是某种确定性的(比如你知道了手机号,就知道对应的运营商一样),那么你可以考虑一下值对象。引用《实现领域驱动设计》中关于值对象特征的定义:

  • 描述了领域中的一件东西
  • 不可变的
  • 将不同的相关属性组合成了一个概念整体
  • 当度量和描述改变时,可以用另外一个值对象予以替换
  • 可以和其他值对象进行相等性比较
  • 不会对协作对象造成副作用

最为重要的就是它描述了领域中的某件东西,并且它是不可变的。值对象一旦创建就不会发生变化,如果你需要表示另外一个东西,用另外一个值对象来代替它。

值对象是DDD中非常重要的部分,我们应该尽可能对使用值对象来建模,因为它是易于使用和替换的。但是值对象的实例化确实一个令人头疼的问题,尤其是聚合中存在1对多的关系时。由于这些内容涉及到DDD的多方面的知识,我不在这里展开讨论了。后续会专门讲值对象的持久化问题。之所以在讲DDD之前首先讲值对象,因为它还是少数几个可以完全脱离DDD并不失其威力的利器。就算你完全不了解DDD,也可以非常顺手的使用值对象。

说了这么多,我相信你也对值对象有个具象的认识了。纸上得来终觉浅,不如看看你现有的代码中哪些可以用值对象来代替吧!

参考文献:

Wikipedia值对象的定义

Martin Fowler值对象的解释

实现领域驱动设计

Power Use of Value Objects in DDD: 强烈推荐

本文来自网易实践者社区,经作者蒋文康授权发布。