04.值对象

207 阅读15分钟

值对象

1.实现领域驱动一书中的定义

通过对象属性值来识别的对象,它将多个相关属性组合为一个概念整体

2.介绍

是指对业务知识进行领域建模后,对某些业务概念仅进行描述的对象,值对象没有唯一标识

值对象其实我们一点都不陌生,语言中的基本类型,如数字、字符串、日期、时间等都是值对象的代表,略带有业务逻辑的通用类型,如人名、货币、电话号码、邮寄地址等也都是值对象

简单来说,值对象本质上就是一个集合。那这个集合里面有什么呢?若干个用于描述目的、具有整体概念和不可修改的属性。那这个集合存在的意义又是什么?在领域建模的过程中,值对象可以保证属性归类的清晰和概念的完整性,避免属性零碎

3.特点

  • 总是与另一个对象相关联,用于描述或度量另一个领域对象
  • 将不同的相关属性组合成一个概念集体
  • 可以被另一个值对象替换,不会影响业务的正确性
  • 没有任何状态,它本身其实就是不可变的
  • 不能独立持久化,必须依附于实体
  • 值对象之间通过属性值判断相等性
  • 无生命周期
  • 无ID

4.实践经验

  1. 将值对象建模为不可变对象:值对象一经创建,则值对象的属性不能修改,如果需要修改属性,必须重新生成值对象,并使用新的值对象整体替换旧的值对象。有时会直接将值对象的属性设置为final,通过构造方法实例化对象之后,其属性就无法更改,这当然是非常好的实践
  2. 妥协:有时候要进行妥协,比如对值对象进行序列化与反序列化,这就导致必须暴露set方法。这种情况,建议在研发团队内部形成开发规范,对值对象的使用方式进行约定,如不通过set修改属性,通过无副作用函数产生新的值对象,使用新的值对象替换旧的等

5.建模

在一个限界上下文内,是否关心某个对象在业务上的唯一性和连续性;如果关心,则将其建模为实体;如果不关心,则建模为值对象

6.案例理解(一)

以订单或者配送服务中对地址信息的建模

用户在下单时,订单,配送服务通常会保存地址的快照,订单或者运单并不关心地址信息是否有唯一标识,也不关心是用户下单时录入的,还是用户从地址簿里选择的,它只是订单(或者运单)的配送地址进行描述

订单的地址信息的生命周期与订单,运单等实体的生命周期相同。通常不会单独关注订单的地址信息,而是关注某个特定订单,运单的地址信息。这是因为地址信息描述的是对应聚合根的配运信息特征,只有在其订单的语境下才有意义

在订单,配送服务中,在设计数据库时,有时会将地址信息存储在单独的一张表中,称之为扩展表。此时虽然该扩展表的记录有主键,但是并没有业务上的唯一标识,因此不要错误地认为地址信息被建模成实体了

此外,一般是通过订单号从数据库查询某个订单的地址信息,脱离了订单的收货地址,业务上没有任何意义

7.案例理解(二)

人员实体原本包括:姓名、年龄、性别以及人员所在的省、市、县和街道等属性。这样显示地址相关的属性就很零碎了对不对?现在,我们可以将省、市、县和街道等属性拿出来构成一个地址属性集合,这个集合就是值对象了

7cf1d14ece9449c05589eda860b5665d.png

8.无副作用的值对象方法

介绍

值对象的属性要求不可变,值对象对外提供的方法不能修改值对象自身的属性。因此值对象对外提供的方法应当实现为无副作用的函数

对于值对象的方法,如果返回类型也是值对象,则应该创建新的值对象进行返回,而不是修改原有的值对象属性

案例

public class CustomInt {
    private int a;
    
    pubilc CustomInt (int a) {
        this.a = a;
    }
    
    public CustomInt add (int x) {
        return new CustomInt(a+x);
    }
}

add方法需要返回CustomInt类型的结果,不应该修改自身的属性并返回当前对象,而是通过创建新的值对象进行返回

JDK中的案例

JDK中常用的BigDecimal也有类似的实现

public class BigDecimalTest {
    public static void main(String[] args) {
        BigDecimal a = new BigDecimal("100");
        BigDecimal b = new BigDecimal("10");
        // 这个方法体现了无副作用方法
        BigDecimal e = a.add(b);
    }
}

9.创建

介绍

与实体的创建过程类似,对于简单的值对象,可以通过直接使用有入参的构造方法进行创建。对于复杂对象,也可以提供工厂或者建造者来创建

通过Factory创建

public class ValueObjectFactory {
    public ValueObject newInstance(prop1,prop2) {
        // 校验逻辑
        Objects.requireNonNull(prop1,"prop1不能为空");
        Objects.requireNonNull(prop2,"prop2不能为空");
		// 创建
		ValueObject valueObject = new ValueObject();
		valueObject.setProp1(prop1);
		valueObject.setProp2(prop2);
		return valueObject;
    }
}

通过Builder创建

就是一个典型Builder模式,代码这里省略了

10.Domain Primitive

介绍

Domain Primitive(DP)可以理解为领域内的基本数据类型。将某些隐藏的领域概念显示抽取出来建模成值对象,并在值对象中提供业务校验,则形成了DP

案例

以Money类为例,在没有抽象出该类之前,amount和currency可能是某个类的属性

public class Entity {
	/**
	 * 金额
	 */
    private BigDecimal amount;
    /**
     * 货币
     */
    private String currency;
}

金额和货币两个属性的联系非常紧密,需要一起配合才能完整表达金钱的概念。因此将金额和货币抽取出来,建模成值对象Money

同时,由于这两个属性必须同时具备才能表达业务含义,因此在创建Money时必须校验

public class Money {
	/**
	 * 金额
	 */
    private final BigDecimal amount;
    /**
     * 货币
     */
    private final String currency;
    
    public Money (BigDecimal amount,String currency) {
        // 业务校验,比如两个都不能是空,这里省略
        
        // 赋值
        this.amount = amount;
        this.currency = currency; 
    }
}

这样在实体里面引用Money值对象即可,这样在任意创建Money的地方,都会默认对amount和currency进行校验,不需要每个用例都校验一遍,避免了业务逻辑的泄露,代码会更清晰可读

11.详解无副作用函数

介绍

无副作用函数的概念在《领域驱动设计-软件核心复杂性应对之道》《实现领域驱动设计》《重构-改善既有代码的设计》等图书中均有提及

函数的副作用指的是函数除了其声明的作用,还在函数体内部进行了一些暗箱操作,主要是针对未声明的写操作,例如,修改某些全局项配置项,修改某些状态值等

这种未声明的副作用很容易导致系统出现无法预知的异常,引发线上事故。一般来说,某个特定的调用方,在某个特定的调用时间调用这种具有未声明副作用的函数,可以得到正确的结果。然而,一旦其他调用者调用了这类函数,或者在错误时机进行了调用,就很有可能得到错误的执行结果

函数产生副作用的问题在CQRS中也有体现,这里提一嘴,咱们在CQRS中详细说

无副作用函数就是除函数声明的作用外,不会引起其他隐藏变化的函数。调用某个函数时,不会修改入参,也不会修改内外部的状态。无副作用函数之所以在DDD中再次被提及,主要是因为无副作用函数的特性与值对象非常贴合,无副作用函数搭配值对象使用,能使得值对象更加强大

副作用的例子(一)

关于函数产生副作用的问题,举个例子:

public ArticleEntity findById(String articleId) {
    // 根据id加载实体
    ArticleEntity entity = repository.load(articleId);
    // 生成一个缓存key,用于统计某个实体被访问的次数
    String key = "article:pv"+articleId;
    cache.incr(key,1);
    return entity;
}

问题:findById方法本应只进行查询并返回文章实体,但却在执行过程中修改了文章的访问次数,因此该方法具有副作用

函数的副作用很容易导致难以排查的错误。以上面的代码为例子,一开始可能正常运行,在其他地方读取该文章访问次数的缓存时,也能返回正确的访问次数。然而,随着需求的迭代,某天有个定时任务不断地根据articleId调用findById方法查询实体,就会突然出现访问次数突增的问题。当然,在实际项目中统计某个页面的访问次数通常通过埋点和大数据进行实时处理,此处只是用来展示函数的副作用,并非实际生产环境中的解决方案

副作用的例子(二)

方法缓存自己的查询结果是无副作用的,例如:

public ArticleEntity findById2 (String articleId2) {
    String key = "article:"+articleId;
    ArticleEntity entity = cache.get(key);
    if(entity!=null) {
        return entity;
    }
    entity = repository.load(articleId);
    cache.set(key,entity);
    return entity;
}

findById2方法通过articleId查询文章。在查询时,先尝试从缓存中获取文章。如果能获取到,则直接返回从缓存中获取的文章;如果获取不到则通Repository的load方法加载并将其缓存起来。虽然这个方法中也操作了缓存,但是并没有对外造成影响,所以这个方法也是无副作用的

实现方式

纯函数实现无副作用函数

纯函数是指用于计算的所有输入均来自方法的入参,函数计算时不依赖非入参的数据,函数执行的结果只通过返回值传递到外部,不会修改入参的函数

举个例子:

public int sum (int x,int y) {
    return x+y;
}
非纯函数实现无副作用函数

非纯函数在执行的过程中依赖了函数外部的数据,如果希望非纯函数成为无副作用函数,那么非纯函数不应该修改函数外部的值

举个例子:

public class CustomInt {
    private int a;
    public CustomInt(int a) {
        this.a = a;
    }
    // 这个方法依赖了属性a,但是并没有修改a的值
    public CustomInt add (int x) {
        return new CustomInt (a+x);
    }
}

以上这个add方法在计算时不仅依赖入参x,还需要依赖属性a,因此add不是纯函数

虽然add方法以来了CustomInt的属性a,但是add方法并没有修改a的值,而是创建了新的CustomInt进行返回,因此add方法也是没有副作用的

12.形态

业务

值对象是DDD领域模型中的一个基础对象,它跟实体一样都来源于事件风暴所构建的领域模型,都包含了若干个属性,它与实体一起构成聚合

我们不妨对照实体,来看值对象的业务形态,这样更好理解。本质上,实体是看得到、摸得着的实实在在的业务对象,实体具有业务属性、业务行为和业务逻辑。而值对象只是若干个属性的集合,只有数据初始化操作和有限的不涉及修改数据的行为,基本不包含业务逻辑。值对象的属性集虽然在物理上独立出来了,但在逻辑上它仍然是实体属性的一部分,用于描述实体的特征

在值对象中也有部分共享的标准类型的值对象,它们有自己的限界上下文,有自己的持久化对象,可以建立共享的数据类微服务,比如数据字典

代码

值对象在代码中有这样两种形态。如果值对象是单一属性,则直接定义为实体类的属性;如果值对象是属性集合,则把它设计为Class类,Class将具有整体概念的多个属性归集到属性集合,这样的值对象没有ID,会被实体整体引用

我们看一下下面这段代码,person这个实体有若干个单一属性的值对象,比如id、name 等属性;同时它也包含多个属性的值对象,比如地址address

739ac57dd35c2291f90656c372fbcec8.png

运行

实体实例化后的DO对象的业务属性和业务行为非常丰富,但值对象实例化的对象则相对简单和乏味。除了值对象数据初始化和整体替换的行为外,其它业务行为就很少了

值对象嵌入到实体的话,有这样两种不同的数据格式,也可以说是两种方式,分别是属性嵌入的方式和序列化大对象的方式

引用单一属性的值对象或只有一条记录的多属性值对象的实体,可以采用属性嵌入的方式嵌入。引用一条或多条记录的多属性值对象的实体,可以采用序列化大对象的方式嵌入。比如,人员实体可以有多个通讯地址,多个地址序列化后可以嵌入人员的地址属性。值对象创建后就不允许修改了,只能用另外一个值对象来整体替换

案例1:以属性嵌入的方式形成的人员实体对象,地址值对象直接以属性值嵌入人员实体中

27b604bb861f6e7fcd9cea6540269371.png

案例2:以序列化大对象的方式形成的人员实体对象,地址值对象被序列化成大对象JSON串后,嵌入人员实体中

a475b2a392cf6b232b3975c5250fa558.png

数据库

DDD引入值对象是希望实现从数据建模为中心向领域建模为中心转变,减少数据库表的数量和表与表之间复杂的依赖关系,尽可能地简化数据库设计,提升数据库性能

如何理解用值对象来简化数据库设计呢?

传统的数据建模大多是根据数据库范式设计的,每一个数据库表对应一个实体,每一个实体的属性值用单独的一列来存储,一个实体主表会对应N个实体从表。而值对象在数据库持久化方面简化了设计,它的数据库设计大多采用非数据库范式,值对象的属性值和实体对象的属性值保存在同一个数据库实体表中

举个例子,还是基于上述人员和地址那个场景,实体和数据模型设计通常有两种解决方案:第一是把地址值对象的所有属性都放到人员实体表中,创建人员实体,创建人员数据表;第二是创建人员和地址两个实体,同时创建人员和地址两张表

第一个方案会破坏地址的业务涵义和概念完整性,第二个方案增加了不必要的实体和表,需要处理多个实体和表的关系,从而增加了数据库设计的复杂性

那到底应该怎样设计,才能让业务含义清楚,同时又不让数据库变得复杂呢?

我们可以综合这两个方案的优势,扬长避短。在领域建模时,我们可以把地址作为值对象,人员作为实体,这样就可以保留地址的业务涵义和概念完整性。而在数据建模时,我们可以将地址的属性值嵌入人员实体数据库表中,只创建人员数据库表。这样既可以兼顾业务含义和表达,又不增加数据库的复杂度

值对象就是通过这种方式,简化了数据库设计,总结一下就是:在领域建模时,我们可以将部分对象设计为值对象,保留对象的业务涵义,同时又减少了实体的数量;在数据建模时,我们可以将值对象嵌入实体,减少实体表的数量,简化数据库设计

另外,也有DDD专家认为,要想发挥对象的威力,就需要优先做领域建模,弱化数据库的作用,只把数据库作为一个保存数据的仓库即可。即使违反数据库设计原则,也不用大惊小怪,只要业务能够顺利运行,就没什么关系

13.优缺点

优点

值对象采用序列化大对象的方法简化了数据库设计,减少了实体表的数量,可以简单、清晰地表达业务概念。这种设计方式虽然降低了数据库设计的复杂度,但却无法满足基于值对象的快速查询,会导致搜索值对象属性值变得异常困难

缺点

值对象采用属性嵌入的方法提升了数据库的性能,但如果实体引用的值对象过多,则会导致实体堆积一堆缺乏概念完整性的属性,这样值对象就会失去业务涵义,操作起来也不方便

总结

所以,你可以对照着以上这些优劣势,结合你的业务场景,好好想一想了。那如果在你的业务场景中,值对象的这些劣势都可以避免掉,那就请放心大胆地使用值对象吧