《领域驱动设计》:从领域视角深入仓储(Repository)的设计和实现
一、前言
“ DDD设计的目标是关注领域模型而并非技术来创建更好的软件,假设开发人员构建了一个SQL,并将它传递给基础设施层中的某个查询服务然后根据表数据的结构集取出所需信息,最后将这些信息提供给构造函数或者Factory,开发人员在做这一切的时候早已不把模型看做重点了,这个整个过程就变成了数据处理的风格 ”——摘 Eric Evans《领域驱动设计》 《领域驱动设计》中的Repository(下面将用仓储表示)层实际上是极具有挑战性的,对于它的理解,也十分重要。本文大部分内容都在众多前辈理论基础上,从一个崭新的领域视觉开始探索,并结合自己的实践感悟进行细致的解析。同时本文不仅仅是DDD前辈的搬运工,也创新提出了 仓储实体转移 的概念,可以提供给读者思考是否在自己场景中可以用到这种模式。即使读者也对仓储有很深的了解,我也觉得本文会对你有新的阅读体验。 导读:
- 本文首先从聚合根的生命周期和生存环境出发,引出了Repository概念,并说明其本质是管理中间过程的 集合容器 (2.1节);
- 根据集合容器的概念,在领域角度去挖掘出Repository的职责,并提出了 仓储实体转移模式 用作对不同仓储实现的对比标准(2.2节);
- 然后从实现例子出发,介绍了一种纯内存实现的仓储,用作体现仓储最佳实现(3.1节);
- 继续从实现例子出发,介绍了关系型数据库下的仓储特点,并描述 面向持久化 的仓储 的特点(3.4节);
二、概念剖析
DDD作者在介绍仓储模式的时候,谈到了大部分技术的过程会入侵领域模型,让开发人员迷失,本文反其道行之,读者可以先假设内存是无限大的,便于我们先关注模型再讨论技术实现,然后我们先从DDD中的重要概念 聚合实体 的领域模型使用出发,挖掘出仓储的本质特征和与之相关领域概念,然后再从本质特征,指导如何实现仓储。
- 标识:实体具有唯一标识,这个唯一标识使得实体和值对象区分开来;
- 状态:实体是具有可以被改变的状态,因此聚合实体无法被静态描述;
- 生命周期:实体拥有生命周期,从实体的创建,到实体的状态的终态;
- 生存环境:实体的活动存在于各个上下文中的领域服务或者应用服务中,其中分用例过程和中间过程;
- 用例过程:只要在执行用例过程的时候才需要实体的存在,其他时候,实体生命周期并没有结束,而是处于中间状态;
-
中间过程: 当没有任何用例在处理一个实体的时候,实体消失了吗?没有,它仍然存在生命周期内,这个时候我们认为实体正处在一种中间过程。
- 放置:建立一个新的聚合实体,这是一个聚合实体生命的开始,在用例过程结束后,把聚合实体放到仓储中;
- 查找:把已经存在的聚合实体找出来,这是一个聚合实体的中间过程到用例过程的行为;
- 管理:它负责聚合实体的中间过程管理,并屏蔽掉中间过程的细节,向领域层提供统一的能力抽象,一些数据统计类的也可以在该范畴内;
- 如何放置实体:为了方便管理,我们通常会采用分治把同一种类型的实体放在一起成为一个集合。相同类型和集合给了我们一个指导就是:仓储的设计应该是一个聚合实体类型对应一个仓储实体,具有一一对应关系,所以仓储实体应该是一个保存相同类型元素的集合容器;
- 如何查找实体:我们知道实体具有唯一标识别,也具有其他特征属性,所以为了查找实体,我们应该通过实体的唯一标识或者特征属性去遍历查找,仓储应当提供这种功能,所以仓储应该针对聚合实体字段具有索引查找功能;
- 如何查找仓储:既然我们提到了需要用仓储来查找实体,那么我们又是如何查到仓储的呢?其实这个很简单,如果一个聚合实体类型只具有一个仓储类型,那么我们把仓储设计为单例的就可以了。
- 一个聚合类型(也就是一个聚合根),最好对应一个仓储(这个不是绝对的);
- 一个仓储应该是单例的,便于先查到到仓储,再查找到聚合实体(当然也不是绝对的);
- 仓储应该是一个集合的抽象概念,并且负责屏蔽中间过程,包括其中的实现细节,如持久化和重建,它最好能 让客户感觉它似乎就一直在内存中一样 ;
- 仓储作为聚合实体的集合,应该具有检索实体的功能,如果从技术角度看,那么将一直持有聚合实体引用;
- 我们的一个用例服务中很可能不需要使用聚合实体本身,而仅使用到符合某种条件的聚合的数量,因此我们没必要查出聚合实体进行统计;
- 具体的基础设施数据库实现,对统计性能有着显著的性能优化,为了使用这些中间技术的优点,把统计这种细节的操作委托给仓储是一个很好的选择。
- 统计和查询有很多时候的应用场景是不修改聚合根状态的,所以这种情况你可能没必要使用仓储完成这件事,CQRS的思想要求我们去分离查询,建立查询模型,所以建立一套查询模型去做这件事是一个好的解耦实践。
- 规格是一个谓词,封装了业务规则,可以明确表达一个特定实体是否满足该规格标准;
- 规则是值对象,可以组合使用,其组合实现与SQL的拼凑非常契合,使得其十分适合应用在仓储;
- 规格的概念引入,使得我们对实体多种检索的需求过程做到了通用化;
- 好的规格实现,链式 API 调用,可以使得编程变得灵活,表达能力强流畅;
- 仓储生成唯一标识别:在利用数据库能力生成唯一ID的时候(例如TDDL的Sequence),因为仓储本身封装数据库细节,所以仓储可以单独提供这种功能,例如 DomainRepository.getInstance().newEntityId() 方法,返回一个由数据库管理的唯一ID。
- 仓储提供工厂方法:聚合实体的创建,不一定是由领域服务完成的,如果我们的聚合实体具有创建模板,那么我们可以假设仓储本身具有大量的新对象池待使用。所以可以这样创建实体:DomainRepository.getInstance().newXXEntity() 返回聚合实体(该方式Evric不推荐);
- 作为Resource,我们通常会给它定一个URI(统一资源识别),用作全网唯一识别,但很少资源库会定义URI,因为实体唯一标识已经足够;
- 作为Resource,仓储一但持有了资源,那么就一直持有并跟踪资源,直到资源被删除;
- 作为Resource,仓储有时会被当作是对远程服务进程封装的机制,这个时候仓储有点像防腐层,但我不建议这样做(国内部分书籍有这种介绍);
- 聚合实体一个时刻只能存在于一个用例过程或者一个仓储实例中;
- 聚合实体无法同时存在在仓储中和用例过程中;
- 聚合实体也无法同时存在于两个用例过程中;
-
放置(put或save): 把聚合实体从用例过程,放置到仓储中,状态变为中间过程,用例过程中不再拥有实体;
-
获取(Take): 用例过程运行中,需要把实体从中间过程,转移到用例过程,完成这个操作后,仓储将不再拥有实体,我特别用take而不是find表达了这种思想。
三、实现剖析
如果有无限大的内存,或者无需持久化的业务,DAO层必然不存在,但仓储(集合容器+检索的数据结构)是仍然存在的。这就是为什么我认为,理解仓储的本质,不应该从技术角度思考,而是从领域角度思。即使我们对仓储在领域上有几乎固定的职责和功能,具体实现的仓储都很难满足其领域模型角度的功能。在《实现领域驱动设计》一书中,Vaughn Vernon提出2种仓储的实现模式:
-
面向集合的资源库: 面向集合的仓储提出的是完全按照集合的理念去设计仓储,就似乎它就是Set数据结构一样。所以他能自动去跟踪聚合实体的变化
-
面向持久化的资源库: 面向持久化的仓储,核心点是合并了插入和更新这两种操作,统一用 save() 操作完全取代仓储旧实体使得仓储的功能更统一。这种数据存储(如MongoDB等文档数据库)通常称之为:面向聚合的数据库(Aggregation-Oriented DataBase)或聚合存储(Aggregation Store)。
熟悉编程的人员很简单就知道这是怎么一回事了。这个实现也很能表达从领域模型的角度看仓储应该是怎么样子的。我总结了该实现特点如下:public class CalendarRepository extends HashMap{private Map<CalendarId,Calendar> calendars;public CalendarRepository(){this.calendars = new HashMap<CalendarId,Calendars>();}public void add(Calendar aCalendar){this.calendars.put(aCalendar.getId,aCalendar);}public Calendar findCalendars(CalendarId calendarId){return this.calendars.get(calendarId);}}
- 仓储应该是一个集合实例,而且无法对仓储进行重复的放置;
- 从仓储获取的聚合实例,应当和放置仓储的实例具有完全一样的状态,在这里是原对象;
- 如果在仓储之外对聚合实例进行了修改,无需“重新保存”聚合实例;
- 这种仓储下的聚合实体,看起来更加像资源Resource;
public class CalendarRepository extends HashMap{//存聚合实体private Map<CalendarId,Calendar> calendars;//标记实体被逻辑移除private Map<CalendarId,Thread> calendarsTakenAway;public CalendarRepository(){this.calendars = new HashMap<CalendarId,Calendars>();}public synchronized void add(Calendar aCalendar){this.calendars.put(aCalendar.getId,aCalendar);//移除逻辑删除calendarsTakenAway.remove(aCalendar.getId)}//注意我们改了命名方法,变为了take,获取,体现仓储不再拥有实体public synchronized Calendar takeCalendars(CalendarId calendarId){//如果已经被取过,无法再取if(calendarsTakenAway.containsKey(calendarId)){return null;}Calendar calendar = this.calendars.get(calendarId);//逻辑删除calendarsTakenAway.put(calendarId,Thread.currentThread());return calendar;}}
- 悲观锁:在一个调度者(线程)使用该聚合实体前,先对聚合实体进行加锁,其他调度者则无法获取实体进行操作
- 阻塞悲观锁:如果调度者发现聚合实体被锁了之后,则停止调度直到等待得到实体锁后继续;
- 非阻塞悲观锁:如果调度者发现聚合实体被锁了之后,不等待锁,立即返回做其他用例;
- 乐观锁:一个调度者认为冲突可能性不大,所以可以先获取聚合实体进行事务操作,但是当它想把聚合持久化的时候,发现有人操作过这个聚合,则回滚自己所有的操作。
上面的代码,用例服务知道自己要更新什么字段,并自行去做了更新,但当我们关注模型思维用到仓储的之后,针对以上的功能实现用例服务就不应该关注到更新哪一个字段这个和持久化相关的操作,而是让仓储需要自行去对比,哪些字段变化了,然后更新到数据库中去,用例服务会是下面所示的样子:public class BusinessService {@Resourceprivate TaskDao taskDao;@Resourceprivate SubTaskDao subTaskDao;@Transactionalpublic void onFinished(String subTaskId,String taskId){//查出所有子任务List<SubTask> subTasks = subTaskDao.getAllSubTask(taskId);//找出回传的子任务SubTask callBackTask = subTasks.stream().filter(e->subTaskId.equals(e.getSubTaskId)).findAny();//更新子任务状态callBackTask.setFinished(true);//如果所有子任务完成,更新主任务状态if(allFinished(subTasks)){taskDao.updateStateById(taskId,TaskStatusEnum.FINISHED);}//更新一个字段subTaskDao.updateStateById(subTaskId,TaskStatusEnum.FINISHED);}}
以上就是面向数据开发和面向领域模型的仓储开发的差别。那么这样的例子应该选择哪一种实现最好呢?这个问题不好回答,既然是DDD那只能选择仓储,这基本涉及的是系统如何设计的问题。简单的系统选择面向数据开发是简单直接的。你应该在什么时候使用「领域驱动设计」这种仓储设计思想,别忘记了它的作用: 复杂性软件应对之道。public class BusinessDomainService {public void onFinished(String subTaskId,String taskId){//获取实体的时候记录快照Task task = DomainRepository.getInstance().taskOf(taskId);//聚合实体负责业务逻辑task.subTaskFinished(subTaskId);//仓储自己识别到底哪个字段变化了,然后更新该字段(简称diff)DomainRepository.getInstance().put(task);}}public class Task {private List<SubTask> subTasks;private TaskStatusEnum status;public void subTaskFinished(subTaskId){//找出回传的子任务SubTask callBackTask = subTasks.stream().filter(e->subTaskId.equals(e.getSubTaskId)).findAny();//更新子任务状态callBackTask.setFinished(true);//如果所有子任务完成,更新主任务状态if(allFinished(subTasks)){status = TaskStatusEnum.FINISHED;}}}
-
聚合内部一致性: 聚合根的存在,最主要是的封装和管理聚合内部各种实体的关联和耦合,包括代码耦合和数据耦合,所以上面的task本身持有所有subTask的引用,而且负责subTask和task的state状态业务规则一致。此时,这个事务处理过程,就无法感知Task封装的一致性逻辑是否由subTask引起了Task实体自身的状态变化成为FINISHED,所以diff的实现就很有必要。
-
领域服务的纯粹性: 如上图所示,因为设置Task的状态规则是由聚合根负责,所以领域服务是不感知的,必须要靠diff,但是如果把diff这个逻辑写在领域服务中,不如把逻辑写在仓储中,因为我们也不应该让领域服务去关注一些技术上的逻辑,增加领域服务逻辑的复杂性。其实这样做,刚好就是仓储本身的职责,封装diff后的仓储让领域服务感觉到聚合实体一直在内存中一样。
-
聚合根的重建工序: 在DAO中,我们可以直接方便从ORM框架中返回数据对象,但是聚合根却不能,因为聚合根是由多个DO组成的,我们的持久化中间件(不管是MySQL关系型还是MongoDB文档型)无法给我们返回一个聚合根实体。所以仓储还得老老实实的把ORM中获取到的DO组装为Entity和Value Object,且要保证查找到的实体是要和原来的实体一摸一样的。这意味着需要“ 重建 ”实体的操作;
- 拆建规则(Convertor):仓储应当知道怎么拆,就应该怎么复原,所以它应该有一套拆解和重建规则,并根据此规则进行复原,Convertor是维护这种规则的一种工具,我建议采用这种命名类封装拆建规则
- 事件溯源(Event Sourcing):还有一种重建工厂的实现是利用实体的快照+实体的领域事件集合回放来恢复聚合实体,有兴趣的同学可以了解一下事件溯源;
- 聚合根与关联单例:关联单例是一种特殊的重建工序。我用一个领域事件监听器来说明,例如我们的聚合根实体实现了观察者模式,聚合根为主题,内部持有一些单例监听器对象列表,其中一个监听器用作监听聚合根的状态变化发送领域事件,那么这个监听器也应该让仓储负责拆解和恢复。
- 实现复杂:因为聚合的复杂性所以我们其实现起来也非常困难,其中最好模型能配合实现这种复杂性。
- 犯错成本:正如DAO的某个接口只对一个属性更新,那么无论代码有何种bug,最多只会写错一个字段,但仓储全量化更新后,我们在未知情况下手一抖,那么将可能覆盖其他本应安全字段,所以这也提高了我们的犯错成本。断言是解决的一种较好方案
关系型仓储实现方案: 仓储必须要让客户感觉它似乎就一直在内存中一样;但上面提到的 Diff 逻辑让仓储的使用和实现变得困难,设计者需要在整个上下文角度了解仓储的原理细节,因为要追求性能和安全的实现,还要只针对已经变化的字段更新,忽略无变化字段。其中Vaughn Vernon在《实现领域驱动设计》里面提到了两个方法,来解决这个问题:
- 隐式读时复制:在查找聚合实体的时候,记录下聚合实体的所有状态,然后在更新的时候,用新状态diff旧的状态,只对特定字段进行更新;
- 隐式写时复制:在查找到集合实体的时候,仓储把聚合实体的更新操作隐式委派给仓储的某种机制进行,所以每次更新状态实体状态仓储都能跟踪到,并在这个时候对该值标记为脏数据,最后仓储在事务结束的时候把脏数据给刷盘。
看上面代码,在获取方法 taskOf() 中,仓储负责开始对实体进行跟踪,因为外界调用方不感知仓储在跟踪实体,所以称之为隐式,我们可以根据聚合的不同构成自行实现以上提供的两种隐式跟踪的方案的一种,如果是追求性能那么写时复制比较好,如果是采用读时复制,那么Javers开源框架会是一个比较好的选择,但记得一定要做好单测。 以上两种方案其实都是对实体进行 状态跟踪 ,但要注意的是在介绍这两种方案的时候,Vaughn是打算让仓储往面向集合仓储的思路走的(该方法被他归到面向集合一章)。虽然以上两种隐式方案是非常好的实践,但我认为还是可以像在面向内存仓储一节提到的一样,继续引入创新改进为 仓储实体转移 模型,现在我们看一下关系型数据库仓储该如何应对这种模型。public interface TaskRepository{//相当于findTask,获取到的Task会被隐式追踪复制public Task taskOf(String taskId);public void addTask(Task task);public void removeTask(String taskId);//其他/统计/集合操作等//......}
-
领域服务视觉: 在获取(take)到聚合实体后,领域服务可以认为仓储中的聚合实体是不存在的(即使仓储没有删除聚合实体);
-
合并插入和更新(全覆盖) :仓储没有所谓的更新操作,只有直接放置聚合实体到仓储中,可以让仓储判断该插入还是全量更新(其实和用隐式跟踪实现部分更新差别不大,隐式跟踪更安全但多一个复制操作),或者我们直接一点,完全删除实体后再次插入或者全覆盖实体;
-
删除: 不管是否改进模型,当聚合实体生命周期结束都需要去真正的删除实体,这一点确实不好统一;
-
乐观锁: 我们可以在实现的时候在关系型仓储中采用乐观锁保证一个聚合实体不会存在于不同的领域事务中。因为乐观锁只会让其中一个成功;
- 优点:所以它最大的优点就是无需跟踪实体,而是以转移的聚合实体为主;
- 缺点:因为仓储实现要全量覆盖整个聚合状态,所以只适合用在类文档数据库,对于关系型数据库则需要复杂的隐式读/写跟踪了;
- 访问对象DAO:可以封装一层Mapper,或者其他ORM框架,提供DO以及其他统计数据;
- Convertor:维护拆解规则和重建规则,同时复制聚合根监听器的一些组装;
- DO:数据对象,一般和关系型数据表一一对应;
- 隐式状态跟踪:实现一套隐式读时复制和隐式写时复制状态跟踪的逻辑;
四、结束语
对Repository的认知其实和对DDD思维的认识是统一的,他们都是从领域专家角度去对解决方案进行建模。仓储为聚合根在领域知识和工程知识之间做了隔离,并为技术实现提供了统一的概念抽象。这样的模式和例子在DDD中是经常有的,例如:防腐层也是其中的一种,他们都是为了保持领域模型的纯粹性作出了自己的努力。最后由于篇幅问题简要提一下仓储的一些我还能想到的关注点:
参考书籍:
《领域驱动设计》Eric Evans [著].赵俐[译]2016.. 人民邮电出版社 《实现领域驱动设计》Vaughn Vernon[著].滕云[译].2014.中国工信出版集团往期推荐
Java工程师必读手册
工匠追求“术”到极致,其实就是在寻“道”,且离悟“道”也就不远了,亦或是已经得道,这就是“工匠精神”——一种追求“以术得道”的精神。 如果一个工匠只满足于“术”,不能追求“术”到极致去悟“道”,那只是一个靠“术”养家糊口的工匠而已。作者根据多年来的实践探索,总结了大量的Java编码之“术”,试图阐述出心中的Java编码之“道”。
点击阅读原文查看详情。