Go:项目目录怎么拆,才能从脚本走向可维护工程
学 Go 学到这里时,会开始遇到一类很典型的问题:
- 单文件脚本已经能跑,但一加需求就乱
main.go里什么都有:参数解析、配置读取、业务逻辑、HTTP 调用、结果输出- 想“工程化”,于是一下子建了十几个目录,结果自己也记不住
- 看到别人项目里有
cmd、internal、pkg、api、scripts、build,就会觉得目录也应该全套照搬
这时候最容易走向两个极端:
- 一种是完全不拆,最后所有逻辑都塞进一个包里
- 另一种是拆得过度,在业务还没长起来时先搭了一层空架子
这两个方向都会让项目变难维护。
Go 的项目目录设计,真正要解决的从来不是“看起来像不像大厂项目”,而是下面这些更实际的问题:
- 新功能应该放哪,团队成员能不能快速找到
- 不同层的代码能不能保持边界,不互相乱调
- 入口是不是清晰,一个仓库里多个程序能不能共存
- 测试代码能不能就近放置,改动后能不能快速验证
- 配置、依赖和启动流程是不是能稳定扩展
这一篇就围绕一个很实际的小场景来讲:把一个最初只有 main.go 的巡检工具,逐步演进成一个可维护的 Go 工程。
这个巡检工具一开始只做一件事:
- 读取服务名和环境参数
- 调用一个 HTTP 接口做健康检查
- 输出检查结果
后来需求慢慢变多:
- 要支持命令行模式和定时任务模式
- 要支持配置文件
- 要补单元测试
- 要把 HTTP 调用和业务规则拆开
- 要给团队里其他同学持续维护
这时候,“目录怎么拆”才真正变成工程问题。
一、这篇文章要解决什么问题
读完这一篇,应该能独立回答这些问题:
- Go 项目什么时候还可以继续像脚本一样写
- 什么时候说明你该拆目录、拆包了
cmd、internal、pkg分别在解决什么问题- 项目目录和代码层次边界应该怎么对应
- 多入口程序、配置、测试和依赖该怎么放
- 哪些“看起来很专业”的拆法其实是在过度设计
如果这些问题能答清,后面你写 Go CLI、任务执行器、HTTP 服务、定时任务时,工程骨架会稳很多。
二、先看一个最容易失控的起点:脚本式单文件
先看最常见的第一版:
main.go:
1 | package main |
这个版本不是不能用。
如果你只是:
- 学 Go 的第一周
- 写一个一次性脚本
- 只自己用两次
那它是能接受的。
但只要多加两个需求,它马上就会变得很难维护:
- 再加一个入口,比如批量巡检模式
- 再加一组重试规则
- 再加一个测试环境和生产环境的配置切换
- 再加结果上报
这时问题就出现了:
- 参数解析和业务逻辑混在一起
- 配置读取和 HTTP 调用混在一起
- 没法对核心逻辑做单元测试
- 没法复用“巡检”能力到另一个入口
- 所有依赖都从
main一路传不清楚
目录设计的意义,就是从这里开始出现的。
三、先把一句话原则说清楚
Go 项目目录不是为了好看,而是为了让下面三件事稳定成立:
- 入口清晰
- 依赖方向清晰
- 职责边界清晰
如果一个目录拆分不能提升这三件事,那多半只是形式上的“工程化”。
更直接一点:
cmd解决的是入口问题internal解决的是仓库内边界和私有实现问题pkg解决的是“是否要给仓库外复用”这个问题
先把这三个问题分开想,目录就不会乱。
四、什么时候还不需要拆复杂目录
一学到工程化,就很容易想一步到位搭标准目录。
其实不是。
下面这些情况,完全可以先保持简单:
- 只有一个入口
- 业务逻辑非常薄
- 没有多人长期维护
- 没有明确复用需求
- 还在快速试错阶段
这时一个小而稳的结构就够了:
1 | service_probe/ |
或者稍微进一小步:
1 | service_probe/ |
也就是说,不要为了“像工程”而过早复制复杂骨架。
五、什么时候说明你该开始拆目录了
一般出现下面这些信号时,说明单文件脚本快到边界了:
main.go超过两三百行,而且混合了多类职责- 同一套业务逻辑要被多个入口复用
- 你想写测试,却发现逻辑都粘在
main里 - 配置项开始变多,启动流程开始复杂
- 同事接手时,很难判断哪个目录是核心逻辑
- 代码之间开始出现循环依赖风险
这时候就该从“脚本组织方式”转向“工程组织方式”。
注意,这里说的是逐步演进,不是一次性重构成一个巨型框架。
六、先给出一个可维护版本的最小目录
对于这个巡检工具,先给一个足够稳、又不过度的版本:
1 | service_probe/ |
先别急着背目录名,先看它背后的含义:
cmd放入口程序internal放仓库内部业务与实现pkg只放少量明确要复用给仓库外的公共包testdata放测试输入样例
这已经能覆盖大多数中小型 Go 项目的主干需求。
七、cmd 到底在解决什么问题
cmd 的核心作用非常朴素:
一个可执行程序一个目录。
比如这个项目里:
cmd/probe是命令行巡检入口cmd/probe-worker是定时执行入口
它们可以共用内部逻辑,但启动方式不同。
一个典型的 main.go 应该尽量薄,只负责:
- 解析参数
- 加载配置
- 组装依赖
- 调用应用服务
- 处理退出码和日志
看一个更像工程代码的入口:
cmd/probe/main.go:
1 | package main |
这里 main 没有直接写业务规则,也没有自己处理 HTTP 细节。
这就是入口该有的边界。
八、为什么 main 里不该塞满业务逻辑
看到这里时,很容易觉得:
- 反正最终也是从
main进来 - 把逻辑写在
main里最直接
但这样会立刻带来三个问题:
逻辑很难测试
因为main很难像普通函数一样稳定调用和断言。逻辑很难复用
另一个入口想复用巡检逻辑时,只能复制代码。逻辑很难替换依赖
比如你想把真实 HTTP client 换成 fake client 做测试,就很麻烦。
所以更稳的做法是:
main负责启动internal/app/...或internal/...负责业务编排- 更底层的实现各自放进合适的包
九、internal 的意义,不是高级,而是“明确私有”
internal 是 Go 很有价值的一点。
它不是一种命名习惯,而是语言工具链支持的边界:
internal下的包只能被当前仓库内部导入- 仓库外代码不能随便引用它
这解决的是一个很实际的问题:
哪些代码只是当前项目的内部实现,不希望外部项目依赖。
比如这个巡检工具里的这些内容,通常都该放 internal:
- 业务模型
- 应用服务
- HTTP 调用适配层
- 配置装载
- 数据库存储实现
- 任务编排逻辑
因为这些东西本来就不是为了给外部项目当公共库用的。
也就是说,默认情况下,先用 internal,而不是先用 pkg。
十、pkg 不是“专业项目标配”,而是慎用选项
看到 GitHub 上一些项目里有 pkg,常见误判是:
- 只要是正式项目,就应该把通用代码放
pkg
这是很常见的误解。
pkg 真正适合的是:
- 这个包确实有明确对外复用价值
- 你愿意为它的 API 稳定性负责
- 其他仓库导入它是合理的
比如:
- 一个明确要复用的日志格式化工具
- 一个对外暴露的 SDK 小组件
- 一个稳定的字符串或 HTTP 小工具包
如果还不确定,那就先放 internal。
因为一旦外部项目开始依赖 pkg 里的代码,你后面改接口的成本就会显著变高。
一句更实用的判断标准是:
不确定时先私有,确定要共享时再公开。
十一、目录只是表面,真正关键的是依赖方向
目录拆得很整齐,项目也依然可能很乱。
根因通常不是目录名错了,而是依赖方向错了。
对这个巡检工具来说,一个更稳的方向通常是:
1 | cmd -> app -> domain/usecase -> infra |
或者不强调名字,只强调职责:
1 | 入口层 -> 业务编排层 -> 具体实现层 |
什么意思?
cmd只负责启动app负责串业务流程probe、config、store这些包负责各自职责- 低层实现不要反过来依赖高层入口
只要方向反过来,比如:
internal/probe反过来 importcmd/probeconfig包里去调业务逻辑- 底层 HTTP client 直接依赖 CLI flag 参数结构
项目很快就会变形。
十二、先给这个小项目定一个更稳的层次边界
可以把巡检工具的代码职责大致分成四类:
入口层
解析参数、启动应用、设置退出码应用编排层
校验输入、调用检查器、汇总结果领域或核心逻辑层
巡检规则、请求模型、结果模型、错误分类基础设施层
HTTP 调用、配置读取、文件写入、数据库访问
不一定非要给它们起很“架构化”的名字,但职责至少要分清。
看一个简单的分配:
1 | internal/ |
这里:
app/probeapp负责流程编排probe负责核心巡检逻辑config负责配置装载
已经足够清晰。
十三、一个完整的小项目目录示例
把前面的思路再落成一个更完整的样子:
1 | service_probe/ |
这份结构里有几个刻意保留的点:
- 测试文件尽量和被测代码放在一起
testdata放共享的输入样例pkg只有一个真正可能被外部复用的小工具- 没有为了“像框架”再拆出很多空目录
十四、应用服务层怎么写,才能把目录和边界对应起来
来看核心编排服务:
internal/app/probeapp/service.go:
1 | package probeapp |
这个 Service 很像前面几篇文章里一直强调的那种“应用层”:
- 它不关心命令行 flag 怎么来
- 它不关心 HTTP 请求具体怎么发
- 它只关心业务流程怎么串起来
这样目录拆分就不只是文件移动,而是真正有了边界。
十五、配置代码应该放哪,别让它长进业务包里
配置是另一个很容易越写越乱的地方。
很多项目的问题不是没有配置目录,而是:
- 所有包都能随手读环境变量
- 所有函数都能直接打开配置文件
- 配置结构散落在多个包里
更稳的做法是:
- 在一个集中位置完成配置加载
- 把解析后的配置对象传给需要的组件
- 避免业务层直接依赖配置读取动作
例如:
internal/config/config.go:
1 | package config |
这里关键不是 Config 结构本身,而是边界:
- 配置加载发生在
config包 - 业务代码拿到的是已经解析好的值
- 业务层不需要知道配置文件格式细节
十六、测试应该跟着代码走,而不是最后集中补
做目录设计时,很容易只想到生产代码,忘了测试也需要结构。
Go 项目里更自然的做法通常是:
- 单元测试文件和代码放在同目录
- 集成测试按需要单独标识
- 共享测试数据放
testdata
例如:
1 | internal/ |
单元测试:
1 | package probeapp |
测试结构如果一开始就跟着目录一起落下来,后面维护成本会小很多。
十七、错误示例一:把所有代码都塞进 pkg
这是很常见的“假工程化”。
错误目录可能长这样:
1 | service_probe/ |
看起来很整齐,但问题是:
pkg会暗示“这些都可以给外部项目导入”- 实际上这些包大多只是本项目内部实现
- 后面别人一旦导入,你的改动成本会变高
如果没有明确外部复用需求,这样拆只会把私有实现过早公开。
十八、错误示例二:照着别人的大仓库一口气建十几层
另一个常见问题是:
apibizservicedaorepositorymodeltypesutilscommonlib
全部一次性建出来。
问题不在于这些名字一定错,而在于:
- 当前项目规模根本撑不起这些层
- 很多目录一开始是空的
- 同学为了“有地方放”就开始乱放
- 最后目录多了,边界反而更糊
目录层数一旦超过团队的认知负担,维护体验会显著下降。
Go 项目尤其要警惕这种“先搭架子再找内容填”的习惯。
十九、错误示例三:公共模型到处 import,最后耦合成团
还有一种更隐蔽的问题:
- 为了“复用”,搞一个大而全的
model包 - 所有层都 import 它
- 甚至配置、HTTP 响应、数据库记录、业务实体全塞进去
最后会发生什么?
- 任何字段变动都会影响很多包
- 业务层和基础设施层被一套结构硬绑在一起
- 包之间依赖越来越密
更稳的方式通常是:
- 让每个包拥有更贴近自己职责的数据结构
- 只在必要边界上做转换
- 不要为了省几个类型定义,把整个项目绑成一团
二十、一个更贴近真实工作的演进案例
假设这个巡检工具的演进分四步。
第一步:个人脚本
只有一个人临时查服务状态:
1 | service_probe/ |
这一步没问题。
第二步:开始重复使用
你发现这工具一周要跑很多次,于是开始拆出核心逻辑:
1 | service_probe/ |
第三步:增加第二个入口
现在除了命令行,还要加一个 worker 定时跑:
1 | service_probe/ |
第四步:团队长期维护
开始有配置、共享测试数据、少量对外工具:
1 | service_probe/ |
这就是更合理的演进方式:
- 从小到大
- 从需求出发
- 一层一层长出来
而不是第一天就把终局目录搭满。
二十一、如果要支持多个入口,目录和组装方式该怎么定
多个入口是 cmd 最常见的价值场景。
比如:
cmd/probe给开发同学手动执行cmd/probe-worker给定时任务执行cmd/probe-api给 HTTP 服务方式执行
这三个入口的共同点是:
- 业务目标相近
- 启动方式不同
- 依赖组装方式可能也不同
这时最好的做法不是复制业务逻辑,而是让不同入口共享应用服务。
例如:
cmd/probe组装命令行参数cmd/probe-worker组装任务列表和调度器cmd/probe-api组装 HTTP handler
但它们最终都调用 internal/app/probeapp 的核心流程。
这才是“一个仓库多个程序”的正确打开方式。
二十二、验证这套目录是否合理,可以看哪几个信号
目录好不好,不是看名字漂不漂亮,而是看你改需求时痛不痛。
可以用下面几条检查:
- 加一个新入口时,是否只需要新增一个
cmd/... - 业务规则调整时,是否主要改
internal/app或核心业务包 - 基础设施切换时,是否只需要改底层实现包
- 测试是否能只针对某个包快速运行
- 新同学是否能在几分钟内找到程序入口和核心逻辑
如果这几条都做不到,说明目录虽然拆了,边界还没真正建立。
二十三、最小验证步骤:这类工程至少要能做哪些检查
虽然这篇文章重点是目录结构,但工程要成立,至少要能支撑这些验证动作:
1 | go test ./... |
目录一旦清晰,这些命令就会非常自然:
- 测哪个包,一眼就知道
- 跑哪个入口,一眼就知道
- 出问题先查哪层,也更清楚
这就是目录设计真正带来的回报。
二十四、常见排障:目录拆了,但项目还是不好维护,通常卡在哪
如果一个 Go 项目已经有 cmd、internal、pkg,维护体验还是差,通常是这几类问题:
1. main 依旧过胖
表现:
- 参数解析、配置读取、初始化、核心逻辑全在入口里
处理:
- 把业务编排下沉到应用服务层
2. internal 里没有边界,只有大杂烩
表现:
- 所有代码都塞在
internal/common、internal/util这类目录里
处理:
- 按职责重新拆分,而不是按“通用”命名兜底
3. pkg 放了太多私有实现
表现:
- 只是仓库内部代码,却被放到了公开目录
处理:
- 收回
internal,缩小对外暴露面
4. 包循环依赖
表现:
- 为了共享结构,多个包互相 import
处理:
- 重画依赖方向,把高层依赖低层,而不是互相缠绕
5. 测试无法就近编写
表现:
- 想测某个流程时,要启动整个程序
处理:
- 把可测试逻辑从入口中抽出来,放到普通包函数或服务里
二十五、边界:不是所有 Go 项目都需要 cmd/internal/pkg 三件套
这里必须把边界讲清楚。
下面这些项目,不需要完整上这套结构:
- 一次性脚本
- 学习性质的小练习
- 只有几十行逻辑的内部小工具
- 生命周期很短的迁移脚本
这类项目最优解往往不是“工程化到位”,而是“足够简单且不误导后续维护者”。
相反,如果是下面这些场景,这套结构就很有价值:
- 多入口程序
- 长期维护的 CLI 或服务
- 需要多人协作
- 需要稳定测试和持续迭代
- 需要明确区分内部实现和潜在对外能力
所以结论不是“必须用标准目录”,而是:
按项目生命周期和复杂度,选择能支撑当前阶段的最小结构。
二十六、练习题
如果你想确认自己真的理解了这一篇,可以做下面几道练习:
- 把一个目前只有
main.go的 Go 小工具,重构成cmd + internal的最小结构。 - 试着给它增加第二个入口,判断哪些逻辑应该共享,哪些逻辑只属于某个入口。
- 列出你项目里现在放在
pkg下的包,逐个判断它们是否真的需要对外公开。 - 给一个配置读取流程补一组单元测试,并把样例文件放进
testdata。 - 找出你项目里最胖的
main.go,把业务编排抽到应用服务层,再比较测试难度前后有什么变化。
这些练习做完,你对目录设计的理解会比单纯记术语牢固得多。
二十七、结语:目录不是模板,演进能力才是目标
Go 项目目录最容易被误解成一种“固定模板”:
- 好像只要有
cmd - 有
internal - 再来一个
pkg
项目就自动工程化了。
其实不是。
真正重要的是你能不能让项目持续维持这几个状态:
- 新入口容易加
- 新需求知道放哪
- 依赖方向不乱
- 测试能就近落地
- 私有实现和公共能力边界清楚
所以更准确的结论应该是:
Go 项目目录不是为了追求统一长相,而是为了让代码随着需求增长时,仍然保持可理解、可测试、可替换、可协作。
从脚本走向工程,不是先学会搭大骨架。
而是先学会在正确的时机,做最小但关键的拆分。