《实现领域驱动设计》读书笔记(3) - 战略建模之架构

系列大纲: 《实现领域驱动设计》读书笔记

本文大纲:

分层架构

分层架构的一个重要原则是: 每层只能与位于其下方的层发生耦合。「严格分层架构」只允许某层与直接位于其下方的层发生耦合,而「松散分层架构」则允许任意上方层与任意下方层发生耦合。由于「用户界面」「应用服务」经常需要与基础设施打交道,许多系统都是基于「松散分层架构」的。

在分层架构中,领域的核心域通常只位于架构的其中一层,「用户界面」「应用服务」均位于其上。

根据笔者的理解,用户界面层对应所有对系统产生消费行为的客户端,可能是人也可能是其他系统,用户界面是应用层的直接消费方。

有人认为既然用户界面需要对用户输入进行验证,那么它就应该包含业务逻辑。事实上,用户界面进行的验证和领域模型的验证是不同的,在用户界面中使用的只是数据的渲染和展现,而领域模型的验证的关注点却跟一致性有关,此时可以使用展现模型将用户界面与领域模型解耦。

「应用服务」位于应用层中,「应用服务」「领域服务」的职责是不同的,后续的文章专门针对两者进行了讨论。

领域逻辑不应该出现在应用服务中,应用服务可以用于控制持久化事务和安全认证,或者向其他系统发送基于事件的消息通知,另外还可以用于创建邮件以发送给用户。应用服务本身并不处理业务逻辑,但它是领域模型的直接消费者,它主要用于协调领域对象的操作,应用服务是很轻量的。同时,应用服务是表达用例和用户故事的主要手段。因此,应用服务通常的用途是: 接收来自用户界面的输入参数,再通过资源库获取到聚合实例,然后执行相应的操作,例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@Transactional
public void CommitBacklogItemToSprint(string tenantId, string backlogItemId, string sprintId){
// construct wanted id objects.
var tenantId = new TenantId(tenantId);
var backlogItemId = new BacklogItemId(backlogItemId);
// get backlogItem object from repository.
var backlogItem = backlogItemRepository.BacklogItemOfId(tenantId, backlogItemId);

var sprintId = new SprintId(sprintId);
var sprint = sprintRepository.SprintOfId(tenantId, sprintId);

// call the commit method from backlogItem.
backlogItem.CommitTo(sprint);
}

上述代码很好的诠释了前文提及的关于应用服务「协调领域对象的操作」的功能

如果应用服务比上述功能复杂许多,这通常意味着领域逻辑已经泄露到应用服务中了,此时的领域模型将变成「贫血领域模型」。因此,最佳实践是将应用服务做成很薄的一层。

六边形架构(端口与适配器架构,洋葱架构)

依赖倒置原则

  • 高层模块不应该依赖于低层模块,两者都应该依赖于抽象
  • 抽象不应该依赖于细节,细节应该依赖于抽象。

当传统的分层架构引入了依赖倒置原则,会发现已经不存在分层的概念了,无论是高层还是低层都依赖于抽象,好像把整个分层架构推平了。在六边形架构中,不同的消费者通过「对等」的方式与系统交互,当需要新增消费者时,只需添加一个新的适配器将客户输入转化成能被系统 API 所理解的参数就行了。同时,系统输出,例如「图形界面」「持久化」「消息」等都可以通过不同方式实现,并且是可替换的,对于每种特定的输出,都有一个新的适配器负责完成相应的转化功能。

六边形架构提倡用「内部区域」「外部区域」来看待整个系统,在外部区域中,不同的客户代码提交输入,内部系统用于获取持久化数据,并对程序输出进行存储,或在中途将输出转发到另外的地方(比如消息)。

依据笔者理解,端口和适配器的意思是,将系统想象成一般的计算机,HTTP 协议和 AMPQ 协议以及用户界面可看作不同的端口,而适配器则负责将来自这些协议的数据转化成系统 API 能够理解的数据。

在使用六边形架构时,我们应该根据用例来设计应用程序,而不是根据需要支持的客户数目来设计。任何客户都可能向不同的端口发出请求,但是所有的适配器都将使用相同的 API。

应用程序位于六边形架构的「内部区域」,公共 API 通过「应用服务」暴露给外部区域,而如前文所述,应用服务是领域模型的直接消费者,所有的输入都将委派给内部的领域对象。

我们可以将资源库的实现看作是持久化适配器,该适配器用于访问先前存储的聚合实例,或者保存新的实例,我们可以通过不同的方式实现资源库,如关系型数据库,文档型数据库以及内存数据库,他们分别对应着不同的适配器,但服务于同一种端口——持久化,即同一个端口可以有多种适配器。

六边形架构的好处在于易于测试,整个应用程序和领域模型可以在没有客户和存储机制的条件下进行设计开发。基于六边形架构,可以扩展为 SOA,REST,事件驱动架构,CQRS 架构或者数据网织或基于网格的分布式缓存,还有可能 Map-Reduce 这种分布式并行处理方式。

面向服务架构(Service-Oriented Architecture, SOA)

服务的设计原则如下:

  • 服务契约: 通过契约文档,服务阐述自身的目的与功能
  • 松耦合: 服务将依赖关系最小化
  • 服务抽象: 服务只发布契约,而向消费方隐藏内部逻辑
  • 服务重用性: 一种服务可以被其他服务重用
  • 服务自治性: 服务自行控制环境与资源以保持独立性,这有助于保持服务的一致性和可靠性
  • 服务无状态性: 服务负责消费者的状态管理,但不能与服务的自治性发生冲突
  • 服务可发现性: 消费方可以通过服务元数据来查找服务和理解服务
  • 服务组合性: 一种服务可以由其他服务组合而成,而不管其他服务的大小和复杂性如何

这些原则可以与六边形架构结合起来,此时服务边界位于最左侧,而领域模型位于中心位置,消费方可以通过 REST,SOAP 和消息机制获取服务。

业务服务可以由任意数量的技术服务来提供,技术服务可以是 REST 资源,SOAP 接口或消息类型。业务服务强调业务战略,即如何对业务和技术进行整合。

REST(Representational State Transfer)

REST 既不是使用 HTTP 直接发送 XML/JSON,也不是将 URI 的查询参数传递给方法。REST 是一种架构风格,架构风格之于架构就像设计模式之于设计一样,它将不同架构实现共有的东西抽象出来,使得我们在谈论架构时不至于陷入技术细节中。分布式系统架构存在多种架构风格,包括客户端-服务器架构风格和**分布式对象(例如远程过程调用)**风格。REST 是 Web 架构的一种架构风格,和其他技术一样,我们可以通过不同的方式来使用 Web 协议,有些使用方式符合设计者的初衷,而有些则不然。例如,你可以使用关系型数据库管理系统(RDBMS)创建表,列,外键关联,视图和约束等,你也可以只创建一张包含两列的表,一列表示「键」,一列表示「值」,然后将序列化之后的对象保存在值列中。此时,你依然在使用 RDBMS,但你却使用不到多少 RDBMS 的功能,如查询,组合,排列和分组等。

同理,Web 协议既可以按照它的设计初衷为人所用——此时便是一种遵循 REST 架构风格的方式——也可以通过一种不遵循其设计初衷的方式为人所用。因此,当我们没有足够充分的理由享受 REST 风格的 HTTP 所带来的好处时,采用另一种分布式系统架构可能是合适的,就像在保存拥有唯一键的数值时,NoSQL 键值对存储方式是一种更好的选择一样。

RESTful HTTP 服务端的关键方面

「资源」是关键的概念,系统的设计者将决定哪些有意义的「东西」可以暴露给外界,并且给这些「东西」一个唯一的身份标识。通常来说,每种资源都拥有一个 URI,每个 URI 都需要指向某个资源。

另一个关键方面是「无状态通信」,消息是自描述的,例如,HTTP 请求本身便包含了服务端所需要的全部信息,服务端可以使用其本身的状态来辅助通信,重要的是: **我们不能依靠请求本身来创建一个隐式上下文环境(会话)**。无状态通信保证了不同请求之间的相互独立性,这在很大程度上提高了系统的可伸缩性。

如果将资源看作对象,那么每一个对象都支持相同的接口,可以调用的方法是一个固定的集合,它们全都可以用 HTTP 动作表示,其中最重要的有 GETPUTPOSTDELETE。这也是将 REST 与其他架构风格区别开来的关键。虽然乍一看这些方法将会转化成 CRUD 操作,但通常我们所创建的资源并不表示任何持久化实体,而是封装了某种行为,当调用 HTTP 动词对应的操作时,实际上是在调用这些行为。

依据笔者理解,对象化的资源并不代表任何领域模型中的实体,而是根据某一项业务操作抽象出来的资源块,其中包括用以展示的数据和某些行为。

在 HTTP 规范中,每种 HTTP 方法都有一个明确的定义,比如 GET 方法只能用于「安全」的操作:

  • 它可能完成一些客户并没有要求的动作行为
  • 它总是读取数据
  • 它可能被缓存起来

最后,通过使用 HATEOAS(Hypermedia as Engine of Application State),REST 服务的消费方可以沿着某种路径发现应用程序可能的状态变化。简单来讲,就是单个资源并不独立存在,不同资源是相互链接在一起的,对于服务器来说,这意味着在返回中包含对其他资源的链接,由此消费方便可通过这些链接访问到相应的资源。

REST 和 DDD

不应该将领域模型直接暴露给外界,这样会使系统接口变得非常脆弱,领域模型的任何改变都会导致系统接口的改变。要将 DDD 与 RESTful HTTP 合并起来使用,我们有两种方式。

第一种方法是为系统接口单独创建一个有界上下文,再在此上下文中通过适当的策略来访问核心模型,这是一种经典的方法,它将系统接口看作一个整体,通过资源抽象将系统功能暴露给外界,而不是通过服务或远程接口。这种方法让核心域和系统接口之间完成了解耦。

另一种方法用于需要使用标准媒体类型的时候。如果某种媒体类型并不用于支持单个系统接口,而是用于一组相似的客户端-服务器交互场景,此时可以创建一个领域模型来处理每一种媒体类型。这种方法本质上为 DDD 中的共享内核或发布语言。

这里提到的媒体类型表示 MIME type。

通常来讲,添加新资源并在已有资源中创建到新资源的链接是非常简单的,要添加新的格式也同样如此。另外,基于 REST 的系统也是非常容易理解,系统被分为很多较小的资源块,每一个资源块都可以独立测试和调试。HTTP 设计本身以及 URI 成熟的重写与缓存机制使得 RESTful HTTP 成为一种不错的架构选择,该架构具有很好的松耦合性和可伸缩性。

命令与查询职责分离 - CQRS

「资源库」中查询所有需要显示的数据是困难的,特别是在需要显示来自不同聚合类型与实例的数据时,领域越复杂,这种困难越大。一种被软件系统广泛采用的做法是使用「数据传输对象(Data Transfer Object, DTO)」,即从不同的资源库中获取聚合实例,然后再将它们组装成 DTO。

然而,查询这些数据所带来的性能消耗可能会随着数据量增大而显著降低,另外一种办法是使用 「CQRS(Command-Query Responsibility Segregation)」。CQRS 是将紧缩(Stringent)对象(或组件)设计原则和命令-查询分离(CQS)应用在架构模式中的结果。

一个方法要么是执行某种动作命令,要么是返回数据的查询,而不能两者皆是。换句话说,问题不应该对答案进行修改。一个方法只有在具有参考透明性的时候才能返回数据,此时该方法不会产生副作用。

[Bertrand Meyer]

在对象层面,这意味着:

  • 如果一个方法修改了对象的状态,该方法便是一个命令(Command),它不应该返回数据,在 Java 和 C# 中,这样的方法应该声明为 void
  • 如果一个方法返回了数据,该方法便是一个查询(Query),此时它不应该通过直接或间接的手段修改对象的状态,在 Java 或 C# 中,这样的方法应该以其返回的数据类型进行声明

在领域模型中,我们通常会看到同时包含命令和查询的聚合,也经常在资源库中看到不同的查询方法,这些方法对对象属性进行过滤。但在 CQRS 中,我们忽略这些常态的情形,而是通过另一种方式来查询用于呈现的数据。

假设,一个聚合不再有查询方法,只有命令方法,资源库也将变成只有 Add()Save() 方法(分别支持创建和更新操作),同时只有一个查询方法,如 FromId(),这个唯一的查询方法以聚合 ID 作为参数,然后返回该聚合实例。资源库不能使用其他方法来查询聚合,比如对属性进行过滤等。在将所有查询方法移除之后,我们将此时的模型称为「命令模型(Command Model)」,但我们仍然需要向用户显示数据,为此我们将创建第二个模型,该模型专门用于优化查询,称之为「查询模型(Query Model)」

你可能会认为: 这种架构风格需要大量的额外工作,我们解决了一些问题,同时带来了另外的问题,而且我们需要编写更多的代码。但无论如何,不要急于否定这种架构,在某些情况下,新增的复杂性是合理的。

客户端和查询处理器

客户端可以是 Web 浏览器,也可以是桌面应用程序,它们将使用运行在服务器端的一组查询处理器。查询处理器表示一个只知道如何向数据库执行基本查询并将查询结果以某种格式返回的简单组件。

事件驱动架构

事件驱动架构不见得必须与六边形架构一同使用,但引入六边形架构有助于理解事件驱动架构。

长时处理过程(Saga)

todo..

事件源(EventSource)

事件源是指: 某个聚合上的每次命令操作,都有至少一个领域事件发布出去,该领域事件描述了操作的执行结果。每一个领域事件都将被保存到「事件存储」中,每次从资源库中获取某个聚合时,我们将根据发生在该聚合上的历史事件来重建该聚合实例,事件的作用顺序与它们的产生顺序相同。

随着时间推移,发生在聚合实例上的事件越来越多,那么,重放这些成百上千的事件会对那些操作繁忙的模型造成影响,为了避免这种瓶颈,我们可以通过聚合状态「快照」的方式来进行优化。可以创建一个聚合内存状态的快照,此时的快照反应了聚合在事件存储历史中某个事件发生后的状态。为了达到这样的目的,我们需要利用该事件及其发生前的所有事件来重建聚合实例,之后对聚合状态进行序列化,再把序列化之后的快照保存在事件存储中。这样,便可通过聚合快照来实例化某个聚合,接着再重放比快照更新的事件来修改聚合的状态,直至读取时发生在聚合上的最后一个事件。

创建快照所需的前置事件数量临界值可以由团队确立,例如,发现某个聚合在接收到 50 个事件之后为其创建快照可以获得最佳性能,那么 50 就是其临界值。

事件通常以二进制的方式保存在事件存储中,这使得事件源不能用于查询操作。事实上,为事件源所设计的资源库只有一个接受聚合 ID 的查询方法,因此需要另外的方法来支持查询,通常将 CQRS 和事件源一同使用。