第六章 领域对象的声明周期
接下来,我们将注意力转移到生命周期的开始阶段,使用factory(工厂)来创建和重建复杂对象,并使用aggregate来封装它们的内部结构。最后,在生命周期的中间和末尾实验repository(存储库)来提供查找和检索持久对象并封装庞大基础设施的手段。
使用aggregate进行建模,并且在设计中结合使用factory和repository,这样我们就能够以整个生命周期为一个单元系统地曹总模型对象。aggregate可以划分出一个范围,这个范围内的模型元素在生命周期各个阶段都应该维护其固定规则。factory和repository在aggregate基础上进行操作,将特定生命周期转换的复杂性封装起来。
6.1 模式:aggregate
将关联减至最少的设计有助于简化对象之间的遍历,并在某种程度上限制关系的急剧增多。但大多数业务领域中的对象都具有十分复杂的联系,以至于最终会形成一个很长、很深的对象引用路径,我们不得不在这个路径上追踪对象。在某种程度上,这种混乱状态反映了现实世界,因为现实世界中就很少有清晰的边界。这在软件设计中是一个重要问题。
案例:删除一个person对象,连同删除的还有姓名、出生日期和工作描述。地址如何处理,可能还有其他人住在同一地址。
在多个客户对相同的一些对象进行并发访问的系统中,这个问题更加突出。当很多用户对系统中的对象进行查询和更新时,必须防止他们同时修改互相依赖的对象。范围错误将导致严重的后果。
在具有复杂关联的模型中,要想保证对象更改的一致性是很困难的。不仅互不关联的对象需要遵守一些固定规则,而且紧密关联的各组对象也要遵守一些固定规则。然而,过于谨慎的锁定机制又会导致多个用户之间毫无意义地互相干扰,从而使系统不可用。
换句话说,我们如何知道一个由其他对象组成的对象从哪里开始,又到何处结束呢?在任何具有持久化数据存储的系统中,对数据进行修改的事务必须要有一个范围,而且要有一种保持数据一致性的方式(分布式事务)。但这些特殊的解决方案分散了人们对模型的注意力,很快人们就会回到"走一步,看一步"的老路上来。
实际上,要想找到一种兼顾各种问题的均衡解决方案,要求对领域有更深入的理解,例如要了解特点类的实例之间的更改频率这样的深层次因素。我们需要找到一个对象间冲突较少而固定规则联系更紧密的模型。
尽管从表面上看这个问题属于数据库事务方面的一个技术难题,但它的根源却在模型,归根结底是由于模型中缺乏明确定义的边界。从模型得到的解决方案将使得模型更易于理解,并且使设计更易于沟通。当模型被修改时,它将引导我们对实现做出修改。
首先,我们需要用一个抽象来封装模型中的引用。aggregate就是一组相关对象的集合,我们把它作为数据修改的单元。每个aggregate都有一个根(root)和一个边界(boundary)。边界定义了aggregate的内部都有什么。根则是aggregate中所包含的一个特定entity。在aggregate,根是唯一允许外部对象保持对它的引用的元素,而边界内部的对象之间则可以互相引用。除根意外的其他entity都有本地标识,但这些标识只有在aggregate内部才需要加以区别,因为外部对象除了根entity之外看不到其他对象。
汽修厂软件:
汽车模型:汽车是一个具有全局标识的entity,我们需要将这部汽车与世界上所有其他汽车区分开,车辆识别号
我们可能想跟踪4个轮胎的历史转速。可能想知道每个轮胎的里程数和磨损度。要想知道哪个轮胎在哪儿,必须将轮胎标识为entity。但我们可能不会关心这些轮胎在这辆汽车上下文之外的标识。如果更换了轮胎并将旧轮胎送到回收厂,那么软件将不再需要跟踪它们,它们会成为一堆废旧轮胎中的一部分。没人会关心它们的转动历史。
更重要的是,即使轮胎被安在汽车上,也不会有人要系统中查询特定的轮胎,然后看看这个轮胎在哪辆汽车上。人们只会在数据库中查找汽车,然后临时查看一下这部汽车的轮胎情况。因此,汽车是aggregate的根entity,而轮胎只是出于这个aggregate的边界之内。另一方面,发动机组上都刻有序列号,而且有时是独立于汽车被跟踪的。在一些应用程序中,发动机可以是其自己的aggregate根。
固定规则(invariant)是指在数据变化时必须保持不变的一致性规则。aggregate内部的成员之间可能存在固定关系。aggregate中的所有规则并不是每时每刻都被更新为更新的状态。通过事件处理、批处理或其他更新机制,在一定的时间内可以解决部分依赖性。但在每个事务完成时,必须要满足aggregate内所应用的固定规则的要求。
现在,为了实现这个概念上的aggregate,需要对所有事务应用一组规则
- 根entity具有全局标识,它最终负责检查固定规则
- 根entity具有全局标识。边界内的entity具有本地标识,这些标识只有在aggregate内部才是唯一的
- aggregate外部的对象不能引用除根entity之外的任何内部对象。根entity可以把对内部entity的引用传递给它们,但这些对象只能临时使用这些引用,而不能保持引用。根可以把一个value object的副本传递给另一个对象,而不必关系它发生什么变化,因为它只是一个value,不再与aggregate有任何关联
- 作为上一条规则的推论,只有aggregate的根才能直接通过数据库查询获取。所有其他对象必须通过关联的遍历才能找到
- aggregate内部的对象可以保持对其他aggregate根的引用
- 删除操作必须一次删除aggregate边界之内的所有对象。(利用垃圾收集机制,这很容易做到。由于除根以外的其他对象都没用外部引用,因此删除了根以后,其他对象均会被回收)
- 当提交对aggregate边界内部的任何对象的修改时,整个aggregate中的所有固定规则都必须被满足
我们应该将entity和value object分门别类放到aggregate中,并定义每个aggregate的边界。在每个aggregate中,选择一个entity作为根,并通过根来控制对边界内其他对象的所有访问。并允许外部对象保持对根的引用。对内部成员的临时引用可以被传递出去,但仅在一次操作中有效。由于根控制访问,因此不能绕过它来修改内部对象。这种设计有利于确保aggregate中的对象满足所有固定规则,也可以确保在任何状态变化时aggregate作为一个整体满足固定规则。
有一个能够声明aggregate的技术框架是很有帮助的,这样就可以字段实施锁定机制和其他一些功能。如果没有这样的技术框架,团队就必须靠自我约束来使用时先商定的aggregate,并按照这些aggregate来编写代码。
6.2 模式:factory
当创建一个对象或创建整个aggregate时,如果创建工作很复杂,或者暴露了过多的内部结构,则可以使用factory进行封装。
对象的功能主要体现在其复杂的内部配置以及关联方面。我们应该一直对一个对象进行提炼,直到所有与其意义或在交互中的角色无关的内容已完全被提出为止。一个对象在它的生命周期当中要承担大量的职责。如果再让复杂对象负责其自身的创建,那么职责的过载将会导致问题发生。
每种面向对象的语言都提供了一种创建对象的机制,但我们仍然需要一种更加抽象且不与其他对象发生耦合的构造机制。
因此:
应该将创建复杂对象的实例和聚合的职责转移给一个单独的对象,这个对象本身在领域模型中可能没有职责,但它仍是领域设计的一部分。提供一个封装所有复杂装配操作的接口,而且这个接口应该不需要客户引用要被实例化的对象的具体类。在创建aggregate要把它作为一个整体,并确保它满足固定规则
1)每个创建方法都有原子方法,而且满足被创建对象或aggregate的所有固定规则。factory应该以一致的状态来生成对象。在生成entity时,这意味着创建满足所有固定规则的整个aggregate,但在创建完成后可以向聚合添加一些可选元素。在创建不变的value object时,这意味着所有属性必须被初始化为正确的最终状态。如果factory通过其接口收到了一个创建对象的请求,而它又无法正确地创建出这个对象,那么它应该抛出一个异常,或者调用某种其他机制,以确保不会返回错误的值
2)factory应该被抽象为所需的类型,而不是创建出具体的类。
1. 选择factory及其应用位置
一般来说,factory的作用是使创建出的对象将细节隐藏起来,而且我们把factory用在那些需要隐藏细节的地方。这些决定通常与aggregate有关。
2. 有些情况下只需使用构造函数
以下情况最好使用简单的、公共的构造函数
- 类(class)是一种类型(type)。它不是任何相关层次结构的一部分,而且也没有通过接口实现多态性
- 客户关心的是实现,可能是将其作为选择strategy的一种方式
- 客户可以访问对象的所有属性,因此向客户公开的构造函数中没有嵌套的对象创建
- 构造并不复杂
- 公共构造函数必须遵守与factory相同的规则:它必须是一个原子操作,而且满足被创建对象的所有固定规则
不要在构造函数中调用其他类的构造函数。构造函数应该保持绝对简单。复杂的装配,特别是aggregate,需要使用factory。选择使用小的factory method的门槛并不高。
3. 接口的设计
当设计factory的方法签名时,无论是独立的factory还是factory method,都有记住以下两点。
- 每个操作都必须是原子的
- factory将与其参数发生耦合
使用抽象类型的参数,而不是它们的具体类。factory与产品的具体类发生耦合,而无需与具体的参数发生耦合。
4. 固定规则的逻辑应放置在哪里
factory负责确保它所创建的对象或aggregate满足所有固定规则,然而在把应用于一个对象的规则移动到该对象外部之前应三思。factory可以将固定规则检查工作委派给产品,而且这通常是最佳选择。
5. entity factory与value object factory
6. 重建已存储的对象
用于重建对象的factory与用于创建对象的factory很类似,主要有以下两点不同:
- 用于重建对象的entity factory不分配新的跟踪id。如果重新分配id,将与先前的对象ID发生冲突。因此,在重建对象的factory中,标识属性必须是输入参数的一部分
- 当固定规则未被满足时,重建对象的factory采用不同的处理方式。当创建新对象时,如果未满足固定规则,factory应该简单地拒绝创建对象,但在重建对象时则需要更灵活的响应。如果对象已经在系统的某个地方存在(例如在数据库中),那么不能忽略这个事实。但是,同样也不能任凭规则被破坏。必须通过某种策略来修复这种不一致的情况,这使得重建对象比创建新对象更困难
6.3 模式:repository
我们可以通过对象之间的关联来找到对象。但当它处于生命周期的中间时,必须要有一个起点,以便从这个起点遍历到一个entity或value。
客户需要以一种符合实际的方式来获取对已存在的领域对象的引用。如果基础设施随随便便地就允许开发人员获得这些引用,那么他们可能会增加很多可遍历的关联,这会使模型变得非常混乱。另一方面,开发人员可能使用查询从数据库中提取他们所需的数据,或是直接提取几个具体的对象,而不是通过从aggregate根开始导航来得到这些对象。这样会导致领域逻辑泄漏到查询和客户代码中,而且entity和value object变成单纯的数据容器。大多数用于数据库访问的基础设施的技术复杂性很快就会使客户代码变得混乱,这将导致开发人员放弃领域层,最后使模型变得无关紧要。
持久化的value object一般可以通过从某个entity遍历来找到,在这里entity就是把对象封装在一起的aggregate的根。
在所有持久对象中,有一小部分必须能够通过基于对象属性的搜索来全局访问。当不易通过遍历的方式来访问某些aggregate根的时候,就需要使用这种访问方式。它们通常是entity,有时是具有复杂内部结构的value object,有时还可能是枚举value。而其他对象则不宜使用这种访问方式,因为这会混淆它们之间的重要区别。毫无约束的数据库查询可能会破坏领域对象的封装和aggregate。技术基础设施和数据库访问机制的暴露会增加客户的复杂度,并妨碍模型驱动的设计。
repository将同一类型的所有对象表示为一个概念集合(通常是模拟的)。它的行为类似于集合,只是具有更复杂的查询功能。在添加或删除相应类型的对象时,repository的后台机制负责将对象添加到数据库中,或从数据库中删除对象。这个定义表明,aggregate把一组紧密相关的职责收集到一起,这些职责提供了对aggregate根的从其生命周期开始直至结束的全程访问。
客户使用查询方法向repository请求对象,这些查询方法根据客户所指定的标准(通常是特定属性的值)来挑选对象。repository检索被请求的对象,并封装数据库查询和元数据映射机制。repository可以根据客户所要求的各种标准来挑选对象。它们也可以返回汇总信息,例如有多少个实例满足查询条件。repository甚至能返回汇总计算,例如所有匹配对象的某个数值属性的总和。
repository解除了客户的巨大负担,使客户只需与一个简单地、易于理解的接口进行对话,并根据模型向这个接口提出它的请求。要实现所有功能需要大量复杂的技术基础设施,但接口很简单,而且从概念上讲,接口与领域模型是紧密联系的。
因此:
为每种需要全局访问的对象类型创建一个对象,这个对象就相当于该类型的所有对象在内存中的一个集合的替身。通过一个众所周知的接口来提供访问。提供添加和删除对象的方法,用这些方法来封装在数据存储中实际插入或删除数据的操作。提供根据具体标准来挑选对象的方法,并返回属性值满足查询标准的对象或对象集合(所返回的对象是完全实例化的),从而讲实际的存储和查询技术封装起来。只为那些确实需要直接访问的aggregate根提供repository。让客户始终聚焦于模型,而将所有对象存储和访问操作交给repository来完成。
repository有很多优点,包括:
- 它们为客户提供了一个简单地模型,可用来获取支持持久对象并管理它们的生命周期
- 它们使应用程序和领域设计与持久化技术(多种数据库策略甚至是多个数据源)解耦
- 它们体现了有关对象访问的设计决策
- 可以很容易将它们替换为"哑实现(dummy implementation)",以便在测试中使用(通常使用内存中的集合)
1. repositoryd的查询
最容易构建的repository用硬编码的方式来实现一些具有特定参数的查询。这些查询可以使各种各样的,例如通过标识来检索entity,通过某个特定属性值或一个复杂的参数组合来请求一个对象集合、根据值域(例如日期范围)来选择对象。
尽管大多数查询都返回一个对象或对象集合,但返回某些类型的汇总计算也是符合repository概念的,例如符合条件的对象数目,或模型中所有匹配对象的某个数值属性的总和。
基于specification的查询
DDD 中的那些模式 — 使用 Specification 管理业务规则 - 知乎 (zhihu.com)
ddd的战术篇: Factory和Specification_even_he的博客-CSDN博客
2. 客户代码可以忽略repository的实现,但开发人员不能忽略
案例:系统将工厂中每件产品的信息汇总到一起。开发人员使用一个名为all objects(所有对象)的查询来进行汇总,这个操作对每个对象进行实例化,然后选择他们所需的数据。这段代码的结果时一次性将整个数据库装入内存中!这个问题在测试中并未发现,原因为测试数据较少。
mybatis plus getOne(),我以为是从多个中取一个,谁知道是只能返回结果有一个,不然报错 Too many result
字典翻译,从system服务查询连表两张,从自己服务数据库查询,一批数据要查多次
这是一个明显的禁忌,而一些更不容易注意到的疏忽可能会产生同样重要的问题。开发人员需要理解使用封装行为的隐含问题,但这并不意味着要熟悉实现的每个细节。设计良好的组件使可以被刻画出来的。
3. repository的实现
- 对类型进行抽象
- 充分利用repository与客户解耦的优点
- 把事务的控制权留给客户
4. 在框架内工作
5. repository与factory的关系
factory负责处理对象生命周期的开始,而repository帮助管理生命周期的中间和结束。
从领域驱动设计的角度来看,factory和repository具有完全不同的职责。factory负责创造新对象,而repository负责查找已有对象。
6.4 为关系数据库设计对象
三种情况:
- 数据库是对象的主要存储库
- 数据库是为另一个系统设计的
- 数据库是为这个系统设计的,但它的任务不是用于存储对象