-
本文首先从聚合根的生命周期和生存环境出发,引出了Repository概念,并说明其本质是管理中间过程的集合容器(2.1节);
-
根据集合容器的概念,在领域角度去挖掘出Repository的职责,并提出了仓储实体转移模式用作对不同仓储实现的对比标准(2.2节);
-
然后从实现例子出发,介绍了一种纯内存实现的仓储,用作体现仓储最佳实现(3.1节);
-
继续从实现例子出发,介绍了关系型数据库下的仓储特点,并描述面向持久化的仓储的特点(3.4节);
2.1 聚合实体
-
标识:实体具有唯一标识,这个唯一标识使得实体和值对象区分开来;
-
状态:实体是具有可以被改变的状态,因此聚合实体无法被静态描述;
-
生命周期:实体拥有生命周期,从实体的创建,到实体的状态的终态;
-
生存环境:实体的活动存在于各个上下文中的领域服务或者应用服务中,其中分用例过程和中间过程;
-
用例过程:只要在执行用例过程的时候才需要实体的存在,其他时候,实体生命周期并没有结束,而是处于中间状态;
-
中间过程:当没有任何用例在处理一个实体的时候,实体消失了吗?没有,它仍然存在生命周期内,这个时候我们认为实体正处在一种中间过程。
-
放置:建立一个新的聚合实体,这是一个聚合实体生命的开始,在用例过程结束后,把聚合实体放到仓储中;
-
查找:把已经存在的聚合实体找出来,这是一个聚合实体的中间过程到用例过程的行为;
-
管理:它负责聚合实体的中间过程管理,并屏蔽掉中间过程的细节,向领域层提供统一的能力抽象,一些数据统计类的也可以在该范畴内;
-
如何放置实体:为了方便管理,我们通常会采用分治把同一种类型的实体放在一起成为一个集合。相同类型和集合给了我们一个指导就是:仓储的设计应该是一个聚合实体类型对应一个仓储实体,具有一一对应关系,所以仓储实体应该是一个保存相同类型元素的集合容器;
-
如何查找实体:我们知道实体具有唯一标识别,也具有其他特征属性,所以为了查找实体,我们应该通过实体的唯一标识或者特征属性去遍历查找,仓储应当提供这种功能,所以仓储应该针对聚合实体字段具有索引查找功能;
-
如何查找仓储:既然我们提到了需要用仓储来查找实体,那么我们又是如何查到仓储的呢?其实这个很简单,如果一个聚合实体类型只具有一个仓储类型,那么我们把仓储设计为单例的就可以了。
-
一个聚合类型(也就是一个聚合根),最好对应一个仓储(这个不是绝对的);
-
一个仓储应该是单例的,便于先查到到仓储,再查找到聚合实体(当然也不是绝对的);
-
仓储应该是一个集合的抽象概念,并且负责屏蔽中间过程,包括其中的实现细节,如持久化和重建,它最好能让客户感觉它似乎就一直在内存中一样;
-
仓储作为聚合实体的集合,应该具有检索实体的功能,如果从技术角度看,那么将一直持有聚合实体引用;
2.2 仓储职责
-
我们的一个用例服务中很可能不需要使用聚合实体本身,而仅使用到符合某种条件的聚合的数量,因此我们没必要查出聚合实体进行统计;
-
具体的基础设施数据库实现,对统计性能有着显著的性能优化,为了使用这些中间技术的优点,把统计这种细节的操作委托给仓储是一个很好的选择。
-
统计和查询有很多时候的应用场景是不修改聚合根状态的,所以这种情况你可能没必要使用仓储完成这件事,CQRS的思想要求我们去分离查询,建立查询模型,所以建立一套查询模型去做这件事是一个好的解耦实践。
-
规格是一个谓词,封装了业务规则,可以明确表达一个特定实体是否满足该规格标准;
-
规则是值对象,可以组合使用,其组合实现与SQL的拼凑非常契合,使得其十分适合应用在仓储;
-
规格的概念引入,使得我们对实体多种检索的需求过程做到了通用化;
-
好的规格实现,链式 API 调用,可以使得编程变得灵活,表达能力强流畅;
-
仓储生成唯一标识别:在利用数据库能力生成唯一ID的时候(例如TDDL的Sequence),因为仓储本身封装数据库细节,所以仓储可以单独提供这种功能,例如 DomainRepository.getInstance().newEntityId() 方法,返回一个由数据库管理的唯一ID。
-
仓储提供工厂方法:聚合实体的创建,不一定是由领域服务完成的,如果我们的聚合实体具有创建模板,那么我们可以假设仓储本身具有大量的新对象池待使用。所以可以这样创建实体:DomainRepository.getInstance().newXXEntity() 返回聚合实体(该方式Evric不推荐);
-
作为Resource,我们通常会给它定一个URI(统一资源识别),用作全网唯一识别,但很少资源库会定义URI,因为实体唯一标识已经足够;
-
作为Resource,仓储一但持有了资源,那么就一直持有并跟踪资源,直到资源被删除;
-
作为Resource,仓储有时会被当作是对远程服务进程封装的机制,这个时候仓储有点像防腐层,但我不建议这样做(国内部分书籍有这种介绍);
-
聚合实体一个时刻只能存在于一个用例过程或者一个仓储实例中;
-
聚合实体无法同时存在在仓储中和用例过程中;
-
聚合实体也无法同时存在于两个用例过程中;
-
放置(put或save):把聚合实体从用例过程,放置到仓储中,状态变为中间过程,用例过程中不再拥有实体;
-
获取(Take):用例过程运行中,需要把实体从中间过程,转移到用例过程,完成这个操作后,仓储将不再拥有实体,我特别用take而不是find表达了这种思想。
-
面向集合的资源库:面向集合的仓储提出的是完全按照集合的理念去设计仓储,就似乎它就是Set数据结构一样。所以他能自动去跟踪聚合实体的变化
-
面向持久化的资源库:面向持久化的仓储,核心点是合并了插入和更新这两种操作,统一用 save() 操作完全取代仓储旧实体使得仓储的功能更统一。这种数据存储(如MongoDB等文档数据库)通常称之为:面向聚合的数据库(Aggregation-Oriented DataBase)或聚合存储(Aggregation Store)。
3.1 内存仓储
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;
}
}
-
悲观锁:在一个调度者(线程)使用该聚合实体前,先对聚合实体进行加锁,其他调度者则无法获取实体进行操作
-
阻塞悲观锁:如果调度者发现聚合实体被锁了之后,则停止调度直到等待得到实体锁后继续;
-
非阻塞悲观锁:如果调度者发现聚合实体被锁了之后,不等待锁,立即返回做其他用例;
-
乐观锁:一个调度者认为冲突可能性不大,所以可以先获取聚合实体进行事务操作,但是当它想把聚合持久化的时候,发现有人操作过这个聚合,则回滚自己所有的操作。
3.2 关系型数据库仓储
public class BusinessService {
@Resource
private TaskDao taskDao;
@Resource
private SubTaskDao subTaskDao;
@Transactional
public 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);
}
}
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旧的状态,只对特定字段进行更新;
-
隐式写时复制:在查找到集合实体的时候,仓储把聚合实体的更新操作隐式委派给仓储的某种机制进行,所以每次更新状态实体状态仓储都能跟踪到,并在这个时候对该值标记为脏数据,最后仓储在事务结束的时候把脏数据给刷盘。
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:数据对象,一般和关系型数据表一一对应;
-
隐式状态跟踪:实现一套隐式读时复制和隐式写时复制状态跟踪的逻辑;
3.3 仓储的架构
参考书籍:
《领域驱动设计》Eric Evans [著].赵俐[译]2016.. 人民邮电出版社