Repository 仓储,你的归宿究竟在哪?(上)
写在前面
写这篇博文的灵感来自《如何开始DDD(完)》,很感谢young.han兄这几天的坚持,陆陆续续写了几篇有关于领域驱动设计的博文,让园中再次刮了一阵“DDD探讨风”,我现在不像前段时间那样“疯狂”了,写博文需要灵感,就像这篇一样。那篇博文除去其他的一些问题探讨,留给我印象最深的就是:领域服务中使用仓储,下面摘自文中我的一段评论:
- 领域服务中去调用仓储,这一点是我一直所纠结的地方,我现在做的项目是领域服务中是不参杂着仓储的,这个操作是在应用层中,比如:_userRepository.Add(user);
- 兄台接下来说的观点,首先要明确一点的是:仓储应不应该在领域服务中进行调用???我上次写了那篇文章,其实到最后也没讨论出个结果,反正我现在所做的是,领域服务不实现仓储调用。你可以结合测试驱动开发就知道没什么了,DDD+TDD,其实领域模型最好的业务体现是在哪?不是在领域模型,而是领域模型的单元测试,它是很好的描述这个业务用例,如果你的领域模型的单元测试出了问题,那就是领域模型出了问题,其实兄台可以试着写下你这个业务场景下的领域模型的单元测试,也就是一个业务用例的单元测试,看看会发生什么?还有就是应用层的伪代码。
文中Luminji兄这样回复我:“领域服务不用仓储,那我们怎么单元测试领域服务?仅此一点,就说明领域服务必用仓储。反之,倒是上层,如控制器这里不应该用仓储。”其实原本大家的焦点不应该放在仓储上面的,而应该放在领域驱动设计的核心-领域模型上,为此我还曾写了几篇关于领域模型设计的博文,但是一个完整的应用程序不只是包含领域模型,还有其他的东西需要进行探讨,虽然它不像领域模型那么重要,但同样必不可少。
Luminji兄的评论,让我意识到需要把领域驱动设计中的其他概念明确探讨下了,如果对一些概念模糊不清,或者不能很好的明确其职责,这样就很容易导致我们在领域驱动设计的过程中陷入一些困境,就像我之前所掉进的坑-《设计窘境:来自 Repository 的一丝线索,Domain Model 再重新设计》。
以下内容只是个人对仓储概念及其问题进行探讨,并非是结论总结,仅供各位仁兄参考。
《实现领域驱动设计》
在进行正文探讨之前,我先啰嗦几句。
《实现领域驱动设计》这本书,我在之前觉得没必要阅读,因为当时认为学习领域驱动设计,只要精读下 Eric Evans 的经典著作《领域驱动设计-软件核心复杂性应对之道》就可以了,但是DDD是需要进行实践的,Eric Evans 只是提出领域驱动设计这个概念,有关于其实现,书中并没有花很大的精力去讲解,而《实现领域驱动设计》这本书正是弥补了这一点。
这两本书的阅读顺序,当然是先阅读《领域驱动设计》,然后再阅读《实现领域驱动设计》,如果你是第一次读第一本书,它会颠覆你对软件设计的一些看法,然后让你不能自拔的“爱上它”,不知道你有没有,反正我是这样,然后你在做一些应用程序设计的时候,会尝试使用领域驱动设计,虽然有些步履蹒跚,但是走出第一步是很重要的。关于阅读第二本书,我的建议是,在阅读之前,先根据第一本书中的指导,自己尝试去实践领域驱动设计,最好是做一些实际业务场景的应用,在这个过程中,完全按照自己对领域驱动设计的想法去实现,虽然可能会掉进一些深坑,但是我觉得只有这样你才会理解的更加深刻。至于为什么自己实践过领域驱动设计再去阅读第二本书?因为实践过后阅读的话,你会与作者产生一些共鸣,这是很奇妙的感觉,就像译者-腾云这样所说:
《实现领域驱动设计》这本书,我现在也只读了第十二章-资源库(译者把 Repository 翻译为资源库,和仓储是一个意思,我更喜欢仓储这个名词,后面就用它来表示 Repository 了),阅读仓储这一章的时候,我是带着问题进行阅读的,也就是仓储的职责是什么?它的归宿究竟在哪?但是很可惜,我在这一章节中并没有找到我要寻找的答案,因为作者主要讲解的是仓储的实现,但是我发现了其他一些有意思的东西,下面希望和各位仁兄分享下(或许有点偏离主题了,但是我觉得应该会蛮有意义的)。
仓储(Repository) VS 数据访问对象(DAO)
有关于仓储的概念,我不止在一篇博文中进行说明,但是这边既然和数据访问对象进行比较的话,还是要声明一下,下面来自《领域驱动设计》书中的定义:
Repository(仓储):协调领域和数据映射层,利用类似与集合的接口来访问领域对象。
也可以像 dudu 这样进行直白的理解:Repository 是一个独立的层,介于领域层与数据映射层(数据访问层)之间。它的存在让领域层感觉不到数据访问层的存在,它提供一个类似集合的接口提供给领域层进行领域对象的访问。Repository 是仓库管理员,领域层需要什么东西只需告诉仓库管理员,由仓库管理员把东西拿给它,并不需要知道东西实际放在哪。
仓储是领域驱动设计中产生的概念,也就是说,如果你的应用程序不是基于领域驱动设计的,那在设计中使用仓储是不是有点不伦不类呢?首先,就像 Eric Evans 所定义中明确的那样:协调领域和数据映射层,两个关键字领域和数据映射层,这里面的领域是指领域模型(实体和值对象),这是桥的一头,另一头就是数据映射层,也就是我们常说的 ORM 工具,在 .NET 领域也就是我们常用的 EntityFramework,很多人认为 EntityFramework 就包含仓储,好像之前有人发表过博文阐述过这个问题,但是你看下仓储的定义,就会发现这不是一个概念的问题。除了这两个关键词,还有一个动词就是协调,仓储协调的是什么?怎么协调的?这个概念需要明确下,桥的一头-领域模型(主要是实体对象),这个就不多说了,桥的另一头-ORM(对象关系映射),因为我们大部分情况下使用的是关系型数据库,如何对数据进行管理?当然 DAO 是一种(这边先不多说),还有就是使用 ORM,它可以让你很方便的进行数据和对象映射转换,如果你的项目是基于事务脚本模式设计的,那就没必要使用 ORM 工具了,因为使用简单的 SQL 更合适,说了这么多,好像都没说到重点,其实仓储协调的是 ORM 中的“O”,也就是对象的概念,它是在数据映射层之上的,是一种概念,而不是一种实现,这个概念很重要。
有时候,仓储和数据访问对象会当作同义词来看待,因为他们都提供了对持久化机制的抽象,在 DAO 中比较好理解,仓储中的持久化机制主要体现在 ORM 中,但是这并不属于仓储,更不属于 DAO,所以有时候我们认为所有的持久化抽象称为 DAO,并不是很准确,我们需要确定的是这种模式是否得到了真正的实现。
仓储和 DAO 是不同的,一个 DAO 主要从数据库表的角度来看待问题,并且提供 CRUD 操作,这种模式适用于事务脚本程序中,这是因为,这些与 DAO 相关的模式通常只是对数据库表的一层封装。而另一方面,仓储和数据影射器(ORM)则更加偏向于对象,因此通常被用于领域模型中。
还有一点内容就是存储过程的探讨,在《实现领域驱动设计》书中,作者也提到了,他不建议我们在基于领域驱动设计的应用中去使用存储过程,因为我们的建模团队并不能很好的理解存储过程所使用的语言,此外,通常来说他们也看不到存储过程的实现,而这些都是有饽于 DDD 目标的,但是有时候使用存储过程是为了程序性能,这是一个取舍的问题,就像我们使用 ORM 一样,我们需要对这个概念进行明确清楚,以防止我们在领域驱动设计的过程中参杂一些其他的东西。
有关仓储和数据访问对象的探讨,最后的结论是,通常来说,你可以将仓储当作 DAO 来看待,但是请注意一点,在设计仓储时,我们应该采用面向集合的方式,而不是面向数据访问的方式。这有助于你将自己的领域当作模型来看待,而不是 CRUD 操作。
以下几段话来自netfocus兄:
- 仓储是面向领域的,仓储定义的目的不是db驱动的,仓储管理的数据的最小粒度是聚合根,这两点和DAO有很大不同;
- 仓储用于实现聚合的生命周期,聚合创建后,如果不用了,会放回仓储,需要用时,再从仓储取出来(也就是唤醒聚合的意思);所以仓储就是聚合的温床。按照仓储的定义,它是一个集合,所以我们只会为仓储提供类似集合的接口,比如Add,Remove,Get这种操作;因为集合没有Save的说法,所以仓储上不需要有Save,更不会有Commit,也不会有Delete等概念。因为是集合,所以可以理解为一个无限大的内存空间,我们不关心集合是否太大,也不关心背后的持久化,这些不是DDD该思考的东西,我们可以用Dapper来实现,也可以用Mongo,也可以用EF。
- Save, Delete, Commit这些都是持久化的概念,最多在应用层表达。
关于仓储(Repository),你必须知道的几个概念。
1,仓储的两种设计方式:面向集合和面向持久化
面向集合和面向持久化,这两种类型的仓储设计方式,在《实现领域驱动设计》中有很详细的讲解,作者还附带了几个具体的实现,比如 Hibernate 实现、TopLink 实现等等,这个必须赞一个,感兴趣的朋友,可以进行阅读下。这面我简单说明下,这两种设计方式的不同之处,举个最直白的例子。
面向集合方式:
this.UserRepository.Add(user);
面向持久化方式:
this.UserRepository.Save(user);
可能很多朋友看到这,会不以为然,需要明确一点,在领域驱动设计中,不论是变量或是方法的命名规则都非常重要,因为其代码就是代表着一种通用语言,你要让人家可以看懂。在面向集合方式中,新对象的添加使用的是 Add,而在面向持久化方式中,不论是新对象的添加或是修改,都是使用的 Save,如果是基于 Unit Of Work(工作单元),会有 Commit。
2,不允许同一聚合实例多次添加到仓储中
关于这一点其实很多人都知道,因为聚合存在唯一性,仓储是管理它的集合,所以不可能在集合中存在多个同一聚合。另外在面向集合方式实现中,当从仓储中获取一个对象并对其进行修改时,我们并不需要“重新保存”该对象到仓储中,因为集合维护了对该对象的引用,而修改将直接作用在该对象上。
3,仓储实现方法返回类型建议为 void
我们在定义仓储接口的时候,一般会这样定义:
bool Add(TAggregateRoot aggregateRoot);
比如添加聚合实例的方法返回值为 bool 类型,但是有时候返回 true 并不一定代表着该聚合实例成功添加到仓储中了,因此,对于仓储来说,返回 void 可能会是更好的方式。那如何判断该聚合实例成功添加到仓储中了呢?我们一般会在仓储实现中进行异常捕获,这一点内容,在书中有讲解,我们可以自定义异常信息,友好的抛出一个异常。
4,对聚合实例的批量操作,最好不要使用 addAll() 和 removeAll() 方法
有时候我们在单个事务中,对多个聚合实例进行添加或删除的时候,为了方便,我们会使用 addAll() 和 removeAll() 方法,但是,我们使用这种方式,并不能对单个聚合实例操作进行监控,建议方式是循环调用 add() 和 remove() 方法。
5,聚合中删除聚合实例的正确表达是什么?
有时候,在应用程序设计中,对实例对象的生命周期管理就代表着其业务逻辑的体现,我们一般在设计中删除对象使用的是 delete,具体表现是从数据库中直接将数据删除掉,这是在事务脚本中的实现方式,在领域驱动设计中,其实是不存在对象删除这一说法的,正确的表达应该是,将聚合实例标记为失活的(disabled),不可用的(unusable),也就是说在仓储所涵盖的内容里面,最好不要出现 delete,至于数据库具体持久化中的 delete,这个就不在仓储的概念之中了。
6,仓储在各层中的位置存放
在书中,作者是这样表述的:我们将仓储接口定义放在了与聚合相同的包中(书中所有的示例都是用 java 实现的),而将仓储中的实现类放在了 impl 子包中,这种方式被大量的 java 项目所采用,然而,在协作上下文中,团队成员们,将实现类放在了基础设施层中。
这一点我是和作者持相同观点,比如下面的解决方案:
7,仓储中的级联删除所引出的问题
关于这个问题,其实我也不是很理解,下面引自作者的一段话(P375):
有人可能会依赖于ORM所提供的生命周期事件来完成对象的级联删除。我刻意地没有使用这种方式,因为我强烈反对由聚合来管理持久化,同时我强烈地提倡只使用资源库来处理持久化。当然,有关这两者的争论非常激烈,并且还在继续。因此,在选择时,你需要多方权衡。但是请记住,DDD专家是不会首先考虑使用聚合来管理持久化的。
根据我的猜测,大概是这样的意思,主要是仓储的持久化管理,一种是使用 ORM 攻击所提供的持久化机制,这种方式就使得仓储依赖于这些技术的实现,但是可以为我们在实现仓储的时候省去很多事,比如我们使用 EntityFramework,你会发现我们在实现仓储的时候,变得异常简单。还有一种方式就是作者提到的,建议让仓储自身去实现持久化机制,但是这种方式实现起来比较复杂,我也没具体的找到其实现方法,这边就不多说。
8,Unit Of Work(工作单元)的使用
只需要记住一点:当 Unit Of Work 中的 commit() 方法执行时,所有发生在对象上的修改都将提交到数据库中。
9,count() or size()?
我们有时候计算聚合实例的总数,一般会将实现方法命名为 count(),但是因为仓储应该尽可能的模拟一个集合,因此建议接口定义如下:
int Size();
命名规则是我们在软件开发过程中,最容易忽略的一点,可能在一般的开发过程中不注意会没事,但是在领域驱动设计中,就像之前所表述的那样,代码代表着一种语言,不光是自己能看懂,还要让需求人员可以看懂,至少可以从名字上知道其代表的意思,这一点很重要。
10,聚合根下的子聚合正确方式
有时,如果我们要获取聚合根下的某些子聚合,我们不用先从资源库中获取到聚合根,然后再从聚合根中获取这些子聚合,而是可以直接从资源库中返回。在有些情况下,这种做法是有好处的。比如,某个聚合根拥有一个很大的实体类型集合,而你需要根据某种查询条件返回该集合中的一部分实体。当然,只有在聚合根中提供了对该实体集合的导航时,我们才能这么做,否则,我们便违背了聚合的设计原则。我建议不要因为客户端的方便而提供这种访问方式。更多的时候,采用这种方式是由于性能上的考虑,比如从聚合根中访问子聚合将带来性能瓶颈的时候。此时的查找方法和其他查找方法具有相同的基本特征,只是它直接返回聚合根下的子聚合,而不是聚合根本身。无论如何,请慎重使用这种方式。
以上是书中作者的观点描述,其实最终也没有表述出一个正确的方式,只是说直接访问子聚合,作者不建议这样做,但是有时候为了一些性能问题,我们又不得不权衡利弊一下。除了这个问题之外,还有一个就是仓储执行完查询后,有时候会返回多个聚合的查询结果对象,这个我们一般会将查询结果放在一个值对象中。
11,CQRS 模式引入
对于 CQRS 模式,我没有深入研究过,更没有实践应用过,我的想法是先去把经典DDD理解透,然后再去尝试其他东西,毕竟路要一步一步走,CQRS 模式是对 DDD 的一种很好补充,也就是说它的产生是有一定的理由的,对于领域驱动设计初学者,我个人不建议,一开始就使用 CQRS 模式。
当我们使用用例优化查询时,有时候我们必须创建多个查询方法,什么意思?就是跨聚合查询,这可能意味着你的聚合边界划分的有问题,如果你确定你的聚合边界划分没有问题,那你应该考虑使用 CQRS 模式了,它的应用场景就是这样,凡事都有产生的原因,如果你的应用程序没有很复杂的查询操作,我个人觉得,完全没必要使用 CQRS 模式,有时候不要为了实现而实现。
12,共享仓储
对于这个概念,我没有深入研究过,作者也只是提出了一个思考,这边也不多说,思考如下:
为不同的聚合类型提供单独的资源库究竟给我们带来了什么好处?在聚合子类较少的情况下,为它们使用单独的资源库可能是最好的方式。但是,随着聚合子类数目的增加,而同时它们又具有完全的可互换性时,使用一个共享的资源库便更合适了。
写在最后
本来想一篇博文写完了事,但是看了下内容,写了还蛮多的,其实都还没说到重点上,只是大致讲述了仓储的概念,为防止大家看得累,那分为上下篇来进行讲解。
下篇主要对:仓储,你的归宿究竟在哪?这个问题进行探讨,内容主要包含其职责及调用场景的可行性探讨,具体用代码来验证。
这一篇内容就到这里,欢迎大家拍砖讨论。