主题
Go 项目结构:让目录边界和依赖方向说同一件事
一个订单接口修改价格计算,结果要同时改 handler、service、manager、repository 和 util,目录再整齐也难维护。本文要解决的是:哪些代码放在一起,哪些依赖可以跨过去,以及项目长大时从哪里拆。cmd、internal、pkg 会用到,但它们各自有不同的地位,不能当作必须集齐的文件夹。
示例从一个只有三个包的订单预览程序开始。它不连接数据库,也不伪装成完整电商服务;这样可以直接用实际 import、运行输出和编译失败看清结构。
先区分工具强制的规则和团队惯例
| 名称或机制 | 地位 | 用来解决什么 |
|---|---|---|
package main 与 func main | Go 程序入口规则 | 生成可执行程序 |
internal/ | go 命令强制的导入限制 | 防止目录树以外的代码依赖内部实现 |
| 首字母大写 | 语言的导出规则 | 决定别的包可访问哪些标识符 |
cmd/<程序名>/ | 常见组织方式 | 一个模块有多个命令时区分入口 |
pkg/ | 普通目录名 | 有些团队用它放公开包,没有额外访问权限 |
service/、domain/ 等 | 设计选择 | 表达本项目的责任边界,不是 Go 指令 |
官方布局指南同时给出简单命令、简单包、多个包和多个命令的组织方式,并未规定所有仓库使用同一套目录。布局指南、导出规则
尤其要区分“大写”和“可导入”:internal 内的 Order 即使大写,外部代码也必须先通过 internal 导入规则。pkg/secret 里的 Secret 则不会因为你在团队文档中说“内部用”而自动禁止外部导入。internal 规则
三个包足以解释一个可工作的结构
本例文件布局:
text
shop/
go.mod
cmd/preview/main.go
internal/httpapi/handler.go
internal/order/order.go- order 只知道订单数量与单价如何计算,不知道 HTTP。
- httpapi 把一次 HTTP 调用变成订单计算,再把结果写到响应。
- preview 组装 handler,并用标准库的 HTTP 测试工具发起一次进程内调用。
这里箭头表示 import 依赖,不是 HTTP 请求传播方向:
正在绘制图示…
查看图源代码
flowchart LR
C["cmd/preview<br/>组装与运行"] --> H["internal/httpapi<br/>HTTP 适配"]
H --> O["internal/order<br/>订单规则"]
O --> E["标准库 errors"]
H --> N["标准库 net/http"]
看 order 包中的关键方法:
go
func (o Order) Total(unit int) (int, error) {
if o.Items <= 0 || unit < 0 {
return 0, errors.New("invalid order")
}
return o.Items * unit, nil
}handler 创建 Order{Items: 2} 并以单价 500 调用它。在 shop 下执行:
bash
go run ./cmd/previewtext
status=200 total=1000这段计算只处理教学用小整数,没有处理整数乘法溢出、币种或金额精度,不能直接当作生产计价系统。代码的用途是展示:业务计算不依赖 HTTP 类型,因此后续可以被 CLI、后台任务或单元测试复用。这个结构选择属于设计建议,而计算结果来自附录程序。
用工具核对图是否真实:
bash
go list -f '{{.ImportPath}} -> {{join .Imports ", "}}' ./...本次输出中,订单包一行是:
text
example.com/shop/internal/order -> errors如果这行后来出现 net/http,就值得检查订单规则是否开始处理传输协议;如果出现自己的 httpapi,则已经形成反向依赖。go list 返回包的导入信息,适合用于检查结构,而不只是画一张漂亮的架构图。go list
internal 的墙画在哪里
对 a/b/internal/c,可导入范围由 internal 的父目录 a/b 所覆盖的树决定。模块模式下,go 命令还按相应导入路径规则执行检查;重点不是“同一个仓库”或“同一个 Git 组织”,而是这个边界。go 命令说明、Go 1.27.2 检查实现
本例再建一个独立模块 example.com/outsider,用 replace 指向本地 shop,然后尝试:
go
import "example.com/shop/internal/order"实际编译错误:
text
use of internal package example.com/shop/internal/order not allowedreplace 让编译器找到了源码,不能绕过 internal。这个实验比“目录叫 internal 就是私有的”更准确:它是包导入边界,不是文件加密、操作系统权限,也不会防止别人阅读仓库源码。
若已有多个命令应共享实现,把共享部分放在模块根下的 internal 很合适。若某个包确实要给外部使用,就放在非 internal 路径,并把导出 API 当作兼容承诺审查。没有必要为了“可能有人复用”就提前把所有内部类型公开。
按领域还是按层:看修改会沿着哪里走
下面两种组织都能编译,差别是代码以什么关系聚在一起。
| 组织方式 | 典型路径 | 比较适合 | 主要代价 |
|---|---|---|---|
| 按技术层 | handler/、service/、repository/ | 功能很少,层内规则相近,团队容易看懂 | 同一业务修改横跨多个大包;容易出现万能 service |
| 按领域能力 | order/、billing/、catalog/ | 不同业务有自己的词汇、规则和变化节奏 | 要认真约束领域间调用,否则只是把耦合搬了位置 |
| 领域为主,内部再分适配 | order/、orderhttp/、orderstore/ | 业务逻辑已有规模,外部协议和存储变化独立 | 多包带来接口、数据转换与导航成本 |
这些是本文针对维护成本的判断,不是“领域分包永远更好”的语言结论。Go 关于包命名的官方建议强调包名要表达用途,避免让 util、common、misc 成为无边界的杂物筐。包名建议
例如,税率、折扣与订单状态会一起变化,可以先在 order 包中相互调用;PostgreSQL 的连接串、SQL 错误码和重试策略则属于另一类变化。是否把后者拆成 orderstore,取决于代码规模和测试需要。不要一开始就为每个业务名词创建三层、一套 DTO、一个接口和一份工厂。
相反,看到重复的跨业务调用也别马上扔到 common。先问清共同的是稳定概念,还是今天碰巧同名的字段。“用户地址”和“仓库地址”未必有同样的校验规则;过早共享会让以后两者的变化互相牵制。
循环依赖是具体的编译错误,也可能是责任混在一起的信号
Go 禁止包直接或间接导入自己。附录让 a 导入 b、b 再导入 a,实际得到:
text
imports example.com/cycle/b from a.go
imports example.com/cycle/a from b.go: import cycle not allowed这是语言级约束,不是 lint 偏好。包导入
常见修复有三个方向,先选能说清责任的那个:
- 两包本来就共同维护同一个不变量,可以合并,减少人为边界。
- 提取真正稳定、双方都需要的底层类型或规则;新包必须有清楚职责,不能只是改名的 common。
- 消费方定义一个小接口,让上层在入口处注入实现,避免业务包导入具体适配器。接口应来自实际使用需求,不用为每个结构体预建一个镜像接口。接口审查惯例
举例说,order 需要“保存订单”,但不需要知道 SQL 连接池类型。它可以定义保存能力,数据库适配器实现该能力,由 cmd 组装;数据库适配器依赖订单类型,订单包不再反过来依赖数据库包。这是实际减少依赖边的做法,具体构造和资源释放另见《Go 依赖注入》。
什么时候值得多建一个包
本文建议用三个具体问题检查拆包,而不按文件行数机械切割:
- 它有没有自己的稳定词汇和规则?一个
ParseMoney与订单状态机不一定需要同一包。 - 它是否独立变化或需要独立测试?HTTP 与定时任务共用计算时,拆开传输层通常有帮助。
- 拆完以后依赖图是否更简单?如果每次改一个字段都要新增三份转换代码,先检查是不是切得太细。
小命令可以从根目录一个 main 包开始;多个命令再引入 cmd;内部实现长大再按职责拆 internal。官方布局指南也按程序复杂度逐步引入目录。避免过早分层的意思是保留合适的演进空间,不是等到出现巨型包才允许整理。官方布局示例
资料与实验范围
- 模块组织、包命名:组织和命名建议。
- Go 语言规范:包、导出与 import 环规则。
- cmd/go 的 internal 和 list 文档、Go 1.27.2 包加载源码:工具强制的边界与检查。
- Code Review Comments:Interfaces:消费方小接口建议。
2026-10-13 在 Go 1.27.2、Linux/amd64 运行成功程序、列出实际 imports、执行 vet,并验证两种故意失败:跨 internal 边界、循环导入。go test ./... 在成功示例中只验证包可编译,输出 [no test files],不能把它写成“业务测试通过”。未部署 HTTP 服务。
完整复现文件
按以下路径保存,设置 GOTOOLCHAIN=local、GOFLAGS=-buildvcs=false,执行 bash run_layout.sh。两个失败模块单独放置,避免正常包检查被故意失败的例子污染。
run_layout.sh
bash
#!/usr/bin/env bash
set -eu
cd "$(dirname "$0")/layout/shop"
echo '== allowed internal import =='
go run ./cmd/preview
go list -f '{{.ImportPath}} -> {{join .Imports ", "}}' ./...
go test ./...
go vet ./...
cd ../outsider
echo '== forbidden internal import: expected failure =='
if go build .; then exit 1; else echo expected_failure=true; fi
cd ../cycle
echo '== import cycle: expected failure =='
if go test ./...; then exit 1; else echo expected_failure=true; filayout/cycle/a/a.go
go
package a
import _ "example.com/cycle/b"layout/cycle/b/b.go
go
package b
import _ "example.com/cycle/a"layout/cycle/go.mod
go.mod
module example.com/cycle
go 1.27.2layout/outsider/go.mod
go.mod
module example.com/outsider
go 1.27.2
require example.com/shop v0.0.0
replace example.com/shop => ../shoplayout/outsider/main.go
go
package main
import "example.com/shop/internal/order"
func main() { _ = order.Order{} }layout/shop/cmd/preview/main.go
go
package main
import (
"example.com/shop/internal/httpapi"
"fmt"
"net/http/httptest"
)
func main() {
rec := httptest.NewRecorder()
httpapi.NewHandler().ServeHTTP(rec, httptest.NewRequest("GET", "/", nil))
fmt.Printf("status=%d %s", rec.Code, rec.Body.String())
}layout/shop/go.mod
go.mod
module example.com/shop
go 1.27.2layout/shop/internal/httpapi/handler.go
go
package httpapi
import (
"example.com/shop/internal/order"
"fmt"
"net/http"
)
func NewHandler() http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
total, err := (order.Order{Items: 2}).Total(500)
if err != nil {
http.Error(w, "invalid order", http.StatusBadRequest)
return
}
fmt.Fprintf(w, "total=%d\n", total)
})
}layout/shop/internal/order/order.go
go
package order
import "errors"
type Order struct{ Items int }
func (o Order) Total(unit int) (int, error) {
if o.Items <= 0 || unit < 0 {
return 0, errors.New("invalid order")
}
return o.Items * unit, nil
}