Go 项目长到几万行,最先失控的不是性能,而是 import 图:工具包被 handler 引用,domain 反向依赖 storage,util 成了所有包的交汇点。包结构决定哪一层能拦住变更、哪一层能被单测替代。这篇只给判断与代价。
1. 三条包组织路线
| 组织法 | 结构直觉 | 适合 | 代价 |
|---|---|---|---|
| 按层 | handler/ service/ repo/ |
中小服务、按层分工 | 无内聚,改一个功能横跨四层 |
| 按功能 | order/ payment/ user/ |
中等规模、按业务分工 | 公共逻辑下沉成 util |
| 按领域 | 领域包 + 适配层 | 业务复杂、长生命周期 | 建模成本高,小项目纯亏 |
Go 社区的实际倾向是按功能/领域为主、层为辅:cmd/ 放入口,internal/ 放不对外的东西,业务包内部自带 handler/service/repo。Go 没有包级可见性矩阵,包边界就是唯一封装单位,按层切会把一次功能改动变成跨包改签名。判据是:一次需求改动总在三个以上包之间跳,切分就错了。
2. internal/ 与依赖方向
internal/ 由编译器强制,只挡跨模块引用,不防循环依赖。依赖方向必须自动化检查,否则三个月后失效。
go list -deps ./internal/order | head -20 # 看依赖展开
go list -f '{{.ImportPath}} {{join .Imports " "}}' ./... | grep internal # 找反向引用
// 依赖方向检查,放 *_test.go 即为 CI 门禁(占位实现)
func TestNoDomainToInfra(t *testing.T) {
for _, r := range [][2]string{
{"internal/order/domain", "internal/order/infra"},
{"internal/order/domain", "database/sql"},
} {
if importsAny(t, r[0], r[1]) {
t.Errorf("forbidden: %s -> %s", r[0], r[1])
}
}
}
| 手段 | 强度 | 局限 |
|---|---|---|
internal/ |
编译器强制 | 只管跨模块 |
| 依赖方向单测 | 可自定义 | 需维护规则清单 |
| lint 规则 | 中等 | 易被例外绕过 |
3. 接口定义在消费方
Go 的隐式接口让依赖倒置近乎零成本:接口写在使用者包,提供者只返回具体类型,使用者声明自己需要的最小子集。
package order
type UserLookup interface { Level(ctx context.Context, id string) (int, error) }
type Service struct{ users UserLookup }
func NewService(u UserLookup) *Service { return &Service{users: u} }
| 做法 | 影响 | 何时不用 |
|---|---|---|
| 接口在消费方 | 依赖倒置成立,单测用手写 stub | 接口是跨服务契约(IDL 生成) |
| 接口在提供方 | 使用者被迫接受全部方法 | 提供方是框架,使用者写插件 |
| 提前抽接口 | 抽象泄漏,改一次动两处 | 只有一个实现且不会变 |
判据:接口是为了让上层不依赖下层实现,不是让代码“看起来可测”。 只有一个实现且无测试替身需求的接口是负担。
4. 错误处理的三种风格
| 风格 | 写法 | 优点 | 代价 |
|---|---|---|---|
| 哨兵错误 | var ErrNotFound = errors.New(...) |
比较便宜、语义稳定 | 丢上下文 |
| 自定义类型 | type ValidationError struct{ Field string } |
可带结构化字段 | 要实现 Error/Unwrap |
| 包装 | fmt.Errorf("...: %w", err) |
保留链路 | 字符串拼接易滥用 |
取舍原则:哪一层要做判断,就在哪一层保留可判断性。仓储把驱动错误翻译成哨兵/类型错误,服务层判断,接口层映射状态码。
var ErrNotFound = errors.New("not found")
func (r *Repo) Get(ctx context.Context, id string) (*Order, error) {
o, err := r.db.Find(ctx, id)
if errors.Is(err, sql.ErrNoRows) {
return nil, fmt.Errorf("get order %s: %w", id, ErrNotFound)
}
if err != nil {
return nil, fmt.Errorf("get order %s: %w", id, err)
}
return o, nil
}
5. errors.Is/As 的纪律
switch {
case errors.Is(err, ErrNotFound):
http.Error(w, "not found", http.StatusNotFound)
default:
var ve *ValidationError
if errors.As(err, &ve) {
http.Error(w, ve.Field, http.StatusBadRequest)
return
}
log.Error("unhandled", "err", err, "path", r.URL.Path)
http.Error(w, "internal", http.StatusInternalServerError)
}
Is做语义判断,As取结构化字段;- 不用
err.Error()字符串做分支,文案一改分支静默失效; - 包装用
%w而非%v,否则Is链路断在半路; - 边界统一翻译:
ErrNotFound → 404、ValidationError → 400,其余 500 并打全栈。
错误不是控制流:用错误表示“记录不存在”会把正常路径和异常路径混在一个链上。判据是发生频率——每请求都发生的用返回值,每故障才发生的用错误。
6. 配置与装配:不用框架时的 main
func main() {
cfg := config.Load() // 读配置并校验,失败即退出
db := postgres.MustOpen(cfg.DB)
repo := order.NewRepo(db)
svc := order.NewService(repo, cfg.Limits)
h := httpapi.NewServer(svc, cfg.HTTP)
run(h, cfg.HTTP) // 启动 + 优雅关闭
}
| 装配方式 | 适用 | 代价 |
|---|---|---|
| main 手工装配 | 绝大多数服务 | main 变长,按子系统拆 wireX() |
| 代码生成 | 依赖节点很多 | 多一层生成与报错可读性 |
| 反射容器 | 少见 | 装配错误推迟到运行期 |
配置必须能在启动时校验失败;用默认值兜底的项要显式写默认值,而不是“读不到就当空”。
7. HTTP 分层与中间件顺序
h := recoverMW( // 1. panic 兜底,最外层
requestIDMW( // 2. 生成/透传 trace id
accessLogMW( // 3. 日志,需要 id,故在内层
limitMW( // 4. 限流
authMW( // 5. 鉴权,越早拒绝越省资源
routeMW(mux))))))
| 顺序错误 | 后果 |
|---|---|
| 日志在 id 之外 | 日志无关联 id |
| recover 不在最外层 | panic 越过中间件,进程退出 |
| 限流在鉴权前 | 匿名流量吃光配额 |
| 超时在最内层 | 上游已返回,下游还在跑 |
8. 并发模型的落点
| 模型 | 适用 | 代价 |
|---|---|---|
| 每请求一 goroutine | HTTP 默认形态 | 无背压时线性堆积 |
| worker pool | 昂贵资源(连接、GPU、配额) | 队列本身是隐患 |
| 信号量限并发 | 只限制单个下游 | 排队时间不可见 |
| 异步任务表 | 请求不必等结果 | 同步语义变最终一致 |
默认是每请求一 goroutine + 对每个下游设限,而不是全局池。背压落点:队列有界、满了拒绝、超时可取消。
sem := make(chan struct{}, DOWNSTREAM_MAX) // 有界并发即背压
func call(ctx context.Context) error {
select {
case sem <- struct{}{}:
defer func() { <-sem }()
case <-ctx.Done():
return ctx.Err() // 排队中被取消,不再压下游
}
return doCall(ctx)
}
9. 超时与优雅关闭
context 的价值是取消能传递,因此它必须出现在每个跨边界签名里。
srv := &http.Server{Addr: cfg.Addr, Handler: h,
ReadHeaderTimeout: 5 * time.Second, WriteTimeout: 30 * time.Second}
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
go func() { _ = srv.ListenAndServe() }()
<-ctx.Done()
sctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
_ = srv.Shutdown(sctx) // 等在途请求结束,超时即放弃
两条纪律:内层超时短于外层(否则外层先放弃,内层还在写);关闭超时是硬上限,无限等会让滚动发布卡住。
10. 部署形态对包结构的影响
| 形态 | 对结构的影响 | 风险 |
|---|---|---|
| 单二进制多命令 | cmd/xxx 各一入口 |
共享代码改动影响所有命令 |
| 多进程拆服务 | 需要独立契约包与客户端包 | 契约一改全部发版 |
| 容器单进程 | 结构不变,配置来自环境变量 | 本地与线上配置来源不一致 |
| 同进程多模块插件 | 需显式注册与生命周期 | 初始化顺序成隐性依赖 |
形态变化不应倒逼包结构大改:拆服务要重排目录,说明业务与传输层已耦合。
11. 可观测的最小集合
log.Info("request", "rid", rid, "method", r.Method, "path", r.URL.Path,
"status", st, "dur_ms", ms, "err", errStr)
metrics.RequestDur.WithLabelValues(route, strconv.Itoa(st)).Observe(sec)
| 项 | 最小要求 | 缺失后果 |
|---|---|---|
| 结构化日志 | 带 request id 与状态码 | 无法串起一次请求 |
| 指标 | 请求量/耗时/错误率,按路由 | 只能翻日志看趋势 |
| Trace | 跨服务传播 trace id | 跨服务延迟无法归因 |
| 健康检查 | 区分存活与就绪 | 启动中即接流量 |
12. 排错
| 现象 | 原因 | 方向 |
|---|---|---|
import cycle not allowed |
双向依赖、共享模型包 | 公共类型下沉,或改接口方向 |
util 被所有人引用 |
无归属公共函数堆积 | 拆回所属功能包 |
| 单测必须连数据库 | 仓储未接口化 | 在消费方定义最小接口 |
| 分支判断失效 | 文案被改、错误被 %v 包装 |
哨兵错误 + errors.Is |
| 发布时请求被截断 | 没等在途请求 | Shutdown + 硬超时 |
偶发 deadline exceeded |
内层超时长于外层 | 统一超时预算,逐层递减 |
13. 边界
| 场景 | 判断 |
|---|---|
| 单文件工具/脚本 | 不分层,main 写完 |
| 单体 CLI | 不需要 DI 框架与接口 |
| 包结构大重构 | 收益常低于成本,先加依赖检查挡新增 |
| 只有一家下游 | 先保证可观测,不做容错抽象 |
| 团队无分层共识 | 先定可检查的规则,再谈目录 |
重构包结构的最佳时机是新增业务域的时候:那时有正当理由按目标结构落地,而不是把历史代码一次性搬家。
14. 自测
- 什么信号说明包是按层切而不是按功能切?
internal/能挡住哪种依赖,挡不住哪种?- 只有一个实现时,该不该抽接口?
- 为什么不能用
err.Error()做分支判断? - 限流与鉴权谁在前,为什么?
- 关闭服务时为什么先停止接收新请求再等在途请求?