原创 | DDD领域驱动设计第三话

本文详细阐述了值对象在领域驱动设计(DDD)中的重要性,强调了其作为不可变、不可追踪唯一标识的特性。值对象用于度量、描述和概念的整体,通常优于实体作为建模工具。文中提到了值对象的创建、替换、相等性和行为设计原则,并提供了示例代码。此外,讨论了如何根据业务场景判断是否应将领域概念设计为值对象,以及如何在实际开发中平衡值对象的使用。
摘要由CSDN通过智能技术生成

作者:潘吉祥


值对象(value object)

详解值对象之前,先来解释一下看过前两期推文的读者心中的疑惑,此类读者之前可能了解过DDD,他们对DDD有自己的理解,可能觉得笔者的文章趋向DDD中的战术设计,而没有战略架构,认为走向了DDD-lite的陷阱。

 

也有很多文章指出,DDD在实践中失败最大的罪魁祸首就是DDD-lite:整个DDD开发的过程中,没有“通用语言”的支撑,只是将DDD中的实体、值对象、领域事件、聚合等相关概念在技术层面进行设计。我不否认这个观点,因为确实如此。

 

DDD的最大的价值在于业务驱动开发思想,领域模型准确地反映业务语言,而这个价值是一种描绘中的期望和理想,以此纠正以数据为驱动的开发方式。然而目前的情况是,我们现在的开发方式不仅仅是以数据驱动这么糟糕,而且一定程度背离了面向对象的思维方式。controller编排service,service更新表数据,这实际是一种面向过程的开发,甚至只能称之为事务脚本而已。

 

就从这一层面来讲,DDD-lite并不失败,至少它纠正了我们一直以来错误的开发方式,并在一定程度上实现了DDD(好的领域对象的定义,即使没有经历通用语言的过程,它也表达了一部分的业务语言,因为开发者将领域对象的定义和业务紧密关联起来,而不是和数据库表关联),这已经提高了程序的可扩展性、可维护性和可测试性。

 

本系列文章将DDD的战略架构和战术设计分开描述,并且将战术设计放在了前面,我认为对于学习来说将两部分分开是有好处的。事实证明,试图将二者交替说明,往往会让读者误以为DDD-lite就是DDD。当然,这并不是作者本身的问题,这是作为开发者们看问题的本能思考的结果。

 

正文开始:

关于值对象的定义,第一话有其解释,这里不再赘述。

原则上来说,我们应该首先考虑使用值对象进行建模,而不是实体。即使必须是一个实体,那么实体的属性也应该由值对象来实现,而不是子实体。因为值对象的特点方便我们进行测试、维护和使用。我们可以创建并传递值对象,并在不需要的时候丢弃他。

 

实际开发中,当我们特别关注一个对象的属性,而较少关注它的行为或其他存在的时候,该对象就可以设计为一个值对象。记住,值对象不可变(这里的不可变概念请参考string类型),并且没有唯一标识,且尽量简单构建和存在。

 

尽管以上已经对值对象进行了不少的说明,但是在实际开发中能够我们还是会犹豫一个领域概念应该设计成实体还是值对象。下面给出一些判断依据:(通用语言要始终贯穿值对象的建模)


特征

1度量或描述

如果某个领域概念这只是一种度量或者描述,那么他应该设计为一个值对象。比如age、name、status等等,现实世界中并不客观(通俗来说就是看不见、摸不着、闻不到)地存在这些东西,他们只是作为一种主观概念存在,更好地帮助我们认识和理解世界。

 

2不可变

一个值对象在创建出来之后就不能改变了,我们需要确保它的稳定性(引用不能改变,但值可以替换)。

示例:

public final class Username implements Serializable {

    private final String name;

    public Username(Stringname) {
        this.setName(name);
    }

    @Override
    public String toString(){
        return "name='"+ name ;
    }
}

首先该类应该设计为final,属性私有化(类的属性都应该设计为私有的,否则就失去了对该属性的完全控制)。属性的改变只能有构造方法进行初始化,或者定义私有化的set方法,该方法只能被该对象的构造方法进行调用。如果确实需要改变他的值,也不应该定义修改方法,而是考虑值替换,下文将会提到。

(我们尽量避免在值对象中引用实体,因为实体的可变性会破坏值对象的原则,即使遇到必须引用的情况,出发点也应该是正确的。)

 

3概念(意义)整体

一个设计的值对象,既可以处理单个概念,也可以处理多个相关联的概念。然而一旦处理多个相关连的属性,那个这多个属性之间的组合应该是缺一不可的,缺少其中任何一个概念都将不能构成这个值对象的领域概念。(领域驱动的思想来看,一些没有关联的属性被设计在一个值对象中是毫无意义的。)

 

举个例子,司机拉货的时候,会计算拉货的总量,通常我们是这样设计的,goods_num(货物数量),goods_unit(货物计量单位(KG或者吨)),此时我们可以使用一个值对象total(总量)将这两个概念整合起来。

public final class Total implements Serializable {
    private final Integer goodsNum;

    private final String goodsUnit;

    public Total (IntegergoodsNum, String goodsUnit) {
        this.goodsNum = goodsNum;
        this.goodsUnit = goodsUnit;
    }

    @Override
    public String toString(){
        return "total{"+goodsNum +goodsUnit + '}';
    }
}
Total total = new Total(5, "吨"); =》Total{5吨}

我们可以认识到,单独的goodsNum和goodsUnit都无法成为一个实际上有意义的概念(这里的意义指的是单独的两个属性为无法计算出此次货物的价格)。因此,我们应该将它设计为一个整体。当然了,上面所示的案例并不完美,因为我们还可以用一个值对象来表示goodsUnit,并将它设计为一个枚举类型。

 

如果你想得再深一点,这里的total依然是一个比较分散的值对象,因为不同重量的货物类型,它最终的价格依然是不同的。因此我们产生这样一个新的建模:

public class GoodsOfTotal implements Serializable {
    private final String goodsName;

    private Total total;

    public GoodsOfTotal(StringgoodsName, Total total) {
        this.goodsName = goodsName;
        this.total = total;
    }

    @Override
    public String toString(){
        return "GoodsOfTotal{"+
                "goodsName='"+ goodsName + '\'' +
                ",total=" + total +
                '}';
    }
}

比如说这个司机拉了5吨的啤酒。这个概念是完整的,表示5吨的xxx货物。同样我建议将goodsName同样设计为一个值类型。这里只是在表述一种建模思想。这也不是说total这个值类型是不完整的,因为我说过,即使在total中我们同样可以将两个属性分别定义为值对象。

事实上我们也应该将goodsNum和goodsUnit设计到total中去,而不是直接在GoodsOfTotal填充3个值类型。这表现了一种层层剥离的建模思想,有助于我们更好地理解我们所建模的领域。这样total可以实现任意上下文的组合,同时,也只有在确定了上下文的情况下,GoodsOfTotal这个值对象才能真正被确定实际意义。

 

4可替换性

如果一个实体中的值对象能够一直反映该实体的状态,那么可以一直维持这样的引用。否则我们就需要进行值对象的替换。记得,我们这里说的是可替换,并没有说这个值对象可修改,这是两种不同的概念。

 

假设我们系统中有一个user的实体,里面有一个username的值对象,该值对象的初始值是“拉姆”,user允许修改属性,客户需要替换为“蕾姆”。

这里是正确的替换方式

Username username = new Username("拉姆");
username = new Username("蕾姆");

我们直接将属性赋予了一个新对象的引用,而不是使用username.setName,当然了,这其实也做不到,因为我们在建模username的时候,已经私有化了他的set方法,来保证他不可变,而且我们也没有提供其他任何的行为方法来让外部去修改初始化后的username值对象。

 

从这里我们看出值对象的特性之间是相互关联的,同时解答了不可变特性中最后的说明—如果我们的值对象需要被改变,那么我们应该使用值替换,而不是定义行为方法。值替换的意义在于它既满足了被引用实体的状态变化需求,同时满足了值对象初始化不可变的原则(最初的值对象“拉姆”由于失去了引用,将会在不久后被垃圾回收期回收,从始至终我们并没有修改过这个对象本身)。

 

虽然达到了修改实体属性的目的,但是这种方式本身的表达性不够强,在DDD中你始终要关注一个重点:表达性。下面我们将介绍更好的替换方法。

 

5相等性

一个系统中存在很多相等的值对象,但是这并不意味着引用它们的实体本身也相同。

通常,如果两个对象的类型和属性都一样,我们认为他们相等。

那么当我们定义一个值对象之后需要定义对应的equals方法,首先需要判断两个值对象的class是否相等,然后还需要判断两个值对象的属性是否相同:

@Override
public boolean equals(Object o) {
    if (o == null || getClass() !=o.getClass()) return false;
    Username username = (Username) o;
    return this.name.equals(username.getName());
}

如果两个值对象相等,我们可以在聚合中使用唯一标识的值对象进行查询。假如username作为唯一标识,我们可以创建一个和已存在的实体相同的username值对象,从而进行查询。

6行为无副作用

无副作用的意思是该行为对于对象的操作,只会产出一个结果,并不会修改对象,造成对象的不一致。常见的就是一些数学函数,只要你输入固定的值,它就会产生固定的结果,结果是一个新的产物,而不是对输入的修改。

 

Java8中的lambda在设计上也按照这样的规范进行了设计,即函数式编程。了解lambda的同学应该知道,在lambda中不允许修改外部的一个变量(当然我们可以通过一些形式的改变达到修改的目的,但这并不是函数表达式所建议的),因为可能会造成函数输出结果的不确定性,这会不利于函数并行化改造。

 

对于值对象而言,虽然我们尽量避免在其中定义行为,但在现实开发中这难免出现这样的情况,此时我们所定义的行为(方法)必须要满足无副作用的要求。下面是一个示例

Username username = new Username("拉姆");
username = username.changeName("蕾姆");

这样的效果和上面的可替换的例子是一样的(username = new Username("蕾姆")),但是这比上面的更具表达性,不会有歧义产生(注意清晰、严谨的表达对于DDD设计来说是十分重要的,这意味着相关的领域专家即使不会编码,但是看到模型相关定义就能够获取相关领域的足够的信息)。

实现如下:

public UsernamechangeName(String name) throws ValidationException {
    if(null == name || "".equals(name)) {
        throw new ValidationException("name不能为空");
    }
    return new Username(name);
}

在这个方法中,我们并没有修改值对象的状态,只是创新了新的值对象,并替换引用。

我们在值对象中定义的方法都应该满足无副作用的原则。

 

到这里,我们已经了解了值对象的特征,凡是满足以上特征或者应该满足以上特征的我们就要考虑将它设计为一个值对象。

 

现在,你可能又会认为绝大多数对象都应该设计为一个值对象,而不是实体,理论上来讲确实是这样的。抛开DDD,我们在开发中所遇到的领域概念绝大多数都是一些概念化的东西,并不是客观存在的事物,人,订单,商店,这些实实在在的存在是极其有限的(你可能会说世界上有多少万种植物,多少种动物,多少种元素,等等,把这位读者拉出去,乱棍打S),更多时候,我们是将专注点放在他们具体的属性描述和度量上,业务同样如此。

 

这里又要提醒读者,凡事讲究中庸之道。值对象的设计同样没必要过于苛刻,将项目中所有的属性都用一个单独的值对象去建模,如果有时候语言本身提供的值对象完全能够满足简单的需求,就完全没有必要去重新构建。

 

同样地,以上所说的都是一些应该遵循的原则,这意味着不是强制性的。就像做炒米饭,菜谱建议你用隔夜米饭去做,那样口感和色型会更好,如果你用现做的米饭去炒同样可以,没有人说这样做出来的炒饭会让人中毒身亡。DDD的设计理念同样如此,它建议你遵循一套方法规范,使得你的项目更加清晰、可维护和可测试,而不是成为一个没人愿意接手,没有人敢于维护的“大泥球”。

这意味着假如你将项目按照DDD的指导实施的时候,尽量遵循它所抽象出来的一些普遍性的原则,即使有时你由于特殊情况不得不违背这些原则,那么你也应该尽量向着这些原则目标去思考和靠拢。


【推荐阅读】
一款 Java 开源的 Spring Boot 即时通讯 IM 聊天系统

个税年度汇算开始!有人退了2w元,一文看懂如何操作

图解 Spring 解决循环依赖

字符串拼接还在用StringBuilder?试试StringJoiner吧,真香!

Spring中毒太深,离开Spring我居然连最基本的接口都不会写了

天猫二面:内存耗尽后Redis会发生什么?

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

码农code之路

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值