实现领域驱动设计(DDD)系列详解:如何设计聚合

一、引入聚合设计的必要性

(一)聚合设计的来源

聚合是主要的业务逻辑载体,DDD中所有的战术实现都围绕着聚合根展开。

它对外代表的是一个整体,类似于一个大的对象,内部是由有主从之分的很多对象组成的。聚合是一个行为在逻辑上高度一致的对象群,注意,它是一个对象群体的总称。聚合的内部结构如同一棵树,每个聚合都有一个根,其他对象和聚合根之间都是枝叶与树根的关系。
在这里插入图片描述

在大自然中,对于一个有限大小的持续活动的系统(限界上下文),它必须提供一种在自身元素之间更容易访问的流动方式。这种结构方式能让实体或事物更容易地流动(变化增加),消耗最少的能量到达最远的地方,就连街道和道路这些人为构建的物体,往往也是有序的模式,以提供最大的灵活性。可以实现无序复杂性的有序化,左侧是无序的复杂性,而右侧是根据树形结构进行的有序化。
在这里插入图片描述
这样有序化的好处是:只有“根”能引用或指向其他对象,“根”自身不能被其他任何对象引用;“根”类似团队的小组长,队员都要向其汇报工作。

这就是聚合根的设计来源,聚合根拥有自己边界内的数据所有权,以及行为职责的管理权限。

(二) 数据与行为分离的弊端

数据和行为两者兼顾的所有权只有聚合才能具有,为什么需要数据和行为两者兼顾呢?通常情况下,数据和行为是分离的,行为在服务中实现,而数据隔离在数据表中,行为通过服务转为SQL语句去操作数据表,这种方式的问题是隔离了行为和数据的紧密逻辑关系。

案例:有一场足球比赛,需求如下:
1)举办一个比赛,有两个队参加。
2)比赛在某个时间开始,只能开始一次。
3)比赛结束后,统计积分。

采用传统的ER分析方法,会得到下面的模型。
1)Match:记录比赛数据。
2)Team:记录参赛队伍数据。
3)Score:记录分数数据。
4)MatchService:比赛服务。

public class Match{
	private Long id;
	private String matchAddress;//比赛地址
	private Date beginTime;//开始时间
	private Date endTime;//结束时间
	private Integer matchStatus;//比赛状态
	//...
}
public class Team{
	private Long id;
	private Long matchId;//比赛ID
	private String teamName;//队伍名称
	private Integer teamNumber;//队伍人数
	//...
}
public class Score{
	private Long matchId;//某个比赛ID
	private Long teamId;//某个队伍ID
	private Integer number;//分数
}

这里的数据表有三个,主表是比赛表,程序通过比赛服务对比赛表的CRUD操作来实现比赛的“开始”和“结束”行为。

//比赛服务
public class MatchService{
	//注册比赛
	public void registerMatch(Long teamId1,Long teamId2){
		Match match = new Match(beginTime(),endTime());
		matchMapper.insert(match);
		Team team1 = new Team(1);
		Team team2 = new Team(2);
		team1.setMatchId(match.getId());
		team2.setMatchId(match.getId());
		teamMapper.insert(team1);
		teamMapper.insert(team2);
	}
	//开始比赛
	public synchronized void startMatch(Long matchId){		
		Match match = matchMapper.selectById(matchId);
		match.setMatchStatus(1);//设置比赛开始中
		if(now()>match.getBeginTime() &&  now()<match.getEndTime()){
			Score score1 = new  Score(match,team1);
			score1.setNumber(0);
			Score score2 = new  Score(match,team2);
			score2.setNumber(0);
			scoreMapper.insert(score1);
			scoreMapper.insert(score2);
			//...
			scoreMapper.update(score1);
			scoreMapper.update(score2);
		}
		//....
	}
	//结束比赛,统计数据
	public void endMatch(Long matchId){
		match.setMatchStatus(2);//设置比赛已结束
		if(now() > match.getEndTime()){
			List<Score> scores1 = scoreMapper.select(matchId,teamId1);
			List<Score> scores2 = scoreMapper.select(matchId,teamId2);
			print(sum(scores1));
			print(sum(scores2));
		}
	}
}

这种方式没有考虑到行为和数据的有机结合,“开始”和“结束”等行为属于比赛自身,不能将其分离到比赛服务中实现。

弊端1:

比赛有两种行为:“开始”和“结束”;当外界调用“开始”方法时,比赛状态应该被设置为“已开始”,同理,“结束”方法被调用时,比赛状态被设置成“已结束”。

如果数据和行为相分离,比赛服务就只能先查询比赛表的状态字段,然后再判断是否符合业务规则。

因为比赛数据表是一种数据结构无法加入“开始”和“结束”等行为,技术绑架使业务实现变得扭曲,应该用更好的范式来表达业务。

类是一种行为和数据相结合的表示方式,那么无疑使用比赛类来映射比赛这个概念是合适的。在比赛类的“开始”和“结束”方法中实现业务规则,这种实现方式是直接映射业务领域概念的

在这里插入图片描述
弊端2:
假设比赛中有一条业务规则:只有比赛结束后才有比赛分数。如何实现呢?

将比赛分数计算放在比赛结束这个行为中,分数计算行为从属于比赛结束行为,使分数计算在比赛结束后执行。但是,如果有外界客户端想知道比赛分数,那么在直接查询比赛分数表时,比赛可能还没有结束,所以要先查询比赛状态是否为结束再查询比赛分数。结果,“只有比赛结束后才有比赛分数”这段逻辑遍布在各个数据和行为环节中,如果逻辑改变,例如只有比赛开始后才有比赛分数,那么所有相关环节都必须改变,代码难以修改。

这种情况下,初级工程师很难快速接手,因为他必须弄清楚所有环节的逻辑,这会让他感觉非常复杂(Complicated),因为程序没有有序地结构化业务逻辑。

如果业务逻辑是系统核心,将其散落在各处肯定不是领域驱动的设计。
在这里插入图片描述
领域驱动设计应该是将业务逻辑视为核心,而且核心只有一个。
在这里插入图片描述

二、聚合的关联关系

(一)关联关系中的聚合

面向对象设计中类之间的关系包含三种:·继承(generalization)、关联(association)、依赖(dependency)。

其中,关联关系代表了类之间的一种结构关系,用以指定一个类的对象与另一个类的对象之间存在连接关系。

存在一种特殊的关联关系:关联双方分别体现整体与部分的特征,代表整体的对象包含了代表部分的对象。依据关系的强弱,包含关系又分为组合(composition)关系与聚合(aggregation)关系。

例如,学校School与教室Classroom的关系为组合关系,学校被销毁,教室也就不存在了,学校与教室之间的关系用黑色菱形表示。而教室Classroom与学生Student的关系为聚合关系,教室被销毁,学生依然存在,教室与学生之间的关系用空心菱形表示。
在这里插入图片描述

为表达关联关系,举个例子,假设有一个Product类,它与一个Category类发生关联,使用UML类图(类图是表达类之间结构关系的一种图)表达如下:

在这里插入图片描述
上述类图对应的Java代码如下:

public class Product {
	private String name;
	private int productId;
	private Category category;
	//...
	public void changeName(String name){}
}

Java等面向对象语言中,关联可实现为引用,当在Product类中将Category作为其字段属性引用时,就表示ProductCategory是一种关联关系.

而关系数据表或ER模型使用外键来表示这种关系。

无论UML、Java还是关系数据库,它们都用不同的方式表达了“关系”这个概念。

其中,一种表示双方分别体现整体与部分特征的更加紧密的关联,就是聚合(Aggregation)

DDD中的聚合设计就是要找出这两种更强的关联。

例如Product和Category这个案例,从业务领域看,在Product管理的有界上下文中,Product作为聚合根,Category是不是Product的组成部分呢?

Category属于Product的分类定义,而Product需要分类定义,那么Category应该是Product的组成部分,自然也就在Product聚合边界内。

Product是由Category等组成的,Category是Product的组成部分,这种有层次的组合关系是不是类似于前面提到的树形结构呢?借鉴大自然的构造定律使得复杂领域变得有序化、结构化、层次化,这正是软件设计的目标。

在这里插入图片描述
注意图中两张图的区别是,右边图中关联线的一端变成了一个菱形,这表示聚合的整体方是Product,Category只是作为其部件存在。当然这种设计意图在Java等语言中的表示仍然是Product类引用Category作为其字段属性。

(二)聚合的设计约束

在设计聚合时,应遵循一种思想:聚合内部的对象都是在数据和行为上高度关联和一致的,除此以外的其他关系应该被抛弃。

这里鲜明地主张了一种非黑即白的可行动的设计理念——如果关系不是很紧密,那么就隔断,如果非常紧密就放在一起。

设计时需要从业务领域中追查出高聚合低关联的关系。

1.控制类的关系

控制类的关系无非从以下3点入手:

  • 去除不必要的关系;
  • 降低耦合的强度;
  • 避免双向耦合。

对象模型是真实世界的体现。真实世界的两个领域概念存在关系,对象模型就会体现这种关系,但对关系类型的确认以及对关系的实现却需要审慎地处理。如果确定类之间的关系没有必要存在,就要果断地“斩断”它。

例如,配送单需要订单的信息,看起来需要为它们建立关系,但由于配送单已经和包裹存单建立了关系,从而间接获得了订单的信息,就需要斩断配送单与订单之间的关系。

另外,倘若关系不可避免,就需要考虑降低耦合的强度。

一种策略是引入泛化提取通用特征,形成更弱的依赖或关联关系,如Car对汽车的泛化使得Driver可以驾驶各种汽车。

正确识别合成还是聚合的关联关系,也能降低耦合强度。

组合表示的整体/部分关系定义为“物理包容”,即整体在物理上包容了部分。这也意味着部分不能脱离于整体单独存在。区分物理包容是很重要的,因为在构建和销毁组合体的部分时,它的语义会起作用。

例如,订单Order与订单项OrderItem就体现了物理包容的特征,一方面Order对象的创建与销毁意味着OrderItem对象的创建与销毁,另一方面OrderItem也不能脱离Order单独存在,因为没有Order对象,OrderItem对象是没有意义的。

与“物理包容”关系相对的是聚合代表的“逻辑包容”关系,即它们在逻辑上(概念上)存在组合关系,但在物理上整体并未包容部分,例如Customer与Order。虽然客户拥有订单,但客户并没有在物理上包容拥有的订单。客户与订单的生命周期完全独立。

存在双向关联的两个类必然会带来双向耦合,因此需要在建立对象模型时注意保持类的单一导航方向。例如,Student与Course存在多对多关系,一个学生可以参加多门课程,一门课程可以有多名学生参加。
在这里插入图片描述

public class Student{
	private Set<Course> courses = new HashSet<>();
}
public class Course {
	private Set<Student> students = new HashSet<>();
}

Student与Course之间彼此引用形成了双向导航。从调用者角度看,双向导航是一种“福音”,因为无论从哪个方向获取信息都很便利。但是虽然调用方便了,对象的加载却变得有些笨重,关系更加复杂,甚至出现循环加载的问题。

领域设计模型除了要正确地表达真实世界的领域逻辑,还需要考虑质量因素对设计模型产生的影响。例如,具有复杂关系的对象对于运行性能和内存资源消耗是否带来了负面影响?

当我们通过资源库分别获得Student类和Course类的实例时,是否需要各自加载所有选修课程与所有选课学生?不幸的是,当你为学生加载了所有选修课程之后,业务场景却不需要这些信息——这不是白费力气嘛!

延迟加载(lazy loading)虽然可以解决问题,但它不仅会使模型变得更加复杂,还会受到ORM框架提供的延迟加载实现机制的约束,使得领域设计模型受到外部框架的影响。

2.引入边界

在一个复杂的软件系统中,即使通过正确地甄别和控制关系来改进模型,但由于规模的原因,由对象建立的模型最终还是会形成如图所示的一张彼此互联互通的对象网。这张对象网好像错综的蜘蛛网,通过一个类的对象可以导航到与之直接或间接连接的类。
在这里插入图片描述

因此需要引入边界来降低和限制领域类之间的关系,不能让关系之间的传递无限蔓延。

领域设计模型并非真实世界的直接映射。如果真实世界缺乏清晰的边界,在设计时,我们就应该给它清晰地划定边界。

划定边界时,同样需要依据“高内聚松耦合”原则,让一些高内聚的类居住在一个“社区”内,彼此友好地相处;不相干或者松耦合的类分开居住,各自守住自己的边界,在开放“社交通道”的同时,随时注意抵御不正当的访问要求。如此一来,就能形成睦邻友好的协作条约。

这种边界不是限界上下文形成的控制边界,因为它限制的粒度更细,可以认为是类层次的边界。每个边界都有一个主对象作为“社区的外交发言人”,总体负责与外部社区的协作。一旦引入这种类层次的边界,就可以去掉一些类的关系,仅保留主对象之间的关系,原本错综复杂的对象网就变成了如图所示的由各个对象社区组成的对象图,图中的关系变得更加简单而清晰。
在这里插入图片描述
如果规定边界外的对象只能访问边界内的主对象,即将边界视为对内部细节的隐藏,就可以去掉外界不关心的对象.
在这里插入图片描述
Eric Evans将这种类层次的边界称为聚合,边界内的主对象称为聚合根

聚合(aggregate)模式:“将实体和值对象划分为聚合并围绕着聚合定义边界。选择一个实体作为每个聚合的根,并允许外部对象仅能持有聚合根的引用。作为一个整体来定义聚合的属性和不变量,并将执行职责赋予聚合根或指定的框架机制。”

(三)聚合的定义和特征

1.聚合的定义

聚合的基本特征。

  • 聚合是包含了实体和值对象的一个边界。
  • 聚合内包含的实体和值对象形成一棵树,只有实体才能作为这棵树的根。这个根称为聚合根(aggregate root),这个实体称为根实体(root entity)。
  • 外部对象只允许持有聚合根的引用,以起到边界的控制作用。
  • 聚合作为一个完整的领域概念整体,其内部会维护这个领域概念的完整性,体现业务上的不变量约束。
  • 由聚合根统一对外提供履行该领域概念职责的行为方法,实现内部各个对象之间的行为协作。

在这里插入图片描述

聚合内部可以包含实体和值对象。由于聚合必须选择实体作为根,因此一个最小的聚合就只有一个实体。聚合根是整个聚合的出入口,通过它控制外界对边界内其他对象的访问。在进行领域设计建模时,我们往往以根实体的名称指代整个聚合,如一个聚合的根实体为订单,则称其为订单聚合。但这并不意味着存在一个订单聚合对象。

**聚合是边界,不是对象。**订单根实体本质上仍然属于实体类型。

聚合内部只能包含实体和值对象,每个对象都遵循信息专家模式,定义了属于自己的属性与行为,故而能够在聚合边界内做到职责的分治,但对外的权利却由聚合根来支配。聚合边界就是封装整体职责的边界,隔离出不同的访问层次。对外,整个聚合是一个完整的设计单元;对内,则需要由聚合来维持业务不变量和数据一致性。

2.对象的聚合和DDD的聚合

我们必须厘清面向对象的聚合(object oriented聚合,OO聚合)与领域驱动设计的聚合(DDD聚合)之间的区别。

例如,Account(账户)与Transaction(交易)之间存在OO聚合关系,一个Account对象可以聚合0~n个Transaction对象,但它们却分别属于两个不同的DDD聚合,即Account聚合和Transaction聚合,如图所示。

在这里插入图片描述
当然,也不能将OO组合与DDD聚合混为一谈。例如,Question(问题)与Answer(答案)共同组成了一个DDD聚合,该DDD聚合的根实体为Question,它与Answer实体的类关系为OO组合关系。
在这里插入图片描述
OO聚合与OO组合代表了类与类之间的组合关系,体现了整体包含了部分的意义。DDD聚合是边界,它的边界内可以只有一个实体对象,也可以包含一些具有关联关系、泛化关系和依赖关系的实体与值对象。

三、聚合的设计原则

引入聚合的目的是通过合理的对象边界控制对象之间的关系,在边界内保证对象的一致性与完整性,在边界外作为一个整体参与业务行为的协作。显然,聚合在限界上下文与类的粒度之间形成了中间粒度的封装层次,成为表达领域知识、封装领域逻辑的自治设计单元。它的自治性与限界上下文不同,体现为图15-16所示的完整性、独立性、不变量和一致性。
在这里插入图片描述

(一)完整性

聚合作为一个受到边界控制的领域共同体,对外由聚合根体现为一个统一的概念,对内则管理和维护着高内聚的对象关系。对内与对外具有一致的生命周期。

例如,订单聚合由Order聚合根实体体现订单的领域概念,调用者可以不需要知道订单项OrderItem,也不会认为配送地址Address是一个可以脱离订单单独存在的领域概念。要创建订单,订单项、配送地址等聚合边界内的对象也需要一并创建,否则这个订单对象就不完整。

同理,销毁订单对象乃至删除订单对象(倘若设计为可删除)时,在订单聚合边界内的其他对象也需要被销毁乃至删除。

概念的完整性还要受业务场景的影响。例如,在汽车销售的零售商管理系统中,针对整车销售场景,汽车代表了一个整体的领域概念:只有组装了发动机、轮胎、方向盘等必备零配件,汽车才是完整的。但是,对于零配件维修场景,需要对发动机、轮胎、方向盘等零配件进行单独管理和单独跟踪,不能再将它们合并为汽车聚合的内部对象了。因此,除了要考虑领域概念的完整性,还要考虑领域概念是否存在独立性的诉求。

(二)独立性

追求概念的完整性固然重要,但保证概念的独立性同样重要。

既然一个概念是独立的,为何还要依附于别的概念呢?

例如,发动机需要被独立跟踪,还需要被纳入汽车这个整体概念中吗?·一旦这个独立的领域概念被分离出去,原有的聚合是否还具备领域概念的完整性呢?例如,“离开了发动机的汽车”概念是否完整?

在理解概念的完整性时,不能将完整性视为关系的集合,认为概念只要彼此关联就是完整概念的一部分,就需要放到同一个聚合中。完整性除了可以通过聚合来保证,也可以通过聚合之间的关系来保证,二者无非是约束机制不同。

例如,考虑到独立跟踪发动机的要求,将其设计为一个单独的聚合,而汽车的完整性仍然可以通过在汽车聚合与发动机聚合之间建立关联的方式来满足。

因为维护一个庞大的聚合需要考虑事务的同步成本、数据加载的内存成本等。且不说这个所谓的“小”到底该多小,至少,“过分的小”带来的危害要远远小于“不当的大”。两害相权取其轻,根据领域概念的完整性与独立性划分聚合边界时,应先保证独立性,再考虑完整性。

考虑独立性时,可以针对聚合内的非聚合根实体询问:

  • 目标聚合是否已经足够完整;
  • 待合并实体是否会被调用者单独使用。

考虑在线试题领域中问题与答案的关系。Question若缺少Answer就无法保证领域概念的完整性,调用者也不会绕开Question去单独查看Answer,因为Answer离开Question没有任何意义。如果需要删除Question,属于该问题的Answer也没有存在的价值。因此,Question与Answer属于同一个聚合,且以Question实体为聚合根。

同样是问题与答案之间的关系,如果是为在线问答平台设计领域模型,情况就不同了。虽然从完整性看,Question与Answer依然表达了一个共同的领域概念,Answer依附于Question,但由于业务场景允许读者单独针对问题的答案进行赞赏、赞同、评论、分享、收藏等操作,还允许读者单独推荐答案(个别答案甚至成为单独的知识材料供读者学习),这些操作与特征相当于给答案赋予了“完全行为能力”。答案具备了独立性,可以脱离Question聚合,成为单独的Answer聚合。

不同于实体,值对象不存在这种独立性。值对象不能单独成为一个聚合,它必须寻找一个实体作为依存的主体,如Money等与单位、度量有关的值对象甚至会在多个聚合中重复出现。有的值对象甚至因此而需要调整设计,升级为实体,如前所述的Holiday类。

确保聚合的独立性可以指导我们设计出小聚合。聚合的边界本身是为了约束对象图,当我们一个不慎混淆了聚合的边界,就会将对象图的混乱关系蔓延到更高的架构层次,这时,设计小聚合的原则就彰显其价值了。

设计在线问答平台时,考虑到案的业务逻辑变得越来越繁杂时,团队规模也将日益增大;随着用户数的增加,并发访问的压力也会增大。为解决此问题,问答平台可能需要单独为答案建立微服务。这时再来审视问与答的领域模型,就体现出Answer聚合的价值了。

对比完整性与独立性,当聚合边界存在模糊之处时,小聚合显然要优于大聚合。换言之,独立性对聚合边界的影响要高于完整性。

(三)不变量

在数据变化时必须保持的一致性规则,涉及聚合成员之间的内部关系。

聚合边界内的实体与值对象都是产生数据变化的因子,不变量要在数据发生变化时保证它们之间的关系仍然保持一致。

不变量就像数学中的“不变式”(英文同样为invariant)或者“方程式”(formula)。例如等式3x + y=100要求x和y无论怎么变化,都必须恒定地满足等号两边的值的相等关系。等式中的x和y可类比为聚合内的对象,等式就是施加在聚合上的业务约束。如此就可将聚合的不变量定义为施加在聚合边界内部各个对象之上,使其遵守一种恒定关系的业务约束,以公式来表达就是:

Aggregate = IV(Root Entity,{Entities},{Value Objects})

其中的IV就是聚合的不变量。

不变量代表了领域逻辑中的业务规则或验证条件,有时也可将不变量理解为“不变条件”或“固定规则”。这是一个充分条件,反过来就未必成立了。

例如,“招聘计划必须由人力资源总监审批”是一条业务规则,但该规则是对角色与权限的规定,并非约束招聘计划聚合内部的恒定关系,不是不变量。又例如,“报表类别的名称不可短于8个字符,且不允许重复”是验证条件,对报表聚合内部报表类别值对象的Name属性值进行单独验证,没有对聚合内对象之间的关系进行约束,自然也非不变量。

业务规则可能符合不变量的定义。例如,“一篇博文必须至少有一个博文类别”是一条业务规则,约束了Post实体和值对象PostCategory之间的关系,可以认为是一个不变量。要满足该不变量,需要将Post与PostCategory放到同一个聚合中,并在创建Post时运用该约束检验聚合的合规性,满足该业务规则,如图所示。
在这里插入图片描述

(四)聚合的业务逻辑一致性

聚合需要保证聚合边界内的所有对象满足不变量约束,其中一个最重要的不变量就是一致性约束,因此也可认为一致性是一种特殊的不变量。

一致性约束可以理解为事务的一致性,即在事务开始前和事务结束后,数据库的完整性约束没有被破坏。

考虑电商领域订单与订单项的关系。在创建、修改或删除订单时,要求订单与订单项的数据保证强一致,因而需要将订单与订单项放到同一个聚合。

反观博客平台博客与博文之间的关系,博客的创建与博文的创建并非原子操作,归属于两个不同的工作单元。虽然业务的前置条件要求在创建博文之前,对应的博客必须已经存在,但并没有要求博文与博客必须同时创建,修改和删除操作同样如此。也就是说,博客与博文不存在一致性约束,不应该放在同一个聚合。

基于一致性原则,可以将事务的范围与聚合的边界对等来看。事实上,事务的ACID特性与聚合的特性确乎存在对应关系。

在这里插入图片描述

“在单个事务中,只允许对一个聚合实例进行修改,由此产生的其他改变必须在单独的事务中完成。” ——Vaughn Vernon

这不失为设计良好聚合的规范,且隐含地表述了事务边界与聚合边界的重叠关系。倘若发现一个事务对聚合实例的修改违背了该原则,需酌情考虑修改。

  • 合并两个聚合:例如在执行分配问题的操作时,需要在修改问题(Issue)状态的同时,生成一条分配记录(Assignment);若Issue和Assignment被设计为两个聚合,根据本原则,可考虑将二者合并。
  • 实现最终一致性:例如在执行取款操作时,需要扣除账户(Account)的余额(Balance),并创建一条新的交易记录(Transaction);若Account和Transaction被设计为两个聚合,而业务操作又要求二者保证事务的一致性,可考虑在二者之间引入事件,实现事务的最终一致。

聚合代表领域逻辑,事务代表技术实现,在确定聚合一致性原则时,可以结合事务的特征辅助我们做出判断,但事务对于一致性的实现却不能作为确定聚合边界的绝对标准。

业务逻辑的一致性需要从两个方面去保证:业务数据和业务行为。这两个方面缺一不可,正如传统系统中,如果只设计数据表结构,而没有SQL调用,就不能实现业务逻辑,同样,如果只有SQL调用,而没有事先设计的表结构,业务逻辑同样无法得到实现。

聚合是体现逻辑一致性的地方,也是保证业务规则实现的地方。有界上下文是根据业务规则的不同来划分,相同的业务规则一般会在同一个有界上下文实现,具体在上下文的哪里实现呢?现在可以明确说,是在聚合中实现。因此,实现逻辑一致性是聚合存在的根本目的。高聚合低关联只是体现逻辑一致性的一个方面,更多的逻辑一致性不但体现在结构关系上,还体现在行为动作的执行上,甚至使用事务实现。

例如订单与商品的关系。订单聚合中订单是聚合根,当删除订单以后,不希望删除商品,因此商品不属于订单聚合,那么如何表达订单条目中的商品概念呢?可以使用商品的标识ID(如productId)作为条目OrderItem的一个组成部分。

聚合的逻辑一致性是最终在聚合根这个类中实现的,那么类的行为就成为逻辑一致性最终落地的保证。如何通过类的行为保证逻辑一致性?

举例如下。NumberRange中有两个属性lower和upper,这两个属性有一个逻辑约束:lower必须小于upper。这也可以称为规则,如何表达这种约束呢?一开始可能会采取如下实现方式:

public class NumberRange {
    private int lower;
    private int upper;

    public int getLower() {
        return lower;
    }

    public void setLower(int value) {
        if(value > upper) throw new IllegalArgumentException();
        this.lower = value;
    }

    public int getUpper() {
        return upper;
    }

    public void setUpper(int value) {
        if(value < lower) throw new IllegalArgumentException();
        this.upper = value;
    }
}

上述代码只是在修改lower或upper的方法中加入了业务规则检查,这样是不够的。假设有两个用户同时调用这个类的同一个对象实例,一个调用setLower(4),另外一个是setUpper(3),结果NumberRange的实例中lower和upper分别为4与3,明显违背了业务规则,虽然使用逻辑判断进行了拦截判断,但最终还是违背了业务逻辑一致性,可以认为这种类的设计没有形成聚合,怎么办?

一个解决办法是通过技术方式,锁定整个NumberRange实例,任何时刻只允许一个用户修改其状态;还有一种办法是重新设计,不要使用setLower和setUpper两个方法,而是使用统一的修改方法,在这个方法中对lower和upper的任何修改都要依据规则判断是否可以执行。这实际是聚合根的一种设计思路。

(五)最高原则

综上,遵循聚合的完整性、独立性、不变量和一致性原则,有利于高质量地设计聚合。完整性将聚合视为一个高内聚的整体;独立性影响了聚合的粒度;不变量是对动态关系的业务约束;一致性体现了聚合数据操作的不可分割,反过来满足了聚合的完整性、独立性和不变量。

领域驱动设计还规定:只有聚合根才是访问聚合边界的唯一入口。这是聚合设计的最高原则。Eric Evans明确提出:“聚合外部的对象不能引用除根实体之外的任何内部对象。根实体可以把对内部实体的引用传递给它们,但这些对象只能临时使用这些引用,而不能保持引用。根可以把一个值对象的副本传递给另一个对象,而不必关心它发生什么变化,因为它只是一个值,不再与聚合有任何关联。作为这一规则的推论,只有聚合的根才能直接通过数据库查询获取。所有其他内部对象必须通过遍历关联来发现。”

例如,订单聚合外的对象要修改订单项的商品数量,就需要通过获得Order聚合根实体,然后通过Order操作OrderItem对象进行修改。考虑如下代码:

//通过订单资源库获得订单的聚合
Order order = orderRepository.orderOf(orderId).get();
//调用Order聚合根实体的方法修改内存中的订单项
order.changeOrderItemQuantity(orderItemId,quantity);
//将内存中的修改持久化到数据库
orderRepository.save(order);

changeItemQuantity()方法的封装符合信息专家模式的要求,会促使聚合与外部对象的协作尽量以行为协作方式进行,同时也避免了作为聚合隐私的内部对象暴露到聚合之外,促进了聚合边界的保护作用。

这一最高原则及基于该原则的推论也侧面说明了聚合独立性的重要性:聚合内部的非聚合根实体只能通过聚合根被外界访问,无法独立访问。若需要独立访问该实体,只能将此实体独立出来,为其定义一个单独的聚合。倘若既要满足概念的完整性,又必须支持独立访问实体的需求,同时还需要约束不变量,保证一致性,就必然需要综合判断。

由于聚合的最高原则规定了访问聚合的方式,使得独立性在这些权衡因素中稍占上风,成为聚合设计原则的首选。至于分离出去的聚合如何与原聚合建立关系,就需要考虑聚合之间该如何协作了。

(六)聚合的协作

论及聚合的协作,无非就是判断彼此之间的引用采用什么形式。形式分为两种:

  • 聚合根的对象引用;
  • 聚合根身份标识的引用。

根据聚合的最高原则,聚合外部的对象不能引用除根实体之外的任何内部对象,但同时允许聚合内部的对象保持对其他聚合根的引用。不过,领域驱动设计社区对此却有不同的看法,主流声音更建议聚合之间通过身份标识进行引用。但是,这一建议似乎又与对象协作相悖。

对象模型与领域设计模型的一个本质区别就是后者提供了聚合的边界。聚合是一种设计约束,没有边界约束的对象模型可能随着系统规模的扩大变成一匹脱缰的马,让人难以理清楚错综复杂的对象关系。一旦引入了聚合,就不能将边界视为无物,必须尊重边界的保护与约束作用。不当的聚合协作可能会破坏聚合的边界。

在考虑聚合的协作关系时,还必须考虑限界上下文的边界。菱形对称架构不建议复用跨限界上下文的领域模型,若参与协作的聚合分属两个不同的限界上下文,自然当谨慎对待。

不能通过一个独断专行的原则统治聚合之间的所有协作场景,无论采用对象引用,还是身份标识引用,都需要深刻体会聚合为什么要协作,以及采用什么样的协作方式。聚合的协作由于都通过聚合根实体这唯一的入口,就等同于根实体的协作,也就体现为根实体之间的关联关系和依赖关系。

1.关联关系

聚合是一个封装了领域逻辑的基本自治单元,但它的粒度无法保证它的独立性,聚合之间产生关联关系也就不可避免。引入聚合的其中一个目的就是控制对象模型因为关联关系导致的依赖蔓延。对于聚合的关联,也当慎重对待。

对象引用往往极具诱惑力,因为它可以使得一个聚合遍历到另一个聚合非常方便,仿佛这才是面向对象设计的正确方式。

例如,当Customer引用了由Order聚合根组成的集合对象时,就可通过Customer直接获得该客户所有的订单:

public class Customer implements AggregateRoot<Customer> {
	private List<Order> orders;
	public List<Order> getOrders(){
		return this.orders;
	}
}

只要坚持不要在Order中定义对Customer的引用,就能避免双向导航。这样的引用关系是否合理呢?

关键在于该由谁来获得客户的订单。由Customer履行订单的查询是不合理的,更何况,Customer聚合与Order聚合并不在同一个限界上下文,如此设计还会导致两个限界上下文的领域模型复用。

在领域驱动设计中,资源库才是Order聚合生命周期的真正主宰!要获得客户的订单,需从订单资源库而非客户导向订单:

List<Order> orders = orderRepository.allOrdersBy(customerId);

Order和Customer并非对对方一无所知。既然不允许通过对象引用,唯一的方法就是通过身份标识建立关联。只有如此,OrderRepository才能通过customerId获得该客户拥有的所有订单。这种关联是非常隐晦的,也可保证限界上下文之间的解耦。
在这里插入图片描述
Customer与Order在对象模型中属于普通的关联关系(即非组合的关联关系),又位于不同的限界上下文,彼此通过身份标识建立关联情有可原。然而,两个关联的聚合若属于同一个限界上下文,且属于整体/部分的组合关系,是否也需要通过身份标识建立关联呢?

在代码模型中,当你将一个聚合或聚合的集合定义为另一个聚合的字段时,就意味着主聚合需要承担其字段的生命周期管理工作。这一做法已经违背了聚合的设计原则。例如,博客Blog和博文Post分属两个聚合,定义在同一个限界上下文中。它们之间存在组合关系,如下实现仍然不合理:

public class Blog implements AggregateRoot<Blog> {
	private List<Post> posts;
	public List<Post> getPosts(){
		return this.posts;
	}
}

Blog聚合和Post聚合的生命周期应由各自的资源库分别管理。当BlogRepository在加载Blog聚合时,并不需要加载其下的所有Post,即使采用延迟加载的方式,也不妥当。如果我们将发出导航的聚合称为主聚合,将导航指向的聚合为从聚合,则正确的设计应使得:

  • 主聚合不考虑从聚合的生命周期,完全不知从聚合;
  • 从聚合通过主聚合根实体的ID建立与主聚合的隐含关联。

Blog聚合指向Post聚合,Blog为主聚合,Post为从聚合,则设计应调整为:

public class Blog implements AggregateRoot<Blog> {
	private BlogId blogId;
	...
}
public class Post implements AggregateRoot<Customer> {
	private PostId postId;
	//通过主聚合的blogId建立关联
	private String blogId;
	...
}

既然不允许聚合根之间以对象引用方式建立关联,那么聚合内部的对象就更不能关联外部的聚合根了,这在一定程度上会影响编码的实现。

public class OrderItem extends Entity<OrderItemId> {
	//Product为商品聚合的根实体
	private Product product;
	private Quantity quantity;
}

直接通过OrderItem引用的Product聚合根实例即可遍历商品信息。问题在于,Order聚合的资源库无法管理Product聚合的生命周期,也就是说,OrderRepository在获得订单时,无法获得对应的Product对象。既然如此,就应该在OrderItem内部引用Product聚合的身份标识。

public class OrderItem extends Entity<OrderItemId> {
	//Product聚合的身份标识
	private String productId;
}

通过身份标识引用外部的聚合根,就能解除彼此之间强生命周期的依赖,也避免了加载引用的聚合对象。不管订单和商品是否在同一个限界上下文,若遵循菱形对称架构,订单要获得商品的值都需要通过南向网关的端口获取,区别仅在于调用的是资源库端口,还是客户端端口。

只要OrderItem拥有了Product的身份标识,就可以在领域服务或应用服务通过端口获得商品的详细信息。假设订单和商品分处不同限界上下文,应用服务想要获得客户的所有订单,并要求返回的订单中包含商品的信息,就可以通过OrderResponse响应消息的装配器OrderResponseAssembler调用ProductClient获得商品信息,并将其组装为OrderResponse消息:

//Order的应用服务
public class OrderAppService {
    @Service
    private OrderService orderService;
    @Service
    private OrderResponseAssembler assembler;

    public OrdersResponse customerOrders(String customerId) {
    	//首先获取Order聚合的集合
        List<Order> orders = orderService.allOrdersBy(customerId);
        //调用组装器将身份标识转换为商品信息
        List<OrderResponse> orderResponses = orders.stream.map(order -> assembler.assemble(order))
                .collect(Collectors.tolist());
        //返回订单含商品详情信息的响应
        return new OrdersReponse(orderResponses);
    }
}
public class OrderResponseAssembler{
    @Service
    private ProductClient productClient;
    //调用ProductClient获得商品信息
    public OrderResponse assemble(Order order){
        OrderResponse orderResponse =transformFrom(order);
        //获取订单的订单项集合并进行相应转换
        List<OrderItemResponse> orderItemResponses = order.getOrderItems.stream()
            .map(oi ->transformFrom(oi))
            .collect(Collectors.toList());
        //获取所有订单项的详细信息
        orderResponse.addAll(orderItemResponses);
        return orderResponse;
    }
    private OrderResponse transformFrom(Order order){...}
    
    //根据订单项的商品标识获取商品名称、价格等信息
    private OrderItemResponse transformFrom(OrderItem orderItem){
        OrderItemResponse orderItemResponse = new OrderItemResponse();
        ...        
        ProductResponse product = productClient.productBy(orderItem. getProductId());
        orderItemResponse.setProductId(product.getId());
        orderItemResponse.setProductName(product.getName());
        orderItemResponse.setProductPrice(product.getPrice());
    }
}

若担心每次根据商品ID获取商品信息带来性能损耗,可以考虑为ProductClient的实现引入缓存功能。倘若订单上下文与商品上下文被定义为单独运行的微服务,这一调用还需要跨进程通信,需考虑网络通信的成本。此时,引入缓存就更有必要了。

考虑到限界上下文是领域模型的知识语境,在订单上下文中的订单项关联的商品是否应该定义在商品上下文中呢?

显然,在订单上下文定义属于当前知识语境的Product类(若要准确表达领域概念,也可以命名为PurchasedProduct)。该类拥有身份标识,其值来自商品上下文Product聚合根的身份标识,保证了身份标识的唯一性。它虽然具有身份标识,却可以和商品名、价格一起视为它的值,它的生命周期附属在Order聚合的OrderItem实体中,它也无须变更其值,故而可定义为Order聚合的值对象,它的数据与订单一起持久化到订单数据库中。Order的资源库在管理Order聚合的生命周期时,会建立OrderItem指向PurchasedProduct对象的导航。这一设计规避了数据冗余,因此更加合理。原本跨聚合之间的关联关系变成了聚合内部的关联,问题自然迎刃而解了。

在建立领域设计模型时,我们不能照搬面向对象设计得来的经验,直接通过对象引用建立关联,必须让聚合边界的约束力产生价值。

2.依赖关系

依赖关系产生的耦合要弱于关联关系,也不要求管理被依赖对象的生命周期。只要存在依赖关系的聚合位于同一个限界上下文,就应该允许一个聚合的根实体直接引用另一个聚合的根实体,以形成良好的行为协作。

聚合之间的依赖关系通常分为两种形式:

  • 职责的委派;
  • 聚合的创建。

一个聚合作为另一个聚合方法的参数,就会形成职责的委派。

例如,结算账单模板为结算账单提供了模板变量的值、坐标和顺序,可以将二者在生成结算账单时的协作理解为“通过结算账单模板填充内部的值”。将SettlementBillTemplate聚合根实体作为参数传入SettlementBill的方法fillWith(),就是理所当然的实现:

public class SettlementBill {
	private List<BillItem> items;
	...
	public void fillWith(SettlementBillTemplate template){
		items.foreach(i -> i.fillWith(template.composeVariable()));
	}
}

SettlementBill.fillWith(SettlementBillTemplate)方法的定义也形成了这两个聚合根实体之间良好的行为协作。一个聚合创建另外一个聚合,就会形成实例化(instantiate)的依赖关系。这实际是工厂模式的运用,牵涉到对聚合生命周期的管理。

四、 设计聚合的几种方法

聚合的设计有以下五种方式。

1)更换主谓宾语句顺序。
2)根据领域事件。
3)通过职责行为。
4)通过事务边界。
5)按时间边界。

1. 更换主谓宾语句顺序。

DDD本质上是一种语言形式分析学。对于需求表达,有时除了领域专家自己以外人们很难清楚他们的专有名词所指为何,有些是特别专业的词语,需要很深入的专业背景,几十年的深度耕耘浓缩在几个词语上,不是行外人说理解就能理解的。

以电商系统下单为案例,对于用户下单有以下不同表示方法。

1)用户正在订购一本书。

2)一本书是由用户订购的。

3)一本书的订单被用户下了。

这三句描述的是同一个意思,只是形式不同,主谓宾次序不同。这种方法主要适合“主语”习惯人群,这类人群对第一个出现的主语比较敏感,一般主语是人,因此,对人的身份角色很敏感。

第一句:用户正在订购书籍,可以模拟表示成“用户”这个聚合,这个聚合体的大小怎么样?是不是太大?如果用户购买了数千本书怎么办?

public class User {
	public Long userId;
	public List<Book> books;
}

第二句:一本书是由用户订购的。主语是“书”,表示为“书”这个聚合,其中包含订购了这本书的所有用户的所有订单。真的需要所有这种历史订单吗?不需要在一个巨大的聚合中聚合所有内容,那是上帝式的聚合。

public class Book {
	public Long bookId;
	public List<Order> orders;
}

第三句:一本书的订单被用户下了。主语是“订单”,表示为“订单”这个聚合,这里的主语主要是将前面“订购”这个谓语动词主语化了,所以,也可以从谓语动词或主谓宾关系中寻找聚合。当订单为聚合时,所有与同一订单相关的信息都放在一个地方,聚合体很小,每个订单可以创建一个新的聚合对象实例,可以根据需要添加用户信息和订单行。

public class Order {
	public Long orderId;
	public Long userId;
	public List<orderItem> items;
}

在这三种聚合的建模中,通常情形一个用户可能买有数千本书,一本书可能有数十万个订单,一个订单有数个订单项。显然,第三种包含的对象最少,最适合建模成这种聚合。

有人可能会说,这里关键不是主谓宾颠倒,而是订单这个词语的挖掘,但是这种挖掘方式也是通过谓语动词、职责或命令事件法去实现的,当重点放在谓语动词上,才会发现发生的事实。

当然,主谓宾调换方法是针对业务统一语言,而不是技术语言。“学生取消课程注册”清楚地表达了业务意图,而“从注册表中删除了一行”和“一个取消原因被添加到学生反馈表”则不太清楚,后两句都是技术语言,或者说是数据库语言,其中有“表”和“行”等词语,这些陈述语句无论怎样翻转主谓宾,都无法找出其真正的语义,因为它们的所指目标是在数据库这个范畴的世界,而不是指向真实世界,条条大路通罗马,但如果这个罗马是纸上的罗马,就永远到不了。

2. 根据领域事件设计聚合

在业务领域中,经常发生的是“活动”和“事实”,“事实”是在“活动”中发生的,发生的“事实”通过领域事件表达,那么,“活动”就使用聚合表达,这样,“事实”发生在“活动”中,就可以表达为:领域事件是在有界上下文的聚合中发生的。
在这里插入图片描述
命令是具体落到聚合根这个对象上,当聚合根根据业务规则或逻辑执行了这个命令,实际上就代表聚合根内部的状态发生了改变,一些事实发生了,聚合根再抛出领域事件。聚合相比有界上下文而言,更能落实在具体代码设计上,如果使用传统SOA架构做比喻,有界上下文的设计代表服务的设计,而聚合的设计代表数据表关系的设计,当然传统数据库设计是先有数据表才有关系表,而DDD的革命性正是在这里——先有关系才有关系内的对象。

这里举一例子,通过事件风暴讨论医生处方管理领域:医生开处方这个功能设计中,通常可能将开处方作为医生的一个动作行为。
在这里插入图片描述

public void Doctor {
	//处方
	public void Prescribing(){}
}

这其实是一种朴素的直观抽象,直接根据主谓宾语法结构,将主语“医生”等同于类名,将“开处方”等同于类的行为。这种映射是没有根据的,类的名称不一定对应自然语言的主语,类的名称是依据统一的通用语言,而通用语言根据上下文而定,然后再根据聚合的名称最终决定类的名称,可见DDD实际上也是一套类的命名法则。

首先罗列出医生开处方这个活动中的命令和事件。命令是:(医生)开处方;事件是:处方开了。

前者表示医生的意图,后者表示领域中发生的事实,现在有了命令和事件,按照时间线排列一下:医生开处方之前有什么流程活动?是不是需要预约和检查?开处方之后有什么活动?从药房取药吗?

所以医生开处方只是整个流程中的一个环节,这个环节是病情诊断环节,应该是病情诊断这个有界上下文中发生的活动。那么“病情诊断”这一通用语言中涉及什么关系呢?

医生、病情、患者、处方这四个对象会在这个有界上下文发生关系,这个关系可以使用聚合来表达,那么聚合根是什么?

医生是聚合根吗?如果有这种想法,还是没有逃脱“主语狂想症”。DDD没有规定主语就肯定是聚合根,聚合根的职责是保持一致性边界,这样聚合内部才能保证逻辑一致性,那么开处方这项活动有哪些逻辑一致性需要保证?处方的药和病情需要对应,这是起码的业务规则,业务规则在哪里,哪里就是真正的主语,是真正需要被关心的。很显然,在“处方”这个聚合根中,可以放置药和病情的规则约束,医生只是处方的开具者,并不是业务规则的拥有者,领域系统才是业务规则的拥有者,医生可能反而是被规则监督的对象。
在这里插入图片描述

聚合根是处方,其主要包含业务规则为药和病情的规则不变性约束,聚合边界内有药、病和医生等对象。

3. 根据单一职责设计聚合

聚合的逻辑一致性不但表现在紧凑的结构关系上,还表现在高度一致、高度凝聚的职责行为上,这也是OOAD中单一职责的一个实现。从职责行为这个角度可以看到以下聚合模型特征。

第一种是信息拥有者模型。当一个对象是信息的拥有者时,它的职责是“知道这些信息”,不应该期望和其他对象协作获得这些已经知道的信息。

信息可以从“会话”中获取,谁实现这段会话?如果有其他对象参与会话,它就是协作者。还可以从信息首先发生位置寻找:哪个是首先知道信息的?有无信息的传送?可通过UML顺序图寻找这里面行为发生的先后顺序,确定主次之分,主要操作者就是聚合根。例如游戏系统中,每场游戏会话就是一个聚合,在这个聚合中集中了这场游戏上下文的所有信息。

第二种是决策控制者模型。控制者是和协作者有区别的,控制者能区分事情,决定采取什么行动;而协作者通常是让它做什么就做什么,自己很少做决定,没有主见。可以从决定性行动来自哪里来寻找控制者,一般决策都来自业务规则,如果这些决策是复杂的,则使用聚合中其他对象分担责任。

从职责角度分析前面的医生开处方案例,如图所示。
在这里插入图片描述
将开处方这个职责分配给医生,貌似非常合理,其实这里忽视了软件自身在其中扮演的角色。将开处方分配给医生,实际是将医生变成了控制者模型,但问题是,软件在哪里?医生已经是控制者了,软件还能做什么?做计算器的工作吗?因此,软件设计不是写作文,不是坐而论道,以毫不相干的姿态评论它,而是要将软件参与其中,将软件变成强势的控制者、拥有者模型。

医生作为一个角色参与了开处方这项活动,软件作为服务者在这项活动中提供了开处方这项具体职责服务,医生只要给处方服务下命令(开处方),处方服务将寻求背后的领域逻辑做决策或决定,是否可以执行医生的这个命令。聚合作为逻辑规则的决策者,其中可能含有业务规则,规则检查通过后,告诉协作者可以执行,然后协作者去回复医生处方已经开具成功。

因此,在设计聚合时,需要将聚合模型和服务模型进行区分。聚合与服务模型的区分是控制者与协作者的区别:如果一个对象监听用户的动作命令,然后简单委托请求给周围的一些对象,它是在传递做决策的职责,也就是说,它在请求决策控制者做决策,它自己并没有做决策;它也可能做些控制者模型让它做的一些协调工作。

例如前面讨论的比赛案例,当使用传统数据表+服务实现时,看看比赛服务接口,如图所示。
在这里插入图片描述
比赛服务中有“开始比赛”和“结束比赛”两个行为,从职责定义角度看,做出比赛开始和比赛结束不是协作者“服务”的职责,而应该是决策控制者“聚合”的职责,或者说,做决定的权力不能是“服务”。
在这里插入图片描述
比赛服务虽然还是有startMatch()和endMatch()方法,但是已经被掏空,业务逻辑转移到聚合根中了,比赛服务只是作为一个协作者而存在,本身没有任何业务决策权。

有人可能问,聚合需要保存自身数据到数据库,保存操作数据库的职责应该放在聚合中还是服务中?首先考虑,保存数据到数据库这个职责是属于业务领域还是属于技术领域?领域专家是否关心这件事?很显然它属于技术领域,它属于在聚合和数据库之间的协作职责,所以将聚合的数据委托给数据库保存。

如果服务中有太多协作性代码(超过20行),那么可能有一些隐藏的模型没有发现,因为协作代表了对象之间的依赖,协作越多,依赖就越多。检查这些协作活动是否涉及决策决定,如果是就要考虑是否背后有一个控制者的聚合模型了。

4. 按时间边界设计聚合

聚合是存在于有界上下文中的对象模型,很显然,聚合也会受到时间线的深刻影响。
以货物运输系统为案例,图所示为项目需求文档。
在这里插入图片描述
该项目的核心是设立调度中心功能。
1)对所有任务统一整理,集中派发。
2)提供相关信息以便于调度执行派发任务操作,监控任务执行状态,提高任务派发合理性,减少不合理用车及人为错误率。

该系统涉及的角色如下。
1)客户:提出运输业务的要求。

2)放箱公司:提供集装箱放箱和提箱。

3)一级调度:将任务分配到车队。

4)二级调度:将任务分配到司机。从时间线或业务流程理解具体业务能力,如图所示。这是领域系统与组织外部角色的流程说明,类似办事流程,描述了作为一个客户如何与该组织打交道,但是注意到,系统不是给外部角色客户直接使用的,而是供该组织的内部角色使用的,因此,需要重点查看内部作业流程图,如图所示。
在这里插入图片描述
在这里插入图片描述
这时分析建模的问题空间,可以按照谓语动词、职责或领域事件三个角度分析。

1)谓语动词法:(一级调度)制作计划大表,输入参数是内部委托单。

2)职责法:一级调度的责任是制作计划大表,这是他的每天工作职责所在。

3)领域事件法:(一级调度)根据内部委托单制作完成了计划大表。

以看出,这三种分析方法基本都是相同的,具体偏好取决于个人习惯,也可以三种方法相互结合验证。

一级调度的职责不只是制作计划大表,如果只是这个职责,估计他只是一个普通的制表员或计划办事人员。他更重要的职责和权力是根据计划大表和作业要求分配运输任务号,分配对象是车队,这里下发到车队有发出命令的意味。

用命令/事件法分析如下:(一级调度)根据内部委托单制作完成了计划大表(发生的事件),再根据计划大表和作业要求分配了任务号(发生的事件),将任务号下发到车队(命令)。这里有一个事件转命令的过程,存在命令转事件或事件转命令的地方都可能存在业务逻辑。当有这个敏感性以后,再注意需求中提到“作业要求”由三个部件组成:作业时间、门店位置和业务类型,可能这里存在复杂的数据结构关系了,“作业要求”由这三个部件组成,这是一个整体部分关系。

整体部分代表了一种结构上的聚合,整体部分关系本身意味着复杂性,因此,这里可能存在一个聚合。DDD聚合是针对领域复杂性而设计的,既然这里是复杂的,那么是否存在聚合?这个聚合是否过大?是否需要划分有界上下文来分割它们?

一级调度员的职责范畴应该划分到一个有界上下文中,因为在这个一级调度有界上下文中,存在一个复杂的整体部分关系,它是整个车队运营的核心数据在“作业要求”中的汇集,作业时间代表车辆出发时间,门店位置事关车队位置、货物位置,业务类型事关收费以及运输过程的安全性等。

在一级调度有界上下文中,输入的命令应该是来自受理有界上下文的委托单,输出事件是发往派车有界上下文的任务号,可以使用发布/订阅方式将运输任务推送到车队二级调度所在的派车上下文。

在一级调度有界上下文中发生了两种动作事件:首先是制作大表,其次是分配任务号。这是两个有前后顺序的逻辑过程,如果没有大表,就无法分配任务号,但是只有计划大表,也无法产生运输任务,那么它们是否属于一个聚合结构?还是分成两个聚合呢?

从时间边界上看,制作计划大表和分配任务号是否在一个时间点上完成?

如果这两种工作各自都很复杂,就会花费一级调度员很多时间去完成,甚至需要由内部分工,专门设置制表员岗位制作计划大表,然后专门设置任务分配员岗位进行任务号分配,但是从需求中发现,这两件职责工作都是由一级调度员一个岗位角色完成的,而且是在一段时间内必须完成,因此,制作计划大表和分配任务号属于在一段时间边界内所做的两件事情。

当深入计划大表的内容时,发现其中记录了作业的要求,包括作业时间、门店位置和业务类型,一级调度员需要将货物运输路线与车队位置进行匹配,这非常类似滴滴打车的派车算法,只不过这里是由人工实现的。制作计划大表是对运输任务、运输行程等的业务规则安排。

5. 通过事务边界设计聚合

每当发现领域中有两个元素紧密结合在一起时,就有可能会发现潜在的聚合,因为聚合的特点是紧密关联。可以根据这些元素的存储方式发现并设计聚合。例如:用户必须在注册之前输入姓名、邮件地址,如果没有这两个输入,就应该无法创建账户。

也就是说,如果他们不满足业务上所有这些条件,他们创建账户的请求(即事务)将被拒绝。

事务体现了业务规则,这里的业务规则是:在任何情况下,没有姓名和邮件地址的客户都不应存在于系统中,称此为不变业务规则。

从存储技术角度看,当几个元素需要同时存储时,其中一个元素发生改变需要存储,其他元素的改变也需要同时存储;如果其中一个元素提交失败,则需要回滚撤销所有更改。多个元素的更改好像是对一个整体元素修改一样,ACID(原子性,一致性,隔离性,持久性)是用来保证数据库事务可靠处理的特别设计。

例如专家门诊预约系统中,每天都有12个时段,每个时段持续30分钟(上午或下午)。在软件系统中,需要确定业务事务边界(又称为DDD聚合)。开发人员一般的直觉设计中,是将一个30分钟的时间段作为聚合边界,选择这样的时段是因为它较小,并且应该更快地加载和保存在数据库中。但是一个患者可能会预订多次专家门诊,而在业务上这是不允许的,为什么呢?

原来患者每次预约以后,都需要医院一方确认,然后安排相应的医生出诊,如同用户下了订单以后商家需要确认安排出货一样。一些患者预约两个时间段,相当于看两次病,这是不恰当的,还可能是黄牛从中倒卖,因此,业务规则是:一个患者不能预约两次或多次。

这实际上在技术上反映出了二次提交或重复提交问题,其实这不是一个技术问题,而是业务问题,开发人员只要在代码中添加一条规则,即无论何时患者提交预约请求,如果他们在同一天已经进行了预约,则第二次预约将被拒绝。

6. 通过ER模型设计聚合

大部分开发人员都具有数据库表设计背景,这是作为一个开发人员的基础技能。ER模型是数据库表设计的抽象,这里介绍一个从ER模型设计中发现聚合的办法,以便习惯于数据库表设计的开发人员逐步转移到DDD聚合设计上。如果有两种数据表,一种表是主表,另外一种表是明细表,主表是总体概括,或者是明细表发生变动时需要同时修改的共享的一个表,这在ER模型中称为星形模型.
在这里插入图片描述
在这个星形ER图中,有一个主表,如同核心,围绕主表有其他关系表。主表类似一种汇总表,而具体明细表则是其关系表,例如主表可能是科目余额表,而明细表则是借贷明细表,科目余额=科目期初余额+借贷明细,主表状态就是科目余额。财务这套业务体系本身的设计就有明显的树形结构特征。每当有一笔借贷明细加入时,科目余额将会有变动,这时主表和明细表都必须一起在一个事务中改变,不能只修改明细表,而不修改科目余额。找到必须一起更改的条目,就能找到聚合的一部分。星形模型与聚合模型的合并设计如图所示。
在这里插入图片描述
主表等同于聚合根,明细表类似领域事件的集合,明细表里记录的是每一笔发生的交易,这些都是发生的事实记录,因此等同于一笔笔领域事件记录。

主表的状态字段等同于聚合根状态。假如主表是个人账户,而明细表代表进出明细,那么每次发生一笔进出,个人账户的余额状态就发生变动

。例如,如果个人账户余额初始是100元,今天进账30元,出账消费20元,那么个人账户的余额就编程为100+30-20=110,状态值从100元变成了110元。

在数据库编程范式中,将进账30元和出账20元看成进出明细表中的两条记录,而从DDD领域事件角度看,它们属于发生的两次领域事件(进账30元事件和出账20元事件),将数据库范式中的进出明细看成是进出事件集合了。

很多系统都是基于传统的ER模型和数据库事务机制实现的,如何将这种传统架构重构为以聚合为主的DDD设计呢?

在传统面向数据库的编程方法中,通常是服务+(主表/明细表)的架构,也就是业务逻辑基本在服务之中,服务向数据库发送SQL命令或存储过程,给明细表增加一条记录,然后改变主表的状态。这时的事务可能使用数据库连接池的事务,也就是打开一个数据库连接直至关闭,这个过程中保证了ACID事务过程;或者使用跨数据库连接池的两段事务(2PC),2PC是在服务中实现的,因此,如果重构这种传统架构,那么就要从服务开始。

在Java的Spring框架开发应用时,经常会使用其JTA事务注释“@Transaction”实现2PC,这个注解标注在服务的方法上,代表这段方法会在一个2PC事务上下文中处理,如果发现这种情况,就可以判断这段方法内可能会涉及聚合一部分的操作,再根据上一节的职责驱动设计,分辨出服务和聚合(服务是协作者,而聚合才是决定者),重构这段服务方法,将负责决策和决定的部分从服务中剥离,重构成聚合对象群。

public class EmployeeService {
	@Trancational
	public void updateEmployee(){
		dao.task1();
		dao2.task2();
		dao3.task3();
		dao4.task4();
	}
}

在@Transactional标注的方法中涉及多个数据源的操作,这四个操作要么一起完成,要么全部不完成,这就是一种不变性和一致性要求。那么考虑:这里面是否存在聚合模型?是不是将这四个任务变成一个聚合根对象的四个方法就完成聚合设计了呢?例如将更新任务放入聚合根类Employee中:

public class Employee {
	public void update (){
		dao.task1();
		dao2.task2();
		dao3.task3();
		dao4.task4();
	}
}

服务由此变成:

public class EmployeeService {
	@Trancational
	public void updateEmployee(){
		employee.update();
	}
}

上面的代码中,服务虽然只是起到传递命令的委托作用,但是这样抽象的聚合模型是不对的,因为dao.task1、dao.task2、dao.task3、dao.task4四个任务涉及四个数据库的操作,至少操作了四张表。

聚合模型首先是一种高聚合的组合结构,虽然将四个任务放在一个方法里了,但是这四个任务操作的数据还是在聚合边界之外,也必须纳入聚合边界,这样才能形成封装性,保证逻辑真正的一致性,否则聚合边界之外的数据可能被其他任务操作修改导致不一致。存放于数据库的数据也属于聚合边界之外。

假设这四个任务涉及的对象是A、B、C、D,则Employee变成:

public class Employee {
	private A a;
	private B b;
	private C c;
	private D d;
	public void update (){
		a.task1();
		b.task2();
		c.task3();
		d.task4();
	}
}

对应的UML类图如图所示,这是一个星形/树形的聚合模型。
在这里插入图片描述

数据库的操作属于技术领域,非业务领域,因此DAO之类的数据库操作不应该出现在聚合模型中。这四个对象是从DAO操作的四个数据表中抽象出来的,形成了Employee的组成部分,这样的形式才真正表达了父子关心的聚合,同时也表示这四个对象必须一起修改,实现逻辑变化的一致性,真正做到根据事务边界抽象出聚合模型。

当实现上述根本改变以后,服务中的@Transactional是否还有必要呢?因为updateEmployee()方法委托给聚合根Employee的update(),Employee是不涉及多个数据源和数据库的,而原来的@Transactional是一个支持多数据源的分布式事务JTA实现。

这就引出一个很大的问题:聚合模型下还需要分布式事务吗?聚合模型的视角是否与分布式事务的视角完全不同?

分布式事务的意思是事务分布在多个地方,甚至在不同的主机上。注意这句话的主语是“事务”,将事务必须存在作为第一原则,作为逻辑推理的假设前提。这段逻辑分解如下。

1)世界上有一个过程化的事务概念。

2)如果事务过程涉及的环节不是在一起,而是分布式地存在。

3)那么就需要分布式事务。

但是,根据事务边界设计聚合,聚合的意思就是放在一起,紧密聚合在一起,那肯定是在一个地方部署运行了,因此,根本不存在涉及多个环节的过程化。

那么结合聚合和业务流程的概念,它的分析逻辑变成:

1)有一个流程。

2)聚合设计将流程各个环节变成一个个聚合。

3)整个流程需要事务吗?需要的是什么样的事务?要么全部执行,要么全部不执行,流程中任何一个环节执行失败,之前执行的环节进行回退。

以出差参加会议为例,为了实现这个功能,需要分四个步骤实现:报名参加的会议,预订酒店,预订机票,预订目的地的巴士。
在这里插入图片描述
当一个流程中任何一个环节发生异常时就要取消之前的所有步骤,比如预订机票出错,就要取消之前预订的酒店,取消计划会议,这样整个流程就好像没有执行一样,这是业务意义上长时间分布式事务的实现机制。

public class EmployeeService {
	@Trancational
	public void updateEmployee(){
		dao.task1();
		dao2.task2();
		dao3.task3();
		dao4.task4();
	}
}

这里涉及了四个不同数据源(数据库)的task操作,是一种跨多数据源的分布式事务,很可能这涉及了一个流程,需要从有界上下文、聚合等DDD视角重新设计这个用例了。

另外,进行聚合设计时,也需要避免一个大聚合。大聚合可能就是一个分布式事务过程,聚合需要以原子方式更新,有两种处理事务的方法:乐观和悲观并发

乐观并发能导致更小的聚合,悲观并发导致更大的聚合,乐观并发可能带来的是最终一致性。

当然,为了彻底解决上述问题,应避免在聚合中使用共享状态,而是采取记录领域事件,然后从头播放这些事件到当前时间,这样就能获得当前状态。避免共享,就避免了争夺,也就无需用锁,并发和事务也就不需要了,这是事件溯源(EventSourcing,ES)架构的来源。

public abstract class AbstractAggregate<ID> extends AbstractEntity<ID> implements Aggregate<ID> {
    /**
     * 聚合中的所有领域事件
     */
    @JsonIgnore
    @QueryTransient
    @Transient
    private final transient List<DomainEventItem> events = Lists.newArrayList();

    protected void registerEvent(DomainEvent event) {
        events.add(new DomainEventItem(event));
    }

    protected void registerEvent(Supplier<DomainEvent> eventSupplier) {
        this.events.add(new DomainEventItem(eventSupplier));
    }

    @Override
    @JsonIgnore
    public List<DomainEvent> getEvents() {
        return Collections.unmodifiableList(events.stream()
                .map(DomainEventItem::getEvent)
                .collect(Collectors.toList()));
    }

    @Override
    public void cleanEvents() {
        events.clear();
    }

    /**
     * 领域事件项目
     */
    private static class DomainEventItem {
        DomainEventItem(DomainEvent event) {
            Preconditions.checkArgument(event != null);
            this.domainEvent = event;
        }

        DomainEventItem(Supplier<DomainEvent> supplier) {
            Preconditions.checkArgument(supplier != null);
            this.domainEventSupplier = supplier;
        }

        private DomainEvent domainEvent;
        private Supplier<DomainEvent> domainEventSupplier;

        public DomainEvent getEvent() {
            if (domainEvent != null) {
                return domainEvent;
            }
            domainEvent = this.domainEventSupplier != null ? this.domainEventSupplier.get() : null;
            return domainEvent;
        }
    }
}

这个聚合根中,其组成部分就是一个事件集合.

聚合根的其他方法都是对集合中的事件进行播放或重播,这可以用作一个超级类,在不同的有界上下文中被继承使用,不同的只是领域事件的类型,类型不同代表上下文不同。

使用纯粹的事件集合替代共享状态虽然巧妙解决了并发和事务问题,但是带来了实现的复杂性,在实际中可根据领域的复杂程度进行取舍,如果相比领域本身的复杂性这点复杂性带来的成本可忽略不计,那么根据ES设计聚合无疑是一个一劳永逸的好办法。

五、设计聚合的案例(订单系统中的聚合)

以电商系统中的订单为例阐述聚合的设计。按时间线发现电商系统中的有界上下文,订单有界上下文是其中一个。重新检查一下电商系统的用例图,如图所示。
在这里插入图片描述
根据这个简单的用例图,使用UML时序图表达,如图所示。
在这里插入图片描述
1)用户下单:用户是聚合吗?这个聚合体的大小怎么样?是不是太大了?如果用户购买了数百种商品怎么办?

2)商品由用户订购:商品作为聚合吗?商品被很多用户订购,那是不是都放在商品中呢?具体的商品种类和数量和某个用户发生关系是在订购上下文中,需要使用一个概念表达这种关系,表达这种订购活动,是不是可以采取命令-事件的方法来分析?

在这里插入图片描述
用户下单是一个命令,聚合执行一定的业务规则检查,执行了这个命令,“下单成功了”的事件被抛出。在订单上下文中,命令和事件都是针对一个聚合发生的,这个聚合表达了一种逻辑不变性,就是维持“一个用户购买了多少种商品总价是多少”这样的业务关系,这种关系记录了用户的购买活动,因此,表达人类活动中业务关系不变性的是“合约”。这里“合约”的术语,或者称为统一语言是“订单”。

1. 信息拥有者模式

以上是结合主谓宾更换以及命令-事件法来发现聚合的方法,下面可以通过职责来设计一下这个聚合。聚合是决策者,有权力的对象,它承担哪些职责?首先,它知道些什么?

它应该知道什么人购买了哪些商品,总价是多少,客户地址在哪里。形象地将订单的组成画图表示出来,如图所示。
在这里插入图片描述
订单中有订单明细,每条明细使用业务术语称为“订单条目”或“订单项”,订单项中有商品和数量,那么是不是直接使用商品管理上下文中的Product来表示商品这个概念呢?

Product已经是商品管理上下文边界内的聚合根了,如果这里再次引用它,那么就跨越了有界上下文的边界,这是原则问题,不能让步。

但是这里又要表达商品这个概念,怎么办呢?

任何事物都可以使用其标识表达,如同身份证号码可以标识人一样,商品的标识是ProductId,那么这里就使用ProductId代表商品。

从聚合的逻辑一致性角度看,聚合内的所有对象都是一致变化的,实际上聚合代表一个封装了很多小对象的大对象,在边界内的所有对象应该一起变化。如果订单删除了,订单项目也应该没有了,那商品也应该删除吗?

显然不是。

收货地址也应该删除吗?

显然不是。删除订单,只是删除某个时间点上一个活动关系的表达。
在这里插入图片描述

这两个类之间的关系是一种聚合关系,更严格地说是组合关系,订单是由订单条目组合而成的,没有订单条目,就没有订单,因此使用UML聚合符号表示它们的聚合关系,如图所示。订单和订单条目是一种1:N(一对多)的关系,一个订单由多条订单项组合,这个“多”的关系使用集合items表达。

public class Order {
	private String orderId;
	private List<OrderItem> items;
	//...
}
public class OrderItem {
	private String productId;
	private int qty;
	//...
}

2. 引用模式

现在再检查订单中还有什么组成部分。前面提到了送货地址,地址可以有独立的地址管理上下文,订单中只是引用一个现成的送货地址。

引用有以下两种模式。

1)直接使用被引用的对象名称,如同Order引用OrderItem一样。

2)将被引用对象的关键标识作为一个新的对象类型引用。

如果采取第一个方案,将Address完全引用过来,这就产生了聚合边界的问题:聚合边界内的变化是一致的,如果订单删除,Address也应该删除,这显然不对。送货地址独立于订单存在,可以把它看成一个自聚合体。

但是订单中确实需要一些地址信息,那么可以将订单需要的地址信息专门创建一个对象类型,这种对象类型称为值对象。也就是包含数据、信息或数值的对象。

这里将创建一个包含地址信息的Address值对象,其标识ID可以指向地址管理上下文中的那个地址(也可以直接从地址管理上下文直接复制过来,以后地址变更时,这里订单的送货地址不会随着变化)。

注意:这里是通过对象的ID值来引用原来的对象,而不是直接引用原来的对象,这两者是有区别的。这种设计的优点是:划清了边界,但是又能保留部分信息。

在这里插入图片描述

这样的模型其实是一种树形模型。

在这里插入图片描述

现在再检查一下这个聚合模型的存在合理性,之前是从职责驱动角度发现这个聚合应该拥有哪些信息结构,信息结构已经找出来了,那么订单是不是就是聚合的根呢?有没有比订单更适合做聚合根的对象还没有挖掘出来?

3.奥卡姆剃刀原理

假设这里有一个称为“交易”的聚合根,它也能表达用户订购商品的概念,但是交易这个概念有一手交钱和一手交货的意思,也就是说在订单有界上下文中还有支付和发货的概念。

其实整个电商系统就是一个网上商品交易系统,引入交易这个概念可能使得聚合大了一些,如图所示。

根据奥卡姆剃刀原理,如无必要,勿增实体。

如果订单能够表达用户订购的含义,那么就没有必要再进行进一步的深度抽象,否则容易抽象出上帝对象来,如果思维中没有一个否定上帝的思维习惯,那么就总会不自觉尽情发挥抽象思维,直至天人合一,这种抽象思维反而会将简单事情复杂化。

在这里插入图片描述

4. 控制者模式

前面通过“知道什么”的职责设计了订单聚合的层次结构,下面再根据职责中的“决定些什么”来设计。

决策是聚合模型作为控制者模型的主要职责,因为它拥有很多信息,所以可以进行更好地决策。决策涉及业务规则,聚合里面是不是有一些业务规则呢?

联想到前面只是做了“用户下单”这个功能,支付和发货还没有实现,但是这里的支付和发货并不是真正实现如何付款和如何发货。

当前是订单上下文,命令和事件应该围绕订单这个聚合发生,订单里的付款和发货是针对订单而发生的,用来标识该订单是否已经支付和是否已经发货。改变订单状态并不是那么简单,而是有其业务逻辑和规则在其后支撑。

在这里插入图片描述
商品管理上下文中,只有新增了商品才可能查询到商品,否则查询不到,这就形成了自然的资源限制的逻辑顺序,但是在订单上下文中,下单后可支付,也可直接发货(货到付款模式),那么下单后到底是支付还是发货?

这需要根据业务规则是否支持货到付款来决定。

通过领域事件检查一下这里的逻辑顺序:

第一步:下单命令。是否可以生成订单?订单结构生成,下单事件完成。

第二步:支付命令。是否可以进行支付?业务规则如果允许(例如检查是否有库存),支付才可完成,支付真正完成还有待进入支付细节,如从哪种支付途径扣款等,但是在扣款支付之前,必须得到可以支付的决策允许。

第三步:发货命令。是否可以进行发货?业务规则如果允许(例如检查是否已经进行了支付),发货事件可完成,将发货事件发送到发货有界上下文,转变为命令,实现具体发货处理。

假设业务策略不支持货到付款,那么,在这里就有一个业务上的逻辑规则。

第一步:下单。

第二步:支付。

第三步:如果已支付则发货。

使用UML状态图表达这种逻辑规则,如图所示。

在这里插入图片描述

if(当前状态已支付)
发出发货命令
else
不能发货

这就是业务规则,可以说带有IF语句判断的业务情况基本都属于业务规则,明白这个现象有利于有针对性地发现业务规则,重构为聚合模型。

通常这些IF判断语句散落在服务代码中。聚合模型中封装了这种业务规则,根据这种业务规则进行决策,因此称其为控制者模型,代表它是一个有实权的“大领导”。

下面进一步的问题来了,如何确保这种控制规则落实到代码实现呢?原则肯定是要保证前面IF语句的伪代码在聚合根订单中实现,任何外部命令涉及支付或发货,都需要首先征得聚合根订单中这条规则的检查同意后才能实施。

5. 订单状态集中控制实现

因为业务规则需要集中到聚合中实现,实际上意味着订单的状态将集中控制。首先看看订单状态散落在服务中的情况,此时订单的状态实现如图所示。
在这里插入图片描述

public enum OrderStatus {
	PLACED,
	PAYED,
	DELIVERYED
}

下面是发货服务的代码:

public class OrderService {
	public void delivery(){
		switch(order.getOrderStatus()){
			case PLACED:
			print("订单已就绪,未支付,不能发货");
			break;
			case PAYED:
			print("订单已支付,可以发货");
			break;
			case DELIVERYED:
			print("订单已发货,什么也不能做");
			break;
		}
		//...
	}
}

这种实现方法中,是在服务中检查状态,然后决定是否可以支付和发货,这些非常重要的业务逻辑和决策放在一个委托者服务中执行了。
在这里插入图片描述
此时,如果有一个打包货物的处理,那么在打包服务中还要对订单当前状态进行一次判断和修改,这些订单重要的状态判断与修改散落在各个服务的方法代码中,以至于最后在系统运行时,因为状态问题而使一些业务无法执行,但是无法搞清楚当时为什么是那种状态。

状态的修改与判断将会非常混乱,这样的系统无疑是无法稳定运行的,新程序员也很难接受、修改和拓展程序,他需要阅读所有状态修改和判断的方法代码,并且穷尽其中的逻辑状态,也就是MECE原则:相互独立,完全穷尽。

聚合是领域做业务决策的地方,可以将这段决策代码放入聚合根Order中:

public class Order {
	public void delivery(){
		switch(order.getOrderStatus()){
			case PLACED:
			print("订单已就绪,未支付,不能发货");
			break;
			case PAYED:
			print("订单已支付,可以发货");
			break;
			case DELIVERYED:
			print("订单已发货,什么也不能做");
			break;
		}
		//...
	}
}

重要的状态判断是在delivery()内部实现的,方法内部实现是程序员具体的作业,那么这样做就可以了吗?

若是作为设计人员,不将领域中重要的业务逻辑明确、显式地设计出来,而是通过文档或其他交流方式告诉程序员这个方法内的代码需要进行条件判断、这是很重要的业务规则,这种方式是行不通的。需要将业务规则明确、显式地设计出来。

如何明确显式设计出来呢?

通过订单的聚合结构设计,引入状态模式,设计一个OrderStatus类,如图所示。OrderStatus类有两个子类:Payment和Delivery,表示已支付事件/状态或已发货事件/状态。这里使用了继承关系,而不是聚合关系,因为已支付或已发货中只能在某个时刻存在一个,不可能同时存在。这种设计的好处是,对事件或状态的变化增加了扩展性,如图所示。

在这里插入图片描述
如果订单状态新增了是否已打包(packed)、是否已分发(dispatched)或是否有库存stocked,只要新增新的状态或事件类型,根本不会改变订单聚合的结构,因为订单聚合根只与OrderStatus有关,和其具体实现子类无关,这种聚合不变性范围区分了可变和不变,从而让设计更加有弹性,能够应对更多变化。

public class OrderStatus {
	private int state = -1;
	public OrderStatus next(){
		if(state == 0){
			return  new Payment();	
		}else if(state == 1){
			return new Delivery();
		}else {
			return new OrderStatus(-1);	
		}
	}
}

这里的next封装了切换到下一状态的规则动作,相当于状态机封装,当每次在Order中需要改变订单状态时,调用状态机进行检查并切换,代码如下:

public boolean setOrderStatus(OrderStatus orderStatus){
	OrderStatus nextOrderStatus = getOrderStatus().next();
	if(nextOrderStatus.getState() == orderStatus.getState()){
		this.m_OrderStatus = nextOrderStatus;
		return true;
	}else {
		return false;
	}
	
}

首先从状态机获得当前状态的下一个状态,然后检查进来的命令要切换的目标状态是否和状态机下一个状态吻合,如果一致就可以真正改变状态。

这样,通过状态模式封装了状态的集中切换,相当于设置了一个全局状态开关,所有服务或其他聚合涉及改变订单状态的命令时,都要通过此状态机进行检查并切换。

6. 做什么和怎么做的分离

上一节将状态集中、显式地表示了出来,强调了状态对于是否允许发生下一步事件的重要性,也是业务规则和决策权的体现。

这些代表决策权的业务规则可能是:下单后也不一定能够支付,需要再次检查库存,可能在这几分钟库存已经没有货了,另外,如果支付失败,支付也不能完成,不能将订单状态修改为已支付。“是否可以支付”属于决策权,决定方向性,是领导的职责,而“如何进行支付”则是办事员的职责。

打个比方:领导和小张说,他有个客户需要订购一批货,库存里有没有?小张说有,领导就吩咐小张去办;小张具体去实现了,领导给客户回话说,你准备收货吧,后来小张发现客户资金不够付款,再回来请示领导,是不是可以赊账?领导说,不可以,取消发货。

在订单聚合中聚合了关键的决策权:是否可支付?是否可发货?当业务规则检查通过后,可以发出“可支付事件”和“可发货事件”,当支付或发货的具体过程成功后,才有“已支付事件”和“已发货事件”。进行支付时的代码如下:

public void payment(){
	if(checkOrderStatus(new Payment())){
		print("订单已就绪,可以支付");
		//向支付接口提出扣款命令
		//如果扣款命令成功,则订单状态变为已支付
		setOrderStatus(new Payment());
	}
	
}

注意:这里是向支付接口发出支付扣款命令,而不是直接在这里进行扣款的具体操作,等待支付接口返回成功,再将当前状态改为已支付。

当然这里的支付代码需要有一个事务机制,可以在checkOrderStatus被调用时,在内部实现一个有timeout的锁定,然后在这段代码中使用Synchronized,保证每个时刻只有一个用户请求执行,在setOrderStatus方法内再将锁释放。如果为了获得更好的性能和吞吐量,可以考虑单写原则模式,这是在LMAX架构的实现原理,更大规模的系统更可以考虑使用消息队列进行排序,将支付命令请求放入队列中排队,每时每刻只有一个命令能作用于这段代码,从而也能保证支付细节和支付状态更改的一致性。

这里值得注意的是,“做什么”和“怎么做”通常有时必须在一个事务进程中,如果根据前面的方法按照事务边界划分聚合,那可能会将执行支付的细节和是否可支付等决策放在一个聚合里面。

其实不放在一个聚合里面也能实现一致性,只不过这是一种最终一致性,而不是聚合内部的强一致性,例如可以将其作为业务流程设计,通过补偿退回的方式进行撤销。

7. 在服务中验证聚合

服务除了是一个协作者以外,还可以对聚合模型进行验证测试,如同饭店的服务员不只是端茶送菜的,也可以对业务决定者大厨烧出的菜进行初步测试。现在订单聚合模型基本完成了,是否能够符合业务用例?在建模过程中是否走偏了方向?这些也都可以在服务中验证。

起初只是粗略发现“订单/交易服务组件”代表了一个有界上下文,现在可以具体设计一个OrderService。这个服务是一个协作者,它负责协作角色用例功能和聚合模型。角色用例功能是:用户下单、支付和商家发货;聚合模型是Order(订单)。这两者之间通过服务OrderService搭桥。
在这里插入图片描述
这里设计一个OrderService接口,其中的方法如下。

● placeOrder():实现用户下单。执行完成后的订单是“已下单”的订单。

● payment():实现用户支付。执行完成后的订单是“已支付”的订单。

● delivery():实现商家发货。执行完成后的订单是“已发货”的订单。
在这里插入图片描述
当进行聚合设计再进行这样的核对时,可以检查是否有功能遗漏。这里主要的遗漏是支付和发货的具体细节,这与第三方接口有关。这两个领域属于核心领域之外的支持子域,用以支持订单作业。

8. springboot代码实现

  1. service:放置设计出的服务组件接口模型OrderService
public interface OrderService {
    public Order placeOrder(OrderItem orderItem);
    public Order payment(String orderId);
    public Order delivery(String orderId);
}
public class OrderServiceImpl extends OrderService{
    public Order placeOrder(OrderItem orderItem){
        Order order = new Order();
        Address address = new Address();
        address.setStreet("no.1");
        address.setZip("10000");
        order.setAddress(address);
        OrderItem orderItem1 = new OrderItem();
        orderItem1.setProductId("1");
        orderItem1.setQty(2);
        order.addOrderItem(orderItem1);
        OrderItem orderItem2 = new OrderItem();
        orderItem2.setProductId("1");
        orderItem2.setQty(2);
        order.addOrderItem(orderItem2);
        return orderRepository.save(address);
    }
    public Order payment(String orderId){
        Order orderSaved = orderRepository.findById(orderId);
        if(orderSaved.setOrderStatus(new Payment())) orderRepository.save(orderSaved);
        return orderSaved;
    }
    public Order delivery(String orderId){
        Order orderSaved = orderRepository.findById(orderId);
        if(orderSaved.setOrderStatus(new Delivery())) orderRepository.save(orderSaved);
        return orderSaved;
    }
}

2)domain:放置设计出的类图聚合模型Order和其聚合子对象。

@Table("order_table")
public class Order {

	private Collection<OrderItem> items;
	private Address m_Address;
	private OrderStatus m_OrderStatus;
	@Id
	private String id;

	public Order() {
		items = new ArrayList<>();
		m_OrderStatus = new Placed();
	}

	public void addItem(OrderItem item) {
		items.add(item);
	}

	public Collection<OrderItem> getItems() {
		return items;
	}

	public void setItems(Collection<OrderItem> items) {
		this.items = items;
	}

	public Address getM_Address() {
		return m_Address;
	}

	public void setM_Address(Address m_Address) {
		this.m_Address = m_Address;
	}

	public OrderStatus getM_OrderStatus() {
		return m_OrderStatus;
	}

	public boolean setM_OrderStatus(OrderStatus m_OrderStatus) {
		OrderStatus orderStatusN = getM_OrderStatus().next();
		if (orderStatusN.getState() == m_OrderStatus.getState()) {
			this.m_OrderStatus = orderStatusN;
			return true;
		} else
			return false;
	}

	public String getId() {
		return id;
	}

	public void setId(String id) {
		this.id = id;
	}

	private Order(Address address, Collection<OrderItem> items) {
		this.m_Address = address;
		this.items = items;
		this.m_OrderStatus = new Placed();
	}

	public static OrderVOBuilder builder() {
		return new OrderVOBuilder();
	}

	public static class OrderVOBuilder{
		private Address address;
		private Collection<OrderItem> items;
		public OrderVOBuilder withAddress(Address address) {
		    this.address = address;
			return this;
		}
		public OrderVOBuilder withItems(Collection<OrderItem> items) {
            this.items = items;
			return this;
		}
		public Order build() {
			return new Order(address, items);
		}
	}
}
public class OrderItem {

	private String productId;
	private int qty;

	public OrderItem() {

	}

	public String getProductId() {
		return productId;
	}

	public void setProductId(String productId) {
		this.productId = productId;
	}

	public int getQty() {
		return qty;
	}

	public void setQty(int qty) {
		this.qty = qty;
	}
}
public class OrderStatus {

	protected int state = -1;
	@Id
	private String id;

	public OrderStatus(int state) {
		this.state = state;
	}

	public int getState() {
		return state;
	}

	public void setState(int state) {
		this.state = state;
	}

	public OrderStatus next() {
		if (state == 0)
			return new Payment();
		else if (state == 1)
			return new Delivery();
		else
			return new OrderStatus(-1);
	}
}
@Table("order_status")
public class Payment extends OrderStatus {

	public Payment() {
		super(1);//表示已支付
	}

	public OrderStatus next() {
		return new Delivery();
	}
}
@Table("order_status")
public class Placed extends OrderStatus {
	public Placed() {
		super(0);
	}

	@Override
	public OrderStatus next() {
		return new Payment();
	}
}
@Table("order_status")
public class Delivery extends OrderStatus {

	public Delivery() {
		super(2);//已发货

	}

	@Override
	public OrderStatus next() {
		return null;
	}
}
public class Address {

	@Id
	private String id;
	private String street;
	private String zip;

	public Address() {

	}

	public String getStreet() {
		return street;
	}

	public void setStreet(String street) {
		this.street = street;
	}

	public String getZip() {
		return zip;
	}

	public void setZip(String zip) {
		this.zip = zip;
	}
}

3)repostiory:是领域模型Order的持久保存仓库。

public interface OrderRepository extends CrudRepository<Order,String>{}

六、 聚合根建模示例

一个大而全的聚合根可以囊括所有业务逻辑,这前景很诱人,但是却不合适,易造成并发风险。
例如,一篇博客包括标题、内容、作者、标签、评论等。

public class Blog{
    private BlogId blogId;
    private String title;
    private Author author;
    private Post post;
    private Set<comment> comments;
    private Set<tag> tags;
}

(1)通过唯一标识引用其他聚合
每个聚合根必须拥有一个全局唯一标识,同时通过唯一标识引用其他聚合。这样可以减少对象的加载,减小聚合,内存加载更快。

public class Blog{
    //聚合根Blog的全局唯一标识
    private BlogId blogId;
    private String title;
    //通过唯一标识引用其他聚合
    private AuthorId authorId;
    private PostId postId;
    private Set<comment> comments;
    private Set<tag> tags;
}

(2)建模对象导航性
通过唯一标识引用其他聚合时,这样就无法直接获得对象,这时候在聚合中直接使用资源库,被称为失联领域模型
推荐在调用聚合根的行为方法之前,使用资源库或领域服务来获取所需要的对象。应用服务可以在此之前做控制分发给聚合。所以聚合根不应引用基础设施。

public class BlogService{
    public void changeAuthorName(String blogId, String authorId){
        Blog blog = blogRepository.blogOfId(new BlogId(blogId));
        Author author = authorRepository.authorOfId(new AuthorId(authorId));
        blog.changeName(author);
        blogRepository.save(blog);
    }
}
public class Blog{
//省略属性
    public void changeName(Author author){
    //Todo
    }
}

(3)不要在单个事务中更新多个聚合根
如果某个用例需要修改多个聚合根实例,而需求的实现交给单个事务时,无论写得多好,这样的用例都无法反映真正的聚合。这样,我们可能会从多个聚合中创建新的聚合,这样的方式是错误的。

可以使用消息的领域事件来更新多个聚合根。除非为了方便用户界面,或者没有消息、定时器或者后台线程等技术机制保证。

(4)乐观并发
一般只为根实体创建版本号。每次在聚合内部执行状态修改命令时,根实体的版本号都会随之增加。
这样可以避免多个用户同时修改根实体的状态。

public class Product extends ConcurrencySafeEntity{
    private Set<item> items;
    public void reorder(ItemId itemId, int ordering){
        for(Item item:this.items){
            item.reorder(itemId, ordering);
        }
        this.version(this.version()+1);
    }
}

当对根实体进行深度修改时,若本身是实体则不需要手动增加版本。、
也可以关联根实体的属性,使得版本自动增加。
或者子实体和根实体双向关联,使得版本自动增加。
或者对根实体进行拆分,只包含简单属性和值对象,这样修改状态,版本自动增加。
或者选择将聚合根的单个值进行持久化的引擎。

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值