2.1.1 服务架构选型

在一个基于微服务架构构建的应用中,每个服务也需要有自己的架构,本节我们就来介绍常用的架构模式。

1.被妖魔化的单体应用

我们听到的许多架构演进的案例都是从批判单体应用开始的:“随着应用变得越来越复杂,单体的劣势凸显,于是我们开始了应用的微服务改造工作。”

所谓众口铄金,这样的故事听得多了,大家无形中就会对单体应用产生一种刻板印象,一提起单体应用就嗤之以鼻,仿佛它是一种落后的、充满缺点的应用形态。这种认知是非常片面的,因为我们忽略了应用规模、组织结构这些客观因素对软件开发产生的影响。

图2-1展示了单体应用和微服务在不同复杂度下对生产效率的影响。横轴代表复杂度,纵轴代表生产效率。可以看到,复杂度较低时,单体应用的生产效率要高于微服务,只有在复杂度逐渐增加的情况下,单体应用的劣势才逐渐显现并导致生产效率下降。

图2-1

当我们从头开始构建一个应用时,如何确定它能否满足客户的真正需求,或者其技术选型是否合理呢?一个好的办法是先构建一个简单的版本,看看它的运行效果如何。在这一阶段,主要的考量因素是成本和交付时间,应用需要尽快获得客户的反馈以便快速迭代。在这种场景下,单体应用很可能是最合适的选择。单体应用以一个单一的、整体的形态,完成从用户接口到数据访问的完整代码流程。最初单体应用被认为是缺乏模块化设计的应用,但我们依然可以通过模块化的设计来支持部分逻辑的重用。一个比较合理的构建方法是,让应用在逻辑上保持整体统一,但注意设计好内部模块,包括对外接口和数据存储等。这样一来,当向其他架构模式迁移时就会省很多事。

使用单体应用结构可以获得如下好处。

● 开发简单:只需要构建一个单独的应用程序。

● 测试简单:开发者只需要写一些端到端的测试用例并启动应用调用接口即可完成测试。

● 部署简单:没有过多的依赖,只需要将应用程序整体打包部署到服务器上。

如果业务足够简单,并且对开发速度等方面有要求,开发单体应用依然是一个合理的选择,只要注意设计好应用内部的模块即可。

2.分层架构

在分解复杂的软件系统时,最常用的设计手段就是分层。分层设计的例子比比皆是,比如TCP/IP网络模型。这种组织方式一般是,上层使用下层定义的服务,下层对上层隐藏自己的实现细节。

分层在一定程度上为应用提供了解耦能力,层次之间的依赖降低了,可以相对容易地替换某一层的具体实现。分层也有缺点,首先是过多的层次会影响性能,数据在每一层传递时通常需要被封装成对应的格式。另外,当上层修改时,有可能会引起级联修改,比如你要在用户界面上增加一个数据字段时,需要对存储层的查询方法同时做对应的修改。

20世纪90年代前后,两层结构是一种比较先进的设计,例如客户端/服务端架构,如图2-2所示。我们熟知的Docker采用的就是经典的客户端/服务端架构。

图2-2

使用两层结构经常要面对的一个问题是业务逻辑写在哪层。如果应用的数据操作是简单的增删改查,选择两层结构是合理的。一旦业务逻辑变得复杂,这些代码写到哪一层都不太合适。出现这一问题的原因是缺乏领域建模。

随着业务的变更和发展,最初设计的数据库表常常无法准确呈现出当前的业务状况,这时转到三层结构是更好的选择。通过引入一个中间层,可以解决业务逻辑的归属问题,我们可以将这一层称为领域层,或者业务逻辑层。

基于三层结构,我们可以用抽象出来的领域模型来描述应用,不必关心数据的存储和结构,数据库管理员可以在不破坏应用的情况下更改数据的物理部署。同样,三层结构也可以让展示层和业务逻辑层分离。展示层只负责和终端用户交互,将业务逻辑层返回的数据展示给用户,不执行任何计算、查询、更新业务等操作。而业务逻辑层负责具体的业务逻辑,以及与展示层的对接,如图2-3所示。

图2-3

如果我们将展示层看作前端(与网页相关的部分),那么业务逻辑层和数据访问层就是后端。后端可以分两层,不过一个更常见的分层方式是将后端也分为三层,比如下面这样。

● 接口层:或称应用层,负责与前端对接,完成从前端请求参数到业务数据对象的编解码工作,处理通信层面的功能,调用下层真正的业务逻辑。构建这一层的目的是避免将非业务功能引入领域层,保证领域层中业务逻辑的纯粹性。这一层通常可以使用门面模式(Facade)来构建,这样就可以对接不同的展示层,比如网页、移动端App等。

● 领域层:领域相关的业务逻辑部分,其中只包含相对纯粹的业务代码。

● 数据访问层:与数据源对接,将业务对象转换为存储数据并保存到数据库中,这个过程可简单理解为“对象—关系映射”(ORM)。

分层结构的弊端是无法展现出应用可能具有多个展示层或数据访问层的事实。比如应用同时支持网页端和移动端访问,但它们都会被划分在同一层里。另外,业务逻辑层对数据访问层也会有所依赖,给测试带来一定困难。

在讨论分层结构的时候,我们需要明确“层”的粒度问题。通常认为“tier”这个词所描述的层是物理上的隔离,比如客户端/服务端。而我们讨论的主要是“layer”所代表的层,是基于代码层面的一种隔离。

3.六边形架构

对于构建一个微服务来说,在大部分场景下使用分层结构是可以满足需求的,不过还有一种更加流行的架构风格:六边形架构(Haxagonal Architecture),也叫端口适配器架构(ports and adapters architecture)。它以业务逻辑为中心来组织代码,图2-4展示了这种架构的形态。

图2-4

中间的六边形是具体的业务逻辑,包括业务规则、领域对象、领域事件等,这部分是应用的核心。在六边形的边界上有进出的端口,通常以某种协议的API形式呈现,与之对应的是外部的适配器,它们将完成外部系统的调用,并通过端口与应用交互。适配器分为入站和出站两种,入站适配器通过调用入站端口处理来自外界的请求,例如MVC模式下的控制器,它定义了一组RESTful接口,出站适配器通过调用外部系统或服务处理来自业务逻辑的请求。比如一个实现了数据库访问的DAO对象,或者是一个基于分布式缓存的客户端,这些都是典型的出站适配器。

六边形架构分离了系统层面和业务层面的具体实现,将整个架构分成了两部分。

● 系统层面:应用的外层边界,负责与外部系统的交互,以及非业务属性的实现。

● 业务层面:也可以称为领域层面,是应用的内层边界,负责核心业务逻辑的实现。

我们用一个很常见的外卖订单的业务场景来举例,看看六边形架构是如何工作的。

首先,用户通过手机上的外卖App下单,App是位于架构边界之外的,属于前端内容,通过某种协议(比如gRPC)发送订单请求给入站适配器RequestAdapter。适配器负责将JSON格式的请求入参封装成入站端口DeliveryService需要的对象,然后由DeliveryService调用领域层的Delivery执行具体的业务逻辑。生成的外卖订单需要保存到数据库,它被出站端口DeliveryRepository从领域模型转换为数据库关系对象,最后调用DBAdaptor完成存储。在源码结构上可以用不同的目录来划分,示例如下。

可以发现,端口实现的好坏会直接影响整个应用的解耦程度。上面例子中的入站端口体现了封装思想,将前端请求参数、数据转换、协议等通信层的技术细节隔离在了业务逻辑之外,也避免了领域模型向外层泄露。而出站端口是抽象的体现,它将对领域模型的操作定义为接口,业务层只需调用接口,不用关心具体的外部服务的实现,同样完成了解耦。

六边形架构的目标是创建松散耦合的应用,通过端口和适配器连接需要的软件环境和基础设施。它主要的优点是业务逻辑不依赖于适配器,这样可以在代码层面获得更好的分离度,让领域的边界更加清晰。六边形架构的理念和领域驱动设计里的界限上下文思想非常契合,在服务的拆分和设计中配合使用能获得不错的效果,《实现领域驱动设计》一书中有详细的介绍。除此以外,六边形架构的可扩展性也更好。比如我们想添加一种新的通信协议,或者引入一个新的数据库,只需要实现对应的适配器即可。六边形架构对测试的支持也更加友好,因为隔离了外层系统,测试业务逻辑变得更容易。六边形架构解决了分层架构的弊端,对于构建应用中的每个服务而言是更好的选择。