《实现领域驱动设计》 (美)弗农著 14章 应用程序

只供参考,喜欢请支持正版图书

在这里插入图片描述

用户界面

渲染领域对象
在这里插入图片描述渲染数据传输对象
一种渲染多个聚合实例的方法便是使用数据传输对象(Data Tranfcr Object, DTO) [Fowler,PofEAA]。DTO将包含需要显示的所有属性值。应用服务通过资源 库(12)读取所需的聚合实例,然后使用一个DTO组装器(DTOAssemble) [Fowler, P of EAA]将需要显示的属性值映射到DTO中。之后,用户界面组件将访问每一个 DTOM性值,并将其渲染到显示界面中。

在这种方式中,对数据的读和写都是通过资源库完成的。这种方式的好处 在r不会存在延迟加载的问题,因为DTO组装器会直接访问聚合中需要用来创建 DTO的所存数据。另外的一个好处是:它可以解决展现层(Presentation Tier)和业 务层(Business Tier)存在物理分离的情况,此时我们需要对数据进行序列化,然 后通过网络将其传输到展现层中。

有趣的是,DTO模式原本就是用于在远程的展现层中显示数据的。此 时,DTO在、Ik务层中创建,再序列化,然后通过M络发送,最后在展现层中反序列 化。如果你的展现层不是远程的,那么这种模式在很多时候将给你的系统带来没 有必要的复杂性,即YAGN1 ( “You Ain’t Gornia Neet It”,你并不需要它)。它的缺 点在于,我们需要创建一些与领域对象非常相似的类。另外,我们需要创建一些必 须由虚拟机(比如JVM)所管理的大对象,而事实上这些对象却与单虚拟机应用 架构不相匹配。

在使用DTO时,我们的聚合设计需要考虑到DTO组装器对聚合数据的查询。 此时,我们需要慎宽考虑,因为我们不应该暴露出太多的聚合内部结构。我们应该 尽量将客户端从聚合的内部状态中完全解耦。你应该允许客户端一在本例中即 DTO组装器——深度访问聚合的状态吗?这并不是一个好的主意,因为它使客户 端与聚合实现紧密地耦合起来。

使用调停者发布聚合的内部状态

要解决客户端和领域模型之间的耦合问题,我们可以使用调停者模式 [Gamma et al.],即双分派(Double-Dispatch)和回调(Callback)。此时,聚合将通过调停者接口來发布内部状态。客户端将实现调停者接口,然后把实现对象的 引用作为参数传给聚合。之后,聚合双分派给调停者以发布自身状态,在这个过程 中,聚合并没有句外暴露自身的内部结构。这里的決窍在于,不要将调停者接门与 任何显示规范绑定在一起,而是关注于对所感兴趣的聚合状态的渲染:
在这里插入图片描述通过领域负载对象渲染聚合实例

在没有必要使用DTO时,我们可以使用另一种改进方法。该方法将多个聚 合实例中需要显示的数据汇集到一个领域负载对象(Domain Payload Object, DPO)中[Vemon,DPO]。DPO与DTO相似,但是它的优点是可以用于单虚拟机应 用架构中。DPO中包含了对整个聚合实例的引用,而不是单独的属性。此时,聚合 实例集群可以在多个逻辑层之间传输。应用服务(请参考“应用服务”一节)通过 资源库获取到所需聚合实例,然后创建DPO实例,该DPO持有对所有聚合实例的 引用。之后,展现组件通过DPO获得聚合实例的引用,再从聚合中访问需要显示的 属性。

这种方式的优点在于,它简化了在不同逻辑层之间传输集群数据的过程。和 DTO相比,DPO更容易设计,并且消耗更少的内存。在创建DPO之前,由于聚合实 例必须被读到内存中,因此之后在使用DPO时,这些聚合实例已经存在了。

当然,这种方式也是存在一些潜在缺点的。由TDP〇与DTO相似,它照样需要 聚合提供一些方法以读取聚合的状态。为了避免用户界面和模型之间的紧耦合, 我们可能还需要使用前一节讲到的调停者。

此外,我们还需要处理另一种情况。由于DPO持有的是对整个聚合实例的引 用,延迟加载的对象/集合并未被加载到内存中。在创建DPO时,我们没有必要访问 所有所需的聚合属性。由于在应用服务的方法结束时,事务已将随之提交,之后在 展现组件中访问那些延迟加载的属性时,程序将抛出异常。

要解决延迟加载的问题,我们可以选择即时加载,或者使用领域依赖求解 器(Domain Dependency Resolver, DDR) [Vernon, DDR]。这是一种策略模式 (Strategy) [Gamma et al.],通常来说,对于每一个用例流,我们都会使用一种策 略。对于某个用例流所需要的所有延迟加载的属性,对应的策略都会强制性地对 其进行访问。这样的策略访问作用于应用服务提交事务并返回DPO之前。我们可 以对这样的策略进行硬编码以手动的访问所有延迟加载的属性,或者可以使用简 单的表达式语言,并通过反射的机制来访问这些属性。后者的优点在于,它可以访 问那些隐藏的属性。当然,在可能的情况下,你也可以定制查询过程以即时地加载 聚合属 性。

聚合实例的状态展现
如果你的程序提供了REST (4)资源,那么你便需要为领域模型创建状态展 现以供客户端使用。有一点非常重要:我们应该基于用例来创建状态展现,而不是 基于聚合实例。从这一点来看,创建状态展现和DTO是相似的,因为DTO也是基于用例的。然而,更准确的是将一组REST资源看作一个单独的模型——视图模型 (View Model)或展现模型(Presentation Model) [Fowler, PM]。我们所创建的展现模型不应该与领域模型中的聚合状态存在——对应的关系。否则,你的客户端 便需要像聚合本身一样了解你的领域模型。此时,客户端需要紧跟领域模型中行 为和状态的变化,你也随之失去了抽象所带来的好处。

用例优化资源库查询
与其读取多个聚合实例,然后再通过编程的方式将它们组装到单个容器 (DTO或DPO)中,我们可以转而使用用例优化査询。此时,我们可以在资源库中 创建一些查询方法,这些方法返问的是所有聚合实例属性的超集。查询方法动态 地将查询结果放在一个值对象(6)中,该值对象是特别为当前用例设计的。请注 意,你设计的是值对象,而不是DTO,因为此时的查询是特定于领域的,而不是特 定于应用程序的。这个用例优化的值对象将被直接用于渲染用户界面。

用例优化查询的动机与CQRS (4)相似。然而,用例优化查询依然会使用资源 库,而不会直接与数据库打交道(比如使用SQL)。要了解这两者的不同,请参考资 源库(12)中的相关讨论。当然,如果你打算在用例优化査询之路上继续走下去, 那么你已经离CQRS很近了,此时考虑转用CQRS也是一种不错的选择。

处理不同类型的客户端
如果你的应用程序必须支持多种不同类型的客户端,你该怎么办呢?这些客 户端可能包括R1A、图形界面、REST服务和消息系统等。另外,各种测试也可以被 认为是不同类型的客户端。此时,你的应用服务可以使用一个数据转换器(Data Transformer),然后由客户端来决定需要使用的数据转换器类型。应用层将双分 派给数据转换器以生成所需的数据格式。以下是一个基于REST的客户端所使用 的用户界面:
在这里插入图片描述CalendarApplicationService 的 calendarWeek()方法接受 2 个参数,一个是 Date 类型的对象,另一个是CalendarWeekDataTransformer接口的某个实现类对象。在 该例中,实现类是CalendarWeekXMLDataTransformer,该类为CalendarWeekData 创建一个XML格式的状态展现。CalendarWeekData的value〇方法将以指定的数 据格式返回该CalendarWeekData的状态展现,在本例中即为String格式的XML文 档。

需要指出的是,对于上面的例子来说,更好的方式是对数据转换器进行依赖 注人。硬编码只是为了让上面的例子更容易理解

CalendarWeekDataTransformer 还可以有以下实现类:

• 	CalendarWeekCSVDataTransformer
•	CalendarWeekDPODataTransformer
•	CalendarWeekDTODataTransformer
•	CalendarWeekJSONDataTransformer
•	CalendarWeekTextDataTransformer
•	CalendarWeekXMLDataTransformer

对于处理不同类型的客户端来说,还有另一种方式,对此我们将在“应用服 务”一节中进行讲解。

渲染适配器以及处理用户编辑

有时,你需要在界面中显示领域数据,并且允许用户编辑这些数据。此时,我 们可以使用一些模式来帮助我们划分职责。当然,有太多的框架可以帮助我们完 成这样的任务。对于有些用户界面框架来说,我们必须釆用一些特定的模式。有些 时候,这些模式是好的,但另外的时候就不见得了。而另外的一些框架可能更加复 杂。

无论应用层是通过什么方式来提供领域数据的——DTO、DPO或者状态展 现——也不管你使用的是什么样的展现框架,你都可以从展现模型4中获益。它的 目标是分离展现与显示之间的职责。虽然展现模型可以用于Web 1.0的应用程序, 但是我认为它的长处在于Web 2.0的R1A,或者桌面客户端。

在使用这种模式时,我们需要将视图设计成被动的,即它们只用于显示数据和 用户界面控件。在渲染视图时,有两种方法:

1.根据展现模型,视图完成自我渲染。我认为这是一种更自然的方式,并且在展 现模型和视图之间完成了解耦。
2.视图由展现模型进行渲染。这种方式在测试起来要容易一些,但是它却将展 现模型与视图耦合起来。

我们可以将展现模型看成是一种适配器[Gamma et al.]。它根据视图之所需向 外提供属性和行为,由此隐藏了领域模型的细节。这也意味着,此时的展现模型不 lh是向外提供领域对象或DTO的属性,而是在渲染视图时,展现模型将根据模型 的状态做出一些决定。比如,要在视图中显示一个特定的控件,这并不会与领域模 型中的属性存在直接的关系,而是可以从这些属性中推导得出。我们不会要求领域 模型对视图显示属性提供特别的支持,而是将职责分给展现模型。此时,展现模 M通过领域模型的状态推导出一些特定于视图的指示器和属性值。

使用展现模型的另一个好处在于,如果聚合不提供JavaBcan所规定的getter 方法,而用户界面框架恰恰又需要这样的getter方法,那么展现模型可以完成这样 的适配转换工作。多数基于Java的Web框架都要求对象提供公有的getter方法,比 如getSummary()和getStory()等,但是对领域模型的设计却倾向于使用流畅的、特 定于领域的表达式来反映通用语言(1)。此时,我们将使用summary()和story() 这样的方法命名,这便与用户界面框架产生了阻抗失配。此时,展现模型可以将 summary()方法适配到gctSummaryO方法,将story()方法适配到getStory( >方法, 从而消除模型与视图之间的冲突:
在这里插入图片描述当然,展现模型可以对先前所讲的任何一种方式进行适配,包括DTO、DPO, 或者用于发布聚合内部状态的调停者。

此外,展现模型还可以跟踪用户的编辑。这并不是向展现模型添加更多的职 责,因为它本来就应该具有双向的适配功能,从模型到视图,再从视图到模型。

有一点需要注意的是,展现模型并不是围绕着应用服务或者领域模型的一个 重量级门面[Gamma et al.]。诚然,当用户通过用户界面完成某个任务之后,它们通 常会调用诸如“应用”或“取消”之类的操作。此时,展现模型应该能反映出这样 的操作过程,即围绕着应用服务的一个最小化门面:
在这里插入图片描述在视图中,在用户单击命令按钮之后,changeSummaryWithType方法将被 调用。对于editTracker所跟踪的编辑修改,BacklogltemPrcsentationModel将负责 于应用服务交互以应这些修改。在这个过程中,没有另外的旁观者来等待用户的 编辑并做相应的操作。因此,我们可以认为,展现模型代表着视图向应用服务提供 了一个最小化的门面,即作为高层接口的changeSummaryWithType()方法使得对 BacklogItemApplicationService的使用变得更加简单。但是,我们不应该在展现模 型中出现使用应用服务的细节,或者甚至直接将展现模型本身作为领域模型的应 用服务。我们希望看到的是,在展现模型中简单地将处理逻辑委派给更复杂、更重 量级的门面:BacklogltemApplicationService

以上这种方式可以很好地协调领域模型和用户界面。我们甚至可以将它看作 是最强大的用户界面管理模式。但是,对于任何一种视图管理技术来说,我们依然 会经常与应用服务API交互。

应用服务

在有些情况下,用户界面将使用独立的展现模型组件来汇集多个限界上下文 (2),然后将汇集后的数据组合到单个视图中。无论你的用户界而渲染了单个模型 还是多个,它都需要和应用服务交互。

应用服务是领域模m的直接客户。至于我们可以将应用服务放在什么样的逻 辑位置,〖青参考架构(4)。应用服务负责用例流的任务协调,每个用例流对应了一 个服务方法。在使用ACID数据库时,应用服务还负责控制事务以确保对模型修改 的原子提交。在本节中,我只会简要地讨论到事务控制,更多的讨论请参考资源库 (12)。另外,应用服务还会处理和安全相关的操作。

将应用服务与领域服务(7)等同起来是错误的。它们并不相同,我们将在下文 中讨论到它们之间的区别。我们应该将所有的业务领域逻辑放在领域模型中,不 管是聚合、值对象或者领域服务;而将应用服务做成很薄的一层,并且H使用它们 來协调对模型的任务操作。

示例应用服务
让我们来看看应用服务的一个示例接U和实现类,该应用服务用于管理身份 与访问上下文中的Tenant。这是一个示例,而不是最终的解决方案。
首先是应用服务的接口

在这里插入图片描述以上6个应用服务方法用于创建Tenant、激活和禁用已有Tenant、邀请其他 Tenant 和查询 Tenant 等。

领域模型中的有些对象类型被用于这些方法的签名中,这意味着用户界面需 要知道这些类型,并且依赖于它们。有时,应用服务被设计成将用户界面完全地 隔离于领域模型。此时,应用服务中的方法签名中将只出现原始类型(im、long和 double等)和String类型,有可能还有DTO。但是,更好的方法是使用命令[Gamma cl al.]对象。当然,这里并不存在对错之分,更多的是有关你自己的口味和R标。在 本书中,我们对每一种风格都提供了示例展示。

在不使用模型中的对象类型时,我们避免了依赖和耦合,但是却失去了强类 型检查和基本的验证。在不把领域对象作为返回类型的情况下,我们则需要提供 DTO。此时,我们需要创建一些额外的类型,从而有可能增加系统的复杂性。另外,由于系统不断地对DTO进行创建和垃圾回收,这有可能还会导致不必要的内 存耗费。

如果你将领域对象暴露给不同类型的客户端,那么每种客户端都需要单独地 处理这些对象类型。因此,在这种情况下,耦合问题将更加严重。要解决这样的问 题,我们至少可以对部分服务方法进行改进。正如之前所讨论的,我们可以使用数 据转换器作为返回类型:
在这里插入图片描述在这里插入图片描述
在这里插入图片描述这是一种声明式的、方法层面的安全授权,它可以阻止未授权的用户对应用服 务的访问。当然,对于未被授权的用户来说,用户界面可以隐藏那些能够导航到应 用服务的相关信息。但是,对于恶意的攻击者来说,隐藏是无济于事的,而上面的 安全注解则能提供防卫。

这种声明式的安全机制和丨dOvation提供的安全机制是不同的。SaaSOvation 的员工登录IdOvation的方式与Tenant用户是不一样的。特别地,那些拥有 SubscriberRepresentative角色的员工是可以执行这些敏感的服务方法的,而对下 订阅方的用户来说,则不可以。当然,这需要在IdOvation和Spring Security之间进 行集成。

现在,让我们看看pr〇visionTenant()方法,该方法将委派给领域服 务。这也向我们展示了应用服务和领域服务的区别,特别是当我们看到 TenantProvisioningService领域服务的内部时,这种区别就更加明显了。在领域服 务中,存在着大量的领域逻辑,但是对于应用服务则不然。我们可以设想一下以上 领域服务所完成的操作(没有提供代码):

1.	实例化一个新的Tenant聚合,并将其添加到资源库中。
2.为该Tenant指派一个新的管理员,其中包括为这个新的Tenant准备一个
Administrator角色,并且发布 Tenant AdministratorRegistered事件。
3.发布TenantProvisioned事件。

如果应用服务所包含的已经超出上面的第I步,那么此时领域逻辑便会从模型 中泄露出去。对于第2步和第3步来说,由于它们并不属于应用服务的职责,因此我 们干脆将所有3个步骤一起放在领域服务中。在使用领域服务时,我们“将这个显 著的过程.……放在了领域模型中[Evans]。5”同时,应用层依然管理着事务、安全 和任务委派等操作,即将这个显著的准备Tenant的过程委派给了领域模型。

但是,请注意provisionTenant方法的参数列表。这里总共有9个参数,并且还 可能存在更多。对于这样的情况,我们可以通过一个简单的命令[Gamma et al.]对 象予以避免。命令对象即“将一个请求封装到一个对象中,从而使得我们对客户端进行参数化,包括不同的请求、队列或者日志请求等;另外,命令对象还支持撤销 操作。”换句话说,我们可以将命令对象看成是序列化的方法调用。在本例中,除了 撤销操作,我们希望得到命令对象所带来的所有其他好处。以下是一个简单的命 令类:
在这里插入图片描述

这里的ProvisionTenantCommand并没有使用领域对象,而是一些基本的类 型。它拥有一个多参数的构造函数和一个没有参数的构造函数。公有的setter方法 使得我们将UI中的字段映射到相应的ProvisionTenantCommand属性中(比如,考 虑使用JavaBean或者.NETCLR属性)。你可以将命令对象看成是一个DTO,但是, 命令对象所能表达的要比DTO多。由于我们根据操作来命名命令对象,它的意图将 更加明显。我们可以将命令对象的实例传给应用服务的方法:
在这里插入图片描述

解耦服务输出

先前,我们讨论到了数据转换器。对于不同类型的客户端,数据转换器将提 供客户端所需的特定数据类型。此时,不同的数据转换器将实现一个共有的抽象 接口。从客户端的角度,我们可以通过以下方式来使用数据转换器:
在这里插入图片描述

应用服务被设计成了具有输入和输出的API,而传入数据转换器的目的即在于 为客户端生成特定的输出类型。

现在,让我们考虑另一种完全不同的方式:使应用服务返回void类型而不向客 户端返回数据。这将如何工作呢?事实上,这正是六边形架构(4)所提倡的,此时 我们可以使用端口和适配器的风格。对于本例,我们可以使用单个标准输出端口, 然后为不同种类的客户端创建不同的适配器。此时,应用层的prcwisionTenantO方 法将变成:

在这里插入图片描述

组合多个限界上下文

在以上的例子中,我们并没有谈及到单个用户界面需要多个领域模型提供数 据的情况。在这种情况下,上游模型中的概念被集成到了下游模型中,采用的方法 是将上游的概念翻译成下游模型中的术语。

这和图14.3中所展示的是不同的。在图14.3中,我们需要将多个模型组合成 一个单一的展现。图中的产品上下文(Products Context)、讨论上下文(Discussion Context)和检查上下文(Review Context)都属于外部模型。如果这样的场景出 现在你自己的应用程序中,那么你需要好好考虑如何设计模块(9)结构并对其命 名,以及在应用服务中如何平滑地处理不同模型之间的摩擦。

一种方式是采用多个应用层,这种方式与图14.3中所示的不同。在这种方式 中,我们需要为每个用户界面组件都提供所有的应用层,此时的用户界面组件将向 领域模型靠近。基本上,这只是一种Portal-Portlet的风格而已。另外,这种方式也无 法与用例流保持一致,而这却正是用户界面所关注的。

在这里插入图片描述

由于是应用层来管理用例,此时最简单的方法可能是创建一个单一的应用层, 并使该应用层来组合多个模型,如图14.3所示。另外,又由于这个应用层中的服务 并不包含领域逻辑,它的唯一功能便是将不同模型中的聚合对象组合成用户界面所 需的内聚对象。在这种情况下,我们可以根据数据组合的目的来命名用户界面和应 用层中的模块:

com.consumerhive.productreviews.presentation
com.consumerhive.productreviews.application

这里的Consumer Hive向外提供产品检查和讨论。它把产品上下文从讨论上下 文和检查上下文中分离出来。但是,presentation和application模块表明它们位于同 一个用户界面之下。虽然该用户界面将从多个外部源中获取产品目录,但是这无妨 大碍,因为讨论和检查才是它的核心域。

说到核心域……这里你是否察觉出了些什么?这里的应用层不是成了一个拥有 内建防腐层(3)的新领域模型吗?是的,它是一个新的、廉价的限界上下文。在该 上下文中,应用服务对多个DTO进行合并,产生的结果有点像贫血领域对象(1)。 这种方式也有点像是在通过事务脚本(1)来建模核心域。

如果你认为Consumer Hive是将三个模型组合成一个新的领域模型(1),并将 其放在一个新的限界上下文中,那么你可通过以下方式来命名新模型中的模块:

com.consumerhive.productreviews.domain.model.product 
com.consumerhive.productreviews.domain.model.discussion com.consumerhive.productreviews.domain.model.review

最终,你需要决定如何对这种场景进行建模。你会考虑使用战略设计甚至战 术设计来创建一个新模型吗?对于这种场景,我们至少需要回答以下问题:我们是 将多个限界上下文组合到单个用户界面屮呢,还是创建一些新的、清晰的限界上下 文?我们应该仔细地考虑每一种情形,而不是草率地做决定。最终,最好的方式是 那些对业务最有益的方式。

基础设施

基础设施的职责是为应用程序的其他部分提供技术支持。这里,虽然我们避 免对分层(4)的讨论,但是保持着依赖倒置原则的心态依然是有用的。因此,从架 构上讲,无论基础设施位于什么地方,只要它的组件依赖于用户界面、应用服务和 领域模型中的接口,而这些接口义需要特殊的技术支持,那么它都能工作得很好。 这样,在应用服务获取资源库时,它只会依赖于领域模型中的接口,而实际使用的 则是基础设施中的实现类。在图14.4中,静态的UML结构图向我们展示了这个过 程。
在这里插入图片描述对资源库的查找可以通过依赖注入[Fowler, DI]或者服务工厂(Service Factory)隐式地完成。本章的最后一节“企业组件容器”讨论了这两种方式。这里, 我再次给出前文中关于应用服务的一个例子,从中我们可以看到如何使用服务工 厂来获取资源库:
在这里插入图片描述

对于以上的应用服务来说,我们还可以将资源库注入到服务类中,或者通过 构造函数的方式将资源库传人。
资源库的实现被放在了基础设施层中,因为它们负责处理数据存储,而这些 不属于模型的职责。你可以使基础设施层实现那些与消息相关的接口,比如消息 队列和E-mail等。如果还有一些特殊的用户界面组件来处理诸如图表之类的展现, 那么它们也应该放在基础设施层中。

只供参考,喜欢请支持正版图书
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值