《实现领域驱动设计》 (美)弗农著 12章 资源库

面向集合资源库

这些像集合一样的对象都是和持久化相关的。每一种聚合类型都将拥有一个 资源库。通常来说,聚合类型和资源库之间存在着一对一的关系。然而有时,当两 个或多个聚合位于同一个对象层级中时,它们可以共享同一个资源库。在本章中, 我们将分别对这两种情况进行讨论。

严格来讲,只有聚合才拥有资源库。如果一个限界上下文(2)中没有使用聚 合,那么使用资源库也没有多大意义。如果你只是随机地、直接地获取和使用实体 (5),而不用考虑聚合的事务边界,那么你可以不考虑使用资源库。然而,对于那 些不怎么关心DDD原则的人来说,他们可能只是从技术上使用DDD模式,此时他 们可能会釆用资源库,而不是DTO。此外,有些人会考虑直接使用持久化机制的 Session或者Unit of Work [P of EAA]。这些并不是建议你避免使用聚合,而事实上恰恰相反。当然,这也只是一个选择问题。

在我看来,存在两种类型的资源库设计,即面向集合(collection-oriented)的 设计和面向持久化(persistence-oriented)的设计。有时,面向集合的设计方式可能 是你所需的,而有时面向持久化的设计则是最好的方式。在本章中,我将首先讲到 面向集合的资源库,然后再讲面向持久化的资源库。
在这里插入图片描述如果我们希望向集合中添加一个对象,我们可以使用add()方法。之后,如果我 们想删除该对象,可以调用removeO方法,同时将该对象的引用作为参数传入。在 下面的测试中,对于某种新建的集合,我们希望它能够用来存放Calendar实例:
在这里插入图片描述以上的所有的断言都能够通过,因为即使同一个Calendar实例被添加了两次, 在第二次添加时,它并不会修改Set的状态,这对于面向集合的资源库来说也是如 此。对丁•一个面向集合的资源库CalendarRepository,如果我们先后两次向其中添加同一个Calendar聚合实例,那么第二次添加并不会对该资源库产生影响。每一个 聚合都拥有一个全局的唯一标识,该标识位于根实体(5, 10)中。正是由于该唯一 标识,类似于Set的资源库才能避免对同一个聚合实例的多次添加。

对于资源库所模拟的Set集合来说,理解它的工作方式是重要的。无论使用了 那种类型的持久化机制,我们都不允许将同一个聚合实例多次添加到资源库中。

此外,如果要对资源库中的一个对象进行修改,我们并不需要“重新保存”该 对象。重新考虑集合的情形,要修改其中的一个对象,我们只需要先从集合中获取 到该对象的引用,然后在该对象上执行行为方法即可。

面向集合资源库精要

一个资源库应该模拟一个Set集合。无论采用什么类型的持久化机制,我们都不应该允 许多次添加同一个聚合实例。另外,当从资源库中获取到一个对象并对其进行修改时,我们 并不需要“重新保存”该对象到资源库中。

作为演示,让我们对java.util.HashSet进行扩展,并向扩展类中添加一个方法, 该方法根据唯一标识查找对象实例。我们将该扩展类命名成CalendarRepository, 在本质上它H是一个内存中的HashSet:
在这里插入图片描述通常来说,我们并不会因为要创建一个资源库而去扩展HashSet类,这里我们 U是举一个例子而已。在该例中,我们可以将一个Calendar实例添加到一个特定的 Set中,之后再对其进行查找和修改:
在这里插入图片描述正如你所想,这需要背后的持久化机制提供一些特殊的功能支持。此时的持 久化机制必须能够隐式地跟踪发生在每个持久化对象上的改变。有多种方法都可 以达到这样的目的,包括:

1.隐式读时复制(Implicit Copy-on-Read) [Keith & Stafford]:在从数据存储中读取一个对象时,持久化机制隐式地对该对象进行复制,在提交时,再将该复制 对象与客户端中的对象进行比较。详细过程如下:当客户端请求持久化机制从 数据存储中读取一个对象时,该持久化机制一方面将获取到的对象返回给客户 端,一方面立即创建一份该对象的备份(除去延迟加载部分,这些部分可以在 之后实际加载时再进行复制)。当客户端提交事务时,持久化机制把该复制对 象与客户端中的对象进行比较。所有的对象修改都将更新到数据存储中。

2.隐式写时复制Implicit Copy-on-Write) [Keith & Stafford]:持久化机制通过委 派来管理所有被加载的持久化对象。在加载每个对象时,持久化机制都会为 其创建一个微小的委派并将其交给客户端。客户端并不知道自己调用的是委 派对象中的行为方法,委派对象会调用真实对象中的行为方法。当委派对象 首次接收到方法调用时,它将创建一份对真实对象的备份。委派对象将跟踪 发生在真实对象上的改变,并将其标记为“肮脏的”(dirty)。当事务提交时, 该事务检查所有的“肮脏”对象并将对它们的修改更新到数据存储中。

以上两种方式之间的优势和区別可能会根据具体情况而不同。对于你的系统 来说,如果两种方案都存在各自的优缺点,那么此时你便需要慎重考虑了。当然, 你可以选择自己最喜欢的方式,但是这不见得是最安全的选择。

无论如何,这两种方式都有一个相同的优点,即它们都可以隐式地跟踪发生 在持久化对象中的变化,而不需要客户端自行处理。这里的底线是,持久化机制, 比如Hibernate,能够允许我们创建一个传统的、面向集合的资源库。

另一方面,即便我们能够使用诸如Hibernate这样的持久化机制来创建面向集 合的资源库,我们依然会遇到一些不合适的场景。如果你的领域对性能要求非常 高,并且在任何一个时候内存中都存在大量的对象,那么持久化机制将会给系统 带来额外的负担。此时,你需要考虑并决定这样的持久化机制是否适合于你。当 然,在很多情况下,Hibernate都是可以工作得很好的。因此,虽然我是在提醒大家 这些持久化机制冇可能带来的问题,但这并不意味着你就不应该釆用它们。对任何 工具的使用都需要多方位权衡。

Hibernate实现
不管是对于面向集合的资源库,还是面向持久化的资源库,在创建的时候都 有两个主要的步骤。首先,我们需要定义公有接口;其次,我们至少需要提供一种 实现。

对于面向集合的资源库来说,我们首先需要定义能够模拟集合的接口,然后 再使用一种持久化机制来实现该接口,比如Hibernate。接口的定义通常与下面的 CalendarEntry Repository相似:

在这里插入图片描述

将接口定义与它将存储的聚合放在相同的模块(9 )中。在本例 中,CalendarEntryRepository与CalendarEntry放在相同的模块(Java包)中。实现类将被放置在另外的包中,对此,我们将之后讨论。

CalendarEntryRepository中的方法与集合(比如java.util.Collection)提供的方法非常相似。要添加一个新的CalendarEntry实例,我们可以调用该资源 库的add() 方法。多个CalendarEntry实例可以通过addAllO方法予以添加。在CalendarEntry实例被添加之后,它们将被保存到数据存储中。之后,我们便可以 通过唯一标识重新获取这些实例。与add()和addAUO方法相对应的是removeO 和removeAUO方法,它们用于从集合中删除一个或多个实例。

就个人来讲,我并不喜欢使这些方法返回Boolean类型的结果,因为对于一个 添加方法来说,有时返回true并不能保证对实例的成功添加。因此,对于资源库来 说,返回void可能是更好的方式。

资源库接口的另一个重要方面是查找方法:
在这里插入图片描述现在,让我们来看看该资源库的实现。对于资源库的实现类来说,我们可以 将其放在另外的模块中。有些人喜欢在聚合和资源库的模块之下新创建一个模块 (Java包),即:
在这里插入图片描述
这种方式使得我们可以在领域层中对资源库实现类进行管理,但是此时的实 现类需要位于一个特殊的包中。这样,你可以将领域概念与持久化相关概念分离开来。在上例中,我们将资源库接口定义放在了与聚合相同的包中,而将资源库的 实现类放在了impl子包中,这种方式被大量的Java项目所采用。然而,在协作上下文 中,团队成员们将实现类放在了基础设施层中:
在这里插入图片描述这种方式使用了依赖倒置原则(4)。此时,从逻辑上讲,基础设施层位于所有 层之上,并且向下单向地引用领域层。
这里的HibernateCa丨endarEntryRepository是一个Spring中的Bean,它拥有一个无参构造函数,此外它还依赖于另一个基础设施层的对象,该对象是被注入进 来的:
在这里插入图片描述在这里插入图片描述此外,removeO和removeAUO方法便非常简单了,我们只需要调用Seesion的 delete〇方法即可。但是,在身份与访问上下文中,存在一个一对一映射的例子,此时 我们应该小心了。对于这种关系,我们并不能级联式地进行删除,因此必须同时显 式地删除位于关联关系两端的对象:
在这里插入图片描述
在上例中,一个User包含了一个Person。首先,我们应该删除Person对象,然后 再删除聚合根User对象。如果只删除了User,而没有删除Person,那么在相应的数 据库表中,Person将变成“孤儿(Orphan) ”。因此,通常来说,我们应该避免使用一对一关联,而应该使用多对一的单向关联。然而,我故意地使用了一对一的双向关 联,是因为我想向大家展示这种关联关系所带来的更大的麻烦。

需要注意的是,我们有多种方式都可以处理这种情况。有人可能会依赖于 ORM所提供的生命周期事件来完成对象的级联删除。我刻意地没有使用这种方 式,因为我强烈反对由聚合来管理持久化,同时我强烈地提倡只使用资源库来处 理持久化。当然,有关这两者的争论非常激烈,并且还在继续。因此,在选择时, 你需要多方权衡。但是请记住,DDD专家是不会首先考虑使用聚合来管理持久化 的。

最后是nextldentity()方法的实现:
在这里插入图片描述
该方法并没有使用持久化机制或者数据存储来生产唯一标识,而是釆用了相 对较快并且可靠的UUID生成器。

面向持久化资源库

Coherence实现
和面向集合资源库一样,我们首先需要定义接口,然后才是实现。在下面的面 向持久化资源库接口中,我们定义了一些基于保存操作的方法,这些方法将用于 Oracle 的 Coherence 数据网格:
在这里插入图片描述这里的ProductRepository并非与前面的CalendarEntryRepository全然不同。它们之间的不同之处在于保存聚合的方式。在本例中,我们使用了save〇和saveAll() 方法,而不是add〇和addAll()方法。但是从逻辑上来说,它们都完成相似的功能。 最大的不同在于客户端对这些方法的使用。在使用面向集合风格时,聚合实例只有 在新创建的时候才会使用add()或addAll()方法;然而在使用面向持久化风格时,无 论是创建聚合还是修改聚合,我们都必须使用save()或saveAll()方法:
在这里插入图片描述

额外的行为

对于资源库来说,除了前文讲到的那些典型的行为之外,我们还可以向资源库 接口中添加一些额外的行为。其中之一便是计算聚合实例的总数。你可能会将该行 为方法命名为c〇unt(),但是由于一个资源库应该尽可能地模拟一个集合,因此我 们可以考虑使用以下方法:
在这里插入图片描述
这里的size()方法和java.utiLCollection中的一模一样。在使用Hibernate时,该 方法可以实现为:
在这里插入图片描述在数据存储(包括数据库或数据网格)中,我们可能还需要执行一些计算过 程来满足某些非功能性需求。比如,我们需要将数据从数据存储中搬移到业务逻 辑执行的地方,而有时这是一个非常漫长的过程。这时,我们可能需要将代码迁 移到数据存储中,比如使用数据库的存储过程或者数据网格的条目处理器(entry processor),Coherence便提供了这样的功能。然而,这些功能最好应该放在领域 服务(7)中,因为领域服务正是用T处理那些无状态的、特定于领域的操作。

有时,如果我们要获取聚合根下的某些子聚合,我们不用先从资源库中获取 到聚合根,然后再从聚合根中获取这些子聚合,而是可以直接从资源库中返回。在 有些情况下,这种做法是有好处的。比如,某个聚合根拥有一个很大的实体类型集 合,而你需要根据某种查询条件返回该集合中的一部分实体。当然,只有在聚合根 中提供了对该实体集合的导航时,我们才能这么做,否则,我们便违背了聚合的设 计原则。我建议不要因为客户端的方便而提供这种访问方式。更多的时候,釆用 这种方式是由于性能上的考虑,比如从聚合根中访问子聚合将带来性能瓶颈的时 候。此时的查找方法和其他查找方法具有相同的基本特征,只是它直接返回聚合 根下的子聚合,而不是聚合根本身。无论如何,请慎重使用这种方式。

另外,我们还有可能在资源库中创建一些特殊的查找方法。比如,如果我们 需要在用户界面中显示数据,而这些数据来自于多个聚合,此时我们不用先分别 获取到每个聚合,再从中提取出所需数据,而是可以使用用例优化查询(Use Case Optimal Query)的方法直接查询所需要的数据。此时,我们可以直接在持久化机 制上执行查询,然后将查询结果放在一个值对象(6)中予以返回。

从资源库中返回值对象而非聚合实例并不奇怪。比如,前面我们所使用的 size方法便是如此,该方法以简单值对象的形式返回聚合实例的数目。对于用例 优化查询来说也是一样的,只是此时我们返回的是一个相对复杂的值对象而已。
在使用用例优化查询时,如果你发现你必须创建多个查询方法,那么这很有 可能是一种坏味道,这意味着你对聚合边界的划分是错误的。
然而,如果的确发生了这样的情况,并且你确认对聚合边界的设计是正确的, 那么此你便应该考虑使用CQRS (4) 了。

管理实务

对事务的管理绝对不应该放在领域模型和领域层中6。通常来说,与领域模型 相关的操作都非常细粒度的,以致于无法用于管理事务,另外,领域模型也不应该 意识到事务的存在。那么,对事务的管理应该放在什么地方呢?

通常来说,我们将事务放在应用层(14)中7。[Gamma et al.]。然后为每个主要 的用例创建一个门面[Gamma et al.],门面中的业务方法通常都是粗粒度的,常见 的情况是每一个用例流对应一个业务方法。业务方法对用例所需操作进行协调。 当用户界面层(14)调用门面中的一个业务方法时,该方法都将开始一个事务。同 时,该业务方法将作为领域模型的客户端而存在。在所有的操作完成之后,门面中 的业务方法将提交事务。在这个过程中,如果发生错误/异常,那么业务方法将对事 务进行回滚。

我们可以通过声明式的方法来管理事务,也可以自行编码。无论采用哪种方 式i对事务的管理过程都与以下执行过程相似:

在这里插入图片描述类型层级
在使用面向对象语言来开发领域模型时,我们通常喜欢通过继承来创建类型 层级。此时,我们将默认状态和行为放在基类中,然后创建子类对其进行扩展。为 仆么不呢?这似乎是避免重复的绝佳方式。

对于共享基类的聚合类来说,我们可以为每一种实际的聚合类型创建一个资 源库,也可以在单个资源库中创建不同的实际聚合类。对于对继承的使用来说, 这两者是不同的。因此,本节不会讨论所有的聚合类型都扩展自同一个层超类型 [Fowler,P of EAA]的情况。

我这里想讨论的是一组数目相对较少的聚合类型,它们都扩展自一个特定于 领域的超类。这些关联密切的聚合所组成的类型层级具有可互换性和多态性的特 征。此时,我们使用单个资源库来保存和获取层级中的不同聚合类型,而客户端无须知道他们所使用的实际类型。这也体现了Liskov替换原则(Liskov Substitution Principle, LSP) [Liskov]

打个比方,你的系统需要使用外部系统提供的各种服务,而你需要处理它 们之间的关系。你决定创建一个抽象基类由于每种服务既存 在共同之处,又存在不同之处,因此你需要创建多个不同的实际服务类,比如 WarbleServiceProvider和WonkleServiceProvider。你希望通过一种通用的方法来访问这些服务:
在这里插入图片描述

这样看来,在很多情况下,创建特定于领域的聚合类型层级的作用似乎并不 大。原因在于:通常来说,资源库所提供的查找方法可以返回任何一个聚合子类 的实例。这意味着查找方法所返回的应该是这些聚合子类的共有超类,就像本例 中的ServiceProvider—样;而不是某个特定的子类,比如WarbleServiceProvider或 WonkleServicePmvider。考虑一下,如果查找方法返回了某个特定的子类,情况会 怎么样?此时,客户端需要知道什么样的唯一标识(或者其他描述属性)对应着什 么样的特定实例。否则,这将导致类型不匹配,或者ClassCastException异常。即便 你能正确地处理这种情况,由于此时的聚合很有可能并不完全地满足Liskov替换 原则,你依然需要知道各个子类所提供的特殊操作。

要解决唯一标识和聚合类型之间的对应问题,你可能会想到通过唯一标识来 判断应该返回什么类型的聚合。当然,你可以这么做,但是这同样会导致问题。客 户端将负责唯一标识和聚合类型之间的映射。另外,在客户端与特定操作之间也 产生了耦合。此时的客户端代码将与以下代码相似:
在这里插入图片描述有时,这种方式并不是有可能产生异常这么简单,它往往意味着一种代码坏 味道。诚然,如果你从这样的类型层级中获得了极大的好处,那么这种一次性 的使用场景也许是一个很好的折中。然而,对于目前这个并不自然的例子,使用 ServiceDescription和scheduleServie〇方法的内部实现似乎已经足够了。请思考:为不同的聚合类型提供单独的资源库究竟给我们带来了什么好处?在聚合子类较少 的情况下,为它们使用单独的资源库可能是最好的方式。但是,随着聚合子类数H 的增加,而同时它们又具有完全的可互换性时,使用一个共享的资源库便更合适 了

多数情况下,这个问题都可以通过在聚合中维护一个描述属性来彻底解决。 请参考值对象(6)中对标准类型的讨论。此时,我们只需要设计单个聚合类型,在 其内部通过不同的标准类型来实现不同的行为。在使用显式标准类型的情况下, 我们只需要创建一个实际的ServiceProvider聚合类,然后在scheduleService方法中根据标准类型来分发服务。同时,我们需要确保这些逻辑不能泄露到客户端中。 要达到这样的目的,我们可以在scheduleService方法中包含该领域特定的选择逻 辑,比如:

在这里插入图片描述如果内部的分发逻辑变得凌乱,我们总能通过设计更小的层级予以处理。事 实上,如果你喜欢,标准类型本身便可以通过状态模式[Gamma et al.]来实现。在 这种情况下,不同的标准类型将实现各自的特有行为。当然,这同样也意味着我们 可以设计单个ServiceProviderRepository来保存不同类型的标准类型。

另外,我们还可以通过基于角色的接口来解决这个问题。比如,我们可以设计 一个SchedulableScrvice接口,然后让多个聚合类型都实现该接口。有关角色和职责的讨论,请参考实体(5)。这里,虽然我们也使用了继承,但是由继承所致的多 态行为并不会泄漏到客户端中。

资源库VS数据访问对象DAO
有时,资源库和数据访问对象——即DAO——被当作同义词看待。它们都提 供了对持久化机制的抽象。然而,ORM工具同样也提供了对持久化机制的抽象, 佣是它既不是资源库,也不是DAO。因此,我们不能将所有的持久化抽象都称为 DAO,而是需要确定这种模式是否得到了真正的实现。

资源库和DAO是不同的。一个DAO主要从数据库表的角度来看待问题,并且 提供CRUD操作。Martin Fowler在[Fowler, P of、EAAJ中将DAO相关设施与领域模 型分离开来对待。他指出,诸如表模块(Table Module)、表数据网关(Table Data Gateway)和活动记录(Active Record)这样的模式应该用于事务脚本程序中。 这是因为,这些与DAO相关的模式通常只是对数据库表的一层封装。而另一方面, 资源库和数据映射器(Data Mapper)则更加偏向于对象,因此通常被用于领域模 型中。

在DAO模式中所执行的CRUD操作都是可以放在聚合中来实现的,因此,我们 应该尽量避免在领域模型中使用这些DAO模式。在正常情况下,我们总是希望出 聚合本身来管理业务逻辑。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值