本文首先从聚合根的生命周期和生存环境出发,引出了Repository概念,并说明其本质是管理中间过程的集合容器(2.1节);
根据集合容器的概念,在领域角度去挖掘出Repository的职责,并提出了仓储实体转移模式用作对不同仓储实现的对比标准(2.2节);
然后从实现例子出发,介绍了一种纯内存实现的仓储,用作体现仓储最佳实现(3.1节);
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 {
private TaskDao taskDao;
private SubTaskDao subTaskDao;
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的状态规则是由聚合根负责,所以领域服务是不感知的,必须要靠diff,但是如果把diff这个逻辑写在领域服务中,不如把逻辑写在仓储中,因为我们也不应该让领域服务去关注一些技术上的逻辑,增加领域服务逻辑的复杂性。其实这样做,刚好就是仓储本身的职责,封装diff后的仓储让领域服务感觉到聚合实体一直在内存中一样。
聚合根的重建工序:在DAO中,我们可以直接方便从ORM框架中返回数据对象,但是聚合根却不能,因为聚合根是由多个DO组成的,我们的持久化中间件(不管是MySQL关系型还是MongoDB文档型)无法给我们返回一个聚合根实体。所以仓储还得老老实实的把ORM中获取到的DO组装为Entity和Value Object,且要保证查找到的实体是要和原来的实体一摸一样的。这意味着需要“重建”实体的操作;
拆建规则(Convertor):仓储应当知道怎么拆,就应该怎么复原,所以它应该有一套拆解和重建规则,并根据此规则进行复原,Convertor是维护这种规则的一种工具,我建议采用这种命名类封装拆建规则
事件溯源(Event Sourcing):还有一种重建工厂的实现是利用实体的快照+实体的领域事件集合回放来恢复聚合实体,有兴趣的同学可以了解一下事件溯源;
聚合根与关联单例:关联单例是一种特殊的重建工序。我用一个领域事件监听器来说明,例如我们的聚合根实体实现了观察者模式,聚合根为主题,内部持有一些单例监听器对象列表,其中一个监听器用作监听聚合根的状态变化发送领域事件,那么这个监听器也应该让仓储负责拆解和恢复。
实现复杂:因为聚合的复杂性所以我们其实现起来也非常困难,其中最好模型能配合实现这种复杂性。
关系型仓储实现方案:仓储必须要让客户感觉它似乎就一直在内存中一样;但上面提到的 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.. 人民邮电出版社
往期推荐
Java工程师必读手册
工匠追求“术”到极致,其实就是在寻“道”,且离悟“道”也就不远了,亦或是已经得道,这就是“工匠精神”——一种追求“以术得道”的精神。 如果一个工匠只满足于“术”,不能追求“术”到极致去悟“道”,那只是一个靠“术”养家糊口的工匠而已。作者根据多年来的实践探索,总结了大量的Java编码之“术”,试图阐述出心中的Java编码之“道”。
点击阅读原文查看详情。