第2章
服务的拆分策略
本章导读
- 理解软件架构,以及它为什么如此重要
- 使用拆分模式中的业务能力模式和子域模式进行单体应用到服务的拆分
- 使用领域驱动设计中的限界上下文概念来分解数据,并让服务拆分变得更容易
有时你必须对你想得到的东西充满敬畏。经过激烈的游说努力,玛丽终于说服公司里的所有人:迁移到微服务架构是一件正确的事情。玛丽感到兴奋和惶恐不安,早上她与架构师会面,讨论从哪里开始。在讨论过程中,显而易见的是,微服务架构模式语言的某些方面(如部署和服务发现)是他们之前未曾接触的领域,但却相对比较简单。微服务架构的关键挑战是将应用程序功能分解为服务。因此,架构设计的第一个也是最重要的工作就是服务的定义。当FTGO团队站在白板周围时,他们茫然四顾,感觉无从下手。
在本章中,你将学习如何为应用程序定义微服务架构。我描述了将应用程序分解为服务的策略。你将了解服务是围绕业务问题而非技术问题进行组织的。我还展示了如何使用来自领域驱动设计(DDD)的思想消除上帝类(God Class),这些类一般是在整个应用程序中使用的全局类,常常导致互相纠结的依赖性,妨碍了服务的分解。
在本章一开始,我会根据软件架构的概念来定义微服务架构。之后,我会尝试从应用程序的需求入手,为应用程序定义微服务架构。我会讨论将应用程序分解为服务的过程中可能遇到的障碍,以及解决它们的策略。但在一切开始之前,我们先来看看架构设计的概念。
2.1 微服务架构到底是什么
第1章描述了微服务架构的关键思想是如何进行功能分解。你可以将应用程序构建为一组服务,而不是开发一个大型的单体应用程序。一方面,将微服务架构描述为一种功能分解是有用的。但另一方面,它留下了几个未解决的问题,包括:微服务架构如何与更广泛的软件架构概念相结合?什么是服务?服务的规模有多重要?
为了回答这些问题,我们需要退后一步,看看软件架构的含义。软件的架构是一种抽象的结构,它由软件的各个组成部分和这些部分之间的依赖关系构成。正如你将在本节中看到的,软件的架构是多维的,因此有多种方法可以对其进行描述。架构很重要的原因是它决定了应用程序的质量属性或能力。传统上,架构的目标是可扩展性、可靠性和安全性。但是今天,该架构能够快速安全地交付软件,这一点非常重要。你将了解微服务架构是一种架构风格,可为应用程序提供更高的可维护性、可测试性和可部署性。
我将通过描述软件架构的概念及其重要性来开始本节。接下来,我将讨论架构风格的概念。然后我将微服务架构定义为特定的架构风格。让我们从理解软件架构的概念开始。
2.1.1 软件架构是什么,为什么它如此重要
架构显然很重要。至少有两个专门讨论该主题的会议:O扲eilly的软件架构会议(https://confe-rences.oreilly.com/software-architecture)和SATURN会议(https://resources.sei.cmu.edu/news-events/ events/saturn)。许多开发人员的目标是成为一名架构师。但什么是架构,为什么它如此重要?
为了回答这个问题,我首先定义术语软件架构的含义。之后,我将讨论应用程序的架构是多维的,并使用一组视图或蓝图进行描述。然后我将强调软件架构的重要性,因为它对应用程序的质量属性有显著的影响。
软件架构的定义
软件架构有很多定义。例如,*上列举了大量的定义(https://en.wikiquote.org/wiki/Software_architecture)。
我最喜欢的定义来自卡耐基梅隆大学软件工程研究所(www.sei.cmu.edu)的Len Bass及其同事,他们在使软件架构成为一门学科方面发挥了关键作用。他们定义的软件架构如下:
计算机系统的软件架构是构建这个系统所需要的一组结构,包括软件元素、它们之间的关系以及两者的属性。
—Bass等著《Documenting Software Architectures: Views and Beyond》
这显然是一个非常抽象的定义。但其实质是应用程序的架构是将软件分解为元素(element)和这些元素之间的关系(relation)。由于以下两个原因,分解很重要:
- 它促进了劳动和知识的分工。它使具有特定专业知识的人们(或多个团队)能够就应用程序高效地协同工作。
- 它定义了软件元素的交互方式。
将软件分解成元素以及定义这些元素之间的关系,决定了软件的能力。
软件架构的4+1视图模型
从更具体的角度而言,应用程序的架构可以从多个视角来看,就像建筑架构,一般有结构、管线、电气等多个架构视角。Phillip Krutchen在他经典的论文《Architectural Blueprints —
The 4+1 View Model of Software Architecture》中提出了软件架构的4+1视图(www.cs.ubc.ca/~gregor/teaching/papers/4+1view-architecture.pdf)。
图2-1展示的这套视图定义了四个不同的软件架构视图,每一个视图都只描述架构的一个特定方面。每个视图包括一些特定的软件元素和它们相互之间的关系。
每个视图的目的如下:
- 逻辑视图:开发人员创建的软件元素。在面向对象的语言中,这些元素是类和包。它们之间的关系是类和包之间的关系,包括继承、关联和依赖。
- 实现视图:构建编译系统的输出。此视图由表示打包代码的模块和组件组成,组件是由一个或多个模块组成的可执行或可部署单元。在Java中,模块是JAR文件,组件通常是WAR文件或可执行JAR文件。它们之间的关系包括模块之间的依赖关系以及组件和模块之间的组合关系。
- 进程视图:运行时的组件。每个元素都是一个进程,进程之间的关系代表进程间通信。
- 部署视图:进程如何映射到机器。此视图中的元素由(物理或虚拟)计算机和进程组成。机器之间的关系代表网络。该视图还描述了进程和机器之间的关系。
除了这四个视图以外,4+1中的+1是指场景,它负责把视图串联在一起。每个场景负责描述在一个视图中的多个架构元素如何协作,以完成一个请求。例如,在逻辑视图中的场景,展现了类是如何协作的。同样,在进程视图中的场景,展现了进程是如何协作的。
4+1视图是描述应用程序架构的绝佳方式。每一个视图都描述了架构的一个重要侧面。场景把视图中的元素如何协作串联在一起。现在我们来看看为什么架构是如此重要。
为什么架构如此重要
应用程序有两个层面的需求。第一类是功能性需求,这些需求决定一个应用程序做什么。这些通常都包含在用例(use case)或者用户故事(user story)中。应用的架构其实跟这些功能性需求没什么关系。功能性需求可以通过任意的架构来实现,甚至是非常糟糕的大泥球架构。
架构的重要性在于,它帮助应用程序满足了第二类需求:非功能性需求。我们把这类需求也称之为质量属性需求,或者简称为“能力”。这些非功能性需求决定一个应用程序在运行时的质量,比如可扩展性和可靠性。它们也决定了开发阶段的质量,包括可维护性、可测试性、可扩展性和可部署性。为应用程序所选择的架构将决定这些质量属性。
2.1.2 什么是架构的风格
在物理世界中,建筑物的建筑通常遵循特定的风格,例如维多利亚式、美国工匠式或装饰艺术式。每种风格都是一系列设计决策,限制了建筑的特征和建筑材料。建筑风格的概念也适用于软件。David Garlan和Mary Shaw(An Introduction to Software Architecture,January 1994)这两位软件架构学科的先驱定义了如下架构风格(https://www.cs.cmu.edu/afs/cs/project/able/ftp/intro_softarch/intro_softarch.pdf) :
因此,架构风格根据结构组织模式定义了一系列此类系统。更具体地说,架构风格确定可以在该风格的实例中使用的组件和连接器的词汇表,以及关于如何组合它们的一组约束。
特定的架构风格提供了有限的元素(组件)和关系(连接器),你可以从中定义应用程序架构的视图。应用程序通常使用多种架构风格的组合。例如,在本节的后面,我将描述单体架构是如何将实现视图构造为单个(可执行与可部署)组件的架构样式。微服务架构将应用程序构造为一组松散耦合的服务。
分层式架构风格
架构的典型例子是分层架构。分层架构将软件元素按“层”的方式组织。每个层都有明确定义的职责。分层架构还限制了层之间的依赖关系。每一层只能依赖于紧邻其下方的层(如果严格分层)或其下面的任何层。
可以将分层架构应用于前面讨论的四个视图中的任何一个。流行的三层架构是应用于逻辑视图的分层架构。它将应用程序的类组织到以下层中:
- 表现层:包含实现用户界面或外部API的代码。
- 业务逻辑层:包含业务逻辑。
- 数据持久化层:实现与数据库交互的逻辑。
分层架构是架构风格的一个很好的例子,但它确实有一些明显的弊端:
- 单个表现层:它无法展现应用程序可能不仅仅由单个系统调用的事实。
- 单一数据持久化层:它无法展现应用程序可能与多个数据库进行交互的事实。
- 将业务逻辑层定义为依赖于数据持久化层:理论上,这样的依赖性会妨碍你在没有数据库的情况下测试业务逻辑。
此外,分层架构错误地表示了精心设计的应用程序中的依赖关系。业务逻辑通常定义数据访问方法的接口或接口库。数据持久化层则定义了实现存储库接口的DAO类。换句话说,依赖关系与分层架构所描述的相反。
让我们看一下克服这些弊端的替代架构:六边形架构。
关于架构风格的六边形
六边形架构是分层架构风格的替代品。如图2-2所示,六边形架构风格选择以业务逻辑为中心的方式组织逻辑视图。应用程序具有一个或多个入站适配器,而不是表示层,它通过调用业务逻辑来处理来自外部的请求。同样,应用程序具有一个或多个出站适配器,而不是数据持久化层,这些出站适配器由业务逻辑调用并调用外部应用程序。此架构的一个关键特性和优点是业务逻辑不依赖于适配器。相反,各种适配器都依赖业务逻辑。
业务逻辑具有一个或多个端口(port)。端口定义了一组操作,关于业务逻辑如何与外部交互。例如,在Java中,端口通常是Java接口。有两种端口:入站和出站端口。入站端口是业务逻辑公开的API,它使外部应用程序可以调用它。入站端口的一个实例是服务接口,它定义服务的公共方法。出站端口是业务逻辑调用外部系统的方式。出站端口的一个实例是存储库接口,它定义数据访问操作的集合。
业务逻辑的周围是适配器。与端口一样,有两种类型的适配器:入站和出站。入站适配器通过调用入站端口来处理来自外部世界的请求。入站适配器的一个实例是Spring MVC Controller,它实现一组REST接口(endpoint)或一组Web页面。另一个实例是订阅消息的消息代理客户端。多个入站适配器可以调用相同的入站端口。
出站适配器实现出站端口,并通过调用外部应用程序或服务处理来自业务逻辑的请求。出站适配器的一个实例是实现访问数据库的操作的数据访问对象(DAO)类。另一个实例是调用远程服务的代理类。出站适配器也可以发布事件。
六边形架构风格的一个重要好处是它将业务逻辑与适配器中包含的表示层和数据访问层的逻辑分离开来。业务逻辑不依赖于表示层逻辑或数据访问层逻辑。
由于这种分离,单独测试业务逻辑要容易得多。另一个好处是它更准确地反映了现代应用程序的架构。可以通过多个适配器调用业务逻辑,每个适配器实现特定的API或用户界面。业务逻辑还可以调用多个适配器,每个适配器调用不同的外部系统。六边形架构是描述微服务架构中每个服务的架构的好方法。
分层架构和六边形架构都是架构风格的实例。每个都定义了架构的构建块(元素),并对它们之间的关系施加了约束。六边形架构和分层架构(三层架构)构成了软件的逻辑视图。现在让我们将微服务架构定义为构成软件的实现视图的架构风格。
2.1.3 微服务架构是一种架构风格
前面已经讨论过4+1视图模型和架构风格,所以现在可以开始定义单体架构和微服务架构。它们都是架构风格。单体架构是一种架构风格,它的实现视图是单个组件:单个可执行文件或WAR文件。这个定义并没有说明其他的视图。例如,单体应用程序可以具有六边形架构风格的逻辑视图。
微服务架构也是一种架构风格。它的实现视图由多个组件构成:一组可执行文件或WAR文件。它的组件是服务,连接器是使这些服务能够协作的通信协议。每个服务都有自己的逻辑视图架构,通常也是六边形架构。图2-3显示了FTGO应用程序可能的微服务架构。此架构中的服务对应于业务功能,例如订单管理和餐馆管理。
在本章后面,我将描述业务能力(business capability)的含义。服务之间的连接器使用进程间通信机制(如REST API和异步消息)实现。第3章将更详细地讨论进程间通信。
微服务架构强加的一个关键约束是服务松耦合。因此,服务之间的协作方式存在一定限制。为了解释这些限制,我将尝试定义什么是服务,解释松耦合意味着什么,并告诉你为什么这很重要。
什么是服务
服务是一个单一的、可独立部署的软件组件,它实现了一些有用的功能。图2-4显示了服务的外部视图,在此示例中是Order Service。服务具有API,为其客户端提供对功能的访问。有两种类型的操作:命令和查询。API由命令、查询和事件组成。命令如createOrder()执行操作并更新数据。查询,如findOrderById()检索数据。服务还发布由其客户端使用的事件,例如OrderCreated。
服务的API封装了其内部实现。与单体架构不同,开发人员无法绕过服务的API直接访问服务内部的方法或数据。因此,微服务架构强制实现了应用程序的模块化。
微服务架构中的每项服务都有自己的架构,可能还有独特的技术栈。但是典型的服务往往都具有六边形架构。其API由与服务的业务逻辑交互的适配器实现。操作适配器调用业务逻辑,事件适配器对外发布业务逻辑产生的事件。
API定义了由客户端调用的操作。有两种类型的操作:命令用来更新数据,查询用来检索数据。当服务的数据发生更改时,服务会发布可供客户端订阅的事件
在第12章讨论部署技术时,你将看到服务的实现视图可以采用多种形式。该组件可以是独立进程,在容器中运行的Web应用程序或OSGI包、云主机或Serverless技术,等等。但是,一个基本要求是服务具有API并且可以独立部署。
什么是松耦合
微服务架构的最核心特性是服务之间的松耦合性(https://en.wikipedia.org/wiki/Loose_coupling) 。服务之间的交互采用API完成,这样做就封装了服务的实现细节。这允许服务在不影响客户端的情况下,对实现方式做出修改。松耦合服务是改善开发效率、提升可维护性和可测试性的关键。小的、松耦合的服务更容易被理解、修改和测试。
我们通过API来实现松耦合服务之间的协调调用,这样就避免了外界对服务的数据库的直接访问和调用。服务自身的持久化数据就如同类的私有属性一样,是不对外的。保证数据的私有属性是实现松耦合的前提之一。这样做,就允许开发者修改服务的数据结构,而不用提前与其他服务的开发者互相协商。这样做在运行时也实现了更好的隔离。例如,一个服务的数据库加锁不会影响另外的服务。但是你稍后就会看到在服务间不共享数据库的弊端,特别是处理数据一致性和跨服务查询都变得更为复杂。
共享类库的角色
开发人员经常把一些通用的功能打包到库或模块中,以便多个应用程序可以重用它而无须复制代码。毕竟,如果没有Maven或npm库,我们今天的开发工作都会变得更困难。你可能也想在微服务架构中使用共享库。从表面上看,它似乎是减少服务中代码重复的好方法。但是你需要确保不会意外地在服务之间引入耦合。
例如,想象一下多个服务需要更新Order业务对象的场景。一种选择是将该功能打包为可供多个服务使用的库。一方面,使用库可以消除代码重复。另一方面,如果业务需求的变更影响了Order业务对象,开发者需要同时重建和重新部署所有使用了共享库的服务。更好的选择是把这些可能会更改的通用功能(例如Order管理)作为服务来实现,而不是共享库。
你应该努力使用共享库来实现不太可能改变的功能。例如,在典型的应用程序中,在每个服务中都实现一个通用的Money类(例如用来实现币种转换等固定功能)没有任何意义。相反,你应该创建一个供所有服务使用的共享库。
服务的大小并不重要
微服务这个术语的一个问题是会将你的关注点错误地聚焦在微上。它暗示服务应该非常小。其他基于大小的术语(如miniservice或nanoservice)也是如此。实际上,大小不是一个重要的考虑因素。
更好的目标是将精心设计的服务定义为能够由小团队开发的服务,并且交付时间最短,与其他团队协作最少。理论上,团队可能只负责单一服务,因此服务绝不是微小的。相反,如果服务需要大型团队或需要很长时间进行测试,那么拆分团队或服务可能是有意义的。另外,如果你因为其他服务的变更而不断需要同步更新自己负责的服务,或者你所负责的服务正在触发其他服务的同步更新,那么这表明服务没有实现松耦合。你构建的甚至可能是一个分布式的单体。
微服务架构把应用程序通过一些小的、松耦合的服务组织在一起。结果,这样的架构提升了开发阶段的效率,特别是可维护性、可测试性和可部署性,这也就让组织的软件开发速度更快。微服务架构也同时提升了应用程序的可扩展性,尽管这不是微服务的主要目标。为了使用微服务架构开发软件,你首先需要识别服务,并确定它们之间如何协作。现在我们来看看如何定义一个应用程序的微服务架构。
2.2 为应用程序定义微服务架构
那么如何定义一个微服务架构呢?跟所有的软件开发过程一样,一开始我们需要拿到领域专家或者现有应用的需求文档。跟所有的软件开发一样,定义架构也是一项艺术而非技术。本节我们将介绍一种定义应用程序架构的三步式流程,如图2-5所示。世界上并没有一个机械化的流程可以遵循,然后指望这个流程输出一个合理的架构。我们只能介绍一个大概的方法,现实世界中,这是一个不断迭代和持续创新的过程。
应用程序是用来处理客户端请求的,因此定义其架构的第一步是将应用程序的需求提炼为各种关键请求。但是,不是根据特定的进程间通信技术(如REST或消息)来描述这些请求,而是使用更抽象的系统操作这个概念。系统操作(system operation)是应用程序必须处理的请求的一种抽象描述。它既可以是更新数据的命令,也可以是检索数据的查询。每个命令的行为都是根据抽象领域模型定义的,抽象领域模型也是从需求中派生出来的。系统操作是描述服务之间协作方式的架构场景。
该流程的第二步是确定如何分解服务。有几种策略可供选择。一种源于业务架构学派的策略是定义与业务能力相对应的服务。另一种策略是围绕领域驱动设计的子域来分解和设计服务。但这些策略的最终结果都是围绕业务概念而非技术概念分解和设计的服务。
定义应用程序架构的第三步是确定每个服务的API。为此,你将第一步中标识的每个系统操作分配给服务。服务可以完全独立地实现操作。或者,它可能需要与其他服务协作。在这种情况下,你可以确定服务的协作方式,这通常需要服务来支持其他操作。你还需要确定选用第3章中描述的哪种进程间通信机制来实现每个服务的API。
服务的分解有几个障碍需要克服。首先是网络延迟。你可能会发现,由于服务之间的网络往返太多,特定的分解将是不切实际的。分解的另一个障碍是服务之间的同步通信降低了可用性。你可能需要使用第3章中描述的自包含服务的概念。第三个障碍是需要维护跨服务的数据一致性。你需要使用第4章中讨论的Saga。分解的第四个也是最后一个障碍是所谓的上帝类(God Class),它广泛应用在整个应用程序中。幸运的是,你可以使用领域驱动设计中的概念来消除上帝类。
本节首先介绍如何识别应用程序的系统操作。之后,会研究将应用程序分解为服务的策略和指南、分解的障碍以及如何解决它们。最后,将描述如何定义每个服务的API。
2.2.1 识别系统操作
定义应用程序架构的第一步是定义系统操作。起点是应用程序的需求,包括用户故事及其相关的用户场景(请注意,这些与架构场景不同)。使用图2-6中所示的两步式流程识别和定义系统操作。这个流程的灵感来自Craig Larman的名著《Applying UML and Patterns》(Prentice Hall,2004)中介绍的面向对象设计过程(www.craiglarman.com/wiki/index.php?title=Book_Applying_UML_and_Patterns) 。第一步创建由关键类组成的抽象领域模型,这些关键类提供用于描述系统操作的词汇表。第二步确定系统操作,并根据领域模型描述每个系统操作的行为。
领域模型主要源自用户故事中提及的名词,系统操作主要来自用户故事中提及的动词。你还可以使用名为事件风暴(Event Storming)的技术定义领域模型,我将在第5章中讨论。每个系统操作的行为都是根据它对一个或多个领域对象的影响以及它们之间的关系来描述的。系统操作可以创建、更新或删除领域对象,以及创建或破坏它们之间的关系。
我们来看看如何定义抽象领域模型。之后,我将根据领域模型定义系统操作。
创建抽象领域模型
定义系统操作的第一步是为这个应用程序描绘一个抽象的领域模型。注意这个模型比我们最终要实现的简单很多。应用程序本身并不需要一个领域模型,因为我们在稍后会学到,每一个服务都有它自己的领域模型。尽管非常简单,抽象的领域模型仍旧有助于在开始阶段提供帮助,因为它定义了描述系统操作行为的一些词语。
创建领域模型会采用一些标准的技术,例如通过与领域专家沟通后,分析用户故事和场景中频繁出现的名词。例如Place Order用户故事,我们可以把它分解为多个用户场景,例如这个:
在这个用户场景中的名词,如Consumer、Order、Restaurant和CreditCard,暗示了这些类都是需要的。
同样,Accept Order用户故事也可以分解为多个场景,如下:
这个场景暗示需要Courier类和Delivery类。在经过几次迭代分析之后,结果显然就是这个领域模型应该包括一些类,如MenuItem和Address等。图2-7显示了核心类的类图。
每一个类的作用如下:
- Consumer:下订单的用户。
- Order:用户下的订单,它用来描述订单并跟踪状态。
- OrderLineItem:Order中的一个条目。
- DeliveryInfo:送餐的时间和地址。
- Restaurant:为用户准备生产订单的餐馆,同时也要发起送货。
- MenuItem:餐馆菜单上的一个条目。
- Courier:送餐员负责把订单送到用户手里。可跟踪送餐员的可用性和他们的位置。
- Address:Consumer或Restaurant的地址。
- Location:Courier当前的位置,用经纬度表示。
类似图2-7这种类图描述了应用程序架构的一个方面。但如果没有对应的场景,这个图也就是仅仅好看而已,并不实用。下一步开始定义对应架构场景的系统操作。
定义系统操作
当定义了抽象的领域模型之后,接下来就要识别系统必须处理的各种请求。我们并不讨论具体的用户界面,但是你能够想象在每一个用户场景下,前端的用户界面向后端的业务逻辑发出请求,后端的业务逻辑进行数据的获取和处理。FTGO是一个Web应用,这意味着它的大部分请求都是基于HTTP的。但也有可能一些客户端会使用消息。相比绑定到具体的通信协议,使用抽象的词汇来描述跟系统操作有关的请求更为合理。
有以下两种类型的系统操作。
- 命令型:创建、更新或删除数据的系统操作。
- 查询型:查询和读取数据的系统操作。
从根本上说,这些系统操作都会对应到具体的REST、RPC或消息端口。但现阶段我们不必在意这些实现细节。让我们先开始识别一些指令。
识别系统指令的切入点是分析用户故事和场景中的动词。例如Place Order用户故事,它非常明确地告诉架构师,这个系统必须提供一个Create Order操作。很多用户故事都会直接对应或映射为系统命令。表2-1列出了一些关键的系统命令。
命令规范定义了命令对应的参数、返回值和领域模型类的行为。行为规范中包括前置条件(即当这个操作被调用时必须满足的条件)和后置条件(即这个操作被调用后必须满足的条件)。例如,以下就是createOrder()系统操作的规范。
前置条件对应着Place Order用户场景中的givens,后置条件对应着场景中的Then。当系统操作被调用时,它会检查前置条件,执行操作来完成和满足后置条件。
下面是acceptOrder()的系统操作规范:
前置条件和后置条件对应着之前用户场景中的描述。
多数与系统操作相关的架构元素是命令。查询虽然仅仅是简单地获取数据,但是也同样重要。
应用程序除了实现指令以外,也必须实现查询。查询为用户决策提供了用户界面。在目前阶段,我们并没有开始为FTGO应用程序构思任何用户界面,但是需要注意,当消费者下订单时往往是如下所示的过程。
1.用户输入送餐地址和期望的送餐时间;
2.系统显示当前可用的餐馆;
3.用户选择餐馆;
4.系统显示餐馆的菜单;
5.用户点餐并结账;
6.系统创建订单。
这个用户场景包含了以下的查询型操作:
- findAvailableRestaurants(deliveryAddress,deliveryTime):获取所有能够送餐到用户地址并满足送餐时间要求的餐馆。
- findRestaurantMenu(id):返回餐馆信息和这家餐馆的菜单项。
在这两项查询中,findAvailableRestaurants()也许是在架构层面尤其重要的一个。它是一个包含了地理位置等信息的复杂查询。地理查询的组件负责找到送餐地址周围所有满足要求的餐馆位置。同时它也需要过滤那些在订单准备和送餐时间范围内没有营业的餐馆。另外,这个查询的性能尤其重要,因为执行这个查询时,客户多数都是“在线急等”的状态,耽误不得。
抽象的领域模型和系统操作能够回答这个应用“做什么”这一问题。这有助于推动应用程序的架构设计。每一个系统操作的行为都通过领域模型的方式来描述。每一个重要的系统操作都对应着架构层面的一个重大场景,是架构中需要详细描述和特别考虑的地方。现在我们来看看如何定义应用程序的微服务架构。
系统操作被定义后,下一步就是完成应用服务的识别。如之前提到的,这并不是一个机械化的流程,相反,有多种拆分策略可供选择。每一种都是从一个侧面来解决问题,并且使用它们独有的一些术语。但是殊途同归,这些策略的结果都是一样的:一个包含若干服务的架构,这样的架构是以业务而不是技术概念为中心。
我们先来看看第一个策略:使用业务能力来定义服务。
2.2.2 根据业务能力进行服务拆分
创建微服务架构的策略之一就是采用业务能力进行服务拆分。业务能力是一个来自于业务架构建模的术语。业务能力是指一些能够为公司(或组织)产生价值的商业活动。特定业务的业务能力取决于这个业务的类型。例如,保险公司业务能力通常包括承保、理赔管理、账务和合规等。在线商店的业务能力包括:订单管理、库存管理和发货,等等。
业务能力定义了一个组织的工作
组织的业务能力通常是指这个组织的业务是做什么,它们通常都是稳定的。与之相反,组织采用何种方式来实现它的业务能力,是随着时间不断变化的。这个准则在今天尤其明显,很多新技术在被快速采用,商业流程的自动化程度越来越高。例如,不久之前你还通过把支票交给银行柜员的方式来兑现支票,现在很多ATM机都支持直接兑现支票,而今,人们甚至可以使用智能手机拍照的方式来兑现支票。正如你所见,“兑现支票”这个业务能力是稳定不变的,但是这个能力的实现方式正在发生戏剧性的变化。
识别业务能力
一个组织有哪些业务能力,是通过对组织的目标、结构和商业流程的分析得来的。每一个业务能力都可以被认为是一个服务,除非它是面向业务的而非面向技术的。业务能力规范包含多项元素,比如输入和输出、服务等级协议(SLA)。例如,保险承保能力的输入来自客户的应用程序,这个业务能力的输出是完成核保并报价。
业务能力通常集中在特定的业务对象上。例如,理赔业务对象是理赔管理功能的重点。能力通常可以分解为子能力。例如,理赔管理能力具有多个子能力,包括理赔信息管理、理赔审核和理赔付款管理。
把FTGO的业务能力逐一列出来似乎也并不太困难,如下所示。
-
供应商管理。
- Courier management:送餐员相关信息管理;
- Restaurant information management:餐馆菜单和其他信息管理,例如营业地址和时间。
- 消费者管理:消费者有关信息的管理。
-
订单获取和履行。
- Order management:让消费者可以创建和管理订单。
- Restaurant order management:让餐馆可以管理订单的生产过程。
送餐。 - Courier availability management:管理送餐员的实时状态。
- Delivery management:把订单送到用户手中。
-
会计记账。
- Consumer accounting:管理跟消费者相关的会计记账。
- Restaurant accounting:管理跟餐馆相关的会计记账。
- Courier accounting:管理跟送餐员相关的会计记账。
- 其他。
*能力包括供应商管理、消费者管理、订单获取和履行以及会计记账。可能还有许多其他*能力,包括与营销相关的能力。大多数*能力都会分解为子能力。例如,订单获取和履行被分解为五个子能力。
这个能力层次的有趣方面是有三个餐馆相关的能力:餐馆信息管理、餐馆订单管理和餐馆会计记账。那是因为它们代表了餐馆运营的三个截然不同的方面。
接下来,我们将了解如何使用业务能力来定义服务。
从业务能力到服务
一旦确定了业务能力,就可以为每个能力或相关能力组定义服务。图2-8显示了FTGO应用程序从能力到服务的映射。某些*能力(如会计记账能力)将映射到服务。在其他情况下,子能力映射到服务。
决定将哪个级别的能力层次结构映射到服务是一个非常主观的判断。我对这种特定映射的理由如下:
- 我将供应商管理的子能力映射到两种服务,因为餐馆和送餐员是非常不同类型的供应商。
- 我将订单获取和履行能力映射到三个服务,每个服务负责流程的不同阶段。我将送餐员可用性管理(Courier availability management)和交付管理(Delivery management)能力结合起来,并将它们映射到单个服务,因为它们交织在一起。
- 我将会计记账能力映射到自己的独立服务,因为不同类型的会计记账看起来很相似。
之后将针对餐馆和送餐员的费用支付和针对消费者的订单收款分开是有意义的。
围绕能力组织服务的一个关键好处是,因为它们是稳定的,所以最终的架构也将相对稳定。架构的各个组件可能会随着业务的具体实现方式的变化而发展,但架构仍保持不变。
话虽如此,重要的是要记住图2-8中显示的服务仅仅是定义架构的第一次尝试。随着我们对应用程序领域的了解越来越多,它们可能会随着时间的推移而变化,特别是架构定义流程中的一个重要步骤是调查服务如何在每个关键架构服务中协作。例如,你可能会发现由于过多的进程间通信而导致特定的分解效率低下,导致你必须把一些服务组合在一起。相反,服务可能会在复杂性方面增长到值得将其拆分为多个服务的程度。此外,在2.2.5节中将描述可能导致你重新审视当前分解决策的几个障碍。
现在让我们看看基于领域驱动设计分解应用程序的方法。
2.2.3 根据子域进行服务拆分
Eric Evans在他的经典著作中(Addison-Wesley Professional,2003)提出的领域驱动设计是构建复杂软件的方法论,这些软件通常都以面向对象和领域模型为核心。领域模型以解决具体问题的方式包含了一个领域内的知识。它定义了当前领域相关团队的词汇表,DDD也称之为通用语言(Ubiquitous language)。领域模型会被紧密地映射到应用的设计和实现环节。在微服务架构的设计层面,DDD有两个特别重要的概念,子域和限界上下文。
传统的企业架构建模方式往往会为整个企业建立一个单独的模型,DDD则采取了完全不同的方式。在这样的模型中,会有适用于整个应用全局的业务实体定义,例如客户或订单。这类传统建模方式的挑战在于,让组织内的所有团队都对全局单一的建模和术语定义达成一致是非常困难的。另外,对于组织中的特定团队而言,这个单一的业务实体定义可能过于复杂,超出了他们的需求。此外,这些传统的领域模型可能会造成混乱,因为组织内有些团队可能针对不同的概念使用相同的术语,而也有些团队会针对同一个概念使用不同的术语。DDD通过定义多个领域模型来避免这个问题,每个领域模型都有明确的范围。
领域驱动为每一个子域定义单独的领域模型。子域是领域的一部分,领域是DDD中用来描述应用程序问题域的一个术语。识别子域的方式跟识别业务能力一样:分析业务并识别业务的不同专业领域,分析产出的子域定义结果也会跟业务能力非常接近。FTGO的子域包括:订单获取、订单管理、餐馆管理、送餐和会计。正如你所见:这些子域跟我们之前定义的业务能力非常接近。
DDD把领域模型的边界称为限界上下文(bounded context)。限界上下文包括实现这个模型的代码集合。当使用微服务架构时,每一个限界上下文对应一个或者一组服务。换一种说法,我们可以通过DDD的方式定义子域,并把子域对应为每一个服务,这样就完成了微服务架构的设计工作。图2-9展示了子域和服务之间的映射,每一个子域都有属于它们自己的领域模型。
DDD和微服务架构简直就是天生一对。DDD的子域和限界上下文的概念,可以很好地跟微服务架构中的服务进行匹配。而且,微服务架构中的自治化团队负责服务开发的概念,也跟DDD中每个领域模型都由一个独立团队负责开发的概念吻合。更有趣的是,子域用于它自己的领域模型这个概念,为消除上帝类和优化服务拆分提供了好办法。
按子域分解和按业务能力分解是定义应用程序的微服务架构的两种主要模式。但是,也有一些有用的拆分指导原则源于面向对象的设计。我们来详细讨论这些原则。
2.2.4 拆分的指导原则
到目前为止,在本章中,我们已经了解了定义微服务架构的主要方法。在应用微服务架构模式时,我们还可以采纳和使用面向对象设计中的一些原则。面向对象设计的一些原则也可以用于指导微服务架构的设计工作。这些原则由Robert C. Martin在他的著作《Designing Object Oriented C++ Applications Using The Booch Method》(Prentice Hall,1995)中提出。第一个原则就是在定义类的职责时,应该遵循单一职责原则(Single Responsibility Principle,SRP)。第二个原则是把类组成包时,应该遵循闭包原则(Common Closure Principle,CCP)。让我们来看看这些原则如何应用到微服务架构。
类所承载的每一个职责都是对它进行修改的潜在原因。如果一个类承载了多个职责,并且互相之间的修改是独立的,那么这个类就会变得非常不稳定。遵照SRP原则,你所定义的每一个类都应该只有一个职责,因此也就只有一个理由对它进行修改。
我们在设计微服务架构时应该遵循SRP原则,设计小的、内聚的、仅仅含有单一职责的服务。这会缩小服务的大小并提升它的稳定性。新的FTGO架构是应用SRP的一个例子。为客户获取餐食的每一个方面(订单获取、订单准备、送餐等)都由一个单一的服务承载。
闭包原则
另外一个有用的原则是闭包原则(CCP):
在包中包含的所有类应该是对同类的变化的一个集合,也就是说,如果对包做出修改,需要调整的类应该都在这个包之内。
—Robert C. Martin
这就意味着,如果由于某些原因,两个类的修改必须耦合先后发生,那么就应该把它们放在同一个包内。也许,这些类实现了一些特定的业务规则的不同方面。这样做的目标是当业务规则发生变化时,开发者只需要对一个交付包做出修改,而不是大规模地修改(和重新编译)整个应用。采用闭包原则,极大地改善了应用程序的可维护性。
在微服务架构下采用CCP原则,这样我们就能把根据同样原因进行变化的服务放在一个组件内。这样做可以控制服务的数量,当需求发生变化时,变更和部署也更加容易。理想情况下,一个变更只会影响一个团队和一个服务。CCP是解决分布式单体这种可怕的反模式的法宝。
单一职责原则和闭包原则是Bob Martin制定的十一项原则中的两项。它们在开发微服务架构时特别有用。在设计类和包时可以使用其余的九个原则。有关单一职责原则、闭包原则和其他面向对象设计原则的更多信息,请参阅Bob Martin网站上的文章《面向对象设计的原则》(http://butunclebob.com/ArticleS.UncleBob.PrinciplesOfOod)。
按业务能力和子域以及单一职责原则和闭包原则进行分解是将应用程序分解为服务的好方法。为了应用它们并成功开发微服务架构,你还必须解决一些事务管理和进程间通信问题。
2.2.5 拆分单体应用为服务的难点
从表面上看,通过定义与业务能力或子域相对应的服务来创建微服务架构的策略看起来很简单。但是,你可能会遇到几个障碍:
- 网络延迟。
- 同步进程间通信导致可用性降低。
- 在服务之间维持数据一致性。
- 获取一致的数据视图。
- 上帝类阻碍了拆分。
让我们来看看每个问题,先从网络延迟开始。
网络延迟
网络延迟是分布式系统中一直存在的问题。你可能会发现,对服务的特定分解会导致两个服务之间的大量往返调用。有时,你可以通过实施批处理API在一次往返中获取多个对象,从而将延迟减少到可接受的数量。但在其他情况下,解决方案是把多个相关的服务组合在一起,用编程语言的函数调用替换昂贵的进程间通信。
同步进程间通信导致可用性降低
另一个需要考虑的问题是如何处理进程间通信而不降低系统的可用性。例如,实现createOrder()操作最常见的方式是让Order Service使用REST同步调用其他服务。这样做的弊端是REST这样的协议会降低Order Service的可用性。如果任何一个被调用的服务处在不可用的状态,那么订单就无法创建了。有时候这可能是一个不得已的折中,但是在第3章中学习异步消息之后,你就会发现其实有更好的办法来消除这类同步调用产生的紧耦合并提升可用性。
在服务之间维持数据一致性
另一个挑战是如何在某些系统操作需要更新多个服务中的数据时,仍旧维护服务之间的数据一致性。例如,当餐馆接受订单时,必须在Kitchen Service和Delivery Service中同时进行更新。Kitchen Service会更改Ticket的状态。Delivery Service安排订单的交付。这些更新都必须以原子化的方式完成。
传统的解决方案是使用基于两阶段提交(two phase commit)的分布式事务管理机制。但正如你将在第4章中看到的那样,对于现今的应用程序而言,这不是一个好的选择,你必须使用一种非常不同的方法来处理事务管理,这就是Saga。Saga是一系列使用消息协作的本地事务。Saga比传统的ACID事务更复杂,但它们在许多情况下都能工作得很好。Saga的一个限制是它们最终是一致的。如果你需要以原子方式更新某些数据,那么它必须位于单个服务中,这可能是分解的障碍。
获取一致的数据视图
分解的另一个障碍是无法跨多个数据库获得真正一致的数据视图。在单体应用程序中,ACID事务的属性保证查询将返回数据库的一致视图。相反,在微服务架构中,即使每个服务的数据库是一致的,你也无法获得全局一致的数据视图。如果你需要一些数据的一致视图,那么它必须驻留在单个服务中,这也是服务分解所面临的问题。幸运的是,在实践中这很少带来真正的问题。
上帝类阻碍了拆分
分解的另一个障碍是存在所谓的上帝类。上帝类是在整个应用程序中使用的全局类(http://wiki.c2.com/?GodClass) 。上帝类通常为应用程序的许多不同方面实现业务逻辑。它有大量字段映射到具有许多列的数据库表。大多数应用程序至少有一个这样的上帝类,每个类代表一个对领域至关重要的概念:银行账户、电子商务订单、保险政策,等等。因为上帝类将应用程序的许多不同方面的状态和行为捆绑在一起,所以将使用它的任何业务逻辑拆分为服务往往都是一个不可逾越的障碍。
Order类是FTGO应用程序中上帝类的一个很好的例子。这并不奇怪:毕竟FTGO的目的是向客户提供食品订单。系统的大多数部分都涉及订单。如果FTGO应用程序具有单个领域模型,则Order类将是一个非常大的类。它将具有与应用程序的许多不同部分相对应的状态和行为。图2-10显示了使用传统建模技术创建的Order类的结构。
如你所见,Order类具有与订单处理、餐馆订单管理、送餐和付款相对应的字段及方法。由于一个模型必须描述来自应用程序的不同部分的状态转换,因此该类还具有复杂的状态模型。在目前情况下,这个类的存在使得将代码分割成服务变得极其困难。
一种解决方案是将Order类打包到库中并创建一个*Order数据库。处理订单的所有服务都使用此库并访问访问数据库。这种方法的问题在于它违反了微服务架构的一个关键原则,并导致我们特别不愿意看到的紧耦合。例如,对Order模式的任何更改都要求其他开发团队同步更新和重新编译他们的代码。
另一种解决方案是将Order数据库封装在Order Service中,该服务由其他服务调用以检索和更新订单。该设计的问题在于这样的一个Order Service将成为一个纯数据服务,成为包含很少或没有业务逻辑的贫血领域模型(anemic domain model)。这两种解决方案都没有吸引力,但幸运的是,DDD提供了一个好的解决方案。
更好的方法是应用DDD并将每个服务视为具有自己的领域模型的单独子域。这意味着FTGO应用程序中与订单有关的每个服务都有自己的领域模型及其对应的Order类的版本。Delivery Service是多领域模型的一个很好的例子。如图2-11所示为Order,它非常简单:取餐地址、取餐时间、送餐地址和送餐时间。此外,Delivery Service使用更合适的Delivery名称,而不是称之为Order。
Delivery Service对订单的任何其他属性不感兴趣。
Kitchen Service有一个更简单的订单视图。它的Order版本就是一个Ticket(后厨工单)。如图2-12所示,Ticket只包含status、requestedDeliveryTime、prepareByTime以及告诉餐馆准备的订单项列表。它不关心消费者、付款、交付等这些与它无关的事情。
Order Service具有最复杂的订单视图,如图2-13所示。即使它有相当多的字段和方法,它仍然比原始版本的那个Order上帝类简单得多。
每个领域模型中的Order类表示同一Order业务实体的不同方面。FTGO应用程序必须维持不同服务中这些不同对象之间的一致性。例如,一旦Order Service授权消费者的信用卡,它必须触发在Kitchen Service中创建Ticket。同样,如果Kitchen Service拒绝订单,则必须在Order Service中取消订单,并且为客户退款。在第4章中,我们将学习如何使用前面提到的事件驱动机制Saga来维护服务之间的一致性。
除了造成一些技术挑战以外,拥有多个领域模型还会影响用户体验。应用程序必须在用户体验(即其自己的领域模型)与每个服务的领域模型之间进行转换。例如,在FTGO应用程序中,向消费者显示的Order状态来自存储在多个服务中的Order信息。这种转换通常由API Gateway处理,将在第8章中讨论。尽管存在这些挑战,但在定义微服务架构时,必须识别并消除上帝类。
我们现在来看看如何定义服务API。
2.2.6 定义服务API
到目前为止,我们有一个系统操作列表和一个潜在服务列表。下一步是定义每个服务的API:也就是服务的操作和事件。存在服务API操作有以下两个原因:首先,某些操作对应于系统操作。它们由外部客户端调用,也可能由其他服务调用。另次,存在一些其他操作用以支持服务之间的协作。这些操作仅由其他服务调用。
服务通过对外发布事件,使其能够与其他服务协作。第4章将描述如何使用事件来实现Saga,这些Saga可以维护服务之间的数据一致性。第7章将讨论如何使用事件来更新CQRS视图,这些视图支持有效的查询。应用程序还可以使用事件来通知外部客户端。例如,可以使用WebSockets将事件传递给浏览器。
定义服务API的起点是将每个系统操作映射到服务。之后确定服务是否需要与其他服务协作以实现系统操作。如果需要协作,我们将确定其他服务必须提供哪些API才能支持协作。首先来看一下如何将系统操作分配给服务。
把系统操作分配给服务
第一步是确定哪个服务是请求的初始入口点。许多系统操作可以清晰地映射到服务,但有时映射会不太明显。例如,考虑使用noteUpdatedLocation()操作来更新送餐员的位置。一方面,因为它与送餐员有关,所以应该将此操作分配给Courier Service。另一方面,它是需要送餐地点的Delivery Service。在这种情况下,将操作分配给需要操作所提供信息的服务是更好的选择。在其他情况下,将操作分配给具有处理它所需信息的服务可能是有意义的。
表2-2显示了FTGO应用程序中的哪些服务负责哪些操作。
把操作分配给服务后,下一步是确定在处理每一个系统操作时,服务之间如何交互。
确定支持服务协作所需要的API
某些系统操作完全由单个服务处理。例如,在FTGO应用程序中,Consumer Service完全独立地处理createConsumer()操作。但是其他系统操作跨越多个服务。处理这些请求之一所需的数据可能分散在多个服务周围。例如,为了实现createOrder()操作,Order Service必须调用以下服务以验证其前置条件并使后置条件成立:
- Consumer Service:验证消费者是否可以下订单并获取其付款信息。
- Restaurant Service:验证订单行项目,验证送货地址和时间是否在餐厅的服务区域内,验证订单最低要求,并获得订单行项目的价格。
- Kitchen Service:创建Ticket(后厨工单)。
- Accounting Service:授权消费者的信用卡。
同样,为了实现acceptOrder()系统操作,Kitchen Service必须调用Delivery Service来安排送餐员交付订单。表2-3显示了服务、修订后的API及协作者。为了完整定义服务API,你需要分析每个系统操作并确定所需的协作。
到目前为止,我们已经确定了每项服务实现的服务和操作。但重要的是要记住,我们勾勒出的架构非常抽象。我们没有选择任何特定的进程间通信技术。此外,即使术语操作表明某种基于同步请求和响应的进程间通信机制,你也会发现异步消息起着重要作用。在本书中,我将会介绍可能影响这些服务协作方式的架构和设计概念。
第3章将介绍特定的进程间通信技术,包括REST等同步通信机制和使用消息代理的异步消息。我将讨论同步通信如何影响可用性并引入自包含服务的概念,该服务不会同步调用其他服务。实现自包含服务的一种方法是使用第7章中介绍的CQRS模式。例如,Order Service可以维护Restaurant Service所拥有的数据的副本,以便消除同步调用Restaurant Service进行订单验证的需要。通过订阅Restaurant Service在其数据发生更新时对外发布的事件,Order Service可以维护Restaurant Service的一份数据副本。
第4章将介绍Saga概念以及它如何使用异步消息来协调参与Saga的服务。除了可靠地更新分散在多个服务中的数据之外,Saga也是实现自包含服务的一种方式。例如,我将描述如何使用Saga实现createOrder()操作,该Saga使用异步消息调用服务,例如Consumer Service、Kitchen Service和Accounting Service。
第8章将描述API Gateway的概念,它将API公开给外部客户端。API Gateway可以使用第7章中描述的API组合模式实现查询操作,而不是简单地将其路由到服务。API Gateway中的逻辑通过调用多个服务并组合结果来收集查询所需的数据。在这种情况下,系统操作被分配给API Gateway而不是服务。服务需要实现API Gateway所需要的查询操作。
本章小结
- 架构决定了软件的各种非功能性因素,比如可维护性、可测试性、可部署性和可扩展性,它们会直接影响开发速度。
- 微服务架构是一种架构风格,它给应用程序带来了更高的可维护性、可测试性、可部署性和可扩展性。
- 微服务中的服务是根据业务需求进行组织的,按照业务能力或者子域,而不是技术上的考量。
-
有两种分解模式:
- 按业务能力分解,其起源于业务架构。
- 基于领域驱动设计的概念,通过子域进行分解。
- 可以通过应用DDD并为每个服务定义单独的领域模型来消除上帝类,正是上帝类引起了阻碍分解的交织依赖项。