Skip to content

Latest commit

 

History

History
466 lines (298 loc) · 26.2 KB

12-Charpter8-ArchitecturalPatterns.md

File metadata and controls

466 lines (298 loc) · 26.2 KB

第 8 章

架构模式

本书至此讨论的战术模式定义了建模和实现业务逻辑的不同方式。在本章中,我们将在更广泛的背景下探索战术设计决策:协调系统组件之间的交互和依赖关系的不同方法。

业务逻辑 VS 架构模式

业务逻辑是软件最重要的部分;然而,它并不是软件系统唯一的部分。为了实现功能性和非功能性需求,代码库必须承担更多责任。它必须与用户交互以收集输入和提供输出,它必须使用不同的存储机制来持久化状态并与外部系统和信息提供者进行整合。

代码库所要处理的各种问题使得它的业务逻辑很容易分散在不同的组件中:也就是说,一些逻辑在用户界面或数据库中被实现了,或者这些逻辑在不同的组件中被重复实现。在实现关注点上缺乏严格的组织,使得代码库难以变更。当业务逻辑必须更改时,代码库的哪些部分必须受到更改的影响可能并不显而易见。这些更改可能会对系统中看似无关的部分产生意想不到的影响。反之,也可能很容易错过必须被修改的代码。所有这些问题都极大地增加了维护代码库的成本。

架构模式为代码库的不同方面引入了组织原则,并在它们之间呈现出清晰的界限:业务逻辑如何连接到系统的输入、输出以及其他基础结构组件。这会影响组件之间的交互方式:它们共享了哪些知识以及组件之间如何相互引用。

选择适当的方式来组织代码库,或选择正确的架构模式,对于在短期支持业务逻辑的实现以及在长期减轻维护成本至关重要。让我们探索三种主要的应用程序架构模式及其用例:分层架构、端口和适配器以及 CQRS。

分层架构

分层架构是最常见的架构模式之一。它将代码库组织成水平的层,每一层处理以下技术问题之一:与消费者的交互,实现业务逻辑,以及持久化数据。你可以在图8-1中看到这一点。

图 8-1. 分层架构

在其经典形式中,分层架构由三层组成:表现层(PL)、业务逻辑层(BLL)和数据访问层(DAL)。

表现层

表现层,如图8-2所示,实现了程序的用户界面,用于与消费者进行交互。在该模式的原始形态中,该层表示为一个图形界面,如 Web 界面或桌面应用程序。

然而,在现代系统中,表现层有着更广泛的范围:即所有触发程序行为的手段,包括同步的和异步的。比如说。

  • 图形用户界面 (GUI)
  • 命令行界面 (CLI)
  • 用于与其他系统进行编程集成的 API
  • 对消息代理中事件的订阅
  • 用于发布传出事件的消息主题

所有这些都是系统接收来自外部环境的请求并传达输出的手段。严格来说,表现层是程序的公开接口。

图 8-2. 表现层

业务逻辑层

顾名思义,这一层负责实现和封装程序的业务逻辑。这是实现业务决策的地方。正如 Eric Evans 所说,1 这一层是软件的核心。

该层是第 5 章到第 7 章中描述的业务逻辑模式实现的地方 -- 例如,活动记录模式或领域模型模式(见图 8-3)。

图 8-3. 业务逻辑层

数据访问层

数据访问层提供对持久化机制的访问。在该模式的原始形态中,指的是系统的数据库。然而,与表现层的情况一样,该层的职责对于现代系统来说更为宽泛。

首先,自从 NoSQL 革命爆发以来,一个系统与多个数据库一起工作变得非常常见。例如,一个用于操作型数据库的文档存储,一个用于动态查询的搜索索引,以及一个用于性能优化操作的内存数据库。

其次,传统数据库并非存储信息的唯一媒介。例如,基于云的对象存储 2 可以用来存储系统的文件,或者消息总线可以用来协调程序的不同功能之间的通信。3

最后,该层还包括与实现程序功能所需的各种外部信息提供者的集成:外部系统提供的 API,或云供应商的托管服务,例如语言翻译、股票市场数据和音频转录(见图 8-4)。

图 8-4. 数据访问层

层间通信

各层以自上而下的通信模型进行整合:每个层只能对其正下方的层持有依赖关系,如图8-5所示。这强制实现了关注点间的解耦,减少了各层之间的知识共享。在图8-5中,表现层只引用业务逻辑层。它对数据访问层的设计决策一无所知。

图 8-5. 分层架构

变体

常见的情况是,分层架构模式扩展了一个额外的层:服务层。

服务层

使用一层服务定义应用的边界,该服务层建立一组可用的操作并协调应用在每个操作中的响应。

  • -- 企业应用架构模式* 4

服务层充当程序的表现层和业务逻辑层之间的中介。考虑以下代码:

namespace MvcApplication.Controllers
{
    public class UserController: Controller
    {
        // ...

        [AcceptVerbs(HttpVerbs.Post)]
        public ActionResult Create(ContactDetails contactDetails)
        {
            OperationResult result = null;

            try
            {
                _db.StartTransaction();
                
                var user = new User();
                user.SetContactDetails(contactDetails)
                user.Save();
                
                _db.Commit();
                result = OperationResult.Success;
            } catch (Exception ex) {
                _db.Rollback();
                result = OperationResult.Exception(ex);
            }

            return View(result);
        }
    }
}

本例中的 MVC 控制器属于表现层。它公开了一个创建新用户的端点。端点使用 User 活动记录对象来创建一个新实例并保存它。此外,它编排数据库事务以确保在发生错误时生成正确的响应。

为了进一步将表现层与底层业务逻辑解耦,可以将此类编排逻辑移至服务层,如图8-6所示。

图 8-6. 服务层

值得注意的是,在架构模式的上下文中,服务层是一个逻辑边界。它不是物理服务。

服务层充当业务逻辑层的外观(facade):它暴露了一个与公开接口中的方法相对应的接口,封装了底层所需的编排逻辑。例如:

interface CampaignManagementService
{
      OperationResult CreateCampaign(CampaignDetails details);
      OperationResult Publish(CampaignId id, PublishingSchedule schedule);
      OperationResult Deactivate(CampaignId id);
      OperationResult AddDisplayLocation(CampaignId id, DisplayLocation newLocation);
      // ...
}

前面的所有方法都对应于系统的公开接口。然而,它们不具备与表示和展现有关的实现细节。表现层的职责仅限于向服务层提供所需的输入并将其响应传回给调用者。

让我们对前面的例子进行重构,将编排逻辑提取到服务层。

namespace ServiceLayer
{
    public class UserService
    {
        // ...
        
        public OperationResult Create(ContactDetails contactDetails)
        {
            OperationResult result = null;

            try
            {
                _db.StartTransaction();
                
                var user = new User();
                user.SetContactDetails(contactDetails)
                user.Save();

                _db.Commit();
                result = OperationResult.Success;
            } catch (Exception ex) {
                _db.Rollback();
                result = OperationResult.Exception(ex);
            }

            return result;
        }

        // ...
    }
}

namespace MvcApplication.Controllers
{
    public class UserController: Controller
    {
        // ...

        [AcceptVerbs(HttpVerbs.Post)]
        public ActionResult Create(ContactDetails contactDetails)
        {
            var result = _userService.Create(contactDetails);
            return View(result);
        }
    }
}

有一个明确的服务层可以带来很多好处。

  • 我们可以重复使用同一个服务层来服务于多个公开接口;例如,一个图形用户界面和一个API。而不需要重复编排逻辑。
  • 它通过将所有相关的方法聚集在一个地方来提高模块化程度。
  • 它进一步解耦了表现层和业务逻辑层。
  • 它使测试业务功能变得更加容易。

也就是说,服务层并不总是必需的。例如,当业务逻辑以事务脚本的形式实现时,它本质上就是一个服务层,因为它已经暴露了一组构成系统公开接口的方法。在这种情况下,服务层的 API 只会重复事务脚本的公开接口,而不会抽象或封装任何复杂性。

另一方面,如果业务逻辑模式需要进行外部编排,如活动记录模式时,则需要服务层。在这种情况下,服务层实现了事务脚本模式,而它所操作的活动记录则位于业务逻辑层。

术语

在其他地方,你可能会遇到用于分层架构的其他术语。

  • 表现层 = 用户界面层
  • 服务层 = 应用层
  • 业务逻辑层 = 领域层 = 模型层
  • 数据访问层 = 基础设施层

为了消除混淆,我使用原始术语来介绍这个模式。也就是说,我更喜欢 "用户界面层" 和 "基础设施层",因为这些术语更好地反映了现代系统和应用层的职责,以避免与服务的物理边界混淆。

何时使用分层架构

业务逻辑和数据访问层之间的依赖关系使得这种架构模式非常适合使用事务脚本或活动记录模式实现其业务逻辑的系统。

然而,该模式使得实现领域模型变得具有挑战性。在领域模型中,业务实体(聚合和值对象)应该没有依赖性,也不应该了解底层基础设施。分层架构自上而下的依赖关系需要跳过一些环节才能满足此要求。在分层架构中实现领域模型仍然是可能的,但我们接下来要讨论的模式更加适合。


选择:Layers VS Tiers

分层(layers)架构经常与 N-Tier 架构混淆,反之亦然。尽管这两种模式有相似之处,但 layer 和 tier 在概念上是不同的:layer 是逻辑边界,而 tier 是物理边界。分层架构中的所有 layer 都受相同的生命周期约束:并作为一个独立的单元来实现、演进和部署。另一方面,tier 是可独立部署的服务、服务器或系统。例如,考虑图 8-7 中的 N-Tier 系统。

图 8-7. N-Tier 系统

上图描绘了基于 Web 系统中涉及到的物理服务之间的集成。消费者使用可以在台式机或移动设备上运行的浏览器。浏览器与反向代理进行交互,反向代理随之将请求转发到实际的 Web 应用。Web 应用在 Web 应用服务器上运行并与数据库服务器通信。所有这些组件可能运行在同一台物理服务器上,例如通过容器,或者分布在多个服务器之间。但是,由于每个组件都可以独立于其余组件进行部署和管理,因此这些是 tier 而不是 layer。

另一方面,layer 是 Web 应用程序内部的逻辑边界。


端口和适配器

端口和适配器架构解决了分层架构的缺点,更适合于实现更复杂的业务逻辑。有趣的是,两种模式都很相似。让我们把分层架构 "重构" 为端口和适配器。

术语

从本质上讲,表现层和数据访问层都代表了与外部组件(数据库、外部服务和用户界面框架)的集成。这些技术实现细节并不反映系统的业务逻辑;因此,让我们把所有这些基础设施的关注点统一到一个 "基础设施层" 中,如图8-8所示。

图 8-8. 表现层和数据访问层合并为基础设施层

依赖倒置原则

依赖倒置原则 (DIP) 指出实现业务逻辑的高层模块不应依赖于低层模块。然而,这正是传统分层架构中发生的情况。业务逻辑层依赖于基础设施层。为了符合 DIP,我们把关系倒置过来,如图8-9所示。

图 8-9. 依赖倒置

现在,业务逻辑层不再夹在技术关注点之间,而是占据了中心位置。它不依赖于任何系统的基础设施组件。

最后,让我们添加一个应用 5 层作为系统公开接口的门面(facade)。作为分层架构中的服务层,它描述了系统暴露的所有操作,并编排了系统执行这些操作的业务逻辑。最终的架构如图 8-10 所示。

图 8-10. 端口和适配器架构的传统分层

图8-10 中描绘的是端口和适配器架构模式。业务逻辑不依赖于任何底层,这是实现领域模型和事件溯源领域模型模式所必需的。

为什么将此模式称为端口和适配器?要回答这个问题,让我们看看基础设施组件是如何与业务逻辑集成的。

基础设施组件的集成

端口和适配器架构的核心目标是将系统的业务逻辑与基础设施组件解耦。

业务逻辑层不直接引用和调用基础设施组件,而是定义了必须由基础设施层实现的 "端口"(译注:端口中定义若干接口)。基础设施层实现 "适配器":也就是端口接口的具体实现,以便与不同的技术一起工作(见图8-11)。

图 8-11. 端口和适配器架构

通过依赖注入或引导(bootstrapping)的方式,抽象端口被解析为基础设施层中的具体适配器。

例如,这是一个端口定义和实现了消息总线的具体适配器:

namespace App.BusinessLogicLayer
{
    public interface IMessaging
    {
        void Publish(Message payload);
        void Subscribe(Message type, Action callback);
    }
}

namespace App.Infrastructure.Adapters
{
    public class SQSBus : IMessaging { /* ... */ }
}

变体

端口与适配器架构也被称为六边形架构、洋葱架构和整洁架构。所有这些模式都基于相同的设计原则,具有相同的组件,并且它们之间具有相同的关系,但和分层架构一样,术语可能有所不同:

  • 应用层 = 服务层 = 用例层
  • 业务逻辑层 = 领域层 = 核心层

尽管如此,这些模式可能会错误地被视为不同的概念。这是通用语言重要性的另一个例子。

何时使用端口和适配器模式

业务逻辑与所有技术问题的解耦,使得端口和适配器架构完美地适用于使用领域模型模式实现的业务逻辑。

命令-查询责任分离

命令-查询责任分离(CQRS)模式基于了与端口和适配器模式相同的业务逻辑和基础设施关注点的组织原则。

让我们看看为什么我们可能需要这样一个解决方案,以及如何实现它

多元化建模(Polyglot Modeling)

在许多情况下,使用系统业务领域的单一模型来解决系统的所有需求可能很困难,甚至是不可能的。例如,正如第 7 章所讨论的,在线交易处理(OLTP)和在线分析处理(OLAP)可能需要系统数据的不同表示。

使用多种模型的另一个原因可能与多元持久化的概念有关。世上没有完美的数据库。或者,正如 Greg Young 6 所说,所有的数据库都是有缺陷的,但各有各的特点:我们经常要平衡对规模、一致性或可支持的查询模型之间的需求。寻找完美数据库的另一种方法是多元持久化模型:使用多个数据库来实现不同的数据相关要求。例如,系统可能使用一个文档存储作为其操作型数据库,一个列存储用于分析/报告,以及一个搜索引擎用于实现强大的搜索功能。

最后,CQRS 模式与事件溯源密切相关。最初,CQRS 是为了解决事件溯源模型受限的查询能力:一次只能查询一个聚合实例的事件。CQRS 模式提供了将投影模型物化为物理数据库的可能性,这些数据库可用于灵活的查询选项。

也就是说,本章将 CQRS 与事件溯源 "解耦"。我想表明的是,即使业务逻辑是用其他任何一种业务逻辑实现模式来实现的,CQRS 也是有用的。

让我们看看 CQRS 如何使用多种存储机制来表示系统数据的不同模型。

实现

顾名思义,该模式分离了系统模型的责任。有两种类型的模型:命令执行模型和读模型。

命令执行模型

CQRS 使用单一模型来执行修改系统状态的操作(系统命令)。该模型用于实现业务逻辑、验证规则以及确保不变性。

命令执行模型也是唯一代表强一致性数据的模型 -- 系统的真相之源。从中应该可以读取业务实体的强一致性状态,并在更新时具有乐观并发支持。

读模型(投影)

系统可以根据需要定义尽可能多的模型,以向用户呈现数据或向其他系统提供信息。

读模型是一个预缓存(precached)投影。它可以驻留在一个持久化的数据库、平面文件或内存缓存中。CQRS 的正确实施允许抹去一个投影的所有数据并从头开始重新生成它。这也使系统能够在未来扩展出更多的投影 -- 那些最初无法预见的模型。

最后,读模型是只读的。系统的任何操作都不能直接修改读模型的数据。

投影读模型

为了使读模型发挥作用,系统必须将变化从命令执行模型投影到它的所有读模型。图 8-12 说明了这个概念。

图 8-12. CQRS 架构

读模型的投影类似于关系数据库中物化视图的概念:无论何时更新源表,更改都必须反映在预缓存视图中。

接下来,让我们看看生成投影的两种方式:同步和异步。

同步投影

同步投影通过追赶式订阅模式获取 OLTP 数据的变化:

  • 投影引擎在上次处理的检查点之后查询 OLTP 数据库以获取已添加的或已更新的记录。
  • 投影引擎使用更新后的数据来重新生成/更新系统的读模型。
  • 投影引擎存储最后处理记录的检查点。该值将在下一次迭代期间用于获取在最后处理的记录之后添加或修改的记录。

这个过程如图8-13所示,并在图8-14中以顺序图的形式显示。

图 8-13. 同步投影模型

图 8-14. 通过追赶订阅同步投影读模型

为了使追赶订阅起作用,命令执行模型必须检查所有追加或更新的数据库记录。存储机制还应该支持基于检查点的记录查询。

检查点可以使用数据库的特性来实现。例如,SQL Server 的 “rowversion” 列可用于在插入或更新行时生成唯一的递增数字,如图 8-15 所示。在缺少此类功能的数据库中,可实现自定义解决方案,此方案增加运行计数器并将其附加到每个修改的记录。确保基于检查点的查询返回一致的结果很重要。如果最后返回的记录的检查点值为 10,则在下一次执行时,新请求记录的值不应低于 10。否则,投影引擎将跳过这些记录,从而导致模型不一致。

图 8-15. 关系数据库中自动生成的检查点列

同步投影方法使得添加新的投影和从头生成现有的投影变成小事一桩。在后一种情况下,你所要做的就是将检查点重置为 0;投影引擎将扫描记录并从头开始重建投影。

异步投影

在异步投影场景中,命令执行模型将所有提交的变更发布到消息总线。系统的投影引擎可以订阅发布的消息,并使用它们来更新读模型,如图8-16所示。

图 8-16. 读模型的异步投影

挑战

尽管异步投影方法有明显的扩展和性能优势,但它也更容易受到分布式计算的挑战。如果消息的处理没按顺序或出现重复,不一致的数据将会被投影到读模型中。

这种方法也使得增加新的投影或重新生成现有的投影更具挑战性。

出于这些原因,建议始终实现同步投影,并可选择在其之上实施额外的异步投影。

模型分离

在 CQRS 架构中,系统模型的职责是根据它们的类型进行分离的。命令只能运行在强一致性命令执行模型上。查询不能直接修改系统的任何持久化状态 -- 无论是读模型还是命令执行模型。

对于基于 CQRS 的系统,一个常见的误解是: 命令只能修改数据,而数据只能通过读模型来获取展示。换句话说,命令执行的方法不应该返回任何数据。这其实并不正确。这种做法产生了意料之外的复杂性,并导致了糟糕的用户体验。

命令应该总是让调用者知道它是成功了还是失败了。如果它失败了,为什么会失败?是否存在关于验证或技术的问题?调用者必须知道如何修复这个命令。因此,命令可以 -- 而且在许多情况下应该 -- 返回数据; 例如,如果系统的用户界面必须反映命令产生的修改。这不仅使消费者更容易使用系统,因为他们会立即收到对其操作的反馈,而且返回的值可以在消费者的工作流程中进一步使用,从而消除了不必要的数据往返的需要。

这里唯一的限制是返回的数据应该来自强一致性模型 -- 命令执行模型 -- 因为我们不能指望具有最终一致性的投影会被立即刷新。

何时使用 CQRS

CQRS 模式对于需要在多个模型中处理相同数据的应用程序来说是非常有用的,这些数据可能存储在不同种类的数据库中。从操作的角度来看,该模式支持领域驱动设计的核心价值,即用最有效的模型来处理手头的任务,并不断改进业务领域的模型。从基础设施的角度来看,CQRS 允许利用不同种类数据库的优势;例如,使用关系型数据库来存储命令执行模型,使用搜索索引进行全文搜索,使用预渲染的平面文件进行快速数据获取,所有的存储机制都可靠地进行同步。

此外,CQRS 天然适用于事件溯源领域模型。事件溯源模型使我们无法根据聚合的状态来查询记录,但 CQRS 通过将状态投影到可查询的数据库中来实现此功能。

适用范围

我们讨论的模式 -- 分层架构、端口和适配器架构以及 CQRS -- 不应被视为系统范围的架构组织原则。它们也不应该是整个限界上下文的高级架构模式。

考虑包含多个子域的限界上下文,如图 8-17 所示。子域可以是不同的类型:核心子域、支撑子域或通用子域。即使是同一类型的子域也可能需要不同的业务逻辑和架构模式(这是第10章的主题)。强制实施单一的、有边界的、上下文范围的架构会导致意料之外的复杂性。

图 8-17. 跨越多个子域的限界上下文

我们的目标是根据实际需求和业务战略来推动设计决策。除了水平划分系统的层,我们还可以引入额外的垂直划分。如图8-18所示,为封装不同业务子域的模块定义逻辑边界,并为每个模块使用适当的工具,这是至关重要的。

适当的垂直边界使单一的限界上下文成为一个模块化的上下文,并有助于防止它变成一个大泥球。正如我们将在第 11 章中讨论的那样,这些逻辑边界稍后可以重构为更加细粒度的限界上下文的物理边界。

图 8-18. 架构切片

结论

分层架构根据其技术关注点分解代码库。由于此模式将业务逻辑与数据访问实现相结合,因此非常适合基于活动记录的系统。

端口和适配器架构倒置了关系:它将业务逻辑放在中心位置,并将其与所有基础设施的依赖关系解耦。这种模式很适合于使用领域模型模式实现的业务逻辑。

CQRS 模式在多个模型中表示相同的数据。尽管这个模式对于基于事件溯源领域模型的系统来说是必须的,但它也可以用于任何需要处理多个持久化模型的系统。

我们将在下一章讨论的模式会从不同的角度解决架构问题:如何在系统的不同组件之间实现可靠的交互。

练习

  1. 在所讨论的架构模式中,哪些可以与以活动记录模式实现的业务逻辑一起使用?
    A. 分层架构
    B. 端口和适配器
    C. CQRS
    D. A 和 C

  2. 在所讨论的架构模式中,哪种模式将业务逻辑与基础设施关注点解耦?
    A. 分层架构
    B. 端口和适配器
    C. CQRS
    D. B 和 C

  3. 假设你正在实现端口和适配器模式,并需要集成云提供商的受控消息总线。应该在哪一层实现这个集成?
    A. 业务逻辑层
    B. 应用层
    C. 基础设施层
    D. 任何一层

  4. 关于 CQRS 模式,以下哪项陈述是正确的?
    A. 异步投影更容易扩容。
    B. 可以使用同步或异步投影,但不能同时使用。
    C. 命令不能向调用者返回任何信息。调用者应始终使用读模型来获取执行操作的结果。
    D. 只要命令源自强一致性模型,它就可以返回信息。
    E. A 和 D。

  5. CQRS 模式允许在多个持久化模型中表示相同的业务对象,因此允许在同一个限界上下文中处理多个模型。这是否与限界上下文的概念相矛盾,即其是一个模型的边界?

Footnotes

  1. Evans, E. (2003). 领域驱动设计: 软件核心复杂性应对之道。波士顿。AddisonWesley

  2. 例如 AWS S3 或谷歌云存储

  3. 在这种情况下,消息总线用于满足系统的内部需求。如果它被公开暴露,它将属于表现层。

  4. Fowler, M. (2002). 企业应用架构模式。波士顿。Addison-Wesley

  5. 由于我们不是在分层架构的背景下,我将自由地使用 应用层 而不是 服务层 这个术语,因为它更好地反映了目的。

  6. Greg Young 的多元化数据。(n.d.)。 2021 年 6 月 14 日从 YouTube 检索。