DDD之Repository

之前的DDD文章中也指出过,现在从理论角度对于repository是错误,但一直没有摸索出最佳实践,都是当DAO使用,区别在于repository是领域层,也没有深入思考过

最近再次温习《DDD第二弹》时,看到了这个评论

domain service不应该直接调用repository,这打破了我对repository的认知,对此让我不得不纠结一下repository,在之前的学习中,从没有听到此规则,repository与domain service都是领域层的,为什么两都不能相互调用呢?

从源头重新梳理一下repository的知识,重新翻阅Eric Evans的《领域驱动设计》和Vaughn Vernon的《实现领域驱动设计》

repository

repository是在《领域驱动设计》第六章领域对象的生命周期提出

factory用来创建领域对象,而repository就是在生命周期的中间和末尾使用,来提供查找和检索持久化对象并封装庞大基础设施的手段

这句话就把repository的职责讲清楚了:

  1. 提供查找和检索对象
  2. 协调领域和数据映射层

在现有技术范畴中,都使用DAO方式,为什么还需要引入repository呢?

尽管repository和factory本身并不是来源于领域,但它们在领域设计中扮演着重要的角色。这些结构提供了易于掌握的模型对象处理方式,使model-driven design更完备

领域驱动设计的目标是通过关注领域模型(而不是技术)来创建更好的软件。假设开发人员构造了一个SQL查询,并将它传递给基础设施层中的某个查询服务,然后再根据得到的表行数据的结果集提取出所需信息,最后将这些信息传递给构造函数或factory。开发人员执行这一连串操作的时候,早已不再把模型当作重点了。我们很自然地会把对象看作容器来放置查询出来的数据,这样整个设计就转向了数据处理风格。虽然具体的技术细节有所不同,但问题仍然存在–客户处理的是技术,而不是模型概念

在DDD思想中,领域模型是最重要的,所有的一切手段都是为了让团队专注于模型,屏蔽一切非模型的技术细节,这样也才能做到通用语言,交流的都是模型

VS DAO

有人总结DDD就是分与合,分是手段、合是目的;对于DDD战略来讲,就是通过分来形成各个上下文界限,在各个上下文中,再去合,很类似归并算法

而聚合就是最小的合,repository相对dao,是来管理聚合,管理领域对象生命周期

  1. 为客户提供简单的模型,可用来获取持久化对象并管理生命周期
  2. 使应用程序和领域设计与持久化技术(多种数据库策略甚至是多个数据源)解耦
  3. 体现对象访问的设计决策
  4. 可以很容易将它们替换为“哑实现”,以便在测试中使用(通常使用内存中的集合)

而DAO的核心价值是封装了拼接SQL、维护数据库连接、事务等琐碎的底层逻辑,让业务开发可以专注于写代码。但是在本质上,DAO的操作还是数据库操作,DAO的某个方法还是在直接操作数据库和数据模型,只是少写了部分代码,并且可以操作任意表对象;在Uncle Bob的《代码整洁之道》一书里,作者用了一个非常形象的描述:

  • 硬件(Hardware):指创造了之后不可(或者很难)变更的东西。数据库对于开发来说,就属于”硬件“,数据库选型后基本上后面不会再变,比如:用了MySQL就很难再改为MongoDB,改造成本过高。
  • 软件(Software):指创造了之后可以随时修改的东西。对于开发来说,业务代码应该追求做”软件“,因为业务流程、规则在不停的变化,我们的代码也应该能随时变化。
  • 固件(Firmware):即那些强烈依赖了硬件的软件。我们常见的是路由器里的固件或安卓的固件等等。固件的特点是对硬件做了抽象,但仅能适配某款硬件,不能通用。所以今天不存在所谓的通用安卓固件,而是每个手机都需要有自己的固件。

从上面的描述我们能看出来,数据库在本质上属于”硬件“,DAO 在本质上属于”固件“,而我们自己的代码希望是属于”软件“。但是,固件有个非常不好的特性,那就是会传播,也就是说当一个软件强依赖了固件时,由于固件的限制,会导致软件也变得难以变更,最终让软件变得跟固件一样难以变更

举个软件很容易被“固化”的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
private OrderDAO orderDAO;
public Long addOrder(RequestDTO request) {
// 此处省略很多拼装逻辑
OrderDO orderDO = new OrderDO();
orderDAO.insertOrder(orderDO);
return orderDO.getId();
}
public void updateOrder(OrderDO orderDO, RequestDTO updateRequest) {
orderDO.setXXX(XXX); // 省略很多
orderDAO.updateOrder(orderDO);
}
public void doSomeBusiness(Long id) {
OrderDO orderDO = orderDAO.getOrderById(id);
// 此处省略很多业务逻辑
}

在上面的这段简单代码里,该对象依赖了DAO,也就是依赖了DB。虽然乍一看感觉并没什么毛病,但是假设未来要加一个缓存逻辑,代码则需要改为如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
private OrderDAO orderDAO;
private Cache cache;
public Long addOrder(RequestDTO request) {
// 此处省略很多拼装逻辑
OrderDO orderDO = new OrderDO();
orderDAO.insertOrder(orderDO);
cache.put(orderDO.getId(), orderDO);
return orderDO.getId();
}
public void updateOrder(OrderDO orderDO, RequestDTO updateRequest) {
orderDO.setXXX(XXX); // 省略很多
orderDAO.updateOrder(orderDO);
cache.put(orderDO.getId(), orderDO);
}
public void doSomeBusiness(Long id) {
OrderDO orderDO = cache.get(id);
if (orderDO == null) {
orderDO = orderDAO.getOrderById(id);
}
// 此处省略很多业务逻辑
}

这时,你会发现因为插入的逻辑变化了,导致在所有的使用数据的地方,都需要从1行代码改为至少3行。而当你的代码量变得比较大,然后如果在某个地方你忘记了查缓存,或者在某个地方忘记了更新缓存,轻则需要查数据库,重则是缓存和数据库不一致,导致bug。当你的代码量变得越来越多,直接调用DAO、缓存的地方越来越多时,每次底层变更都会变得越来越难,越来越容易导致bug。这就是软件被“固化”的后果。

所以,我们需要一个模式,能够隔离我们的软件(业务逻辑)和固件/硬件(DAO、DB),让我们的软件变得更加健壮,而这个就是Repository的核心价值


这也是上面所述的第二点:协调领域和数据映射层

如果说DAO是低层抽象,那么Repository是高层抽象,也更衬托出repository的本质:管理领域的生命周期,不管数据来源于何方,只要把聚合根完整地构建出来就可以

data model与domain model

数据模型与领域模型,按照Robert在《整洁架构》里面的观点,领域模型是核心,数据模型是技术细节。然而现实情况是,二者都很重要

数据模型负责的是数据存储,其要义是扩展性、灵活性、性能

而领域模型负责业务逻辑的实现,其要义是业务语义显性化的表达,以及充分利用OO的特性增加代码的业务表征能力

调用关系

对于domain service不要调用repository,这个规则我不太明白,只能请教作者了,为什么要这样限制? 作者回复:

Domain Service是业务规则的集合,不是业务流程,所以Domain Service不应该有需要调用到Repo的地方。如果需要从另一个地方拿数据,最好作为入参,而不是在内部调用。DomainService需要是无状态的,加了Repo就有状态了。

我一般的思考方式是:domainService是规则引擎,appService才是流程引擎。Repo跟规则无关

业务规则与业务流程怎么区分?

有个很简单的办法区分,业务规则是有if/else的,业务流程没有

作者这样回答,我还是觉得太抽象了,在domain service拿数据太常见,还在看DDD第四讲时,作者有个示例是用domain service直接调用repository的,以此为矛再次追问作者

这儿的domain service是直接使用repo的,如果里面的数据都使用入参,结构就有些怪啊

在这个例子里确实是有点问题的(因为当时的关注点不是在这个细节上),一个更合理的方法是在AppService里查到Weapon,然后performAttack(Player, Monster, Weapon)。如果嫌多个入参太麻烦,可以封装一个AttackContext的集合对象。

为什么要这么做?最直接的就是DomainService变得“无副作用”。如果你了解FP的话,可以认为他像一个pure function(当然只是像而已,本身不是pure的,因为会变更Entity,但至少不会有内存外的调用)。这个更多是一个选择,我更倾向于让DomainService无副作用(在这里副作用是是否有持久化的数据变更)。

如果说Weapon无非是提供一些数据而已,那么我们假设扩展一下,每次attack都会降低Weapon的durability,那你在performAttack里面如果用了repo,是不是应该调用repo.save(weapon)?那为什么不直接在完成后直接用UserRepo.save(player)、MonsterRepo.save(monster)?然后再延伸一下,如果这些都做了,还要AppService干啥?这个Service到底是“业务规则”还是“业务流程”呢?

从另一个角度来看,有的时候也不需要那么教条。DomainService不是完全不能用Repo,有时候一些复杂的规则肯定是要从”某个地方“拿数据的,特别是“只读”型的数据。但是我说DomainService不要调用repo时的核心思考是不希望大家在DomainService里有“副作用”。

对于这种限制,我现在只能想到domain service要纯内存操作,不依赖repository可以提升可测试性

性能安全

这是在落地时,很多人都会想到的问题

性能

查询聚合与性能的平衡,比如Order聚合根,但有时只想查订单主信息,不需要明细信息,但repository构建Order都全部查出来了,怎么办?在《实现领域驱动设计》中,也是不推荐这么干的,使用延迟加载,很多人也觉得这应该是设计问题,不能依赖延迟加载

对此问题请教了作者:

在业务系统里,最核心的目标就是要确保数据的一致性,而性能(包括2次数据库查询、序列化的成本)通常不是大问题。如果为了性能而牺牲一致性,就是捡了芝麻漏了西瓜,未来基本上必然会触发bug。

如果性能实在是瓶颈,说明你的设计出了问题,说明你的查询目标(主订单信息)和写入目标(主子订单集合)是不一致的。这个时候一个通常的建议是用CQRS的方式,Read侧读取的可能是另一个存储(可能是搜索、缓存等),然后写侧是用完整的Aggregate来做变更操作,然后通过消息或binlog同步的方式做读写数据同步。

这也涉及到业务类型,比如电商,一个订单下的订单明细是很少量的,而像票税,一张巨额业务单会有很多很多的订单明细,真要构建一个完整的聚合根相当吃内存

对象追踪

repostiory都是操作的聚合根,每次保存保存大多只会涉及部分数据,所以得对变化的对象进行追踪

《实现领域驱动设计》中提到两种方法:

  1. 隐式读时复制(Implicit Copy-on-Read)[Keith & Stafford]:在从数据存储中读取一个对象时,持久化机制隐式地对该对象进行复制,在提交时,再将该复制对象与客户端中的对象进行比较。详细过程如下:当客户端请求持久化机制从数据存储中读取一个对象时,该持久化机制一方面将获取到的对象返回给客户端,一方面立即创建一份该对象的备份(除去延迟加载部分,这些部分可以在之后实际加载时再进行复制)。当客户端提交事务时,持久化机制把该复制对象与客户端中的对象进行比较。所有的对象修改都将更新到数据存储中。
  2. 隐式写时复制Implicit Copy-on-Write)[Keith & Stafford]:持久化机制通过委派来管理所有被加载的持久化对象。在加载每个对象时,持久化机制都会为其创建一个微小的委派并将其交给客户端。客户端并不知道自己调用的是委派对象中的行为方法,委派对象会调用真实对象中的行为方法。当委派对象首次接收到方法调用时,它将创建一份对真实对象的备份。委派对象将跟踪发生在真实对象上的改变,并将其标记为“肮脏的”(dirty)。当事务提交时,该事务检查所有的“肮脏”对象并将对它们的修改更新到数据存储中。

以上两种方式之间的优势和区别可能会根据具体情况而不同。对于你的系统来说,如果两种方案都存在各自的优缺点,那么此时你便需要慎重考虑了。当然,你可以选择自己最喜欢的方式,但是这不见得是最安全的选择。
无论如何,这两种方式都有一个相同的优点,即它们都可以隐式地跟踪发生在持久化对象中的变化,而不需要客户端自行处理。这里的底线是,持久化机制,比如Hibernate,能够允许我们创建一个传统的、面向集合的资源库。
另一方面,即便我们能够使用诸如Hibernate这样的持久化机制来创建面向集合的资源库,我们依然会遇到一些不合适的场景。如果你的领域对性能要求非常高,并且在任何一个时候内存中都存在大量的对象,那么持久化机制将会给系统带来额外的负担。此时,你需要考虑并决定这样的持久化机制是否适合于你。当然,在很多情况下,Hibernate都是可以工作得很好的。因此,虽然我是在提醒大家这些持久化机制有可能带来的问题,但这并不意味着你就不应该采用它们。对任何工具的使用都需要多方位权衡

《DDD第二弹》中也提到 业界有两个主流的变更追踪方案:这两个方案只是上面两种方案另取的两外名字而已,意思是一样的

  1. 基于Snapshot的方案:当数据从DB里取出来后,在内存中保存一份snapshot,然后在数据写入时和snapshot比较。常见的实现如Hibernate
  2. 基于Proxy的方案:当数据从DB里取出来后,通过weaving的方式将所有setter都增加一个切面来判断setter是否被调用以及值是否变更,如果变更则标记为Dirty。在保存时根据Dirty判断是否需要更新。常见的实现如Entity Framework

Snapshot方案的好处是比较简单,成本在于每次保存时全量Diff的操作(一般用Reflection),以及保存Snapshot的内存消耗。
Proxy方案的好处是性能很高,几乎没有增加的成本,但是坏处是实现起来比较困难,且当有嵌套关系存在时不容易发现嵌套对象的变化(比如子List的增加和删除等),有可能导致bug。

由于Proxy方案的复杂度,业界主流(包括EF Core)都在使用Snapshot方案。这里面还有另一个好处就是通过Diff可以发现哪些字段有变更,然后只更新变更过的字段,再一次降低UPDATE的成本。

安全

设计聚合时,聚合要小,一是事务考虑,二是安全性考虑。当并发高时,对聚合根操作时,都需要增加乐观锁

Reference

一文教你认清领域模型和数据模型

第三讲 - Repository模式

朱兴生 wechat
最新文章尽在微信公众号『码农戏码』