本文提及的架构主要指项目组织的“代码架构”
,注意与微服务架构等名词中的服务架构进行区分。
目前主流的代码目录组织结构有 Hex 六边形架构
、Onion 洋葱架构
、Clean 整洁架构
三种。
历史悠久的项目大都会有很多开发人员参与“贡献”,在没有好的指导规则约束的情况下,大抵会变成一团乱麻。剪不断,理还乱,也没有勇士开发者愿意去剪去理。被迫接手的勇士开发者如果想要增加一个小需求,可能需要花 10
倍的时间去理顺业务逻辑,再花 10
倍的时间去补充测试代码,实在是低效又痛苦。
这是一个普遍的痛点问题,也有无数开发者尝试过去解决它。这么多年发展累积下来,业界自然也诞生了很多软件架构。大家耳熟能详的就有六边形架构(Hexagonal Architecture)
,洋葱架构(Onion Architecture)
,整洁架构(Clean Architecture
)等。这些架构在细节上肯定有所差异,但是核心目标都是一致的:致力于实现软件系统的关注点分离(separation of concerns)
。
关注点分离之后的软件系统都具备如下特征:
不依赖特定 UI
。UI
可以任意替换,不会影响系统重其他组件。从 Web UI
变成桌面 UI
,甚至变成控制台 UI
都无所谓,业务逻辑不会被影响。
不依赖特定框架。以 go web
生态举例,不管是使用 gin
框架还是beego
框架甚至iris
框架,业务逻辑都不会被影响,被影响的只会是框架接入的那一层。
不依赖特定外部组件。系统可以任意使用 MySQL
, MongoDB
, 或 Neo4j
作为数据库,任意使用 Redis
, Memcached
, 或 etcd
作为键值存储等。业务逻辑不会因为这些外部组件的替换而变化。
容易测试。核心业务逻辑可以在不需要 UI
,不需要数据库,不需要 Web
服务器等一切外界组件的情况下被测试。这种纯粹的代码逻辑意味着清晰容易的测试。
软件系统有了这些特征后,易于测试,更易于维护、更新,大大减轻了软件开发人员的心智负担。所以,好的代码架构确实值得推崇。
前文所述的三个架构在理念上是近似的,从接下来的介绍中一定不难看出他们具有相似的圈层结构。图中可以看到,越往外层越具体,越往内层越抽象。这也意味着,越往外越有可能发生变化,包括但不限于框架升级,中间件变更,适配新终端等等。
整洁架构的同心圆结构中可以看见三条由外向内的黑色箭头,它表示依赖规则(The Dependency Rule)
。依赖规则规定外层的代码可以依赖内层,但是内层的代码不可以依赖外层。也就是说内层逻辑不可以依赖任何外层定义的变量,函数,结构体,类,模块等等代码实体。假如说,最外层蓝色层“Frameworks & Drivers & DB
处使用了 go
语言的 gorm
三方库,并定义了 gorm
相关的数据库结构体及其 tag
等。那么内层的 Gateways,Use Cases, Entities
等处不可以引用任何外层中 gorm
相关的结构体或方法,甚至不应该感知到gorm
的存在。
核心层的Entities
定义表示核心业务规则的核心业务实体。这些实体既可以是带方法的类,也可以是带有一堆函数的结构体。但它们必须是高度抽象的,只可以随着核心业务规则变化,不可以随着外层组件的变化而变化。以简单博客系统举例的话,此层可以定义 Blog
,Comment
等核心业务实体。
type Blog struct {...}
type Comment struct {...}
核心层的外层是应用业务层。
应用业务层的 Use Cases
应该包含软件系统所有的业务逻辑。该层控制所有流向和流出核心层的数据流,并使用核心层的实体及其业务规则来完成业务需求。此层的变更不会影响核心层,更外层的变更,比如开发框架、数据库、UI
等变化,也不会影响此层。接着博客系统的例子,此层可以定义 BlogManager
接口,并定义其中的 CreateBlog, LeaveComment
等业务逻辑方法。
type BlogManager interface {
CreateBlog(...) ...
LeaveComment(...) ...
}
应用业务层的外层是接口适配层。
接口适配层的 Controllers
将外层输入的数据转换成内层 Use Cases
和 Entities
方便使用的格式,然后 Presenters,Gateways
再将内层处理结果转换成外层方便使用的格式,然后再由更外层呈现到 Web, UI
或者写入到数据库。假如系统选择关系型数据库作为其持久化方案的话,那么所有关于 SQL
的处理都应该在此层完成,更内层不需要感知到任何数据库的存在。同理,假如系统与外界服务通信的话,那么所有有关外界服务数据的转化都在此层完成,更内层也不需要感知到外界服务的存在。外层通过此层传递数据一般通过 DTO(Data Transfer Object)
或者 DO(Data Object)
完成。接上文博客系统例子,示例代码如下:
type BlogDTO struct { // Data Transfer Object
Content string `json:"..."`
}
// DTO 与 model.Blog 的转化在此层完成
func CreateBlog(b *model.Blog) {
dbClient.Create(&blog{...})
...
}
接口适配层的外层是处在最外层的框架和驱动层。
该层包含具体的框架和依赖工具细节,比如系统使用的数据库,Web
框架,消息队列等等。此层主要帮助外部框架、工具和内层进行数据衔接。接博客系统例子,框架和驱动层如果使用 gorm
来操作数据库,则相关的示例代码如下:
import "gorm.io/driver/mysql"
import "gorm.io/gorm"
type blog struct { // Data Object
Content string `gorm:"..."` // 本层的数据库 ORM 如果替换,此处的 tag 也需要随之改变
}
type MySQLClient struct { DB *gorm.DB }
func New(...) { gorm.Open(...) ... }
func Create(...)
...
至此,整洁架构图中的四层已介绍完成。但此图中的四层结构仅作示意,整洁架构并不要求软件系统必须严格按照此四层结构。只要软件系统能保证“由外向内”的依赖规则,系统的层数多少可自由裁决。
洋葱架构与整洁架构相似,整体结构也是四层同心圆。
洋葱架构最核心的Domain Model
表示组织中核心业务的状态及其行为模型,与整洁架构中的 Entities
高度一致。其外层的 Domain Services
与整洁架构中的 Use Cases
职责相近。更外层的 Application Services
桥接 UI
和 Infrastructue
中的数据库、文件、外部服务等,更是与整洁架构中的 Interface Adaptors
功能相同。最边缘层的 User Interface
与整洁架构中的最外层 UI
部分一致,Infrastructure
则与整洁架构中的 DB, Devices, External Interfaces
作用一致,只 Tests
部分稍有差异。
六边形架构中灰色箭头表示依赖注入(Dependency Injection)
,其与整洁架构中的依赖规则(The Dependency Rule)
有异曲同工之妙,也限制了整个架构各组件的依赖方向必须是“由外向内”。图中的各种 Port
和 Adapter
是六边形架构的重中之重,故该架构别称 Ports and Adapters
。
其实,不仅国外有优秀的代码架构,国内也有。
国内开发者在学习了六边形架构,洋葱架构和整洁架构之后,提出了 COLA (Clean Object-oriented and Layered Architecture)
架构,其名称含义为“整洁的基于面向对象和分层的架构”。它的核心理念与国外三种架构相同,都是提倡以业务为核心,解耦外部依赖,分离业务复杂度和技术复杂度,如下图
虽然 COLA
架构不再是同心圆或者六边形的形式,但是还是能明显看到前文三种架构的影子。Domain
层中 model
对应整洁架构的 Entities
,六边形架构和洋葱架构中的 Domain Model
。Domain
层中 gateway
和 ability
对应整洁架构的 Use Cases
,六边形架构中的 Application Logi
c,以及洋葱架构中的 Domain Services
。App
层则对应整洁架构 Interface Adapters
层中的 Controllers,Gateways
,和 Presenters
。最上方的 Adapter
层和最下方的 Infrastructure
层合起来与整洁架构的边缘层 Frameworks & Drivers
相呼应。
Adapter
层上方的 Driving adater
与 Infrastructure
层下方的 Driven adapter
更是与六边形架构中的 Driving Side
和 Driven Side
高度一致。
├── adapter // Adapter层,适配各种框架及协议的接入,比如:Gin,tRPC,Echo,Fiber 等
├── application // App层,处理Adapter层适配过后与框架、协议等无关的业务逻辑
│ ├── consumer //(可选)处理外部消息,比如来自消息队列的事件消费
│ ├── dto // App层的数据传输对象,外层到达App层的数据,从App层出发到外层的数据都通过DTO传播
│ ├── executor // 处理请求,包括command和query
│ └── scheduler //(可选)处理定时任务,比如Cron格式的定时Job
├── domain // Domain层,最核心最纯粹的业务实体及其规则的抽象定义
│ ├── gateway // 领域网关,model的核心逻辑以Interface形式在此定义,交由Infra层去实现
│ └── model // 领域模型实体
├── infrastructure // Infra层,各种外部依赖,组件的衔接,以及domain/gateway的具体实现
│ ├── cache //(可选)内层所需缓存的实现,可以是Redis,Memcached等
│ ├── client //(可选)各种中间件client的初始化
│ ├── config // 配置实现
│ ├── database //(可选)内层所需持久化的实现,可以是MySQL,MongoDB,Neo4j等
│ ├── distlock //(可选)内层所需分布式锁的实现,可以基于Redis,ZooKeeper,etcd等
│ ├── log // 日志实现,在此接入第三方日志库,避免对内层的污染
│ ├── mq //(可选)内层所需消息队列的实现,可以是Kafka,RabbitMQ,Pulsar等
│ ├── node //(可选)服务节点一致性协调控制实现,可以基于ZooKeeper,etcd等
│ └── rpc //(可选)广义上第三方服务的访问实现,可以通过HTTP,gRPC,tRPC等
└── pkg // 各层可共享的公共组件代码
由此目录结构可以看出通过 Adapter
层屏蔽外界框架、协议的差异,Infrastructure
层囊括各种中间件和外部依赖的具体实现,application
层负责组织输入输出, Domain
层可以完全聚焦在最纯粹也最不容易变化的核心业务规则上。
按照前文 infrastructure
中目录结构,各子目录中文件样例参考如下:
├── infrastructure
│ ├── cache
│ │ └── redis.go // Redis 实现的缓存
│ ├── client
│ │ ├── kafka.go // 构建 Kafka client
│ │ ├── mysql.go // 构建 MySQL client
│ │ ├── redis.go // 构建 Redis client(cache和distlock中都会用到 Redis,统一在此构建)
│ │ └── zookeeper.go // 构建 ZooKeeper client
│ ├── config
│ │ └── config.go // 配置定义及其解析
│ ├── database
│ │ ├── dataobject.go // 数据库操作依赖的数据对象
│ │ └── mysql.go // MySQL 实现的数据持久化
│ ├── distlock
│ │ ├── distributed_lock.go // 分布式锁接口,在此是因为domain/gateway中没有直接需要此接口
│ │ └── redis.go // Redis 实现的分布式锁
│ ├── log
│ │ └── log.go // 日志封装
│ ├── mq
│ │ ├── dataobject.go // 消息队列操作依赖的数据对象
│ │ └── kafka.go // Kafka 实现的消息队列
│ ├── node
│ │ └── zookeeper_client.go // ZooKeeper 实现的一致性协调节点客户端
│ └── rpc
│ ├── dataapi.go // 第三方服务访问功能封装
│ └── dataobject.go // 第三方服务访问操作依赖的数据对象
再接前文提到的博客系统例子,假设用 Gin
框架搭建博客系统 API
服务的话,架构各层相关目录内容大致如下:
// Adapter 层 router.go,路由入口
import (
"mybusiness.com/blog-api/application/executor" // 向内依赖 application 层
"github.com/gin-gonic/gin"
)
func NewRouter(...) (*gin.Engine, error) {
r := gin.Default()
r.GET("/blog/:blog_id", getBlog)
...
}
func getBlog(...) ... {
// b's type: *executor.BlogOperator 属于application层定义的类型
result := b.GetBlog(blogID)
// c's type: *gin.Context
c.JSON(..., result)
}
如代码所体现,Gin
框架的内容全部会被限制在 Adapter
层,其他层不会感知到该框架的存在。
// App 层 executor/blog_operator.go
import "mybusiness.com/blog-api/domain/gateway" // 向内依赖 Domain 层
type BlogOperator struct {
blogManager gateway.BlogManager // 字段 类型gateway.BlogManager是接口类型,通过 Infra 层具体实现进行依赖注入
}
func (b *BlogOperator) GetBlog(...) ... {
blog, err := b.blogManager.Load(ctx, blogID)
...
return dto.BlogFromModel(...) // 通过 DTO 传递数据到外层
}
App
层会依赖 Domain
层定义的领域网关,而领域网关接口会由Infra
层的具体实现注入。外层调用 App
层方法,通过 DTO
传递数据,App
层组织好输入交给 Domain
层处理,再将得到的结果通过 DTO
传递到外层。
// Domain 层 gateway/blog_manager.go
import "mybusiness.com/blog-api/domain/model" // 依赖同层的 model
type BlogManager interface { //定义核心业务逻辑的接口方法
Load(...) ...
Save(...) ...
...
}
Domain
层是核心层,不会依赖任何外层组件,只能层内依赖。这也保障了 Domain
层的纯粹,保障了整个软件系统的可维护性。
// Infrastructure 层 database/mysql.go
import (
"mybusiness.com/blog-api/domain/model" // 依赖内层的 model
"mybusiness.com/blog-api/infrastructure/client" // 依赖同层的 client
)
type MySQLPersistence struct {
client client.SQLClient // client 中已构建好了所需客户端,此处不用引入 MySQL, gorm 相关依赖
}
func (p ...) Load(...) ... { // Domain 层 gateway 中接口方法的实现
record := p.client.FindOne(...)
return record.ToModel() // 将 DO(数据对象)转成 Domain 层 model
}
Infrastructure
层中接口方法的实现都需要将结果的数据对象转化成 Domain
层 model
返回,因为领域网关 gateway
中定义的接口方法的入参、出参只能包含同层的 model
,不可以有外层的数据类型。
前文提及的 Blog
读取过程时序示意图如下: 注意其中Domain是接口,Infra是具体实现
如图,外部请求首先抵达 Adapter
层。如果是读请求,则携带简单参数调用 App
层;如果是写请求,则携带 DTO
调用 App
层。App
层将收到的 DTO
转化成对应的 Model
,调用 Domain
层 gateway
相关业务逻辑接口方法。由于系统初始化阶段已经完成依赖注入,接口对应的来自 Infra
层的具体实现会处理完成并返回 Model
到 Domain
层,再由 Domain
层返回到 App
层,最终经由 Adapter
层将响应内容呈现给外部。
至此可知,参照 COLA 设计的系统分层架构可以一层一层地将业务请求剥离干净,分别处理后再一层一层地组装好返回到请求方。各层之间互不干扰,职责分明,有效地降低了系统组件之间的耦合,提升了系统的可维护性。
无论哪种架构都不会是项目开发的银弹,也不会有百试百灵的开发方法论。毕竟引入一种架构是有一定复杂度和较高维护成本的,所以开发者需要根据自身项目类型判断是否需要引入架构。
不建议引入架构的项目类型:
软件生命周期大概率会小于三个月的
项目维护人员在现在以及可见的将来只有自己的
可以考虑引入架构的项目类型:
软件生命周期大概率会大于三个月的
项目维护人员多于 1
人的
强烈建议引入架构的项目类型:
软件生命周期大概率会大于三年的
项目维护人员多于 5
人的
本文参考:https://mp.weixin.qq.com/s/I2Fx2TIrwXV2kfLj_T5g5g