Go 从 0 到精通 · 第 33 课:项目结构与工程组织
Go 从 0 到精通 · 第 33 课:项目结构与工程组织
学习定位:这是整套 Go 教程的第 33 课,也是阶段五(并发与工程阶段)的第九课。
前置要求:已经完成第 32 课,掌握了go.mod、包管理、错误处理、测试与 Benchmark 的基础能力。
本课目标:理解 Go 项目从“代码能跑”到“结构清晰、易维护”的组织方法,掌握常见目录结构、包拆分原则、cmd/internal的用途,学会把配置、日志和错误包装纳入统一工程结构,并能从零搭出一个小型 Go 工程骨架。
1. 本课你要解决的核心问题
写到现在,你已经会写函数、结构体、接口、并发、测试和性能测试了。
但真正开始做项目时,你很快会碰到另一个层面的问题:
main.go越写越长,几百行甚至上千行- 文件越来越多,却不知道该怎么拆包
- 配置到处读,环境变量满天飞
- 日志有的用
fmt.Println,有的用log.Println,风格不统一 - 错误只会
return err,一旦报错根本不知道是哪一层出的事 - 包之间互相 import,最后出现循环依赖
这时候你会发现:会写 Go 代码,不等于会组织 Go 项目。
项目结构与工程组织,解决的是这几个问题:
- 代码应该按什么维度拆分
- 哪些东西放在根目录,哪些放到子目录
- 业务逻辑、配置、日志、存储逻辑分别放哪
- 怎么让包依赖关系保持清晰
- 怎么让项目以后继续长大时不失控
这一课的重点不是“背目录模板”,而是理解为什么要这样组织。
2. 先建立一个正确认识:Go 工程结构不是越复杂越高级
很多人刚接触工程化,就喜欢先抄一个很大的目录模板:
1 | cmd/ |
然后项目里只有 3 个 .go 文件。
这就是典型的过度设计。
Go 社区对项目结构有一个很重要的共识:
结构要服务于规模,而不是为了看起来专业。
2.1 小项目可以很简单
如果你只是写一个练手的小工具,这样完全没问题:
1 | hello-go/ |
只要代码不多、职责不乱,这就是合理结构。
2.2 项目一长大,结构问题就会暴露
假设你写了一个任务管理 CLI,最开始只有 1 个文件:
1 | package main |
一开始还行,三天后就会变成这样:
main负责所有事情- 文件读写和业务逻辑混在一起
- 输出、日志、错误处理混在一起
- 想加测试时发现逻辑都在
main里,很难测
所以,项目结构的真正目标不是“好看”,而是:
- 让职责分开
- 让依赖方向清晰
- 让代码方便测试
- 让后续迭代不容易失控
2.3 一个实用判断标准
什么时候应该开始拆结构?
当你出现下面任意一种情况时,就该动手了:
- 一个文件超过 200~300 行,还在继续增长
- 一个包里既有业务逻辑,又有存储逻辑,又有命令行解析
- 多个功能开始共享同一段逻辑
- 需要给某一层单独写测试
- 你自己隔一周回来已经不容易快速定位代码
记住一句话:
先让代码跑起来,再在增长点上做结构化拆分。
3. Go 项目结构的三种常见层级
这部分不要死记,重点是理解“项目规模变化,结构怎么跟着演进”。
3.1 第一层:单文件项目
适合场景:
- 学习示例
- 一次性脚本
- 非常小的 CLI 工具
结构:
1 | project/ |
优点:
- 最简单,零心智负担
- 新手最容易启动
缺点:
- 很快堆成“大杂烩”
- 几乎没有可维护性
3.2 第二层:多文件、同包项目
适合场景:
- 小型练手项目
- 功能开始变多,但还没复杂到拆多个包
结构:
1 | project/ |
这些文件都属于同一个 package main。
优点:
- 比单文件清晰
- 不需要处理跨包依赖
缺点:
- 仍然没有真正的边界
- 逻辑很容易继续耦合
- 测试和复用能力有限
3.3 第三层:多包工程项目
适合场景:
- 需要长期维护
- 业务逻辑、存储逻辑、配置、日志开始分层
- 希望结构清晰、可测、可扩展
结构示意:
1 | project/ |
这就是我们这课要重点掌握的层级。
4. 常见目录到底是干什么的
下面这些目录是 Go 项目里最常见的。注意:常见,不等于必须全用。
4.1 根目录
根目录通常放项目级别的信息:
1 | project/ |
常见文件作用:
| 文件 | 作用 |
|---|---|
go.mod |
模块名、Go 版本、依赖声明 |
go.sum |
依赖校验信息 |
README.md |
项目说明、运行方式 |
.gitignore |
Git 忽略规则 |
根目录通常不应该堆很多业务代码。如果项目进入工程化阶段,业务代码最好放进更明确的目录中。
4.2 cmd/
cmd 用来放可执行程序入口。
例如:
1 | cmd/ |
说明:
cmd/todo/main.go是任务管理 CLI 的入口cmd/migrate/main.go是数据库迁移工具入口
如果一个仓库里有多个可执行程序,cmd 特别好用。
一个很重要的实践是:
main.go负责组装依赖和启动程序,不负责承载业务细节。
也就是说,main.go 应该更像“接线员”,而不是“业务实现中心”。
4.3 internal/
internal 是 Go 很有特色的一个目录。
放进 internal 的包,只允许当前模块内部导入,外部模块不能导入。
例如项目结构:
1 | example.com/todo-cli/ |
当前模块内部可以导入:
1 | import "example.com/todo-cli/internal/task" |
但其他项目如果想导入这个路径,会直接编译失败。
这意味着:
internal适合放你的内部实现细节- 可以避免别人把你的内部包当公共 API 使用
- 能强制建立“这是内部实现,不对外承诺稳定性”的边界
4.4 pkg/
pkg 不是 Go 语言强制规定的目录,只是一种约定。
常见理解是:
internal/放内部实现pkg/放希望被外部复用的公共包
但要注意,pkg/ 并不是必须的,很多项目根本不用它。
什么时候适合用 pkg/
- 你明确要提供一个可复用库
- 你希望外部项目可以 import 你的某些包
什么时候不建议一上来就用 pkg/
- 你还不确定哪些代码会被外部复用
- 项目本质是应用,而不是库
- 你只是为了“看起来像大项目”而加
pkg/
对初学者来说,更推荐的原则是:
应用型项目优先考虑 cmd + internal,不要机械套 pkg。
4.5 configs/、scripts/、testdata/
这些目录也是常见的,但都是“按需使用”。
| 目录 | 常见用途 |
|---|---|
configs/ |
示例配置文件、模板配置 |
scripts/ |
构建、发布、初始化脚本 |
testdata/ |
测试输入文件,Go 测试约定会忽略它 |
例如:
1 | project/ |
如果你当前项目根本没有这些需求,就不要先建空目录。
5. 包怎么拆?先看两种常见思路
这是工程组织里最关键的地方。
同一个项目,包拆法可能完全不同。没有绝对唯一答案,但有优劣。
5.1 方案 A:按技术职责拆包
例如:
1 | internal/ |
特点:
config管配置logger管日志model放数据结构service放业务逻辑store放文件、数据库等存储实现
优点:
- 对初学者非常直观
- 依赖关系容易理解
- 很适合单一业务的小项目
缺点:
- 项目一大,和同一业务相关的代码会分散在很多目录里
- 找一个功能需要跨多个目录跳来跳去
5.2 方案 B:按业务领域拆包
例如:
1 | internal/ |
其中 task/ 里可以同时放:
- 任务数据结构
- 任务业务逻辑
- 任务相关接口定义
优点:
- 高内聚,围绕业务组织
- 功能扩展时更自然
- 中大型项目更常见
缺点:
- 对新手来说不如“按职责拆包”直观
- 共享基础能力需要额外抽象
5.3 这一课推荐哪种
对于你当前这个阶段,我建议这样理解:
- 单一业务、体量不大:按技术职责拆包更容易上手
- 业务开始明显分块:按业务领域拆包更利于长期维护
这一课的完整示例会采用一种折中方式:
config、logger、store这类明显的基础设施单独拆出- 核心业务收拢到
task包里
这比“纯技术分层”更贴近真实项目,也比“完全按领域”更容易理解。
6. 包拆分的五条实用原则
不管你按哪种思路拆,下面这五条都非常重要。
6.1 一包一职责
一个包最好有明确边界。
例如:
config:只负责配置加载和校验logger:只负责日志初始化task:只负责任务领域逻辑store:只负责持久化实现
反例是这种“万能包”:
1 | internal/ |
几年经验总结下来,util、common、helper 这类名字往往意味着:
- 边界模糊
- 什么都能往里塞
- 后面一定越来越乱
6.2 依赖方向要单向
好的依赖关系通常是:
1 | main -> config/logger/store/task |
坏的依赖关系通常是互相 import:
1 | task -> store |
一旦这样写,就很容易出现循环依赖。
Go 明确禁止循环 import,这是件好事,因为它逼着你把结构整理清楚。
6.3 接口尽量定义在使用方
比如任务服务需要一个“能加载和保存任务”的存储能力,那么接口更适合定义在 task 包中:
1 | type Repository interface { |
为什么?
因为是 task 服务在“消费”这项能力,它最清楚自己需要什么方法。
这样做的好处:
- 接口更小、更聚焦
- 存储层更容易替换
- 更方便测试时做 mock
6.4 包名要短、准、自然
推荐:
taskstoreconfiglogger
不推荐:
taskservicetaskmanagercommonutilstask_model_package
Go 包名强调简洁,因为调用时会带上包名前缀:
1 | task.NewService() |
如果包名太长,代码会非常啰嗦。
6.5 导出越少越好
能不导出的就别导出。
例如:
1 | type Service struct {} |
像 nextTaskID 这种纯内部辅助函数,就保持小写:
1 | func nextTaskID(tasks []Task) int { ... } |
导出太多,会让包的表面 API 变大,维护成本上升。
7. 一个完整的小型工程示例
下面我们用“任务管理 CLI”作为例子,搭一个结构清晰的小项目。
7.1 最终目录结构
1 | todo-cli/ |
这个结构里,每一层职责都比较清晰:
cmd/todo/main.go:程序入口,解析参数,组装依赖internal/config:读取配置internal/logger:初始化日志internal/store:任务数据的文件存储实现internal/task:任务领域模型与业务逻辑
7.2 初始化模块
1 | go mod init example.com/todo-cli |
这里的模块名只是示意,你实际项目可以换成自己的仓库地址。
8. 先定义业务包:internal/task
业务包通常应该先建,因为它代表项目最核心的能力。
8.1 任务模型
1 | // internal/task/task.go |
8.2 服务层
1 | // internal/task/service.go |
这个包体现了几个关键点:
- 业务逻辑集中在
task包 - 存储能力通过接口抽象
- 错误在当前语义层补充上下文
- 日志记录业务事件,但不吞掉错误
9. 再实现存储层:internal/store
现在我们给 task.Service 提供一个文件存储实现。
1 | // internal/store/file_store.go |
这里你可以看到一个很重要的依赖方向:
1 | store -> task |
也就是说:
task只知道自己需要一个Repositorystore来适配这个接口
这是非常经典、非常实用的工程组织方式。
10. 配置不要到处读:统一放进 internal/config
新手常见写法是这样的:
1 | dbHost := os.Getenv("DB_HOST") |
然后这些逻辑散落在:
main.goservice.gostore.gohandler.go
这会导致两个问题:
- 配置来源不集中,改起来很痛苦
- 很难知道某个环境变量到底在哪里生效
更好的做法是:统一加载,统一校验,统一传递。
1 | // internal/config/config.go |
10.1 配置设计的三条原则
原则 1:配置集中定义
所有运行配置尽量收敛到一个 Config 结构体中。
这样你只要看这个结构体,就知道程序依赖哪些配置。
原则 2:配置加载和业务逻辑分开
task.Service 不应该自己去读环境变量。
因为:
- 它只关心“我要一个数据文件路径”
- 不关心这个路径是从环境变量、命令行还是配置文件来的
原则 3:配置要有默认值和校验
比如:
LogLevel默认INFODataFile默认data/tasks.json- 特别关键的字段可以显式校验
这会让项目在开发环境里更容易启动。
11. 日志不要乱打:统一放进 internal/logger
在小练习里你可以到处 fmt.Println,但工程里最好尽快建立统一日志方式。
11.1 为什么日志要统一
如果项目里同时存在:
fmt.Printlnlog.Printlnpaniclogger.Info
那后面排查问题会非常痛苦。
统一日志至少能带来这些好处:
- 输出格式一致
- 可以控制日志级别
- 更容易定位问题
- 以后切换日志实现代价更小
11.2 用标准库 log/slog
Go 1.21 开始,标准库提供了 log/slog,很适合工程项目的结构化日志需求。
1 | // internal/logger/logger.go |
11.3 日志应该记什么
好的日志通常记录:
- 关键业务事件
- 错误发生点
- 重要上下文信息
例如:
1 | s.logger.Info("新增任务成功", "id", item.ID, "title", item.Title) |
这比单纯打印:
1 | fmt.Println("add ok") |
信息量大很多。
11.4 日志的一个常见误区
不要每一层都打一遍同一个错误日志。
错误链路如果是:
1 | os.ReadFile 失败 |
通常只需要:
- 中间层补充错误上下文
- 最外层决定是否打印日志或退出程序
否则你会看到同一个错误被打印 3 次。
12. 错误包装是工程组织的重要部分
这节课的“工程感”很大一部分来自错误处理方式。
12.1 为什么不能只写 return err
假设底层报错:
1 | open data/tasks.json: no such file or directory |
如果每一层都只是 return err,到了最外层你只能看到这个原始错误。
你不知道:
- 是加载配置失败
- 还是读取任务失败
- 还是保存任务失败
12.2 正确做法:在每一层补充语义
1 | tasks, err := s.repo.Load() |
再往上一层:
1 | if err := run(service, action, title, id); err != nil { |
最后得到的错误链会更有语义,例如:
1 | 操作失败: 读取任务列表失败: 读取文件 data/tasks.json 失败: 权限不足 |
这就非常容易定位了。
12.3 %w 和 errors.Is
错误包装时要用 %w:
1 | return fmt.Errorf("标记任务完成失败: %w", ErrTaskNotFound) |
这样最外层才能用 errors.Is 判断:
1 | if errors.Is(err, task.ErrTaskNotFound) { |
这就是工程里常说的:
- 中间层负责补充上下文
- 边界层负责决定如何处理
12.4 什么时候不该包装
如果当前层没有增加任何新语义,单纯套一层:
1 | return fmt.Errorf("error: %w", err) |
其实价值不大。
包装的关键不是“多包一层”,而是“补充定位信息”。
13. 程序入口应该长什么样
现在把前面的配置、日志、存储、业务拼起来。
1 | // cmd/todo/main.go |
你会发现这个 main.go 有一个明显特征:
它几乎不写业务细节,只做四件事:
- 解析输入
- 初始化依赖
- 调用业务服务
- 处理边界输出和退出码
这就是一个很健康的入口结构。
14. 从这个示例里你应该学到什么
这个小项目虽然不大,但已经有了比较完整的工程轮廓。
14.1 业务逻辑不放在 main
main 不是写业务规则的地方。
如果以后你要:
- 写单元测试
- 增加 HTTP 接口
- 改成 GUI 或 Web 后台
只要 task.Service 还在,核心逻辑就能复用。
14.2 存储实现可以替换
今天是 FileStore,明天可以换成 DBStore:
1 | type DBStore struct {} |
只要它实现了:
1 | Load() ([]task.Task, error) |
就能无缝接进 task.Service。
这就是接口带来的工程价值。
14.3 配置、日志、错误处理都有统一入口
工程组织不只是目录拆分,更重要的是:
- 配置统一加载
- 日志统一初始化
- 错误统一包装和落地处理
这三件事做好了,项目维护成本会明显下降。
15. internal 的价值,再单独讲一次
很多初学者知道 internal,但不知道它真正的工程意义。
15.1 它不是“普通目录”,而是语言级边界
这和你随便建个 private/ 目录完全不同。
private/ 只是命名约定。
internal/ 则是 Go 编译器真的会帮你拦住外部导入。
这意味着:
- 你可以放心把内部实现放进去
- 外部项目无法依赖你的“脆弱内部细节”
- 包边界更稳定
15.2 什么时候应该放进 internal
适合放进 internal 的内容:
- 业务服务实现
- 存储实现
- 配置加载
- 日志初始化
- 只服务于当前项目的工具代码
不太适合放进 internal 的内容:
- 你明确想开放给外部复用的公共库
- 你希望其他项目也能 import 的稳定 API
15.3 一个经验判断
如果你在想:
“这段代码以后可能给别人复用吗?”
如果答案是“不确定”或“多半不会”,先放 internal 通常是更稳妥的选择。
16. 应用型项目和库型项目,结构不一样
这部分很容易混淆,必须单独说清楚。
16.1 应用型项目
比如:
- CLI 工具
- Web 服务
- 后台任务程序
这类项目的目标是“运行起来提供能力”,常见结构是:
1 | cmd + internal |
16.2 库型项目
比如:
- 你写了一个通用的字符串处理库
- 你实现了一个别人可复用的缓存组件
这类项目的目标是“被别的项目 import”,结构往往更简单:
1 | my-lib/ |
或者:
1 | my-lib/ |
此时未必需要 cmd/,也未必需要 internal/。
16.3 推荐判断方式
| 项目类型 | 更常见的结构 |
|---|---|
| 应用型 | cmd + internal |
| 库型 | 根包或少量公开包 |
| 混合型 | 对外库 + 内部应用入口,按需组合 |
所以不要拿“库型项目的结构标准”去要求应用项目,也不要反过来。
17. 测试文件该放哪
这一课虽然主讲结构,但测试位置也属于工程组织的一部分。
17.1 最常见做法:测试和被测代码放一起
例如:
1 | internal/task/ |
这是 Go 最常见、最推荐的做法。
好处:
- 测试和实现离得近
- 重构时不容易漏
- 更符合 Go 社区习惯
17.2 测试数据放 testdata/
如果测试需要样例文件:
1 | internal/store/ |
testdata 是 Go 生态里很常见的约定目录。
17.3 不建议单独建一个巨大的 tests/ 目录
除非你在做:
- 集成测试
- 端到端测试
- 多模块联调测试
否则把所有测试都扔进一个总 tests/ 目录,通常反而更难维护。
18. 从零搭项目时的推荐步骤
如果你以后要自己起一个 Go 小项目,建议按下面顺序来。
18.1 第一步:先起模块
1 | go mod init example.com/myapp |
18.2 第二步:只建必要目录
对于一个小型应用型项目,可以先建:
1 | myapp/ |
18.3 第三步:先确定核心业务包
先问自己:
- 这个项目最核心的业务对象是什么?
- 哪些能力是基础设施?
比如任务工具:
- 核心业务:
task - 基础设施:
config、logger、store
18.4 第四步:把 main 变薄
任何时候只要发现 main.go 开始变胖,就考虑把逻辑往包里挪。
18.5 第五步:边写边补测试
工程化不是最后一口气整理目录,而是边增长边控制结构。
尤其是:
- 核心业务包
- 存储实现
- 配置解析
这些都非常值得尽早加测试。
19. 常见坑总结
19.1 一上来照抄“大而全模板”
1 | cmd/ |
问题不在这些目录本身,而在于:
你当前项目根本不需要它们。
解决思路:
- 从最小结构开始
- 目录随着需求增长
19.2 main.go 里写满业务逻辑
1 | func main() { |
这会让:
- 测试困难
- 复用困难
- 修改困难
正确思路:
main负责组装- 业务逻辑放进领域包
19.3 乱建 util/common/helper
这是工程结构最容易腐化的入口。
如果你发现某段代码想放进 util,先反问一句:
它到底属于哪个明确职责?
大多数时候,它其实应该归属于某个现有包。
19.4 包之间互相 import
1 | task -> store |
这通常说明职责划分不清。
修复思路通常有两种:
- 抽出更稳定的公共抽象
- 把接口挪到真正的使用方
19.5 配置读取散落各处
坏处:
- 不知道配置总入口在哪
- 改默认值很麻烦
- 测试也难做
正确做法:
- 用一个
Config结构体统一承载 - 在启动阶段一次性加载和校验
19.6 错误既记录又层层打印
例如:
store打一遍错误日志service再打一遍main再打一遍
最后控制台全是重复信息。
正确做法:
- 中间层补充上下文并返回
- 最外层决定记录和展示
19.7 导出太多无关 API
如果一个包对外暴露了很多不必要的标识符:
- 使用者不知道该用哪个
- 重构时不敢动
- 包边界会越来越糊
原则还是那句:
默认不导出,只有确实需要给包外访问的才导出。
20. 本课练习
练习 1:重构第 24 课的命令行项目
把第 24 课的命令行小项目从“单包结构”重构为:
cmd/xxx/main.gointernal/configinternal/<核心业务包>internal/store
要求:
main只负责参数解析和依赖组装- 核心业务逻辑移出
main
练习 2:给项目加统一配置入口
为一个已有的小项目增加 Config 结构体,统一承载以下配置:
- 数据文件路径
- 日志级别
- 超时时间
要求:
- 给出默认值
- 对关键字段做校验
- 业务层不能直接调用
os.Getenv
练习 3:给项目加统一日志入口
用 log/slog 为一个小项目创建统一日志初始化函数,要求:
- 支持
DEBUG、INFO、WARN、ERROR - 在业务层记录至少 3 条结构化日志
- 不再使用
fmt.Println做调试输出
练习 4:错误包装练习
模拟三层调用:
- 文件读取层
- 业务处理层
- 程序入口层
要求:
- 底层返回原始错误
- 中间层用
%w包装 - 最外层用
errors.Is判断特定错误并输出友好提示
练习 5:画出你的项目依赖图
任选一个你写过的 Go 小项目,画出包依赖关系图,例如:
1 | main -> service -> repository |
然后检查:
- 有没有双向依赖
- 有没有职责不清的包
- 有没有
util/common这类模糊目录
21. 自测题
21.1 概念题
cmd/目录通常放什么?它和internal/的职责差别是什么?- 为什么说
main.go应该尽量“薄”? internal相比普通目录最大的价值是什么?pkg/是必须的吗?什么时候适合用,什么时候不适合?- 为什么不推荐滥用
util、common、helper这类包名? - 配置为什么应该集中加载,而不是在各层到处
os.Getenv? - 错误包装里的
%w有什么作用?为什么工程里要重视它? - 应用型项目和库型项目在结构上有什么典型区别?
21.2 代码阅读题
下面这个结构和代码有几个明显问题,试着找出来:
1 | todo-app/ |
1 | package main |
点击查看答案
至少有这些问题:
问题 1:所有逻辑都堆在 main 包里,没有明确边界。
- 配置读取、数据读取、业务处理、输出都混在入口里
- 后续很难测试和扩展
问题 2:目录和文件按“随手拆文件”组织,不是按职责或业务边界组织。
util.go是典型模糊命名- 看目录结构无法快速判断职责
问题 3:配置没有集中管理。
- 入口直接读环境变量
- 如果其他地方也读配置,后面会越来越乱
问题 4:错误处理只有简单打印,没有错误上下文。
fmt.Println("load error:", err)无法形成清晰错误链- 应该在各层包装上下文,在边界层统一输出
问题 5:没有统一日志方案。
- 直接
fmt.Println不能控制级别,也没有结构化上下文
更合理的重构方向是:
1 | todo-app/ |
22. 本课总结
这一课你真正要带走的,不是某个固定模板,而是工程组织的判断能力。
| 知识点 | 要点 |
|---|---|
| 结构目标 | 职责清晰、依赖单向、便于测试、便于扩展 |
| 常见目录 | cmd 放入口,internal 放内部实现,pkg 按需使用 |
| 拆包思路 | 小项目可按职责拆,大一些的项目更适合按业务领域收拢 |
| 配置管理 | 统一加载、统一校验、统一传递 |
| 日志管理 | 统一初始化、统一格式、避免到处乱打 |
| 错误处理 | 用 %w 包装上下文,用 errors.Is 做边界判断 |
最重要的四件事:
- 工程结构不是越复杂越高级,而是越匹配当前规模越好
main负责组装依赖,业务逻辑应该放进明确的包- 配置、日志、错误处理必须统一入口,否则项目会越来越乱
- 先建立清晰边界,再考虑扩展能力,
internal是非常实用的边界工具
23. 下一课预告
到这里,阶段五就完整了。你已经掌握了并发、测试、Benchmark 和基础工程组织,接下来要进入 Go 的高级能力。
下一课:泛型入门
会重点讲:
- 为什么 Go 直到后期才加入泛型
- 类型参数是什么
- 约束是什么,怎么写
- 泛型函数和泛型结构怎么定义
- 泛型适合解决什么问题,不适合解决什么问题
学完下一课,你会真正进入 Go 语言“能看懂更现代代码”的阶段。





