不想只做Cruder?实体、聚合根,还不快去了解下
技术专家
于振
现于某大型互联网公司,负责架构工作
曾就职于美团、快手等一线互联网公司
写在前面:
今天我想跟你聊一聊实体和聚合根。这是我们专题系列内容的第二讲了。上一讲,我给大家介绍了在Golang中 如何实现值对象、枚举。感兴趣的朋友,可以有空了看下。
责编 | 韩楠
约 4895 字 | 10 分钟阅读
以下,Enjoy~
《DDD在Go中的落地》这一专题,每篇文章开头,我打算都先简单地跟你聊聊为什么要这么做,比如为何需要使用该领域对象,它解决了什么问题,其次才是对应到代码上该如何实现。
有的时候,知道 Why 比知道 How 要重要的多,也希望你能够做到知其然知其所以然。
过往的一篇分享中,我曾阐述了这样一个观点,对于业务系统,要尽量保持代码的清晰,这样才能带来较低的维护成本。值对象基于业务域中的统一语言,将相关联的属性组合在一起,构成了一个完整的概念整体,从而让业务逻辑更内聚,业务概念也得到了更好的展现。
值对象的优势,在于对细粒度的领域概念的表达,实体的优势又是什么呢?要弄清楚这一点,就要从为什么我们需要从实体说起。很多做业务的同学都会有这样一种错觉,就是自己的工作没有太多的技术含量,平时用的最多的,就是对数据库的增删改查,于是,他们自嘲的称自己是CRUD工程师。
这种观点的由来,跟我们平时的开发方式有很大关系。在接到一个需求后,大多时候,首先想的就是数据要如何存,数据库表要如何设计,表之间又是什么样的关系。所谓的模型,也仅仅是跟数据库表结构高度一致的结构体。
这种开发模式在业务简单的时候,没有任何问题,但是如果业务越来越复杂,逻辑之间约束越来越多,代码在清晰度、可阅读性、可维护性上,都会变得越来越差。就像我们在衣柜里只放两件T恤,你找起来毫不费劲,但是如果乱七八糟地堆上几十件T恤、裤子、袜子等等,不但看着凌乱,用时候找起来也不是很方便。
01⎪ 什么是实体
先来看看怎么理解实体。这里直接引用 IDDD 一书中对实体的定义:
一个实体是一个唯一的东西,并且可以在相当长的一段时间内持续地变化。我们可以对实体做多次修改,故一个实体对象可能和它先前的状态大不相同。但是,由于它们拥有相同的身份标识(identity),它们依然是同一个实体。
—— from IDDD
02⎪ 区别于数据对象
实体有两个突出的特征:唯一的身份标识和可变性,而这两个特征同样存在于数据对象身上,因此,为了避免先入为主地将数据对象等同于实体,这里先说下两者的区别和联系。
▶︎ 数据对象
数据对象一般指的是我们在 model 层定义的一些 struct,这些 struct 的属性跟数据库中某个表的列信息是保持一致的,通过 ORM 软件(我们常用的就是Gorm),可以方便地将数据库表里的一行映射成一个数据对象的实例。
反之亦然。
这些对象之所以叫数据对象,主要原因在于它们只是承载了数据功能。
比如,在一个名为 User 的数据模型中:
我们可以看到它包含了用户名、手机号、性别、年龄,等等。但是,单从这个数据模型上是看不出它可以做什么的。
而具体能做什么、怎么做,则被放到了某个服务(通常会有个 service 层)里面,在服务中通过一些赋值操作 来更新数据对象的某些属性,最后再通过 ORM 保存回数据库中。
这种模式下,数据对象因为缺少了行为,又被称为贫血模型。
从本质上来说,这种方式依然是面向过程的编程范式,本应属于领域模型的业务逻辑 被泄露到了各个 service 中,久而久之,会使得代码越来越难以理解。
▶︎ 实体对象
实体是 DDD 中的领域对象,它是一个富有行为的领域概念。
领域对象里的成员和数据对象里的成员可能是一致的,也可能不一致,这完全取决于你使用什么样的存储技术。
比如,在 MongoDB 这类文档型数据库中,实体模型和数据模型很可能是高度一致的,但是在传统的 MySQL 数据库中,很多时候会将一个实体模型映射成多个数据模型。
考虑在订单中要有配送地址这个场景。
如果是使用 MongoDB ,可能直接存成一个doc:
而在 MySQL 中,就需要将地址信息拆到另外一张表里。
除此以外,实体对象跟数据对象的本质区别,还在于模型的丰富程度,实体对象是包含了丰富的领域概念的。
还是订单这个例子, Order 实体上可能还会定义一些领域方法:
而数据模型就只是光秃秃的一个 Order 结构体。
▶︎ 唯一标识不等同于数据库主键
说到唯一标识,我们很容易联想到数据库表里的唯一主键,认业务的唯一标识就是表记录里的id列,其实这种理解是不太全面的。
数据表里的主键 id 在某些情况下,可以作为实体的唯一标识,但两者本身属于不同的概念。
还是以 Order 实体为例,它在数据库中可能存在类似下面这样的一张表:
那么,这里的 id 只是数据库表里的一个主键,而 order_id 才是 Order 这个领域实体的唯一标识。
再来看一个 Product 的例子,它的定义比较简单:
products 表有一个 id 列作为主键,同时,我们通常也会将这个 id 作为 Product 实体的唯一标识。也就是说,数据库表的主键,有的时候可以作为唯一标识使用,有的时候却不可以。
总之,我们只需要记住,唯一标识和数据库结构没有关系,主键 id 是存储层面的唯一标识,而业务层面的唯一标识才是实体关心的。
03⎪ 如何表示唯一标识
比较教条的做法是无论什么情况,都用一个值对象来存放实体的唯一标识。
值对象具有不变性,这也就保证了实体身份的稳定。
但在一些比较简单的情况下,可以直接使用原始类型(比如string、int)来作为唯一标识。
▶︎ 使用值对象表示唯一标识
这里考虑一个订单号生成的例子,假如我们生成订单号的规则如下:
时间戳+业务类型+下单平台+随机码(或自增码,自增码每天可清零)+支付渠道
那么,通过这样一个订单号,我们可以解析出该订单下单的时间、支付的渠道等信息。
这些信息的解析,跟订单号是密不可分的,这些行为和订单号本身形成了一个完整的业务概念整体。因此,这个时候将订单号编码为一个值对象是合理的。
使用值对象来实现唯一标识,不仅能够更好地表达业务,同时,可以一定程度上规避一些错误。
看下面的代码,我们要提供一个根据订单ID和商品ID,来获取订单项信息的函数:
第一种实现的入参都是 int64 类型,第二种是值对象类型。
对于第一种来说,调用方即使在传参时将 orderId 和 productId 搞反了,编译器也是不会报错的,而这种错误在第二种实现方式中,是完全不可能发生的。
这种表示方式的唯一缺点,在于代码量的增加。
在很多地方,因为必须要对原始类型与值对象类型进行转换(比如数据库里存储的订单号还是 int 类型,但是读取出来要转成领域实体,就需要转成 OrderId 类型,在实体持久化的时候还需要将 OrderId 转成 int),复杂度会有一定的增加。
▶︎ 直接使用 int64 作为唯一标识
上面提到的产品ID,是一个不需要使用值对象做唯一标识的例子。
Product 实体用自增 ID 来代表唯一标识,这个 ID 除了能唯一标识一个产品外,没有其他任何与之相关的行为。
所以这里可以将其简化成一个 int64 类型。int64 也是不可变的,因此其本质上也是符合值对象的特点的。
这种实现方式的缺点是表达能力不强,但好在足够简单。
综合来看:
• 如果是使用通用的ID生成器这类的工具,来生成唯一标识,其本身除了唯一标识一个实体,也没有其他的行为,这种情况下,推荐直接使用原始类型就可以了。
• 如果唯一标识,是按照一定的规则来生成的,并且围绕这个唯一标识还会有一些方法(行为),这个时候最好使用值对象来承载。
04⎪ 生成唯一标识
根据不同的场景,大体上分为两种生成方式:用户传入和系统生成。
▶︎ 用户直接传入唯一标识
这种情况依赖用户的输入,用户需要保证输入的唯一标识真的是唯一的,这通常很难。但是,在某些特殊场景下还是可以做到的。
比如在学校图书馆,管理员录入书本的场景。
我们知道每本书都有一个 ISBN 码,这个 ISBN 码就可以作为书本的唯一标识。管理员在录入书本时,用手持设备直接扫描 ISBN 码,这个扫描到的 ISBN 码,就可以认为是用户直接传入的唯一标识。
▶︎ 系统生成唯一标识
大部分情况下,我们遵循的都是这种方法。无论是上面提到的 OrderId 还是 ProductId,虽然生成的方式不同,但都可以归属到系统生成的范畴。
这里面有一种比较特殊的情况,是使用数据库的自增来生成。
这种情况特殊的点在于,实体创建好了之后,可能还没有来得及分配一个唯一标识,因为此时实体还没有进行持久化。
没有唯一标识的实体,可能需要面对下面两个问题:
• 如果需要发布领域事件,这个时候因为还没生成唯一标识,事件接收者无法知道是哪个实体发出的事件;
• 如果我们创建了多个实体,同时又需要将这些实体暂存在一个map中,因为唯一标识都是零值,会导致部分实体丢失。
如果需要发布领域事件,这个时候因为还没生成唯一标识,事件接收者无法知道是哪个实体发出的事件;
说到这,你可能会问了,那怎么解决这个问题呢?办法是有,就是将唯一标识的生成提前,在实体创建好的时候,保证一定是有唯一标识的。
▶︎ 代码如何实现
通常,我们会在 Repository 接口中,定义一个 NextIdentity 方法,如下:
注:Repository 对应的是 DDD 里的仓储概念,我们在后面的章节会介绍。
而需要用到这个 NextIdentity 方法的地方,一般是在工厂或应用服务里。
注:工厂和应用服务也是 DDD 里的概念,同样放在后面的章节进行介绍。
对于应用服务,基本都需要持有一个对应的 Repository 属性,比如这样:
而如果是在工厂里,就要展开讨论了。
如果工厂是无状态的,也可以让工厂直接持有对应 Repository 属性,实现方式跟在应用服务里类似。
如果工厂是有状态的,那么只能每次前都创建一个工厂的实例,在创建的时候将 Repository 作为参数传入:
无论是哪种形式,都需要先生成唯一标识,再生成实体。
05⎪ 聚合根
聚合,是 DDD 中较为难以理解的一个概念。
很多刚刚接触 DDD 的同学,常犯的错误是设计出一个囊括天地万物的大聚合。这在战略设计阶段往往看不出什么问题,但是一旦要落实到代码层面,就会发现根本行不通。
当然,我们这里也不会过多去讲应该如何设计聚合,这不是这篇文章的重点。
但是,我们至少要知道,设计小聚合是很重要的一条原则。
小聚合的前提,是要保证聚合内的一致性条件不被破坏。这里有篇文章可以帮助大家更好的理解,得空了可以看下。(http://noddd.cn/posts/10b4b980-d4e9-11eb-8739-c101f88c7d5c/)
更多的原则,还可参考这里:(https://weread.qq.com/web/reader/f5032ce071fd5a64f50b0f6kad63251024aad61ab143c7e)
当我们回归到小聚合的设计后,就会发现,聚合根的实现方式跟实体是非常类似的。
还是考虑 Product 这个例子:
假如说我们的产品模型非常简单,只有前面的四个属性,我们是否还有必要再单独定义一个 ProductAggregate 结构体做聚合根呢?
上面这个写法显然是多余的,Product 是实体,但是,如果在聚合根里只包含实体一个属性时,Product 本身也可以当作聚合根来用。
同理,可以推广到稍复杂的情况。
比如在一个订单中,通常会包含总价、支付方式、订单项、地址等内容,如果我们强制区分实体和聚合根的话,可能会写出如下的代码:
但在实际开发中,可以在 Order 中直接引用 OrderItem,这样就省去了对 OrderAggregate 的维护,Order 也变成了实体加聚合根的双重身份:
同时,我们建议所有的聚合根在命名上都加一个 Aggregate 或 Agg 的后缀,用以明确地表示这是一个聚合根。
使用聚合根时,还有一个经常容易犯的错误,就是在一个聚合根中引用了另外一个聚合根。
正确的做法是通过全局唯一标识来引用外部的聚合。
原因就在于,在一个事务中,原则上只能修改一个聚合,如果不持有对其他对象的引用,也就避免了对这个对象的修改。
在你的代码里,如果一个事务必须要修改多个聚合,这个时候就要考虑聚合设计的是否合理,这种情况通常意味着聚合的一致性边界是错误的。
06⎪ 结语
今天,我主要为你介绍了图中包含的这些内容。实体、聚合根,是DDD领域层最核心的概念,其上可能包含了对多个值对象的引用,同时也是业务逻辑主要的载体。
• 实体必须要有唯一标识,这个标识可以是一个值对象,也可以是一个int64或string等基础类型;
• 唯一标识的生成时机,不应该滞后于实体的创建,一般我们会通过Repository的NextIdentity方法先于实体的创建;
• 实体自身应该具有较丰富的业务行为,这也是它跟数据对象本质上的不同;
• 有的时候,实体和聚合根并没有明确的界限,本着构建小聚合的理念,切记不要什么东西都往一个实体里塞。
整体来说,实体的定义跟普通的 Struct 并无太大的区别,唯一需要注意的就是唯一标识的表示。
现在,实体有了,接下来的工作就是如何将其持久化到数据库中,但实体毕竟不同于数据模型,没法直接调用 Gorm 的相关方法。
具体如何做呢?留待我们在下一章节-仓储,再细说。
▶︎ 延伸思考
最后再进一步思考个问题吧。实体和值对象在DDD中是非常重要的领域载体,一般在建模的时候,推荐尽可能地使用值对象来代替实体。有的同学这个时候可能就糊涂了,一件东西要么是实体要么是值对象,还能同时兼顾两者吗?这就要分场景来看了。
我们知道实体和值对象最大的不同,在于是否具有唯一标识。而在某些情况下,我们关心的只是一件东西的属性。
比如我们在网上购买了一台电视,刚送过来的时候你觉得有瑕疵,于是更换了一台,你要关心的是不是就是更换的这台跟你购买的是否为同一型号、同一尺寸,但是对于经销商来说,他关心的就是你退回的是不是之前发给你的那台。
所以说,同样是电视这个物品,在你这里就可以看作是一个值对象,而在经销商那里,就必须看作是一个实体了。
好,今天就先交流到这,后续再见~
推荐阅读