跳转至

DDD降级系列:裁剪值对象

按: 我们经常会遇到一些模式没有被使用的情况。,反者道之动,探究模式没有被采用的原因及其后果,通常会揭示出该模式更为本质的内涵。因此,我想编写一个系列文章,来描述我所见过的DDD降级裁剪情况,讨论这种裁剪的优缺点,以此为基础供大家参考和讨论。本系列文章将从值对象开始。 值对象模式(Value Object Pattern)在领域驱动设计中备受关注,一方面是因为它与数据表驱动开发方式中的概念有所不同。在数据表驱动开发中,通常会基于主键进行CRUD,而值对象模式则提供了一种更加灵活和抽象的建模方式。另一方面,值对象模式的不变性也为设计带来了简洁性。由于值对象的属性不可变,我们可以避免复杂的状态变化和一些设计上的陷阱。

在Vaughn Vernon的《实现领域驱动设计》和张逸老师的《解构领域驱动设计》中,都提到了尽量使用值对象建模。然而,在一些实践项目中,对于值对象的讨论往往会从开始的热切“应值尽值”演变为后来的“谈值色变”,避而不谈,在DDD项目中是否使用值对象模式是一个非常值得探讨的问题。

注: 模式(Pattern)是指一种描述了一个经过了前人实践充分考量过的解决方案的纲要,它可以指导我们解决一个在特定的设计环境中重复出现的相似问题 --《面向模式的软件架构》

什么是值对象

在《领域驱动设计:软件核心复杂性的应对之道》中,值对象的引入是作为实体/引用对象模式(Entity/Reference Object Pattern)相对应的、专门针对那些“没有概念上的标识,它们描述了一个事物的某种特征”的对象的建模方式。作为一种模式(Pattern),值对象模式主要是解决这样的问题:

跟踪ENTITY的标识是非常重要的,但为其他对象也加上标识会影响系统性能并增加分析工作,而且会使模型变得混乱,因为所有对象看起来都是相同的。软件设计要时刻与复杂性做斗争。我们必须区别对待问题,仅在真正需要的地方进行特殊处理。 然而,如果仅仅把这类对象当作没有标识的对象,那么就忽略了它们的工具价值或术语价值。事实上,这些对象有其自己的特征,对模型也有着自己的重要意义。这些是用来描述事物的对象。

--《领域驱动设计:软件核心复杂性的应对之道》

简言之,在业务领域中,有些概念虽然需要建模为对象,却并不具备实体所具有的独特业务标识。例如,我们可能只需要一支红色的铅笔,而并不关心它具体是哪一支。若将其与实体等同对待,则可能导致以下问题:降低系统性能、增加分析工作负担以及使模型变得混乱。然而,若将其简化为非实体的其他对象,便可能忽视其特殊性,从而导致对重要业务概念的忽略。

因此在建模实践中,往往:

当我们只关心一个模型元素的属性时,应把它归类为VALUE OBJECT。我们应该使这个模型元素能够表示出其属性的意义,并为它提供相关功能。VALUE OBJECT应该是不可变的。不要为它分配任何标识,而且不要把它设计成像ENTITY那么复杂。

--《领域驱动设计:软件核心复杂性的应对之道》

综合来看,值对象首先是一个业务上的概念,其次其是与像忒修斯之船一般的实体不同的在业务上不会追踪其来龙去脉的概念。这种概念如果建模为实体会带来一些代价,因此将其建模为不可变、无标识的对象值对象。

忒修斯之船是一个源自古希腊哲学的思想实验,旨在探讨事物的同一性和变化的问题。忒修斯是雅典的一位英雄,他的船在港口里停放了很长时间。随着时间的推移,船上的木板逐渐腐烂,因此人们不得不逐步更换所有的木板。最终,船上的所有部件都被替换了。这个故事引发了一个哲学争论:经过所有部件替换后,这艘船是否仍然是忒修斯的船?换句话说,一个事物在经历了众多变化之后,是否仍保持着其初始的同一性?

尽量使用值对象建模?

在Vaughn Vernon的《实现领域驱动设计》和张逸老师的《解构领域驱动设计》中,都强调了尽可能使用值对象进行建模。不可变对象能够避免许多编程问题,通常来说,一个事物的自由度越高,预测其行为就越困难。通过限制部分对象的自由度,不可变对象自然会带来一些优势。不可变对象在编程时带来的好处有:

不可变对象天然支持线程安全,因为它们在创建后就不会改变状态,多个线程可以并发读取而不会产生冲突。

不可变对象可以增强语义和提高代码可读性,因为它们可以明确表示常量或只读变量。

不可变对象可以避免副作用,即函数或方法不会修改参数对象的值,从而避免了一些隐晦的bug。

不可变对象可以与对象池等技术结合使用,提高内存利用率和性能。

例如Scala这种编程语言就默认使用不可变对象。那是否建模中需要尽量使用值对象建模呢?领域驱动设计,顾名思义设计应该聚焦在领域和领域逻辑,那将本身领域中可变的对象(实体)建模为不可变的对象(值对象)是否是对领域概念的扭曲?这样得出的模型是否会变形,导致领域驱动变成技术人员的自嗨?

举一个常见的订单和订单行的例子,不论技术上把订单行设计为不可变对象每次变更都重新new一个新对象,还是设计为可变对象,你很难说服业务人员订单行是不可变的。DDD领域模型应该取的是业务人员和技术人员的共识。从此例可以看出尽量使用值对象建模是一种混淆技术概念和业务概念的操作。

[ 图 1] 将实体建模为值对象让业务人员感到费解

因此,个人认为不能尽量使用值对象建模,至少不能强行扭曲把有引用追溯价值的实体建模为值对象。在出于性能优化或语言限制(如使用Scala)的考虑时,可以采用以下两种策略:一是在技术层面实现为值对象,但在业务层面仍使用可变对象,这样做的优点是业务人员能够理解模型,缺点是导致业务模型与技术模型脱节;另一种选择是将其建模为值对象,但用备注方式标注其在业务上是可变的,仅操作方式有所不同。显然,这两种方法都会增加建模复杂度,因此不应滥用。仅在不可变对象能带来显著优化空间时,才建议使用这些策略。

https://lh5.googleusercontent.com/915zX1CsJRhwNHgwSaZn_vPaAbwjTHQeRu1OEnrbnRQcs0bC4T4_r93s6lwSgMeCVkUfSSsByNJHB3O6W03bIx9CGCe4YMiTKsrkTWWfW7n8rU2W3N3T5lQHihihf1WFp2VBSwF-iOs17G0JpRHYOw

值对象是个业务概念 在实际业务场景中,我们经常会遇到用于描述事物某种特征的对象,特别是在处理多对多和一对多关系时。例如,在开发DevOps平台流水线时,为了便捷地使用各种功能,我们通常会将常用步骤抽象为模板。这样一来,一个步骤便对应一个模板,而一个模板则对应多个步骤。然而,在业务实践中,我们面临一个挑战:步骤模板由基础设施部门维护,而流水线则由业务开发部门负责。步骤模板中涉及脚本的相关变量设置,若步骤模板更新并新增了变量参数,随之更新的步骤可能导致某些必填参数变为空,从而导致流水线不可用。为解决这个问题,我们会在步骤上存储步骤模板的快照。当步骤模板更新后,流水线步骤不会立即跟随变更。同时,在业务开发部门的dev访问流水线设计页面时,通过版本比较提示模板已更新,让业务部门的dev选择是否进行更新。

[ 图 2] 不使用值对象的模型

在这种情况下,模板快照仅作为描述步骤的一个属性,并且其不可变特性使其适合作为值对象进行建模。在实际代码中,模板快照可能以JSON字段的形式进行存储。

[ 图 3] _通过值对象概念发现隐藏的模型

显然,在建模阶段,将模板快照明确地建模为值对象是一个合理的使用场景。在建模过程中区分值对象和实体不仅能为值对象的性能优化预留空间,而且有助于揭示隐藏在对象之间的中间概念。

https://lh5.googleusercontent.com/hi988jOSY7vTCBpR_jEUZ3D5TuVdrC8-lugX_C0HX-c5WmjYhcVJyF9J6p3Fhuzr6PSV_NV5zH3T1WIo8Q_EWyP25IMfL77kNH-UyLg-AlfFeVqIadbwQ9zSbhKqjeu-rm30fd3PEaCfimCeoibjQg

https://lh5.googleusercontent.com/luydA_GRWv5Z092bowalBPaLDGCR2Mr5A9ZxNuUwT9pOPfEGKBBDNrYQ3CRblv9o-BCugj9VYh--1gNn608FuvTei8Tt-zHtZJe9_55ddzsgwJelrbeEO_KYv4cV_jr9pAGM7Ue4Lvrf5zIjnBSQ2Q

没有主键

值对象的另一个重要特征是没有唯一的业务标识,在领域驱动设计(DDD)中,实体建议使用业务相关的唯一标识,例如订单使用订单编码,车辆使用车牌号或身份证作为ID。因此,在实践中,关于值对象在存储到数据库时是否需要创建ID字段的问题经常引发争论。

在建模阶段,识别业务唯一标识是一项非常有益的操作,因为这有助于更清晰地了解业务需求。然而,人们在理解这一概念时常常将其与数据建模混淆。这可能与过去一度流行的将业务主键作为数据库主键的数据建模方法有关。而在DDD原著中,关于主键在分布式系统中的一致性的讨论表明,作者采用的是业务主键这种方式。然而,当前主流的数据库建模方法是采用代理主键。

业务主键是由现实世界中的属性构成的键,具有业务含义,如订单号、员工编号、商品编号等。业务主键与行内属性具有逻辑关系,从业务层面反映数据的唯一性。业务主键的优势包括:

更好的检索性能,减少连接主表的次数,降低I/O量。

可读性强,易于理解,具有业务含义。

合并相同业务实体更容易,如不同实体主键编码不同。

数据迁移简便,无需重新计算迁移实体的主键。

复杂性低,存储量少,无需增加额外索引。

大型系统中,跨系统一致性容易维护。

代理主键是一个无业务含义的键,仅用于唯一标识记录,如自动递增的ID。代理主键的优势包括:

业务变化时适应性强,无需修改其他实体的外键。

保证系统一致性和可操作性,避免业务主键变化影响其他单据引用。

数据表连接和更新性能高,尤其针对复合主键或CHAR型主键。

主从关系中,业务主键更新不便,需修改多处使用作为外键的数据。

适合与现代JAVA ORM框架搭配使用,如Hibernate。

并发环境下,生成唯一ID更容易,可使用数据库的auto increment类型或sequence对象。

占用存储空间较少。在百万行数据量下,代理主键索引容量=业务主键索引容量×80%,代理主键字段存储容量=业务主键字段存储容量/2。

所以,在建模阶段,实体是一定要找到业务主键的,但是在代码实现时,大可不必将业务主键作为持久化的主键,而建模时值对象不能有业务主键,但代码实现时,加个代理主键也无妨。

使用业务主键和代理主键是由系统规模、一致性的成本、存储实现方式、未来数据迁移的可能性之类的方方面面统一决定的,是一个技术概念,不应影响领域建模。

裁剪值对象

现在我们来讨论下为什么很多实践中将值对象模式裁剪掉。首先,我们需要明确值对象是一个业务概念。然而,在建模过程中,确定值对象往往具有挑战性。这主要是因为在业务中,这类概念可能与快照、历史版本或不可变性相关,而不是DDD中的值对象。作为DDD的专业术语,值对象一方面使业务人员理解起来需要付出额外的成本,另一方面,技术人员也需要避免将技术层面上的不可变对象实现概念与值对象的理解混淆。而且,让一个DDD团队在值对象概念上达成共识本身就是一个挑战,需要付出一定的成本。

那么在团队因为值对象的问题争执不下的时候,是否可以叫停,暂时放弃值对象这个概念呢?我们来逐个讨论值对象要解决的问题:影响系统性能、增加分析工作、使模型变得混乱。

性能问题往往可以后期优化调整,个人也倾向于除了极特殊的性能问题比如循环调用API这种,其他性能问题可以稍微往后放一放。因为性能优化往往跟表达业务是相冲突的,值对象本身只是增加了性能优化的可能性,比如共享型的值对象可以在系统启动时初始化,不会每次访问都生成新的实例。

而增加分析工作和使模型变得混乱是指,如果在建模过程中不使用值对象,可能会导致以下问题:

重复代码和逻辑:如果没有值对象,可能需要在不同的地方手动处理相同的数据,导致代码冗余和混乱。这会增加维护成本并降低代码的可读性。比如业务上颜色是一种值对象,每个用到颜色地方都使用了相同的结构和方法,但是散落在不同的聚合中。

数据不一致性:如果没有值对象,可能会在不同的地方使用不同的数据结构和逻辑处理相同的数据,导致数据不一致性,从而影响系统的正确性和稳定性。而且值对象是不可变,而不注明可能导致不可变的对象被修改。

[ 图 4]裁剪值对象

仔细想想,这些问题问题是否是不可接受的?与其大家对值对象争执不下,不如在团队无法对值对象的概念达成一致的时候,将本应是值对象概念建模为不可变的实体,而且重复建模在不同的聚合里,而不是像值对象一样可以在聚合间共享,用一些模型上的重复换取操作的简单性。

https://lh4.googleusercontent.com/dO1MGSNA9FLkG7uTFayvkXNgNGx70RpHsEAV4_V5aKbhIbwUtLzyXYbtb3LK2V3UxxaYanxNl-0ZcpesnG3s_I800GvpURQznEPf8bFQdM2ho6PZqLwMm9Lao9dWhvNVY6vMJ7g5leC_OO5IXYnkGA

总结

值对象模式是DDD建模中的重要手段,合理地使用值对象模式可以更清晰地描述业务逻辑,降低建模的复杂度,并发现隐藏在关系中的中间对象。然而,这一切都是建立在团队对值对象概念充分理解并达成共识的基础上。如果团队在值对象概念上无法达成一致,或者经常将技术层面上的不可变对象与建模中的值对象混淆,那么可以暂时放弃使用值对象这种建模方式。