如何组织 Go 代码文件

1. 背景

我在阅读别人的代码时,一般会带着问题从 main 函数开始读,慢慢地梳理出一条调用链路,但是阅读时很容易迷失在代码中,因为会在文件之间来回跳转。此时会对源代码的组织方式比较好奇,写代码时也有类似的问题:

  • 应该从1个包开始,逐渐提取出其它的包?
  • 哪些代码需要组成单独的包?
  • 包之间应该有什么样的依赖关系?

Go 语言并没有对文件结构做任何限制,所以这些问题我们可以自己做决定。

在确定文件结构之前,需要确定好的结构应该符合什么特点:

  • 命名清晰:目录名称要能清晰地表达出目录实现的功能
  • 功能明确:目录实现的功能应该是明确的,在整个项目目录中有很高的辨识度,当新增功能时,要能够非常清楚地知道把这个功能放在哪个目录下
  • 可扩展性:每个目录下存放了同类的功能,在项目变大时应该可以放更多同类功能
  • 一致性:项目的不同部分应该采用一致的方式,以便在对项目还不熟悉时就能判断出应该用哪一种方式

代码是关于业务逻辑最新的"文档",良好的代码结构可以让我们查找代码。然而,好的结构是实践出来的。

下面以"啤酒评论服务"为例,展示一些代码组织方式。

scss 复制代码
啤酒评论服务
1 用户(user)可以添加一种啤酒(beer)
2 用户可以添加一个关于啤酒的评论(review)
3 用户可以列出所有啤酒
4 用户可以列出给定啤酒的所有评论
5 提供一个存储数据的选项:可以存储在内存(memory)或者JSON文件中
6 能够添加示例数据

2. 扁平结构

plain 复制代码
flat
├── data.go
├── handlers.go
├── main.go
├── model.go
├── storage.go
├── storage_json.go
└── storage_mem.go

扁平结构是一个很好的开始,它的结构简单,易于理解,我们很容易猜出每个文件的功能,比如 handlers.go 是 HTTP 处理程序。这对于小型应用和库很有用。

由于只有一个包,所以不会产生循环依赖。但也导致所有变量都是全局变量,没办法把东西分开,比如 model.go 可以随意访问和修改 handler.go 中的内容。还有一个缺点无法根据文件结构判断整个项目的功能,因为实现其它功能的代码结构可能是完全一样的。

3. 按照技术功能分组

软件系统的功能可以分为业务功能和技术功能,业务功能是与业务相关的操作,技术功能是与技术相关的操作。这里指的是技术功能。把代码按照技术功能分组,典型的方式是三层架构:

  • 用户界面层:负责提供用户界面
  • 业务逻辑层:负责实现业务逻辑
  • 数据访问层:包括外部依赖,比如数据库访问

我们可以根据代码属于哪一层对代码进行分组。目录结构如下:

go 复制代码
layered/
├── data.go
├── handlers
│   ├── beers.go
│   └── reviews.go
├── main.go
├── models
│   ├── beer.go
│   ├── review.go
│   └── storage.go
└── storage
    ├── json.go
    └── memory.go

这个结构把用户界面层和业务逻辑层放到一起,然后按照业务功能拆分出处理 beer 和 review 的文件。模型定义和存储功能分别使用 models 和 storage 文件夹。

此方式的优点是很容易分析出想找的代码在哪里,另外会把相关的东西放到同一个包中,避免产生全局变量。

缺点也有很多:

  • 需要在不同层之间共享的变量没有合适的地方存放,比如配置信息、常量数据、通用数据模型等,models 是需要在业务逻辑和数据访问层共享的,所以只能抽象出单独的包。
  • 随着项目的迭代,对 model 的需求会越来越多,需要通过一个文件完成一个 model 的所有功能会导致 models 中的字段和函数越来越多。不同的场景需要模型的不同字段,在阅读代码时很难判断什么场景需要什么字段。
  • 业务逻辑直接调用存储函数,导致业务逻辑和数据访问耦合度较高,测试也不方便。

4. 按照业务功能对代码分组

这种方式对应的是领域驱动设计(DDD),DDD 的核心原则是将业务领域的概念和规则视为软件系统的核心,将业务领域划分为独立的有界上下文,降低业务的理解难度,它虽然不是关于如何组织文件的,但是按照业务功能对代码分组是它的一种常见做法。

有界上下文可以看做语义上的边界,不同领域的不同实体中可能有不同的属性,所以在代码中重复定义 model 是有必要的。例如,在销售场景中,"用户"可能有交货时间或者采购成本等属性,而在售后服务场景中的用户可能会有其它属性,比如响应时间和工单数量。在划分界限之后,如果要为用户添加一个属性,可以在不影响其它上下文的情况下添加。

一个领域通常会包含以下内容,这里只列举一部分:

  • 语言:与业务有关的概念和术语,避免一个概念用多个相似的词描述而带来混乱。
  • 模型,举例两个例子:
    • 实体(entity):领域中的持久化对象,通常具有ID
    • 值对象(value object):不可变对象,通常是对象的属性,比如价格
  • 服务:无状态操作,提供通用功能的服务
  • 仓库(storage):提供存储数据的接口
  • 事件:领域中发生的重要事件

下面是一个把"啤酒评论服务"根据 DDD 分组的例子。

go 复制代码
domain_example
├── service
│   ├── adding
│   │   ├── endpoint.go
│   │   └── service.go
│   ├── listing
│   │   ├── endpoint.go
│   │   └── service.go
│   └── reviewing
│       ├── endpoint.go
│       └── service.go
├── main.go
├── model
│   ├── beers
│   │   ├── beer.go
│   │   └── sample_beers.go
│   └── reviews
│       ├── review.go
│       └── sample_reviews.go
└── storage
    ├── json.go
    ├── memory.go
    └── type.go

在 domain 中提供了 3 个 service,分别是 adding 添加啤酒,listing 列出啤酒和评论,reviewing 添加评论,每个 service 中包含了 http handler 和业务逻辑。

beersreviews 是两个 model,提供了查询和保存接口,具体的存储功能由 storage 实现。 它的优点和缺点与按照技术功能分组类似,新增的优点是,可以从文件名看出代码提供了哪些服务。

5. 六边形架构

将按技术功能分组和按业务分组结合起来可以达到更好地效果,六边形架构就是这样一种结构。

六边形架构可以将业务逻辑与外部依赖解耦,看起来像图中的六边形一样,被分成了好几层。领域层负责处理业务逻辑;应用层负责协调和调用业务逻辑,包括接收用户请求、处理异常等;框架层负责与外部依赖的交互,外部依赖包括数据库、网络服务等。

三层架构也是分层结构,但与六边形架构有区别,三层架构是上层依赖下层,从表现层到业务逻辑层,再到数据访问层(输出)。从写代码的角度是上层 import 和调用下层。而六边形架构的把输入和输出放在相同的位置,并且依赖只从外部指向内部,即只会出现外层 import 内层的情况。如果 domain 要调用外层,就需要通过接口实现,这利用了接口的重要用途即依赖反转。我们可以在每个边界都设置接口,外层通过实现接口满足内层的需要,让内层以抽象的方式调用外层。

下面是采用六边形架构的代码结构。

go 复制代码
domain_hex
├── cmd
│   ├── beer-server
│   │   └── main.go
│   └── sample-data
│       ├── main.go
│       ├── sample-data
│       ├── sample_beers.go
│       └── sample_reviews.go
└── pkg
    ├── domain
    │   ├── adding
    │   │   ├── beer.go
    │   │   └── service.go
    │   ├── listing
    │   │   ├── beer.go
    │   │   ├── review.go
    │   │   └── service.go
    │   └── reviewing
    │       ├── review.go
    │       └── service.go
    ├── http
    │   └── rest
    │       └── handler.go
    └── storage
        ├── json
        │   ├── beer.go
        │   ├── repository.go
        │   └── review.go
        └── memory
            ├── beer.go
            ├── repository.go
            └── review.go

cmd 文件夹用来存储二进制文件,pkg 存放实际的 go 的代码,现在可以提供多个 main 函数。 pkg 下有 domain, http, storage 三个文件夹。在 domain 中存放核心业务逻辑。共有 3 个 domain,提供添加啤酒、添加评论和获取数据的功能,每个 domain 下都有自己的模型和服务。现在,不同 domainstorage 中可以有同一个 model,但可以有不同的定义。

另一个改变是,现在由 http 包调用 domain 中的服务,storage 包实现 domain 中定义的存储接口,http 负责初始化 domain 服务,并且在初始化 domain 服务的时候将 storage 作为接口实现传进去了。

尽管 DDD 尝试将业务划分成独立的领域,但是 domain 之间有时是要会共享一些东西的,我们经常会遇到 utils、base、common 等用来存储各种实用函数,可以分为三种情况处理:

  • 第一种情况是,直接复制到使用它的包中,因为 a little duplication is far cheaper than the wrong abstraction;
  • 第二种情况是,确实要共享,那就拆分成多个包,用一个语义明确的名字而不是通用的名字;
  • 第三种如果两个包之间确实有大量东西要共享,那就需要考虑合并成一个包,放到不同文件里。
相关推荐
研究司马懿8 小时前
【云原生】Gateway API高级功能
云原生·go·gateway·k8s·gateway api
梦想很大很大1 天前
使用 Go + Gin + Fx 构建工程化后端服务模板(gin-app 实践)
前端·后端·go
lekami_兰1 天前
MySQL 长事务:藏在业务里的性能 “隐形杀手”
数据库·mysql·go·长事务
却尘1 天前
一篇小白也能看懂的 Go 字符串拼接 & Builder & cap 全家桶
后端·go
ん贤1 天前
一次批量删除引发的死锁,最终我选择不加锁
数据库·安全·go·死锁
mtngt112 天前
AI DDD重构实践
go
Grassto3 天前
12 go.sum 是如何保证依赖安全的?校验机制源码解析
安全·golang·go·哈希算法·go module
Grassto5 天前
11 Go Module 缓存机制详解
开发语言·缓存·golang·go·go module
程序设计实验室6 天前
2025年的最后一天,分享我使用go语言开发的电子书转换工具网站
go
我的golang之路果然有问题6 天前
使用 Hugo + GitHub Pages + PaperMod 主题 + Obsidian 搭建开发博客
golang·go·github·博客·个人开发·个人博客·hugo