我们已经介绍了值对象、实体等领域对象,这些对象在创建出来后,总不能一直被保存在内存当中,因此,就需要在某个时刻持久化到数据库。
在DDD中,负责持久化的组件是仓储,也叫资源库。
简单来说,仓储就是用来持久化聚合根的,但其跟我们平时使用的dal(Data Access Layer)又有所不同。
dal 是对具体数据库的直接操作,是跟数据库类型强相关的,而仓储只服务于聚合根,而且其也只是在概念上规定了对聚合根的持久化,不关心具体存到哪里以及如何存的问题。

接口定义

仓储接口的定义是跟领域对象放在一起的,如下面的目录结构所示:

  1. ├── domain
  2. ├── entity
  3. ├── event
  4. └── repo # 仓储接口的定义放在这里

因为 domain 层原则上不能依赖任何其他层,因此,domain 下所有文件里都不应该 import 任何其他层的代码。
这也就意味着,我们在 repo 中定义的 Repository 接口的入参、出参都应当是领域层的结构体或者是 Golang 里的简单类型。
其次,因为 Repository 不关心底层具体的存储到底是什么,所以我们在命名方法时,应当避免使用带有明显技术色彩的词语,比如inser、update、select、delete这种。通常建议使用save、find、remove这类更加笼统的词汇。
前面在介绍实体时,提到了可以通过在仓储中定义一个 NextIdentity 方法来生成实体的唯一标识。
因此,综合上面的论述,一个 Repository 接口至少应该包含下面几个方法:

  1. type Repository interface {
  2. NextIdentity() (int64, error)
  3. Save(context.Context, *Order) error // 保存一个聚合
  4. Find(context.Context, int64) (*Order, error) // 通过id查找对应的聚合
  5. FindNonNil(context.Context, int64) (*Order, error) // 通过id查找对应的聚合,聚合不存在的话返回错误
  6. Remove(context.Context, *Order) error // 将一个聚合从仓储中删除
  7. }

其中,我们没有明确区分新增和更新操作,而是只定义了一个 Save 方法。
站在 Repository 的角度来看,它的职责只是将领域模型保存起来,到底是新增还是更新是技术层面需要关心的,而不是它。
再次, FindNonNil 跟 Find 比较类似,当指定id对应的实体不存在时,Find 会返回 nil, nil。而 FindNonNil 会认为这是一个错误,error 会返回 NotFound,这在某些场景下会非常有用。
除了这几个基本接口,各个业务可以在这个基础上根据自身场景进行扩展。

实现仓储接口

仓储接口定义在domain层,而具体实现是定义在基础设施层的。
为了区分基础设施层不同的功能模块,可以对基础设施层进一步划分,而仓储相关的代码可以统一放到 infra.persistence 包下。

  1. ├── domain
  2. └── repo
  3. └── order_repo.go
  4. ├── infra
  5. ├── persistence
  6. ├── converter # 可能存在这样一个层,用来做领域模型与数据模型之间的转化
  7. ├── dal
  8. └── order_repo_impl.go

在上面的代码结构中,repo.OrderRepo 接口对应的实现 OrderRepository 放在 order_repo_impl.go 文件中。
dal 中放置的是具体的对数据库表的访问。
converter 这个包可能存在也可能不存在,其主要作用是对领域模型和数据模型进行互转。
当领域模型与数据模型的字段一致时,可以退化为只使用领域模型,也即领域模型兼顾了数据模型的职责。
但是这样的话,所有模型字段必须要是大驼峰法。这个时候就要注意不要在领域之外直接修改字段值,这也是模型退化后我们需要承担的风险。
为了说明仓储的实现,我们先从最简单的情况说起。
现在我们假设 Order 这个聚合中的属性跟 orders 数据库表的字段是一一对应的。
在 OrderRepository 中需要引用到 IdGeneratorClient 和 OrderDal,具体实现如下:

  1. package persistence
  2. func NewOrderRepository(idGenerator *IdGeneragorClient, orderDal *OrderDal) *OrderRepository {
  3. return &ExamRepository{
  4. idGenerator: idGenerator,
  5. orderDal: orderDal,
  6. }
  7. }
  8. type OrderRepository struct {
  9. idGenerator *IdGeneratorClient
  10. orderDal *OrderDal
  11. }
  12. func (r *OrderRepository) NextIdentify() (int64, error) {
  13. return r.idGenerator.Gen()
  14. }
  15. func (r *OrderRepository) Save(ctx context.Context, order *entity.Order) error {
  16. return r.orderDal.Upsert(ctx, order)
  17. }
  18. func (r *OrderRepository) Find(ctx context.Context, id int64) (*entity.Order, error) {
  19. return r.orderDal.SelectById(ctx, id)
  20. }
  21. func (r *OrderRepository) FindNonNil(ctx context.Context, id int64) (*entity.Order, error) {
  22. rlt, err := r.Find(ctx, id)
  23. if err != nil {
  24. return nil ,err
  25. }
  26. if rlt == nil {
  27. return nil, NotFoundErr
  28. }
  29. return rlt, nil
  30. }
  31. func (r *OrderRepository) Remove(ctx context.Context, order *entity.Order) error {
  32. r.orderDal.SoftDelete(ctx, exam)
  33. }

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

  1. Save 方法同时兼具了新增和更新功能,具体的逻辑是在 dal 中通过 Upsert 实现的;
  2. Dal 中方法的命名带有明显的 sql 特征;
  3. 在应用服务中获取某个聚合根时,通常都要判断下聚合根是否存在,我们这里提供的 FindNonNil 方法将这一个常规操作进行了封装;
  4. Order 是定义在领域层的聚合根,而不是数据库表的数据模型。在 Order 中的属性跟 orders 数据库表的字段是一一对应的这个假设下,我们省略了对数据模型的定义。所以,这里的 Order 是身兼数职,但其主要职责还是领域模型,只是为了代码实现上的便利,才妥协同时承担了数据模型的职责;

当上面的假设不成立时,又该怎么办呢?
比如一个 Order 中存在多个 Item,Order 存到 orders 表,Item 存到 order_items 表,通过 order_id 进行关联,其结构如下所示:

  1. type Order struct {
  2. Items []*OrderItem
  3. ...
  4. }

在这种场景下,我们的问题在于,如果某次对 Order 的修改只是更改了某个 Item 的信息,我们要如何执行 Save 方法?
首先,如果我们可以接受将 Items 字段序列化为 json 字符串,在 orders 表中新增这样一个 items 字段来存储 json,这样也可以解决问题,但是当需要对 Item 进行查询时就不太方便了。
另外一种简单粗暴的方式是,不管三七二十一将 Order 中的信息都更新一遍,这样做的缺点也很明显,就是会多出很多无用的 DB 操作:

  1. type OrderRepository struct {
  2. idGenerator *IdGeneratorCient
  3. orderDal *OrderDal
  4. orderItemDal OrderItemDal
  5. }
  6. func (r *OrderRepository) Save(ctx context.Context, order *entity.Order) error {
  7. orderPO := converter.OrderToPO(order)
  8. if err != r.orderDal.Upsert(ctx, orderPO); err != nil {
  9. return err
  10. }
  11. for _, item := range order.Items {
  12. itemPO := converter.OrderItemToPO(item)
  13. if err != r.orderItemDal.Upsert(ctx, itemPO); err != nil {
  14. return err
  15. }
  16. }
  17. return nil
  18. }
  19. ...

在上面代码中,converter 的作用是负责领域模型与数据模型之间的转化,数据模型我们一般用 PO (Persistant Object)表示。
converter 存在的价值在于,数据模型与领域模型并非是完全一致的,converter 负责管理了彼此之间的映射关系。
对于聚合根不是特别复杂的情况,上面的实现方式虽然存在无用 DB 操作,但也还能接受。
在对聚合的设计中有一条规则是要设计小聚合,其原因也在于此。
那如果很不幸我们有一个很大的聚合,无法接受全量更新,要怎么办呢?
通常有两种方法:

  • 一种是基于 Snapshot 的,当聚合根取出后,在内存中先保存一份snapshot,在聚合根写入时,将其跟snapshot做一下diff。
  • 另一种是将聚合根上可以修改的属性设置成私有的,然后通过类似Setter的方法来进行赋值,这样,在setter被调用时我们就知道哪里被修改了。

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

使用Snapshot对变更进行追踪

使用 Snapshot 首先要解决的问题是这个 Snapshot 要保存在哪里?
由于在 Go 中不支持类似 Java 里的 ThreadLocal,并且在 Go 里也不是很建议使用 goroutine local storage,所以对于这个 Snapshot 的存储就不那么方便了。
一种办法是将 Snapshot 放到 Context 中,比如 context 包下有一个 WithValue 方法,但是这个方法是返回一个装饰后的 Context,我们还是无法更改全局的 Context。
因此,我们这里采用了一种妥协的做法,即将 Snapshot 置于对应的聚合根内:

  1. type Order struct {
  2. Id int64
  3. Items []*OrderItem
  4. ... // 其他属性
  5. snapshot *Order
  6. }
  7. func deepCopy(e *Order) *Order {
  8. return &Order{
  9. ... // 对各属性进行深拷贝
  10. }
  11. }
  12. func (e *Order) Attach() {
  13. if e.snapshot == nil || e.snapshot.Id == e.Id {
  14. e.snapshot = deepCopy(e)
  15. }
  16. }
  17. func (e *Order) Detach() {
  18. if e.snapshot != nil && e.snapshot.Id == e.Id {
  19. e.snapshot = nil
  20. }
  21. }
  22. type OrderDiff struct {
  23. OrderChanged bool
  24. RemovedItems []*OrderItem
  25. AddedItems []*OrderItem
  26. ModifiedItems []*OrderItem
  27. }
  28. func (e *Order) DetectChanges() *OrderDiff {
  29. if e.snapshot == nil {
  30. return nil
  31. }
  32. ... // 其他diff逻辑
  33. }

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

  1. type OrderRepository struct {
  2. ...
  3. }
  4. func (r *OrderRepository) NextIdentify() (int64, error) {
  5. return r.idGenerator.Gen()
  6. }
  7. func (r *OrderRepository) Save(order *entity.Order) error {
  8. diff := e.DetectChanges()
  9. if diff == nil {
  10. // diff 为空,说明当前不需要追踪变更,采用全量更新的方式
  11. orderPO := converter.OrderToPO(order)
  12. if err != r.orderDal.Upsert(ctx, orderPO); err != nil {
  13. return err
  14. }
  15. for _, item := range order.Items {
  16. itemPO := converter.OrderItemToPO(item)
  17. if err != r.orderItemDal.Upsert(ctx, itemPO); err != nil {
  18. return err
  19. }
  20. }
  21. } else {
  22. // 根据diff,只更新发生了变更的表
  23. if diff.OrderChanged {
  24. orderPO := converter.OrderToPO(order)
  25. if err != r.orderDal.Upsert(ctx, orderPO); err != nil {
  26. return err
  27. }
  28. }
  29. for _, item := range diff.RemovedItems {
  30. itemPO := converter.OrderItemToPO(item)
  31. if err != r.orderItemDal.SoftDelete(ctx, itemPO); err != nil {
  32. return err
  33. }
  34. }
  35. for _, item := range diff.AddedItems {
  36. itemPO := converter.OrderItemToPO(item)
  37. if err != r.orderItemDal.Create(ctx, itemPO); err != nil {
  38. return err
  39. }
  40. }
  41. for _, item := range diff.ModifiedItems {
  42. itemPO := converter.OrderItemToPO(item)
  43. if err != r.orderItemDal.Update(ctx, itemPO); err != nil {
  44. return err
  45. }
  46. }
  47. }
  48. e.Attach() // 再次调用Attach开始新一轮的追踪
  49. return nil
  50. }
  51. func (r *OrderRepository) Find(id int64) (*entity.Order, error) {
  52. order := ... // 获取Exam聚合根的流程
  53. if order != nil {
  54. order.Attach() // 之后调用Attach方法生成Snapshot,开始追踪
  55. }
  56. return order, nil
  57. }
  58. func (r *OrderRepository) FindNonNil(id int64) (*entity.Order, error) {
  59. ... // 同之前逻辑
  60. }
  61. func (r *OrderRepository) Remove(order *entity.Order) error {
  62. ... // 调用dal执行删除逻辑
  63. order.Detach() // 删除掉 Snapshot 不再追踪
  64. }

这里主要的改动点在于 Save 方法。
我们首先调用了 DetectChanges 方法,这个方法会返回一个 OrderDiff 的实例,通过 OrderDiff ,可以判断出是否有新增/更新/删除 OrderItems ,是否需要更新 orders 表等。
同时,在Find方法里,如果成功获取到了 Order 实例,还要手动调用 Attach 方法,这个方法的主要作用是生成当前 Order 实例的一个快照,后续对 Order 的修改是不会影响到这个快照的,因此,在 Save 的时候就可以拿当前的 Order 跟快照做一下 Diff,从而判断出都做了哪些改动。

总结

实体对象因为跟数据对象不具有一一对应的关系,因此,在实际持久化的时候,就需要用到 converter 来做一个转化。
同时,为了最小化DB操作,还需要知道对实体都做了哪些改动,我们通过 Snapshot 这种方式来实现。
在上面的例子中,Snapshot 的实现还是有些复杂,业务在实际编码时仍然存在不小的工作量。在后面的章节,我们还会继续说一下如何提炼一个 SDK 用以简化 Snapshot 的 Diff 操作。
到这里,貌似我们已经完成了 DDD 中的大部分功能,但其实不然。
DDD作为一个方法论,其要面对的是各种各样复杂的业务问题,随着复杂度变高,就一定存在某些只依赖实体和值对象无法解决的问题。
那这些问题要如何解决呢?我们在下一篇文章中就来说说领域服务。