于振:如何通过仓储,对实体进行持久化处理?





责编 | 韩楠

约 3376 字 | 7 分钟阅读









 以下,Enjoy~ 




关于值对象和实体更多的细节,感兴趣的同学可以有空的时候回过头去看一下。

这些对象在创建出来后,总不能一直被保存在内存当中,因此,就需要在某个时刻持久化到数据库。

在DDD中,负责持久化的组件是仓储,也叫资源库。

简单来说,仓储就是用来持久化聚合根的,但它跟我们平时使用的DAO(Data Access O bject)又有所不同。

DAO 是对具体数据库的直接操作,是跟数据库类型强相关的,而仓储只服务于聚合根,而且它也只是在概念上规定了对聚合根的持久化,不关心具体存到哪里 以及如何存的问题。

在《Clean Architecture》一书中,马丁大叔提到了这样一个观点:

从系统架构的角度来看,数据库并不重要 —— 它只是一个实现细节,在系统架构中并不占据重要角色。如果就数据库与整个系统架构的关系打个比方,它们之间就好比是门把手和整个房屋架构的关系。

这个说法多多少少有些极端,但是我认为作者只是想表达这样一个观点,就是底层的存储是不稳定的,在未来是存在变化的可能的。

如何应对变化呢?

架构设计原则告诉我们,可以通过接口来隔离具体实现细节。

对于接口和实现来说,有一个很奇妙的关系:当我们去修改一个接口时,也一定会去修改对应的具体实现,但是反过来,当我们修改具体实现时,却很少去修改相应的抽象接口。

因此,我们可以认为,接口比实现更稳定。

既然接口更稳定,那我们如果想要设计一个灵活的系统,就要 多引用抽象类型,而非具体实现。

所以,仓储在代码层面的实现中,通常采用的是独立接口的形式。也即在领域层定义仓储的接口,在基础设施层对该接口进行实现。


01   接口的定义

▶︎   在领域层定义接口

仓储接口的定义,是跟领域对象放在一起的,但是两者不应该放到同一个包下。我们一般会采用技术层面的划分方式,比如下面是对领域层的目录结构的进一步划分:

因为 domain 层原则上不能依赖任何其他层,因此,domain 下所有文件里都不应该 import 任何其他层的代码。

这也就意味着, 我们在 repo 中定义的所有 Repository 接口的入参、出参都应当是领域层的结构体或者是 Golang 里的简单类型。

▶︎   仓储方法的命名

因为 Repository 不关心底层具体的存储到底是什么,所以我们在命名方法时,应当避免使用带有明显技术色彩的词语,比如inser、update、select、delete这种。 通常建议使用save、find、remove这类更加笼统的词汇。

▶︎   通用的仓储接口定义

前面在介绍实体时,提到了可以通过在仓储中定义一个 NextIdentity 方法来生成实体的唯一 标识。

 综合上面的论述,一个 Repository 接口,至少应该包含下面几个方法:

其中,我们没有明确区分新增和更新操作,而是只定义了一个 Save 方法。

站在 Repository 的角度来看,它的职责只是将领域模型保存起来,到底是新增还是更新,是技术层面需要关心的,而不是它。

最后再来看一下 FindNonNil 这个方法, 它跟 Find 比较类似,当指定id对应的实体不存在时,Find 会返回 nil, nil。而 FindNonNil 会认为这是一个错误,error 会返回 NotFound, 这在某些场景下会非常有用。

除了这几个基本接口,各个业务可以在这个基础上 根据自身场景进行适当的扩展。


02   接口的实现


▶︎   在基础设施层组织仓储的实现

仓储接口定义在domain层,而具体实现是技术细节,所以是定义在基础设施层的。

为了区分基础设施层不同的功能模块,可以对基础设施层进一步划分,而仓储相关的实现代码 可以统一放到 infra.persistence 这个包下。

在上面的代码结构中,repo 包下的 OrderRepository 接口对应的实现 OrderDBRepository 放在 order_repo_impl.go 文件中。

DAL 中放置的是具体的对数据库表的访问。

converter 这个包可能存在,也可能不存在,其主要作用是对领域模型和数据模型进行互转。当领域模型与数据模型的字段一致时,可以退化为只使用领域模型,也即领域模型兼顾了数据模型的职责。

但是这样的话,所有模型字段必须要是大驼峰法。 这个时候就要注意不要在领域之外直接修改字段值,这也是模型退化后我们需要承担的风险。

为了说明仓储的实现,我们先从最简单的情况说起。

▶︎   单表场景下的仓储实现

现在我们假设 Order 这个聚合中的属性,跟 orders 数据库表的字段是一一对应的。

在 OrderRepository 中需要引用到 IdGeneratorClient 和 OrderDal,具体实现如下:

对于上面代码,有几点说明:

•  Save 方法同时兼具了新增和更新功能,具体的逻辑是在 dal 中通过 Upsert 实现的;

•  Dal 中方法的命名带有明显的 sql 特征;

•  在应用服务中获取某个聚合根时,通常都要判断下聚合根是否存在,我们这里提供的 FindNonNil 方法,将这一个常规操作进行了封装;

•  Order 是定义在领域层的聚合根,而不是数据库表的数据模型。在 Order 中的属性跟 orders 数据库表的字段是一一对应的这个假设下,我们省略了对数据模型的定义。所以,这里的 Order 是身兼数职,但其主要职责还是领域模型,只是为了代码实现上的便利,才妥协同时承担了数据模型的职责。

一起思考下, 当上面的假设不成立时,又该怎么办呢?

▶︎   多表场景下的仓储实现

比如一个 Order 中存在多个 Item,Order 存到 orders 表,Item 存到 order_items 表,通过 order_id 进行关联,其结构如下所示:

这种场景下,我们的问题在于, 如果某次对 Order 的修改只是更改了某个 Item 的信息,我们要如何执行 Save 方法?

首先,如果我们可以接受将 Items 字段   序列化为 json 字符串,在 orders 表中新增这样一个 items 字段来存储 json,这样也可以解决问题,但是当需要对 Item 进行查询时就不太方便了。

另外一种简单粗暴的方式是,不管三七二十一将 Order 中的信息都更新一遍,这样做的缺点也很明显,就是会多出很多无用的 DB 操作:

在上面代码中,converter 的作用是负责领域模型与数据模型之间的转化,数据模型我们一般用 PO (Persistant Object)表示。

converter 存在的价值在于,数据模型与领域模型并非是完全一致的,converter 负责管理了彼此之间的映射关系。

对于聚合根不是特别复杂的情况,上面的实现方式虽然存在无用 DB 操作,但也还能接受。

在对聚合的设计中有一条规则是要设计小聚合,其原因也在于此。

那如果很不幸我们有一个很大的聚合,无法接受全量更新,要怎么办呢?

通常有两种方法:

•  一种是基于 Snapshot 的,当聚合根取出后,在内存中先保存一份snapshot,在聚合根写入时,将其跟snapshot做一下diff;

•  另一种是将聚合根上可以修改的属性设置成私有的,然后通过类似Setter的方法来进行赋值,这样,在setter被调用时我们就知道哪里被修改了。

业界使用较多的,包括在其他语言中,都是采用第一种 Snapshot 的形式,其实现起来相对简单,副作用较少。


03   使用Sn apshot对变更进行追踪


▶︎   如何保存Snapshot

使用 Snapshot 首先要解决的问题,是这个 Snapshot 要保存在哪里?

由于在 Go 中不支持类似 Java 里的 ThreadLocal,并且在 Go 里也不是很建议使用 goroutine local storage,所以对于这个 Snapshot 的存储就不那么方便了。

一种办法是将 Snapshot 放到 Context 中,比如 context 包下有一个 WithValue 方法,但是这个方法是返回一个装饰后的 Context,我们还是无法更改全局的 Context。

因此,我们这里采用了一种妥协的做法,即将 Snapshot 置于对应的聚合根内:

▶︎   通过Sanpshot进行Diff

之后,对 Repository 的实现逻辑进行相应的修改:

这里主要的改动点在于 Save 方法。

我们首先调用了 DetectChanges 方法,这个方法会返回一个 OrderDiff 的实例,通过 OrderDiff ,可以判断出是否有新增/更新/删除 OrderItems ,是否需要更新 orders 表等。

同时,在Find方法里,如果成功获取到了 Order 实例,还要手动调用 Attach 方法,这个方法的主要作用是生成当前 Order 实例的一个快照,后续对 Order 的修改是不会影响到这个快照的,因此,在 Save 的时候就可以拿当前的 Order 跟快照做一下 Diff,从而判断出都做了哪些改动。


04   结语


今天,我与你一同探讨了如何通过仓储,对实体进行持久化处理,在这里,你需要关注下图中的几点:

在实际运行中,我们会通过将实体转化为数据对象的形式来进行持久化。实体对象因为跟数据对象不具有一一对应的关系,因此,这中间就需要用到 converter 来做一个转化。

其次,仓储里的方法在命名上要避免使用类似SQL中的一些词语,而应该使用更加笼统一些的词汇,比如Save、Find、Remove等。

最后,为了最小化DB操作,在每次Save的时候,还需要知道实体都做了哪些改动,我们通过 Snapshot 这种方式来实现。

在上面的例子中,Snapshot 的实现还是有些复杂,业务在实际编码时仍然存在不小的工作量。在后面的几讲里,我们还会继续说一下如何提炼一个 SDK 用以简化 Snapshot 的 Diff 操作。

到这里,貌似我们已经完成了 DDD 中的大部分功能,但其实不然。

DDD作为一个方法论,其要面对的是各种各样复杂的业务问题,随着复杂度变高,就一定存在某些只依赖实体和值对象无法解决的问题。

那这些问题要如何解决呢?可以停下来思考下,最好可以带着或多或少的疑问,到时我们在下一篇文章里就有针对性地来说说领域服务。老样子,我们再延申思考下。

▶︎  延伸思考

在一些Java实现的、相对古老的系统中,我们经常会看到这种写法,就是先定义一个接口:

之后再定义一个实现了该接口的Impl:

通常情况下,这个 XXXServiceImpl 就是 XXXService 的唯一实现方。这种看似是面向接口编程的方法,实则非常没有必要,也让代码变得冗余。

接口的作用主要是用来隔离变化,像上面这种情况,直接定义一个 public class XXXService 就好。

很多时候,我们从书本中看到一些观点、原则,都不应该盲目地去套用,而应该在充分理解底层原理的基础上灵活运用。

正所谓尽信书则不如无书,说的即是。


THE END 

转载请联系ITPUB官方公众号获得授权

—————————————————————————————————

欢迎各领域技术人员投稿

投稿邮箱 |     hannan@it168.com


请使用浏览器的分享功能分享到微信等