代码架构揭秘:从整洁到COLA,你该如何选择

在软件开发中,选择合适的代码框架对于降低系统组件间的耦合、提升系统可维护性至关重要。本文将带你了解三种广为人知的代码框架,并进一步探讨COLA架构。此外,还将分享作者基于COLA架构设计的Go语言项目脚手架实践方案,以期为开发爱好者们提供有益的帮助与启示。

  1. 如何打造优秀的代码架构
    1.1 整洁架构详解
    1.2 洋葱架构的特点
    1.3 六边形架构的优势
    1.4 COLA架构的独特之处
  2. Go语言项目中的代码架构实践推荐
  3. 总结与启示

请注意,本文所提及的代码架构,主要聚焦于项目组织的层面,与微服务架构等宏观服务架构概念有所区别。

为什么我们需要代码架构?

在大型项目中,往往会有众多开发者共同参与,若缺乏良好的架构指导,代码可能会变得杂乱无章,难以维护。这种情况不仅导致开发效率低下,还可能让开发者在尝试增加新功能时陷入困境,需要花费大量时间来理清逻辑和补充测试代码,从而严重影响开发体验。

为了解决这一问题,无数开发者进行了长期探索,并在此基础上诞生了众多软件架构理念,如六边形架构洋葱架构整洁架构等。尽管这些架构在具体细节上有所不同,但它们共同致力于实现软件系统的关注点分离,确保系统具备更高的灵活性、可维护性和可测试性。

实现关注点分离后,软件系统将展现出以下显著特征:

  • UI无关性:系统不依赖于特定UI框架或界面,使得UI的替换不会影响到业务逻辑。无论是从Web界面变为桌面应用,还是控制台界面,都不会对业务逻辑造成干扰。

  • 框架无关性:系统能够灵活适应不同框架,如JavaScript生态中的 koa、express 等 web 框架,electron等桌面应用框架,以及commander等控制台框架。框架的更换不会影响到业务逻辑本身。

  • 外部组件无关性:系统可以无缝切换使用不同的外部组件,如数据库(MySQL、MongoDB或Neo4j)和键值存储(Redis、Memcached或etcd)等。这些变更不会对业务逻辑产生任何影响。

  • 易于测试:核心业务逻辑能够在无UI、数据库或Web服务器等外界组件参与的情况下进行纯粹的代码逻辑测试,从而确保了测试的便捷与高效。

具备上述特征的软件系统不仅测试更为便捷,而且维护和更新也变得轻而易举,这极大减轻了软件开发人员的工作负担。因此,优秀的代码架构值得广泛推崇和应用。

好的代码架构是如何构建的?

前文所提及的三种架构在理念上相互契合,这一共性在图1至图3所示的架构图中也得到了充分体现,它们均展现了类似的圈层结构。从图中可以观察到,随着圈层的向外扩展,其具体性逐渐增强,而内层则更加抽象。这种层次性意味着外层的变化可能性更高,这些变化可能涉及框架的升级、中间件的替换,以及对新终端的适配等众多方面。

2.1 简洁明了的架构设计

在构建良好的代码架构时,简洁明了是一个至关重要的原则。一个好的架构应该能够清晰地展示各个组件之间的关系,避免不必要的复杂性和冗余。通过合理的命名、有效的注释以及直观的代码布局,可以使架构更加易于理解和维护。同时,还需要注意避免过度设计,确保架构能够灵活适应未来的变化需求。

图 1展示了整洁架构的同心圆结构,其中三条由外向内的黑色箭头清晰地指出了依赖规则。依赖规则要求外层的代码可以依赖内层,但内层的代码不得依赖外层。这意味着内层逻辑,如Gateways、Use Cases和Entities等,不应引用或感知到外层中定义的任何变量、函数、结构体、类或模块等代码实体。

以一个简单的博客系统为例,核心层的Entities定义了Blog、Comment等核心业务实体。这些实体高度抽象,仅随核心业务规则的变化而变化,不受外层组件的影响。

核心层的外层是应用业务层,其Use Cases包含软件系统的所有业务逻辑。Use Cases层控制流向和流出核心层的数据流,利用核心层的实体及其业务规则来满足业务需求。此层的变更不会影响到核心层或更外层的组件,如开发框架、数据库或UI等。

继续以博客系统为例,应用业务层可以定义BlogManager接口及其中的CreateBlog、LeaveComment等业务逻辑方法。

接口适配层

接口适配层的外层与内层之间进行数据转换,确保数据在传输过程中保持一致性和可用性。该层通过将外层输入的数据格式转换为内层Use Cases和Entities可用的格式,以及将内层处理结果再转换为外层可用的格式,实现了不同层次之间的顺畅交互。此外,接口适配层还负责处理与外界服务的通信,确保所有关于外界服务数据的转化都在此层完成,从而保证了内层的独立性。

在博客系统的示例中,接口适配层可能涉及将用户输入的数据转换为适合核心层处理的格式,以及将核心层的处理结果再转换为外部框架或工具可识别的格式。这种转换通常通过使用DTO(Data Transfer Object)或DO(Data Object)等数据对象来完成。此外,该层还负责处理与数据库的交互,确保所有SQL处理都在此层完成,从而保证了核心层对数据库的透明性。

框架和驱动层

框架和驱动层处于接口适配层的外侧,主要负责与外部框架和工具进行数据衔接。这一层包含了具体的框架和依赖工具的细节,例如系统所使用的数据库、Web框架、消息队列等。通过与这些外部组件的交互,框架和驱动层确保了内层逻辑与外部世界的顺畅连接。在博客系统的示例中,如果使用gorm来操作数据库,相关的代码可能涉及导入gorm.io/driver/mysqlgorm.io/gorm等包,以实现与数据库的连接和操作。

数据对象与数据库操作

在整洁架构中,数据对象(如blog结构体)扮演着关键角色,它与数据库操作紧密相关。当使用gorm等ORM工具时,数据对象的字段上会添加特定的标签(如gorm:””),这些标签用于指导数据库操作。同时,MySQLClient结构体封装了与数据库的连接和操作,确保了内层逻辑与数据库的独立交互。

此外,整洁架构并不严格要求软件系统必须分为四层。只要系统能遵循“由外向内”的依赖规则,层数设计便可灵活多变。这与洋葱架构相似,二者都强调了四层同心圆的结构,但具体实现上可能有所不同。

2.2 洋葱架构

洋葱架构,与整洁架构相似,也强调了四层同心圆的结构。然而,这两种架构在具体实现上可能有所不同。洋葱架构同样注重内层逻辑与外层依赖的解耦,确保了系统的高内聚和低耦合。

图2展示了洋葱架构的核心组成部分。最内核的Domain Model,作为组织中核心业务的状态和行为模型,与整洁架构中的Entities概念高度契合。外层的Domain Services,其职责与整洁架构中的Use Cases相似。再往外,Application Services作为UI与Infrastructure(如数据库、文件、外部服务等)之间的桥梁,与整洁架构中的Interface Adaptors功能相同。最外层的User Interface与整洁架构中的UI部分相呼应,而Infrastructure则与DB、Devices、External Interfaces等组件作用一致,仅在Tests方面存在细微差异。值得注意的是,尽管六边形架构的外形并非同心圆,但其结构上与洋葱架构存在诸多相似之处。

2.3六边形架构的探讨

在对比了洋葱架构与整洁架构之后,我们进一步探讨另一种架构风格——六边形架构。尽管其外观并非同心圆,但深入其结构,我们会发现它与洋葱架构在某种程度上是相似的。这种相似性不仅体现在各层职责的划分上,更在于它们共同遵循的软件设计原则。因此,通过对比分析,我们可以更全面地理解这两种架构风格的异同与适用场景。

图3展示了六边形架构,其中中灰色箭头代表依赖注入,这一设计理念与整洁架构中的依赖规则有着异曲同工之妙。同样,它也约束了整个架构中组件的依赖方向,必须严格遵循“由外向内”的原则。在六边形架构中,Port和Adapter的多样性及其重要性不言而喻,这也是该架构被俗称为Ports and Adapters的原因。

图4展示了六边形架构的第一阶段,由Pablo Martinez绘制。在这一阶段中,来自驱动边的用户或外部系统输入指令,通过左侧的Port和Adapter进入应用系统进行处理。处理完成后,再经由右侧的Adapter和Port将结果输出到被驱动边的数据库和文件等存储系统中。

Port在系统中扮演着一个与具体实现无关的角色,它定义了外界与系统之间的通信接口。Port并不关心接口的具体实现细节,类似于USB端口能够支持多种设备与电脑通信,却不必了解设备与电脑之间的照片、视频等具体数据的编解码传输过程。

图5展示了六边形架构的第二阶段,同样由Pablo Martinez绘制。在这一阶段中,Adapter负责实现Port所定义的接口,并通过Port与应用系统进行交互。例如,在图示的左侧Driving Side,Adapter可能是一个REST控制器,用于处理客户端与应用系统之间的通信。而在右侧Driven Side,Adapter可能是一个数据库驱动,负责将应用系统的数据写入数据库。值得注意的是,尽管六边形架构在外观上可能与整洁架构有所不同,但其核心层的Domain和边缘层的User Interface与Infrastructure却与整洁架构中的Entities和Frameworks&Drivers保持着完全的一一对应关系。

让我们再次回到图3所示的六边形架构整体图。以Java生态为例,Driving Side的HTTP Server In Port可以接收来自Jetty或Servlet等Adapter的请求,其中Jetty的请求可能源自其他服务的调用。同时,位于Driving Side和Driven Side的Messaging In/Out Port可以处理来自RabbitMQ的事件请求,并将Application Adapters生成的数据写入RabbitMQ。另一方面,Driven Side的Store Out Port可以将Application Adapters产生的数据写入MongoDB数据库,而HTTP Client Out Port则可以将这些数据通过JettyHTTP发送到外部服务。实际上,优秀的代码架构不仅存在于国外,国内同样有着出色的实践。

2.4 COLA架构

在吸收了六边形架构、洋葱架构以及整洁架构的精髓后,国内开发者们共同提出了COLA(Clean Object-oriented and Layered Architecture)架构,其命名寓意为“简洁明了的面向对象与分层架构”这一架构同样强调以业务为中心,力求降低外部依赖的复杂性,并通过分离业务与技术层面来简化整体架构。其整体架构形式,如图6所示。

图6展示了COLA架构,尽管它摒弃了同心圆或六边形的传统形式,但仍然可以清晰地看到前述三种架构的精髓所在。在Domain层中,model元素与整洁架构的Entities、六边形架构和洋葱架构中的Domain Model相呼应。同时,gateway和ability元素则分别与整洁架构的Use Cases、六边形架构中的应用逻辑以及洋葱架构中的Domain Services相契合。App层则类似于整洁架构中Interface Adapters层中的Controllers、Gateways和Presenters。而最上方的Adapter层与最下方的Infrastructure层共同构成了与整洁架构边缘层Frameworks & Drivers相对应的体系。特别值得一提的是,Adapter层上方的Driving adapter与Infrastructure层下方的Driven adapter设计与六边形架构中的Driving Side和Driven Side极为相似。

COLA架构在Java生态系统中已得到广泛应用,并提供了Java语言的原型,便于Java项目脚手架代码的快速生成。受此启发,笔者提出了一种适用于Go语言的COLA架构项目脚手架实践方案。

推荐一种Go代码架构实践

项目目录结构如下:

├── adapter // Adapter层,负责适配各种框架及协议,如Gin、tRPC、Echo、Fiber等
├── application // App层,处理与框架、协议等无关的业务逻辑
│ ├── consumer //(可选)负责处理外部消息,如来自消息队列的事件消费
│ ├── dto // App层的数据传输对象,用于在App层与外部之间传递数据
│ ├── executor // 处理请求,包括command和query
│└── scheduler //(可选)处理定时任务,如Cron格式的定时Job
├── domain // Domain层,包含最核心的业务实体及其规则的抽象定义
│ ├── gateway // 领域网关,以Interface形式定义model的核心逻辑,由Infra层实现
│└── model // 领域模型实体
├── infrastructure // Infra层,负责衔接各种外部依赖和组件,以及实现domain/gateway的具体功能
│ ├── cache //(可选)实现内层所需的缓存功能,如Redis、Memcached等
│ ├── client //(可选)初始化各种中间件client
│ ├── config // 配置实现
│└── database //(可选)实现内层所需的持久化功能,如MySQL、MongoDB、Neo4j等

此架构实践融合了COLA架构的精髓,并结合Go语言的特性进行优化,旨在为Go项目提供一种清晰、可扩展的代码组织方式。

│ ├── distlock //(可选)提供分布式锁功能,支持Redis、ZooKeeper、etcd等实现
│ ├── log // 日志管理,集成第三方日志库,保持内层代码的清洁
│ ├── mq //(可选)消息队列实现,兼容Kafka、RabbitMQ、Pulsar等
│ ├── node //(可选)服务节点一致性控制,基于ZooKeeper、etcd等技术
│ └── rpc //(可选)第三方服务访问,支持HTTP、gRPC、tRPC等协议
└── pkg // 公共组件包,供各层共享

这样的目录结构使得Adapter层能够有效地屏蔽外界框架和协议的差异,而Infrastructure层则专注于各种中间件和外部依赖的具体实现。App层得以专注于组织输入和输出,而Domain层则能够集中精力处理最核心且最稳定的业务规则。同时,各子目录中的文件样例展示了如何针对不同的中间件和功能进行实现。

│ ├── redis.go // Redis客户端构建,统一管理Redis资源
│ ├── zookeeper.go // ZooKeeper客户端构建,用于服务节点一致性控制
│ ├── config
│ │ └── config.go // 配置定义与解析,确保系统运行所需参数正确性
│ ├── database
│ │ ├── dataobject.go // 数据对象定义,为数据库操作提供统一数据结构
│ │ └── mysql.go // MySQL数据库实现,负责数据持久化功能
│ ├── distlock
│ │ ├── distributed_lock.go // 分布式锁接口定义,确保多节点环境下的数据一致性
│ │ └── redis.go // Redis分布式锁实现,利用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" // 引入App层依赖
"github.com/gin-gonic/gin"
)

func NewRouter(engine *gin.Engine) *gin.Engine {
r := gin.Default()
r.GET("/blog/:blog_id", getBlog) // 定义路由规则
return r
}

func getBlog(c *gin.Context) {
// 获取blogID并调用executor层的BlogOperator进行操作
blogID := c.Param("blog_id")
b := executor.NewBlogOperator() // 创建BlogOperator实例
result := b.GetBlog(blogID) // 执行获取博客操作
c.JSON(200, result) // 将结果以JSON格式返回给客户端
}

在上述代码中,Gin框架的代码仅限于Adapter层,其他层对框架细节保持透明,降低了系统间的耦合度。同时,通过明确各层的职责与接口,提高了系统的灵活性与可维护性。

import "mybusiness.com/blog-api/domain/gateway" // 引入Domain层依赖

type BlogOperator struct {
blogManager gateway.BlogManager // 注入Domain层定义的BlogManager接口
}

func (b *BlogOperator) GetBlog() {
blog, err := b.blogManager.Load(ctx, blogID)
// ...(此处省略其他逻辑)
return dto.BlogFromModel() // 通过DTO将数据传递至外层
}

在上述代码中,App层依赖于Domain层定义的领域网关,而该网关接口由Infra层的具体实现注入。当外层调用App层的方法时,通过DTO传递数据。App层将组织好的输入交给Domain层处理,并将得到的结果通过DTO返回给外层。

值得注意的是,Domain层是软件系统的核心层,它不依赖任何外层组件,只能内部依赖。这种设计保证了Domain层的纯粹性,进而保障了整个软件系统的可维护性。

"mybusiness.com/blog-api/infrastructure/client" // 依赖同层的client

type MySQLPersistence struct {
client client.SQLClient // client中已构建好所需客户端,无需引入MySQL、gorm等依赖
}

func (p *MySQLPersistence) Load(...) { // Domain层gateway中接口方法的实现
record := p.client.FindOne(...)
return record.ToModel() // 将DO(数据对象)转换为Domain层model
}

在Infrastructure层中,接口方法的实现需要将结果的数据对象转换为Domain层的model后返回。这是因为在领域网关gateway中定义的接口方法的参数和返回值只能包含同层的model,不能包含外层的数据类型。

前文所述的完整调用流程如图7所示。

图 7 Blog 读取过程时序示意图

在图中,外部请求首先触达Adapter层。对于读取请求,该层会携带简洁参数来调用App层;而对于写入请求,则会携带DTO来调用App层。在App层,收到的DTO将被转换为相应的Model,进而调用Domain层的gateway相关业务逻辑接口方法。由于系统在初始化阶段已经完成了依赖注入,因此接口对应的Infra层具体实现会进行处理并返回Model到Domain层。Domain层再将结果返回给App层,最终经由Adapter层将响应内容呈现给外部用户。

这一过程展示了COLA设计的系统分层架构如何逐层剥离业务请求,进行分别处理后再逐层组装返回。各层之间保持独立,职责明确,从而有效降低了系统组件间的耦合度,提升了系统的可维护性。
总结:

无论采用何种架构,都难以成为项目开发的万能钥匙,也不存在一种适用于所有情况的开发方法论。引入架构会带来一定的复杂度和维护成本,因此,开发者应根据项目的实际情况来决定是否需要引入架构。

以下是一些建议引入架构的项目类型:
  • 软件生命周期可能超过三个月的项目。
  • 拥有多名项目维护人员的情况。
而对于以下类型的项目,可能并不适合引入架构:
  • 软件生命周期极短,如大概率小于三个月的项目。
  • 项目维护人员匮乏,如现在和可预见的未来都只有单一维护人员的情况。

参考文献:

[1] Robert C. Martin, The Clean Architecture,https://blog.cleancoder.com/uncle-bob/2012/08/13/the-clean-architecture.html(2012)
[2] Andrew Gordon, Clean Architecture,https://www.andrewgordon.me/posts/Clean-Architecture/(2021)
[3] Pablo Martinez, Hexagonal Architecture: There Are Always Two Sides to Every Story,https://medium.com/ssense-tech/hexagonal-architecture-there-are-always-two-sides-to-every-story-bc0780ed7d9c(2021)
[4] Jeffrey Palermo,洋葱架构(The Onion Architecture),https://jeffreypalermo.com/2008/07/the-onion-architecture-part-1/ (2008)

以上是本次分享的全部内容,感谢大家的聆听与交流。若您觉得这些信息对您有所助益,不妨分享给更多人,共同学习与进步。

作者:Ddd4j  创建时间:2025-12-09 14:27
最后编辑:Ddd4j  更新时间:2025-12-15 13:14