写点什么

领域驱动设计 101 - 值对象

用户头像
luojiahu
关注
发布于: 2021 年 05 月 29 日
领域驱动设计101 - 值对象

1 概念

与实体相比,值对象(Value Object)最大的特征与区别在于,实体表达了一个有生命周期的、有唯一标识的、处于变化中的事物;而值对象是没有生命周期的无所谓是否具有唯一标识(可能在某些场景下需要唯一标识,但这并不是他的主要属性,可能是为了持久化等其他目的),最重要的是,值对象往往是用来描述或者度量领域中的某个东西的。


这里仍旧以地址为举例,地址往往用来描述另一个事物所在的地方,例如某人的通讯地址,某公司的办公地址,某活动的举办地址等。如果一个人搬家了,那么他将具备一个新的通讯地址,这个新的地址并不由原先的地址经过某种操作而来,而是全新的。

2 特征

2.1 度量或描述

如其定义描述,值对象最根本的特征在于,其主要用来描述或者度量其他事物。如上文提到的地址;又如价格,价格除了金额可能还有货币种类的属性,例如:300 人民币、50 美元、20 港币等等,这些价格可以用来描述超市里的商品,也可以用来描述快递服务,还可以用来描述股票等投资对象等等。

2.2 不变性

值对象另一个重要的特征是其不变性,即一个值对象一旦被创建,便不能再改变了。


这主要由两方面来决定。一方面,值对象常用来度量或者描述事物,如果本身发生改变,就代表了不同的度量或者描述。另一方面,由于值对象本身并不需要唯一标识,也就没有进行状态变化的需要,需要具有不同属性的值对象时,可以通过很低的成本便可以创建出来。


在实现时,值对象的不变性可以通过控制设置属性的函数(如 setter)的作用域来实现,例如将这些函数设置为protectedprivate

2.3 概念整体

与值对象的用途“描述或者度量”相关,值对象的另一个特征是,值对象是作为一个概念整体出现,值对象本身可能是若干属性的集合,也可能仅具有一个属性,但不论如何都从整体上表达了一种概念。


例如,价格包括数值和货币种类,500 美元代表价格,而 500 并不能代表价格,很可能代表任何其他事物的属性,例如两地之间的距离。


再例如,手机号码可能仅由一串数字构成,但是其本身表达了一种特殊的概念,同时也隐藏着某些规则,如号码的长度、各个部分的构成规则等等。

2.4 可替换性

如前面所述,值对象具有不变性和作为概念整体的特征,因此当值对象描述的内容发生变化时,并不会修改值对象的属性,而是整体替换为新的对象。例如,假设某商品的价格发生变化:


Price currentPrice = new Price(300, "USD");...// 价格发生变化, 整体替换为新的对象,而不是修改某个属性currentPrice = new Price(312, "USD");
复制代码

2.5 相等性

如前面描述的,值对象在系统中通常被当做一个整体来创建、替换,系统中可能存在大量值对象实体,可能存在两个相等的值对象实体,但是他们是不同的对象引用。相等性是指,如果两个值对象的类型和属性完全相同,那么这两个实体是相等的。例如,为了判断Price的相等性,需要重写equals方法:


@Overridepublic boolean equals(Object obj) {    if (null == obj || obj.getClass() != this.getClass()) {        return false;    }
Price other = (Price) obj; if (0 == other.getNum().compareTo(this.getNum()) && other.getMoney().equals(this.getMoney())) { return true; }
return false;}
复制代码

2.6 无副作用

无副作用是指,值对象的函数都不应该改变值对象本身的状态,这是由值对象的不变性决定的。例如,假设Price类有进行货币兑换的函数,则应该有:


/** * 兑换为另一种货币. * @param targetMoney * @return */public Price exchangeTo(String targetMoney) {    if (null == targetMoney || "".equals(targetMoney)) {        throw new IllegalArgumentException("targetMoney should not be null or empty.");    }        if (this.getMoney() == targetMoney) {        return this;    }
// 获取兑换汇率 BigDecimal rate = lookUpRate(targetMoney, this.getMoney()); BigDecimal targetNum = rate.multiply(this.getNum()); return new Price(targetNum, targetMoney);}
复制代码


上面的函数在实际实践过程中,也可以通过独立的领域服务实现。

3. 利用值对象优化设计

3.1 表示标准类型

在进行系统设计时,通常都会发现一些标准类型,他们被用来描述其他建模对象。例如,在外汇相关系统中,有货币这一标准类型,用于表示不同的货币种类,例如USD,JPY,EUR等等。在账户相关系统中,有账户类型这一标准类型,如一类、二类、三类账户等等。在内容管理相关系统中,有内容状态这一标准类型,如草稿、已提交、审核中、已发布、驳回等等。


这些标准类型符合上述值对象描述的特性,可以用值对象进行建模。由于标准类型的有限性以及相对稳定性,适合采用 Java 的枚举来实现。例如,文章的状态:


public enum ArticleStatus {    /** 草稿 */    DRAFT(1) {        public boolean isDraft() {            return true;        }    },    /** 提交待审核 */    COMMITTED(2) {        public boolean isCommitted() {            return true;        }    },    /** 审核通过,未发布 */    APPROVED(3) {        public boolean isApproved() {            return true;        }    },    /** 审核拒绝*/    REJECTED(4) {        public boolean isRejected() {            return true;        }    },    /** 发布 */    PUBLISHED(5) {        public boolean isPublished() {            return true;        }    },
/** 下架 */ CANCELLED(6) { public boolean isCancelled() { return true; } };
private int value;
ArticleStatus(int value) { this.value = value; }
public boolean isCommitted() { return false; }
public boolean isDraft() { return false; }
public boolean isApproved() { return false; }
public boolean isRejected() { return false; }
public boolean isPublished() { return false; }
public boolean isCancelled() { return false; }
/** * 状态变化 * @param to * @return */ public boolean canChangeTo(ArticleStatus to) { switch (this) { case DRAFT: return to.isCommitted(); case COMMITTED: return to.isApproved() || to.isPublished() || to.isRejected(); case APPROVED: return to.isPublished(); case PUBLISHED: return to.isCancelled(); case REJECTED: return to.isDraft(); case CANCELLED: default: return false; }
}
public int getValue() { return value; }}
复制代码


上面的类似乎不够简练,但是通过枚举,不仅以比较高的可读性限定了状态的类型范围(而不是数字 1,2,3...),同时还提供了对状态变迁的判断逻辑,这样做到了文章状态变迁规则这一逻辑的内聚,同时为其他方法调用提供了边界的入口。例如,原先的文章状态修改方法:


/** * 改变当前文章的状态. * @param to * @return */public Boolean changeStatusTo(ArticleStatus to) {    if (status == ArticleStatus.DRAFT && to != ArticleStatus.COMMITTED) {        // draft can only change to committed.        return false;    }
if (status == ArticleStatus.COMMITTED && to != ArticleStatus.APPROVED && to != ArticleStatus.REJECTED && to != ArticleStatus.PUBLISHED) { // committed can only change to approved and committed and published. return false; }
/** other logic ommitted. */
status = to; return true;}
复制代码


注意,上面的方法逻辑未实现完全,但是已经表现出比较差的可读性和可维护性。而在对ArticleStatus重新修改后,该方法变得非常清晰:


/** * 改变当前文章的状态. * @param to * @return */public Boolean changeStatusTo(ArticleStatus to) {    boolean canChange = status.canChangeTo(to);    if (canChange) {        status = to;    }    return canChange;}
复制代码

3.2 上下文间传递

在不同上下文之间进行集成时,通常并不需要将当前上下文的实体信息传递给其他上下文。一方面,上游或下游感兴趣的通常并不是全部的属性内容,仅需要部分信息就可以实现上下文之间的协作;另一方面,应该控制当前上下文的信息不泄漏到其他上下文中去,避免过度耦合。如果发现两个上下文之间必须共享实体,这时可能需要重新需要审视上下文的划分了。


这种场景下,采用值对象进行数据传递最为合适。不仅可以降低上下文之间的耦合程度,还降低了维护上下文之间保持一致性的成本。

4. 持久化

4.1 从领域模型出发,而不是数据模型

必须承认数据是一个系统的重要资产,数据模型也需要根据业务领域和采用的存储类型来进行仔细设计。但是,需要强调的是,在构建系统时,应该牢记是从领域模型出发而不是数据模型出发。从数据模型出发,容易陷入数据本身和存储类型所规定的局限,而丢失了对业务领域的真实反映。


例如,我曾经参与开发一个内容管理系统,包括内容发布端、内容审核端以及面向用户提供内容服务的 API 端。其中,最核心的表是文章表,基于这张表我们提供在内容发布、审核端使用的文章列表接口,这个接口要求按照不同的条件进行筛选,并且要求排序、分页;同时提供面向用户的文章列表 API,如某作者的文章列表,某类别的文章列表等等,这些 API 也需要按照不同的条件筛选,如文章状态(只有发布状态的文章对用户可见)等。经过一段时间的发展,文章表变得非常宽,维护变得越来越困难,同时各个接口的逻辑也变得非常难编写,甚至开始出现一些性能问题。


这里的主要问题就在于,从一开始构建系统时,并没有采用领域驱动设计的方法从业务领域而出发,而是从设定的数据模型出发,所有关于文章相关的功能,都是首先围绕着文章表进行设计。例如,对于文章状态的改变,添加一个状态字段,后面对于发布状态的筛选,也是转化为 SQL 语句中的条件。


从领域的角度出发进行分析,考察文章状态,首先发现:对于面向用户提供的 API 服务,仅关注发布状态的文章,此时文章的部分属性与其他状态也不同,例如出现了阅读量等统计数据。很明显,虽然都是文章,这是属于两个上下文的,发布审核上下文需要处理文章状态的流转,而不关注文章的阅读数据统计,而文章服务上下文则不关注文章的状态。


由此,最终对文章表,我们进行了拆分,拆分为文章表和文章发布表,分别服务于不同的业务领域。


上面的讨论,虽然并未采用值对象作为例子,但同样适用于值对象。总的来说,领域驱动设计倡导,首次从领域模型出发进行系统设计,而不是从数据模出发。

4.2 与实体一同持久化

如前所述,值对象通常是作为描述领域中其他事物而被建模出来的,因此通常关联于某些实体。那么,在持久化时,便可以采用与实体一同持久化的方式进行。这主要分为两种情况:作为单独的一列和多列。


作为单独的一列:对于那些简单的仅具有单一属性的领域值对象,这没有什么问题,但是通常值对象包含多个属性(例如,价格),这是就需要衡量是否适合将所有属性都存储在一列中了,主要需要考虑的方面有


  • 是否满足列宽限制:关系型数据库如 MySQL InnoDB,通常都有对于列宽的限制,如果值对象在序列化后有可能超过此限制,则不应该采用。

  • 是否能够应对查询:值对象作为实体的描述性属性,很可能被用作查询条件,而将其存储在一列时,很可能不能采用 SQL 语句实现查询功能。

  • 序列化:因为需要将多个属性的值对象压缩至一列进行存储,必须采用某种方式进行序列化。当然,一般来说,实现序列化并不是什么难题。


作为多列:作为多列进行存储时,不需要面对上面提到的问题,但是需要时刻保持清晰的认识:值对象和实体属于不同的领域模型。

4.3 单独持久化

某些时候,值对象可能需要作为一个单独的领域对象进行持久化。如,某些系统可能对值对象提供维护的功能,虽然不是被高频使用的功能,但是提供值对象的维护入口使得系统在应对需求变化等情况时更加灵活。


此时,需要特别留意的是,可能值对象会与其他相关的实体一同冗余存储,如果值对象由于维护而产生了变化,需要注意在冗余存储的地方也要保持数据一致。

发布于: 2021 年 05 月 29 日阅读数: 14
用户头像

luojiahu

关注

喜欢思考组织、过程、产品的后端开发 2017.01.08 加入

还未添加个人简介

评论

发布
暂无评论
领域驱动设计101 - 值对象