2. 端口和适配器架构(六边形架构)分析

第一个分享的架构是端口和适配器架构,因为它是最简单的,也是非常常见的分层,其他的分层模型几乎都会用到它的思想,只是层的名字被改来改去而已。被称为六边形架构并不是因为它里面有6个部分,只是表达了会有多个外部模块和内部逻辑交互而已,而端口和适配器则是里面两个重要的概念,所以我觉得端口和适配器架构的说法更适合它一些。

端口和适配器架构也有好几个版本,我们从简单到复杂来分析一下

版本1 (来自维基百科)

下面这个架构多少个分层

我的理解是2层,一层是适配器,一层是应用层。至于端口在哪里我们最后才讨论。

这个版本分层上面没有端口的概念,重点突出了适配器和应用层,本质上是为了隔离业务逻辑和技术实现细节。

适配器可以分几类, UI 适配器,通知适配器,数据库适配器等等。我们从上面三种类型的适配器解释下适配器的作用。

UI 适配器 (分离 UI 和业务逻辑)

作用是防止因 UI 改变导致应用层逻辑发生变化。例如前端界面有个显示用户信息的表单,V1版本只显示头像和用户名,后台提供了一个获取头像和手机号的接口。但 V2 版本还增加了一个用户头像,并且要脱敏展示。一般情况下后台应用层就要改。但如果遵循端口和适配器架构,应用层其实提供了模型内全量的用户信息,适配器再根据前端界面的需要来进行裁剪。这样 UI 层发生变化,应用层就不用改了。(前提是用户模型并没有发生变更)

在我们项目里面,哪个地方才是做 UI 适配器呢?

  • 如果采用 BFF 架构,则 BFF 层可作为 UI 适配器
  • 如果采用 nodejs 进行前后端分离,则 nodejs 这层比较适合做 UI 适配器
  • 如果是采用 JSP 去写前端,UI 适配器可以写在 JSP 那里
  • 如果是后台提供接口,前端 js 直接渲染,那可以把 UI 适配器写在后台的Controller层

其实可以看到很多地方都是可以做 UI 适配器的,而 UI 适配器也可以再拆一下。例如端类型(web或者移动端)的适配可以写在BFF层,而通用的 UI 适配,例如手机号脱敏等可以写在 Controller 层,而特定的 UI 适配(浏览器 cookies 转 header 等等),可以在 Nodejs 做。选择非常多,如果这个规则没定好就会出现不知道 UI 适配写在哪里的情况。

这种情况很多,例如界面提示用户对某个资源没有权限,你不知道判断的逻辑是在前端还是后台,或者是后台的哪个地方判断的。需要先抓包看看有没有 http 请求,有的话再看看是不是接口报错了.

UI 适配器是相对较乱的,因为理论上可以做 UI 适配器的地方太多了。所以更需要明确的规则和对分层有一致的理解才能便于团队每个成员的设计都是一致的。

数据库适配器 (分离存储和业务逻辑)

和数据库相关的适配器有2种,一种是面向数据库实体生命周期管理的适配器,常称为持久层。一种是面向聚合的生命周期管理的适配器,这种在 DDD 称为仓储层。

不用纠结层的命名,叫持久层,数据访问层,存储层都没关系,甚至你理解的持久层就是仓储层也没关系。重要的是你对这层的定义和理解和团队的共识。我理解的持久层和仓储层的作用是不同的。所以有必要区分开。

面向数据库实体生命周期管理的适配器(持久层)

这个是我们常常在三层架构里面看到的一层,如果用的是mybatis框架,那对应的 Mapper 就是这一层的内容。Mapper 一般会对应一个 Entity,而 Entity 对应的是一个数据库的表。我们会数据库的表映射到 Entity 里面,方便我们对数据库进行操作。而数据库的表则是数据模型一个重要组成部分。

所以我们可以这样定义这个层的作用

  1. 数据模型的定义和实现
  2. 对底层数据库操作进行封装

对于简单的业务来说,有持久层就够了,但对于复杂的业务来说,单单有持久层还不够,因为持久层有以下问题

  1. 无法直接映射到领域模型
  2. 和框架的耦合太重,难以拓展

持久层难以直接映射到领域模型

对于简单的业务来说,数据模型和领域模型可以做到一致,但复杂的业务不行,因为数据模型无法做到下面几点

  1. 难以很好支持面向对象的特点,例如封装继承多态.

不支持封装: 数据库模型可以简单的支持封装,例如有一个用户实体,可以设计成2个表,一个用户基础信息表,一个用户详细信息表。但如果是这面这种复杂一点点的,就会比较麻烦.

kotlin 复制代码
data class Order (
var id: String
var userId: String,
var orderTime: LocalDatetime,
var address: Address
){
    data class Address(
    var province: String,
    var city: String,
    var district: String
    )
}

对于一个简化的订单实体,因为省市区其实都是地址的组成部分,为了更好的理解我会把地址设计成一个对象(值对象),这种情况下如果是用数据模型则无法支持,如果地址再加个id,用一个地址表关联来实现是否可行。可行是可行,但一般不会这么设计,为了一个地址去增加一个表对数据模型设计上不是很合理。

不支持继承: 对于继承来说数据模型也是只能通过特别的方式来实现,例如父子属性都放到一个表,或者拆开2个父子表,或者拆开1个父n个子表,具体可以参考<企业应用架构模式>里面的介绍。特别的方式意味着需要有专门的逻辑进行转换,那这段逻辑既不属于应用层,也不属于持久层.

  1. 需要考虑性能因素.

数据模型层更多要考虑性能,例如需要通过反范式,冗余字段来提升查询性能。需要把冷热数据分离,读写分离等等。如果只是把 Mapper 作为持久层,那势必只能把冷热分离,读写分离的逻辑写在应用层,导致应用层的逻辑不仅仅包含业务逻辑,从而导致应用层越来越难理解.

  1. 迭代节奏不同.

数据模型层一般修改的成本很高,因为涉及到数据的清洗。例如上面的订单实体,一开始订单的物流信息比较简单,只有一个状态,可能会把物流状态当成订单的一个属性。但后面迭代需要记录物流状态变更时间,变更人等等。为了降低物流状态和订单的耦合,需要把物流状态信息设计成一个实体,里面包括物流当前状态,变更人和变更时间等物流状态相关信息。数据库层面一般来说也要跟着修改,需要单独设计一个物流信息的表。但因为涉及到大量的数据清洗,物流信息可能短期仍然存在订单表中。这样导致上层的业务也没法按最新的模型去实现。

所以这时候就需要领域模型,上层业务依赖领域模型,领域模型可以随时修改,不受历史数据约束。当领域模型修改之后再映射到现有的数据模型。这样就把领域模型和数据存储的设计和问题分离了。领域模型可以不断进行调整来满足上层业务,而只有当有严重性能问题或者领域模型和数据模型映射成本很高时,才考虑专门对数据模型进行重构,这种重构因为有了领域模型进行隔离,对上层业务的影响其实是非常小的.

面向聚合的生命周期管理的适配器 (仓储层)

上面定义了持久层是为了数据模型的持久化,这里定义的仓储层则是领域模型的持久化。上面提到对于复杂的业务来说数据模型和领域模型是有必要分开的,那对应领域模型也需要有单独的"持久层",这层套用 DDD 里面仓储的概念,定义为仓储层.

聚合是领域模型的一个组成部分,而在 DDD 里面明确了仓储的作用是对聚合的生命周期进行管理,一个聚合对应一个仓储,为什么要这样设计以后讲 DDD 聚合的时候会提到。

如果设计成这两种适配器,哪一种会更符合端口和适配器模式里面的适配器呢?答案是仓储层。后面讲到端口的时候会再进行分析。

持久层和仓储层合二为一

前面谈到两个模型关注点不同,对于长期的产品来说把两个分开会更合适一些,硬是把数据模型当成领域模型,业务层需要做模型兼容,代码容易快速腐化。 虽然两个适配器关注点不同,但对于简单的业务来说两者相差不会太大,而且已经有比较好的框架支持两个适配器合二为一。例如 hibernate,hibernate 利用大量的注解或配置把领域模型直接映射到数据库的表。本质上这种方式并不是把两个模型合成一个了,而是只展示领域模型,然后通过配置自动转化成数据模型,所以还是2个模型,但通过规则转换会让数据模型变得没那么直观。

优点在于不需要在代码里面定义数据模型,转换过程都自动化了。但缺点在于配置映射复杂,数据模型变得不够清晰且应对复杂查询会比较困难。

版本2

下面这个架构多少个分层?

我的理解是4层,

  1. 一层是类似整洁架构的框架 & 驱动层,负责驱动硬件,中间件或者框架,例如系统里面 rocketmq-spring-boot-starter,JDBC 驱动等都属于这一层,其实上面提到的面向数据库生命周期管理的适配器也是这一层,因为它是直接和数据库打交道的,面向聚合的生命周期管理的适配器才是端口和适配器架构里面的适配器
  2. 二层是适配器层
  3. 三层是应用层
  4. 四层是领域层

这个架构图比上面版本 1 的多了 2 个地方

  1. 把版本1的适配器分成了框架 & 驱动层和适配器。在版本 2 里面适配器只关心如何和框架&驱动的代码交互,只是胶水代码,只是粘合应用层和架构 & 驱动层,而不用直接和硬件或者中间件,框架交互。好处是框架 & 驱动层能更好的复用,抽取自己的能力,而因为它不关心你是什么应用耦合,因为有适配器在给应用做适配。例如 mq 的 starter 在设计的时候只考虑封装了 mq 的能力,和业务没什么关系,然后靠适配器把业务和 mq 的能力关联起来。

  2. 应用层拆分成应用层和领域层。这两个层的区别后面讲整洁架构的时候会重点讲到为什么还要把领域层拆出来。

其实这个架构已经和整洁架构非常像了,只是没有专门把层指出来和命名,并把依赖方向展示在上面。抽象的图就是这样,有时为了突出重点把一部分内容省略了。所以为什么只看架构图无法直接落地到项目里面,因为架构图并不是具体的标准,而只是为了突出某种思想,具体落地的方式理由非常多。

版本3

下面这个版本有什么特点?

这个版本的端口和适配器架构和版本1 一样会比较精简,因为它想突出适配器的特点,就是适配器可以分为主动适配器和被动适配器。

  • 主动适配器: 调用方向是主适配器调用应用层
  • 被动适配器: 调用方向是应用层调用被动适配器

这两种确实可以这么分,但我们要分类并不是因为我们可以这么分,而是分类对我们有意义,那这么分的意义是什么?值不值得我们这么分。

我们从层的调用方向和依赖关系分析 主动适配器: 调用方向和我们期望的依赖方向一致 被动适配器: 调用方向和我们依赖的调用方向不一致(应用层调用适配器,但我们需要适配器依赖应用层)

要解决被动适配器的依赖问题,就需要采用依赖倒置。而依赖倒置的实现方式就是先从应用层的角度设计接口,而接口是属于应用层,这样就解决了依赖的问题。而主动适配器其实没有这种依赖的问题,所以不需要设计接口。

上面的架构图其实意思是主动适配器也是要抽象接口,那个像夹公仔的符号在 UML 里面代表了接口和实现,半圆是实现,圆形是接口。但我觉得在大部分系统不是特别有必要。当然也有例外,这个问题在整洁架构的介绍中才进行解答。有想到其他作用的同学可以一起讨论一下。

端口是不是属于单独一层?

端口是什么?端口就是应用层需要什么,让适配器帮它实现了。例如应用层需要短信通知某个用户注册成功,应用层会设计一个短信通知用户注册成功的端口,然后让适配器来实现。端口就是一种协议,在代码实现里面,就是版本3里面那个圆形的图案,也就是接口。所以端口没必要专门分一层,因为它只有协议,也就是接口。

那端口是属于应用层还是适配器层呢?那肯定是属于应用层的。因为从设计的层面讲,端口是为了应用层屏蔽具体的视线细节,如果端口是为了适配器层设计的,那应用层岂不是得依赖适配器的设计。从依赖的角度看,适配器是端口的实现,肯定是适配器依赖端口。而应用层会调用端口,如果端口属于适配器层,那就变成应用层依赖适配器层了。

所以端口其实是应用层的接口,但决定接口属于哪一层不是简单的在代码里面把接口和哪一层放在一起,而是这个的接口是面向谁设计的。

下面的接口更可能属于应用层还是适配器层?

java 复制代码
void receiveMq(LoginEventDto loginEvent); 

void saveUserInfo(UserDto user);

void sendSms(SmsDto smsDto);

void insertUser(UserEntity userEntity);

上面的都属于适配器层.

前面一篇文章也有提到,可以从2个角度分析,一个是 方法的命名,一个是参数的类型。

  • void handleLoginMqEvent(LoginEvent loginEvent); 命名有技术实现细节,例如 Mq。常见的还有 cache,sql相关的 select,delete 等,都属于技术语言,为了解决技术问题例如性能,存储而设计的

  • void saveUserInfo(UserDto user); 参数是 Dto 类型,Dto 是数据传输对象,并不属于应用层或者领域层,更多的在版本 2 的架构 & 驱动层和适配器层之间的通讯协议

  • void sendSms(SmsDto smsDto); 这个除了参数问题,命名上虽然没有技术语言,但太通用了,更像是为适配器层设计的。这样设计的问题在于为了让 sendSms 能通用,必须要抽象出通用的逻辑,例如短信模板,短信参数等等,那必须要应用层要去构造这些参数,便污染了应用层。当通用逻辑需要修改,例如增加一个短信的发送时间参数,那应用层就得改,这个依赖其实就是有问题的。因此端口的设计应该不是为了复用或者通用,而是最大程度上表达清楚业务逻辑,屏蔽实现细节,例如命名成 sendOrderSubmitedSms。如果确实想复用,可以在适配器内部再加一层,但这个已经和端口和适配器没什么关系

  • void insertUser(UserEntity userEntity); 这个是很常见的 Mybatis 里面 Mapper 接口的定义,但更像适配器层的接口,而非应用层的接口。端口的设计应该是这样子的 void saveUser(User user)。在端口里面并不关心保存操作是 insert 还是 update 的,也不关心数据数据模型是怎样子的,只知道这个业务模型里面的聚合要保存

版本3 其实也有提到,大部分情况下被动适配器才需要设计接口,也就是才有端口。主动适配器是可以没有端口的,这个根据项目的需求来就行了。

总结

我们分析了 3 个版本的端口和适配器架构,其实版本 2 和版本 3 都是版本 1 的拓展和延伸。简单的版本 1 才是端口和适配器的精髓,也就是把非业务的技术实现逻辑和业务相关的应用逻辑给隔离开。把技术复杂度和业务复杂度分离,从而提升系统的可维护性,让我们可以更专注于处理软件系统的根本问题(<人月神话>)。

相关推荐
我叫啥都行40 分钟前
计算机基础知识复习9.7
运维·服务器·网络·笔记·后端
无名指的等待7121 小时前
SpringBoot中使用ElasticSearch
java·spring boot·后端
.生产的驴2 小时前
SpringBoot 消息队列RabbitMQ 消费者确认机制 失败重试机制
java·spring boot·分布式·后端·rabbitmq·java-rabbitmq
AskHarries3 小时前
Spring Boot利用dag加速Spring beans初始化
java·spring boot·后端
苹果酱05673 小时前
一文读懂SpringCLoud
java·开发语言·spring boot·后端·中间件
掐指一算乀缺钱3 小时前
SpringBoot 数据库表结构文档生成
java·数据库·spring boot·后端·spring
计算机学姐6 小时前
基于python+django+vue的影视推荐系统
开发语言·vue.js·后端·python·mysql·django·intellij-idea
JustinNeil6 小时前
简化Java对象转换:高效实现大对象的Entity、VO、DTO互转与代码优化
后端
青灯文案16 小时前
SpringBoot 项目统一 API 响应结果封装示例
java·spring boot·后端
微尘87 小时前
C语言存储类型 auto,register,static,extern
服务器·c语言·开发语言·c++·后端