模型引力场:聚合
-
强作用力体现:
某个领域模型是另一些模型存在的前提,没有前者,后者就失去了生存的意义。
一组领域模型之间存在关联的领域逻辑,任何时候都不能违反。
一组领域模型必须以一个完整的、一致的状态呈现给外部,而不能是某个中间彼此不和谐的状态。
-
当模型之间的相互作用力强到一定程度的时候,将产生质变,必须把它们当作一个整体,我们称之为聚合
-
聚合是一组相关对象的集合,可以称其为数据修改单元或者事务一致性组合单元。
每个聚合都有一个根(root)和一个边界(boundary)。
边界定义了聚合内部包含的内容,
根则是聚合所包含的一个特定的实体(entity)。
-
聚合概念的必要性:
1)保证模型存在的意义。聚合内的成员脱离聚合根的存在是没有意义的,比如支付之于订单。这不是一个技术问题,而是一个领域逻辑问题,领域专家和用户不会在意一个没有订单的支付对象。
2)时刻保持领域内在逻辑不被违反。这里的关键字是"时刻",即不允许在任何时候把不和谐的状态暴露给用户。相关逻辑一般体现在聚合根内。
3)划定事务的合理边界。在某种程度上,划定聚合就是划定事务边界。如果事务范围过大,锁定机制会导致多个用户之间毫无意义的互相干扰,而如果事务范围过小,会产生实时一致性问题,有把不完整的模型状态呈现给客户的风险。并不是强求时刻一致性就好,因为有性能代价,但无视客户体验也并非良策。聚合代表着两者的平衡。
4)聚合根提供了业务操作入口和界面。前三点偏技术,这一点对领域专家来说可能是最重要的。
聚合对于使用者来说是一个"整体",不能把其中的成员当作独立的成员来看待。
-
聚合内的约束一般归纳为以下6条:
1)只有聚合根具有全局标识,它最终负责领域内在规则的检查。有标识意味着聚合根必须是一个实体,而不是值对象。聚合根负责校验聚合中的规则而不是其他成员或在聚合外部。
2)聚合范围内的实体具有本地标识,这些标识不需要全局范围内唯一,保证在聚合范围内唯一即可。为何不需要全局标识呢?这是由第三条约束决定的。
3)只能通过聚合根才能访问聚合内的其他元素,聚合根是其所在聚合所有模型的唯一操作界面。不要略过订单去操控支付对象,不要忽略车辆而去保养轮胎,不要省掉棋盘去访问棋子,不要不看新闻就去看评论。聚合保证了对象存在的意义,避免产生不符合逻辑的操作。这些例子中,脱离聚合根去访问对象是没有实际意义的。
聚合根可以把对内部实体的引用传递给外部,但只是临时使用这些引用,而不能一直保持引用。这意味着外部获得的是内部对象的一个副本,对这个副本的修改丝毫不影响聚合内真实的对象。
4)只有聚合根才能直接通过数据库查询获取,其他对象必须通过遍历关联发现。当然,聚合的获取一般通过工厂和存储库
5)删除根元素,必须一次删除聚合内所有对象。这个问题在有垃圾回收机制的语言中不用过分操心,因为缺少外部引用,当聚合根被删除之后,其他对象会被自动回收。
6)当聚合内部任何对象被修改时,整个聚合内的所有规则都必须被满足。这里不是指有延迟的最终一致性,而是任何时间点都不存在中间状态。
-
7条聚合设计法则
(1)与生命周期保持一致
如果一个部分脱离整体后不再有业务意义,那么该部分应当属于一个聚合;如果一个模型脱离了聚合根仍有其业务价值,那么它就不属于该聚合。比如,发动机与汽车虽然存在明显的从属关系,但因为发动机有自己的编号且脱离汽车还会被单独跟踪,所以它不属于汽车这个聚合。当然,是不是一个聚合不会影响汽车和发动机正常的关联关系
(2)围绕领域内在逻辑
在定义聚合时,需要识别出满足一个用例共同工作的对象组合,并且这些对象彼此必须时刻保持一致以满足业务用例
(3)与事务的粒度保持一致
(4)不滥用聚合
(5)作用范围宜小不宜大
大的聚合会影响性能。成员数据在聚合被创建时都需要从数据库中加载,当集合成员快速增长时,对内存的占用也不可忽视
小的聚合不仅有利于性能,还有助于事务的成功执行,可以减少事务提交的冲突,系统的可用性也获得了提升(不再锁定资源)
(6)通过标识符引用其他聚合
聚合是小巧的,但关联是丰富的。一个聚合可以引用另一个聚合的聚合根来完成自己的任务。但被引用的聚合根不应该放在引用聚合的内部,这不符合聚合的规则。另外,在引用聚合根时,应该优先考虑全局唯一标识(ID)而不是通过直接的对象引用
(7)利用最终一致性更新其他聚合
聚合内的规则是时刻不能被违反的,但聚合间的规则要弱一些,所以跨越多个聚合的规则不立刻保持一致是可以接受的(容忍时长取决于业务),我们可以应用最终一致性规则来更新其他聚合
最终一致性意味着不同聚合间的数据在一段时间内会存在一定的不一致状态,但它们最终会保持一致,否则就不符合规则了
-
实现方法
-
(1)如何限定访问
尽量不要开放聚合内部成员的可见性为public。如果确实需要引用,也不要开放该属性的设置(set)功能,可以设为私有可见性,从而保护其不被修改。
-
(2)如何验证规则
验证规则是在聚合根完成的,保证规则的方法就是封装聚合的责任和行为,在对应的业务操作和方法中验证领域内在规则,而不是暴露成员
-
(3)如何同生共消亡
往往采用工厂方法来实现同生。工厂一次性创建聚合的所有成员,并且按照一定规则组装。工厂可以放置在聚合根内,也可以是一个单独的领域服务。
不要为聚合成员提供单独的构造函数,理论上也不应将聚合组装的权力交给用户,因为组装体现的是领域逻辑而不是给用户的自由。聚合成员最好只依赖于工厂而被创建,避免被越级使用而产生副作用。
共消亡机制要用到存储库模式,存储库将只为客户端提供对聚合根的访问,我们无法通过存储库去访问不是聚合根的对象,也就不会越过聚合根获得其成员的引用。
在有垃圾回收机制的语言中,没有引用的对象会被定时回收,比如Java、C#。当然,也可以在聚合根使用析构函数或实现IDispose接口,来显式释放其他成员占用的资源。
-
(4)实现事务一致性
第一,不要在一个事务中更新一个以上的聚合。
第二,聚合作为一个整体,持久化时必须在一个事务中以保证内在的一致性。聚合是存储在一个表或多个表中并不重要,重要的是当聚合被持久化时,需要在单个事务中提交,以确保在持文化失败时,聚合不会以不一致的状态被存储。
实现事务一致性很大程度上依赖于所采用的持久化技术。ORM(Object Relational Mapping,对象关系映射)框架如Hibernate和NHibernate提供了对几乎一切数据库命令的显式事务的支持。
-
(5)实现最终一致性
实现最终一致性保证的是聚合间的一致性,而不是聚合内的一致性。常见策略是采用异步方法来处理数据不一致问题,这与本地事务有所区别。虽然异步方法会导致聚合间数据不一致的时间较长,但其实现更为可靠且不会对并发性能产生影响。之所以说更为可靠,是因为当操作失败时通常可以重试,但必须采用kafka、MQ等消息传递技术。
聚合间的最终一致性可以通过异步领域事件来实现,使用EventBus等事件发布和订阅框架可以很方便地发布和订阅领域事件。
** 模型装配线:工厂**
-
在DDD中,工厂是生产领域模型的地方,特别是聚合
-
工厂模式的主要目的是生产对象的实例并提供给调用者
-
为什么需要工厂
(1)解耦:分离领域职责与创建工序
工厂的主要目的是分离模型的领域职责及其复杂的创建工序
(2)通用语言:让创建过程体现业务含义
工厂是领域层中承载领域逻辑的对象
(3)验证:确保所创建的聚合处于正确状态
在工厂中创建新对象时,可以添加逻辑验证以确保创建出的聚合符合领域内在规则。
例如,在聚合根账户上创建订单,要满足账户必须有足够的信用额度
(4)多态:为一种接口生产多个组件
(5)重建:重建已存储的对象
基于持久化机制的对象重建一定要封装在工厂之内。
重建工厂与创建新对象的工厂有以下两个不同点:
1)创建实体对象时,新对象是生成新的标识符,而重建工厂则是获取已有的标识符ID。
2)模型或聚合的内在领域逻辑不满足时,新对象工厂可以直接拒绝生成对象,而重建工厂生成的对象违背规则时,需要设计师采用一种纠错机制,比如默认值等策略来处理冲突。
-
厂址选择
(1)聚合根上的工厂
如果往一个聚合内添加元素,可以在聚合根上添加一个工厂方法,这样聚合内部的元素的生成细节,外部就无须关心了。同时,因为聚合的内在原则检查都在聚合根内,所以可以保证添加的元素都符合领域内在规则
(2)"信息专家"工厂
信息专家模式是把职责分配给具有完成该职责所需信息的那个模型
(3)领域服务类工厂
将工厂单独地构建为领域服务是一种不错的方法,也是最常用的工厂形式
(4)只需使用构造函数的场合
是否任何模型的创建都要经过工厂呢?恰恰相反,我们应该优先使用构造函数而不是工厂,因为领域模型并不一定都是复杂对象或聚合。如果在不需要解耦、不需要创建聚合、不需要表达通用语言、没有内在规则或不需要多态的场合,应该直接使用构造函数new,因为构造函数更简单、方便
若满足以下条件,则可直接选择简单的、公共构造函数。
对象是值对象,且不是任何相关层次结构的一部分,而且不需要创建对象多态性。
客户关心的是具体类,而不是只关心接口。
客户可以访问对象的所有属性,且模型没有嵌套对象的创建。
构造环节并不复杂,客户端创建代价不高。
构造函数必须满足工厂的相同规则:创建过程必须是一个原子操作,且能满足领域内在规则。
(5)厂名选择
1)选择与领域含义相关的命名,如BookTicket(预订车票)、ScheduleMeeting(安排会议)。
2)将Create与要创建的类型名连在一起,以此来命名工厂方法,如CreateWhite-Board。
3)将创建的类型名与Factory连接在一起,以此来命名工厂类型。例如,可以将创建Role对象的工厂类型命名为RoleFactory。
模型货架:存储库 -
领域模型要想保持自己的独立性,离不开存储库将其与持久化机制解耦
-
存储库承担了4个角色:隔离墙、冰箱和菜单、体现通用语言和管理员
-
(1)隔离墙:隔离领域模型与持久化技术,保证领域模型独立性
存储库模式的第一个意义在于保持领域模型与技术持久化机制的分离。这保证了领域模型的独立性,使模型能够在不受底层技术影响的情况下进行演化,让我们可以独立开发领域模型,无须关注架构的技术细节
-
存储库分为两部分
1)存储库接口:位于领域层内,既有标准化的方法,如增加和删除,又有体现通用语言的、业务上、特殊的检索需求。
2)存储库实现:位于基础设施层,实现存储库接口。图6-7是一个典型的依赖倒置架构,领域模型无须关心存储库的实现部分,而只需要和存储库接口打交道即可。
-
2)冰箱和菜单:保鲜且提供菜肴而不是食材
存储库要负责所有对象的持久化工作,同时提供这些对象的访问接口。如果把模型比作一道道菜,存储库就是存放这些菜的冰箱,当你下次使用它们时,依然保持着上次放入冰箱的状态。存储库要保证只保存和提供成品菜肴,也就是聚合
一个聚合提供一个存储库,即一个聚合对应一个存储库,代码实现上也是一对一存在的
-
(3)体现通用语言:让检索请求体现业务含义
除了通用的接口方法,每个聚合的存储库必须支持通用语言的特定查询。存储库不仅是CRUD接口,还是领域模型的扩展,应当以领域专家的术语来编写,应当基于用例实现来构建查询接口,而不是类似于CRUD数据访问的角度来构建。
-
(4)管理员:集合的统计与汇总
作为模型仓库,存储库的另一个实用的功能是扮演仓库管理员,给我们提供关于集合的统计信息,比如对象的数量、集合中所有匹配对象的某个数值属性的总和等。此时,存储库提供的方法不再返回一个聚合根,而是一个值对象。对于"最好贴近原始数据进行计算"的计算原则来说,存储库的这个功能非常强大
-
实现存储库接口时,我们要注意以下几点:
要把Add()方法实现为幂等,即使向集合重复添加相同聚合(一般由ID判断),实际效果仅为一个聚合实例。
如果持久化机制支持对对象变化的跟踪,那么它会自动将内存中的更改保存到数据库中,比如Hibernate框架,在存储库接口定义中不需要添加Save()方法。但如果持久化机制不支持对变化的跟踪,则上述接口中需要添加Save()方法,并在领域对象被改变后,显式调用该方法,以使更改在数据库中生效。
不要在领域模型上维护已更改的标识符,也不要让持久化逻辑干扰领域模型的纯洁性。将跟踪变化的工作交由存储库来处理。
-
存储库与工厂的设计出发点有些相似,但有区别。:
1)负责模型生命周期的不同阶段。工厂负责模型生命周期的开始,而存储库管理模型生命周期的中间和结束。工厂的作用之一是重建已存储的对象,这与存储库有些类似,但此时对象还不存在或未成型,因此具有"新建"的含义。而在存储库中的对象,则让用户感觉是一直存在于内存集合中的既有对象。
2)存储库专注于封装持久化机制,而工厂专注于创建新对象。工厂用数据和领域逻辑来初始化和装配一个复杂的对象(聚合)。新对象创建好之后,需要调用存储库的Add()方法将其添加到存储库中,由存储库负责其在数据库中的存储。
3)工厂更注重于对象创建的多态机制,而存储库分为两个部分,接口部分更注重符合业务需求和通用语言的支持,实现部分的重点则是对持久化技术的封装。
-
存储库与数据访问对象的区别
存储库面向的是内存中的集合方式,而DAO面向数据库表提供CRUD操作。
存储库的设计更加贴近领域,而DAO中方法的业务意图并不明显。
存储库接口必须位于领域层内,且只提供聚合根的访问,DAO没有这种约束。
-
存储库实现的注意事项
(1)不要提供无条件随机查询接口
(2)可以像工厂一样在存储库中使用多态,返回子类或实现类
(3)充分利用存储库接口与实现解耦的特点
(4)存储库中不要涉及事务