聊聊微服务物理设计

前言

这些年,微服务架构大行其道,我们每天或多或少的都在开发微服务。有一个问题,或许会时不时的困扰着你,那就是怎样设计微服务代码的目录结构,也就是如何分层分包,笔者更习惯叫物理设计。

关于微服务物理设计,没有标准答案,在团队内能达成一致即可。然而,如果能遵从某种标准,那么会让团队小伙伴们更信服。笔者经过多年探索,发现基于六边形架构的物理设计,较容易沟通和落地,是一种值得推广的标准化实践。

六边形架构

六边形架构是 Alistair Cockburn 在 2005 年提出的,在这种架构中,不同的客户通过“平等”的方式与系统交互。需要新的客户吗?不是问题。只需要添加一个新的适配器将客户输入转化成能被系统 API 所理解的参数就行。同时,对于每种特定的输出,都有一个新建的适配器负责完成相应的转化功能。

六边形架构也称为端口与适配器,如下图所示:


ddd-hex

六边形每条不同的边代表了不同类型的端口,端口要么处理输入,要么处理输出。对于每种外界类型,都有一个适配器与之对应,外界通过应用层 API 与内部进行交互。上图中有 3 个客户请求均抵达相同的输入端口(适配器 A、B 和 C),另一个客户请求使用了适配器 D 。假设前 3 个请求使用了 HTTP 协议(浏览器、REST 和 SOAP 等),而后一个请求使用了 AMQP 协议(比如 RabbitMQ)。端口并没有明确的定义,它是一个非常灵活的概念。无论采用哪种方式对端口进行划分,当客户请求到达时,都应该有相应的适配器对输入进行转化,然后端口将调用应用程序的某个操作或者向应用程序发送一个事件,控制权由此交给内部区域。

应用程序通过公共 API 接收客户请求,使用领域模型来处理请求。我们可以将仓储的实现看作是持久化适配器,该适配器用于访问先前存储的聚合实例或者保存新的聚合实例。正如图中的适配器 E、F 和 G 所展示的,我们可以通过不同的方式实现资源库,比如关系型数据库、基于文档的存储、分布式缓存或内存存储等。如果应用程序向外界发送领域事件消息,我们将使用适配器 H 进行处理。该适配器处理消息输出,而上面提到的处理 AMQP 消息的适配器则是处理消息输入的,因此应该使用不同的端口。

用户界面层去哪里了?

Eric Evans 在《领域驱动设计-软件核心复杂性应对之道》这本书中提出了经典的四层架构,如下图所示:


ddd-l4.png

上图中,应用层和领域层在六边形架构中也有,基础设施层(Infrastructure)对应六边形架构中的适配层,但用户界面层(User Interface)在六边形架构中却找不到对应项。

你禁不住想问:用户界面层去哪里了?

其实,自从前后端架构分离后,用户界面层就被彻底的拆分出去了,形成一个完全松耦合的前端层。应用层的调用者不再仅限于前端层,还可以是其他微服务。即使是前端层,也可能需要不同的用户交互方式与呈现界面,例如 Web 和移动端 App。

基础的物理设计

遵从六边形架构,微服务物理设计的第一级为 adapter、app 和 domain。

这个很好理解:

  • ms 是微服务,对应整个六边形;
  • adapter 是适配器层,对应六边形最外层;
  • app 是应用层,对应六边形中间层;
  • domain 是领域层,对应六边形最内层。

在六边形架构中,越是内层就越稳定,越是外层相对就越容易变化。根据软件架构中的一个重要原则:代码中不稳定的部分,应该依赖稳定的部分。就是说,外层依赖内层,但内层不能依赖外层。

然而,还有一些代码我们没有考虑到,比如日志,还有用于字符串和日期处理的工具类。这些代码可能会被适配器层、应用层和领域层的任一层调用,理应放在最内层,但 DDD 强调领域层是核心,必须位于最内层,且外层要依赖内层,这是否存在矛盾?事实上,这些代码和六边形中的这几层代码根本不在同一个维度,是对这几层代码起公共的支撑作用的,通常叫做公共层( common )。

我们给出基础的物理设计全景:


basic-physical-design.png

接下来,我们具体聊聊每一层的物理设计。

适配器层物理设计

适配器会把和具体技术有关的请求,翻译成和技术无关的请求,再调用应用层来实现业务功能;在接收到应用层的返回值以后,又转化成技术相关的响应,返回给外界。也就是说适配器层屏蔽了输入输出技术的差异,从而使应用层与具体技术无关,这样就达到了分离关注点的目的。

适配器分为入口适配器(inbound adapter,又称 driving adapter)和出口适配器(outbound adapter,又称 driven adapter)。

入口适配器处理的是外界向系统的调用,常见的外部调用包括 REST 请求,RPC 请求 和 MQ 请求。REST 请求的处理包括路由设置和控制器对象执行两部分,所以在 rest 目录下,又增加了 controller 和 router 两个子目录(也可以直接通过文件来隔离)。在微服务中,REST 消息最为常见,但 RPC 消息和 MQ 消息也时而出现,当遇到时遵从与 REST 同样的物理设计风格即可。

出口适配器处理的是系统向外界的调用,可以分为对其他微服务的调用和对持久化资源的调用两部分,对应的目录分别为 gateway 和 persistence。一般对缓存、文件系统和对象存储服务等的访问,都会算作对持久化资源的访问,我们将这些资源统称为数据库,所以要在 persistence 目录(包)下封装访问数据库资源的客户端(client),并且还要实现在数据库中持久化的领域对象的仓储(repository,简称 repo)接口。同理,在 gateway 目录下,我们需要封装访问其他微服务的客户端,并且还要实现从其他微服务获取的领域对象的仓储接口。

可以看出,仓储接口的实现在 gateway 和 persistence 目录下都有,这恰巧说明了我们在实现层面考虑了不同的技术。数据库和其他微服务属于外部资源,都可以用于领域对象的持久化。如果访问数据库,就叫 persistence;如果访问其他微服务,就叫 gateway。

综上,我们整体看一下适配层的物理设计:


adapter-physical-design.png

应用层物理设计

应用层作为领域层的“门面”,接受来自客户端的请求,本身并不包含领域逻辑,而是对领域层中的逻辑进行封装和编排。领域层封装的逻辑通常是细粒度的,并不适合直接作为 API 暴露给外部。应用层将领域层的处理结果封装为更简单的粗粒度对象,作为对外 API 的参数。这里说的粗粒度对象一般是 DTO(Data Transfer Object),也就是没有逻辑的数据传输对象,应用层负责 DTO 和领域对象的数据转换。需要强调的是,这里的 DTO 仅仅是应用层的 DTO,是和技术无关的,而适配层的 DTO 是和技术有关的。适配层收到消息后,经过反序列化得到适配层 DTO,然后将适配层 DTO 转化成应用层 DTO,最后再调用应用服务完成业务逻辑。另外,还有一些不属于领域层的横切关注点,比如像事务、日志和权限等,也应该放在应用层处理。

应用层主要包括应用服务和 DTO 对象,其中应用服务对应用例(Use Case)或用户故事(User Story),还会处理一些横切关注点。

综上,我们整体看一下应用层的物理设计:


app-physical-design.png

领域层物理设计

领域层最核心的是模型,包括:

  • 系统中有哪些领域对象(实体和值对象)
  • 领域对象之间的关系是什么
  • 领域对象的生命周期管理:用聚合来封装,用工厂来创建和销毁,用仓储来查找和持久化

模型通过 model 目录呈现,model 目录下是领域对象的分组,也用领域对象名来表达,这里有三种情况:

  • 分组是聚合,聚合下有聚合根(实体)、其他实体、值对象、工厂和仓储,分组名就是聚合根的名字;
  • 分组是聚合,聚合下只有聚合根一个实体,其他同上;
  • 分组是值对象,很少见但也存在,分组名就是值对象的名字。分组下除过值对象,还有仓储,仓储的作用不是持久化,而是从另一个微服务获取到该值对象,并且还可以在仓储中封装缓存该值对象的算法。

因为在六边形架构中,内层不能依赖外层,所以仓储在领域层需要定义为抽象,然后在适配器层实现这个抽象。

领域层除过模型,剩下的主要就是领域服务和领域事件了,它们三个是并列的目录。

DDD 强调模型和代码的一致性,因此我们需要基于面向模型的实现模式来准确的表达领域模型,让模型和代码一一映射。考虑到这一点,我们在领域层增加了 base 目录,主要定义战术设计元素的原语,比如聚合根,实体,值对象等。

以 C++ 语言为例,我们看几个原语的定义:

struct AggregateRoot : Entity
{
    AggregateRoot(int id) : Entity(id) {}
};

struct Entity
{
    Entity(int id);

    bool operator==(const Entity* rhs);
    bool operator!=(const Entity* rhs);
    int getId() const;

private:
    int id;
};

struct ValueObject
{
    virtual ~ValueObject() = default;

    virtual bool operator==(ValueObject* rhs) = 0;
    virtual bool operator!=(ValueObject* rhs) = 0;
};

综上,我们整体看一下领域层的物理设计:


domain-physical-design.png

补充:在 model 目录下还可能有 common 包,但比较少见,所以在领域层物理设计视图中没有体现

  • 有的值对象是独立存在的,不依附于任何实体,比如时间段对象 Period,可以用来描述任何实体的属性,应该放在 common 包;
  • 当使用 DCI (Data、Context 和 Interactive)架构模式时,Data 放在领域对象包,Context 放在领域服务包,Role 一般放在领域对象包,但当某个 Role 被多个领域对象复用时,比如 Worker 作为一个 Role,被工人和机器人两个领域对象复用,应该放在 common 包。

公共层物理设计

从物理设计上看,适配器层、应用层、领域层和公共层都位于同一级,但适配器层、应用层和领域层属于同一个平面,而公共层属于另一个平面,且对前一个平面进行支撑。

公共层我们一般放日志包(log)和工具包(util)。

综上,我们整体看一下公共层的物理设计:


common-physical-design.png

扩展的物理设计

当模型变得比较复杂时,我们就需要扩展一下之前的物理设计了,一般有两种方式:多模块和多限界上下文。

多模块

随着业务的长期发展,领域模型中的概念越来越多,一些领域对象及其关系混杂在了一起,超过了一般人的认知负载,这时就需要将这些业务概念分组,每一组是一个高内聚的模块,而模块间尽量低耦合。

微服务有了多个模块后,其物理设计的各层中会增加模块的目录,如下所示:


module-physical-design.png

说明:上图中适配层没有展开,同样可以添加模块目录进行分组。

多限界上下文

限界上下文的拆分因素,既考虑到了业务概念的完整性和一致性,又考虑到了团队的认知负载和可复用性(共享内核)。我们可以说,微服务拆分的基础是限界上下文,接着再根据性能、安全和可用性等非功能性需求继续拆分。

但有时候,可以考虑将几个限界上下文放到一个微服务里。极端情况下,可以将所有限界上下文都放在一个服务里,这就变成了单体架构。

在一些场景下,采用单体架构比较适合,尽管微服务架构现在比较流行。在虚机或裸机,以容器化方式部署的服务,如果已经可以满足用户需求了,那么上云只会带来更大的复杂度和成本,不划算。笔者比较信奉单体优先的架构策略,除非收益大于成本,才会考虑逐步演进到微服务架构。

对于单体架构,当发展到一定程度,业务概念变得非常多,概念之间的关系很复杂,需求仍处于高位,系统越来越难理解了,沟通成本越来越高了,缺陷修复波及面越来越广了,是时候用 DDD 来拆分限界上下文和管理核心复杂度了。

对,是时候了。当限界上下文拆分完成后,不管是将所有限界上下文都放在一个服务里,还是仅将一部分限界上下文放在一个微服务里,都会导致多限界上下文的物理设计,如下所示:


bc-physical-design.png

说明:多个限界上下文复用公共层,限界上下文之间优先采用函数调用,但都封装在了适配层。这就是说,当按限界上下文划分微服务后,仅需修改适配层的代码。

小结

本文遵从六边形架构,阐述了微服务物理设计的方方面面。一般情况下,基础的物理设计就够用了,但在复杂场景下,需要考虑扩展的物理设计。

如果你曾纠结过如何为微服务分层分包,或者如何为微服务设计目录和文件,那么你就是目标读者,希望能给你一定的启发。

你可能感兴趣的:(聊聊微服务物理设计)