查看原文
其他

基础问题不简单 | 怎么合理使用值对象,让你的代码更清晰、更安全?

于振 ITPUB 2023-05-11


 技术专家 

于振

现于某大型互联网公司,负责架构工作

曾就职于美团、快手等一线互联网公司


写在前面:


你好,今天我想与你聊聊如何在Go语言中落地DDD。这部分内容,预计三分钟左右可看完。就此,大体上了解下就行。


这里面,其实主要说了我是基于什么问题背景和思考,才作此分享的,你可以更有针对性地结合具体的业务问题来思考


尤其是实践过程中的业务难点等,后面我将带着大家,最终交付一套Go语言层面切实可行的 比较完整且具有实际指导意义的方法论,助力解决实际业务中的开发问题。


文部分,我打算分成9讲与大家共同探讨,并于后续几周陆续发布,预计每周可以看1~2两篇。今天发布的是前言,以及第一讲(其开头也有放前言部分,若看了这部分,可直接跳过下拉至正文部分看)。


,就先提这些。言归正传,接着聊。


DDD中文又叫领域驱动设计,是我们解决复杂业务问题时非常有效的一个手段,但其本身过于陡峭的学习曲线,也让很多初学者知难而退。


网上虽然充斥着很多关于DDD的学习资料,但大多只是偏向于基本概念的介绍,缺少一个完整的落地实践。


笔者所在的技术团队也曾做过一些战略设计,通过这些设计过程,很多同学对DDD有了更深入的了解。


在战略设计后,我们识别出了很多的值对象、实体、领域事件等等元素,但仍然让大家比较困惑的是,这众多的领域元素要如何跟具体的代码对应起来呢?如果做不到代码跟领域模型的同步更迭,那么DDD对于开发的意义又是什么呢?


我第一次接触DDD应该是在2014年,这一年正好是《实现领域驱动设计》一书在国内出版,依稀还记得当时公司群里异常兴奋的讨论。


从 03 年 Eric Evans 的 Domain Driven Design 到14年 Vaughn Vernon 的 Implementing DDD,整整过去了10多年的时间,业界才算有了一个真正意义上的指导DDD落地的思想。


IDDD一书出版的时候,也正是Java语言大行其道的时候,再加上书中的一些代码示例也是基于Java的,这就造成了人们的认知一度认为只有Java才是最适合实践DDD的。


近些年来,Go语言被越来越多的公司及个人采纳,很多人也开始了在Go语言中落地 DDD 的尝试。


但其实,DDD本身只是一种思想,就像耗子叔曾经在《从面向对象的设计模式看软件设计》一文中提到的,说起设计模式也并非就一定是OO的。(https://coolshell.cn/articles/8961.html)


因此,实践DDD其实无所谓使用什么语言,但同时我们也要看到Go与Java在语言层面的差异,一些实现细节就必须做出调整。


是否有一套切实可行的代码结构与规范,来指导实际业务中的开发呢?遗憾的是,目前在网上貌似是找不到在Go语言层面 比较完整且具有实际指导意义的资料。


所以从今天开始,我会通过一系列的文章,来介绍DDD如何在Go语言中落地。希望通过尽量系统且详细的描述,来降低你实践DDD的门槛,同时能够帮助你解决一些在实际开发中可能遇到的问题。


最后,我还会以一个虚构的系统作为案例,通过对这个Demo的讲解,帮助你更彻底地掌握DDD的核心思想以及落地操作。


在具体的内容安排上,未来会涵盖下图中的一些主题。现在你也可以停下一两分钟看看框架图里,你更为感兴趣部分,在此先道声感谢,谢谢你对本系列内容的关注与支持,期待我们留言区里可以进一步交流:

不过在这之前,因为本系列文章的重点,不在于对DDD相关概念的讲解,以及战略建模的分享。

因此,在继续阅读后续的内容前,你最好对DDD有一些基本的了解,如果还不清楚,建议可以在网上先搜索一些资料阅读。

好,前言部分,我们就先聊到这里,非常感谢你耐心的阅读。



责编 | 韩楠

约 4265 字 | 8 分钟阅读




 以下,Enjoy~ 



接下来,就让我们从最简单也是最基础的领域元素 - 值对象说起吧。

正式说值对象之前,我们先来思考这样一个问题,什么样的代码算是好代码呢?

相信大家或多或少,都接手过一些遗留的业务系统,这些系统的代码大多都能正常工作,或许运行的还是某个关键业务。

某一天,你接到了一个产品需求,经过评估,你认为只需改动很少的几行代码就可以实现。于是,你很快就完成了开发,并进行了充分的自测,你认为肯定不会出错。但不幸的是,上线后还是引发了事故。 

刚提到的场景,相信对于很多做业务的同学都深有感触。

这种问题之所以常见,很多时候是因为我们的代码不够清晰,没有很好地表达业务,看代码的人就只能靠猜。比如我们定义一个注册的方法,需要用到用户名、手机号和密码,很多同学可能会将这个方法定义成这个样子:func Register(string, string, string) User。

那么问题就来了,作为使用方,三个参数要按什么顺序传入呢?这也是代码不够清晰的表现。

在DDD中,值对象通过将相关联的属性组合在一起,构成了一个完整的概念整体,同时使用业务域中的统一语言,可以将一些隐性的概念显性化。正确地使用值对象进行建模,不但能够大幅地提升代码的清晰度、可读性,在一定程度上也会降低系统出错的概率。


01 先从怎么理解值对象说起

值对象本质上就是一个属性的集合,但这些属性并不是随便凑到一起的,它们通常是为了某个共同的目的或概念而存在着。

除此以外,值对象还具有下面两个显著的特点:

• 不变性,值对象在创建出来后是不应该被修改的,如果必须修改对象的某个属性,则需要整体替换成一个新的值对象;


• 无身份标识,这也是与实体相比非常重要的一个不同点。缺少了唯一标识,怎么判断两个值对象是否相等呢?就要看它们所包含的属性是否全部都是相等的了。这就好比我们在现实生活中使用现金,我们关心的只是货币的面值,是100的还是50的,而不会关心这个纸币的编号是多少。

同时,值对象应当具有一定的行为,但同时也要避免过于复杂。


02 实现值对象


▶︎  比较严谨(教条)的实现方案

我们以一个描述货币价值的值对象为例,来看下代码:

在这个例子中,有这么几点需要特别注意:

• 值对象需要使用大驼峰,也就是这个 MonetaryValue 应该是全局可见的,这样一来在领域层之外也是可以访问的。


• 值对象里的各成员应该小写,这样做有两方面的原因:

a.一方面可以避免在包外对属性值的直接修改。假设有个更新数额的操作,因为没法直接在原值上赋值,例如这样的代码val.amount = 2也就行不通了。这么做的好处是可以避免错误的赋值导致的一些问题。

b.另一方面,在包外虽然可以对 MonetaryValue 实例化,但是因为成员不能赋值,也就强迫了使用者必须调用 NewMonetaryValue 方法,从而避免了构造出一个不符合规范的对象


• NewMonetaryValue 是一个工厂函数,并一次性传入构建该值对象所需的所有参数,在这个函数中可以对参数进行合法性校验,这样保证了所有创建出来的对象都是合法的。


• 包外如果有对值对象内部成员进行访问的需要,可以定义一个同名的、采用大驼峰定义的方法,比如这里的 Amount 方法。

种实现方式,虽然严格满足了值对象的一些要求,但是在某些情况下使用起来就不太方便了。

比如,如果我们需要对这个值对象进行json序列化与反序列化。这个时候,因为所有的成员都是未导出的,会导致 Golang 默认的 json 库直接将其忽略,这明显与我们的期望是不符的。

因此,我们在进行编码的时候,还需要充分考虑实现的成本,例如上述需要序列化的场景,直接定义成如下形式可能更合适些:

▶︎  谨慎(最好不)持有指针、slice等类型

在一个值对象中可能持有另外的值对象,比如这里的 Currency,虽然是作为一个枚举使用,但本质上仍然是一个值对象。

当值对象中包含了其他非基本类型(例如指针、struct、slice、map等)的属性时,就要特别注意了,即使我们在值对象里没有对这类成员进行修改,但是仍然无法保证外部不会修改它们的值。

看下面的例子:

虽然 BadDemo 在内部没有任何方法对 s 进行增删操作,但是,如果外部对 ss 进行了修改,比如 ss[0] = "abcd",同样会反应到 s 上。

这样一来也就破坏了值对象的不变性,这种破坏性,有的时候可能会给你带来不可预知的Bug,并且不是特容易发觉。

▶︎  通过新建来对值对象进行修改

值对象也是能够拥有一些行为的,比如上面的 MonetaryValue,可能有一个Add 方法:

这里的 Add 方法跟我们通常的实现可能不太一样,主要在于方法的返回值。

值对象因为要保证不变性,因此,我们不能直接对值对象的内部属性进行修改,而是采用了新建一个值对象的方式。

另外,Add 方法的接收者是一个值接收者,而非指针接收者,使用指针接收者的问题在于可能不小心就修改了内部属性,而造成一些隐式的错误。当然这里也可以不用这么绝对,对于大型的值对象,如果使用值接收者会带来一定的性能损耗,这个时候也可以使用指针接收者。

▶︎  为什么要保证值对象的不变性

费了这么大劲,又是限制接收者类型,又是强调必须返回一个新的值对象,为什么呢? 

我们考虑下面这个场景。

比如说,我们现在在开发一个多人游戏,每个人在一开始的时候都有固定的等值筹码,随着游戏的进行,你可能花费一些筹码,又或者赚取一些筹码。

我们在初始化筹码时,代码可能如下:

之后 player A 通过卖出装备而赚取了更多筹码,假设说这里是按照直接修改值对象内部属性的方式来实现:

那么问题就出现了,我们虽然只给 player A 增加了筹码,但是发现所有 player 都莫名多了10筹码。

一起来思考下,问题的原因是什么,其实就在于,我们共享了这个值对象,但是没有保证它的不可变性。而返回新的值对象的方式,则不存在这个问题:


03 实现枚举


枚举,通常被认为是值对象的一种特化形式,它也属于领域中的元素。

▶︎  以值对象的形式来实现枚举

比如有一个叫 SomeStatus 的枚举,对应有两个枚举值 SomeStatusOne 和 SomeStatusTwo, 那么可以采用如下的形式来定义:

上述代码大部分都遵循了值对象的实现方法,但是也有两点不同:

• 定义一个默认的零值,并提供了一个判断当前枚举是否为零值的方法。零值的作用,是保证在使用到枚举的地方都不会有 nil 的出现,这种保证可以一定程度地避免程序 panic 的发生。

• 所有的枚举值,放到一个 Slice 或 Map 中,便于在创建枚举时进行合法性校验。

可以看到,这种实现方式还是比较麻烦,但是能够很大程度地在代码层面 保证程序的正确性。而且,枚举值的变动频率一般都不会太高,所以成本也仅仅是一次性的。

▶︎  使用原始类型表示枚举

另外一种偷懒的形式,类似下面这种:

跟上面值对象的形式相比,的确是简单了不少,但是最大问题在于代码的不可控性

比如下面这样一个函数:

UpdateStatus 方法接收一个 AnyStatus 类型的参数,之所以这里是一个 AnyStatus 类型而不是一个 int 类型,大概率是希望调用者 能传递一个合法的 AnyStatus 进来,但是这一点是得不到保证的。

调用方可以传入任意一个整数值,而编译器并不会报错:

如果希望代码足够的严谨,那么在 UpdateStatus 方法内部就不得不对传入的值进行校验。这种校验逻辑也会散落到所有用到 AnyStatus 的地方。

另外,从全局来看,任何人在任何地方,都可以直接调用类似这样的代码 AnyStatus(6) 来生成一个枚举值。很显然,这是一个无效的枚举值,也是一个完全不应该存在于领域的对象。

综合看前面两种方式,各有优缺,可以根据各自的情况,在团队内大家做到统一即可。


04 总结


今天,我向你介绍了值对象在Go语言中的实现方式,以及如何正确地定义枚举。

值对象这个概念并不复杂,但是在实现的过程中涉及到的细节会比较多,这也跟它自身的一些特征是分不开的。

值对象最重要的特征是不变性,我们所有的实现,都是围绕这一点展开的。也正因为这种不变性,让我们在业务中可以放心地对其进行复用。

当你在决定一个领域概念 是否要建模成一个值对象时,就要考虑是否具有下面的一些特征:

在代码落地的过程中,你还要注意:

• 值对象的创建必须通过一个简单的工厂函数来实现,这样可以避免不符合业务约束的对象产生;

• 对值对象的修改不能直接修改属性,需要构造一个新的值对象出来;

• 值对象里的成员最好不要包含Map、Slice、指针等结构,否则值对象的不变性可能会遭到破坏;

• 如果需要比较两个值对象,可以定义一个 Equals 函数,在函数里判断是否所有属性都相等;

• Golang 里的一些原始类型,比如 int64、string 等,都可以看做是最简单的值对象来使用。


如果说,是一砖一瓦构建起了高楼和大厦,那么,值对象就是DDD里的砖和瓦。因此,只有保证了值对象实现的正确性,才能在更高的维度去构建实体、聚合根等领域元素。

▶︎  延伸思考

最后,留给你一个思考题。

为了代码足够健壮,我们将值对象里的属性设计成了未导出的,当值对象需要持久化到数据库时,该如何做呢?从数据库中读出数据要重建值对象,又要怎么做呢?

话不多说,下一节,继续和大家聊聊如何实现实体、聚合根。

这一篇,到这里我们就要结束了,非常感谢你耐心的阅读。

很期待我与你能够有更多思想上的共鸣、碰撞。如果愿意分享,这一讲也欢迎转发给你的朋友,和他一起讨论。

同时,若你对这一讲的内容,有相关思考或是疑问,可以多多提出来,留言区里,我们多交流。后续分享再见。



 推荐阅读 

👇🏻

DDD概念复杂难懂,实际落地如何设计代码实现模型?

您可能也对以下帖子感兴趣

文章有问题?点此查看未经处理的缓存