项目长到十几个包后,最初那几次"随手 import"开始收利息:service 引了 repo,repo 又为了一个类型引回 service,编译变慢,改一个字段要重编半个仓库,想单测一个函数得先起数据库。
Go 没有容器,也没有强制的目录语义,所以"分层"在这里只有一种真实表达:包之间的 import 方向。目录名叫 domain 还是 core 无关紧要,go list -deps 打出来的边才是架构本身。
1. 层的表达:方向比目录名重要
分层能不能守住,取决于依赖边是否单向。判据不是"有没有 domain 目录",而是从入口包能不能一路 import 到基础包,且从不反向。
| 层 | 可以 import | 不该出现 | 判据 |
|---|---|---|---|
入口 cmd/ |
装配、配置、适配层 | 业务规则 | 只做创建与串联 |
适配 http/repo |
领域类型、基础设施客户端 | 别的适配层 | 传输层不互引 |
领域 domain/app |
标准库、自身 | net/http、ORM、SDK |
纯逻辑可无依赖测试 |
基础 pkg |
标准库 | 领域包 | 反向依赖禁止 |
守住方向靠工具,不靠自觉:
go list -deps ./... | grep -v vendor # 依赖全景
go list -f '{{.ImportPath}} -> {{join .Imports " "}}' ./... | grep domain
internal/ 的实际作用是编译期可见性围栏:其下包只能被父目录子树内的包 import。它挡"外部模块误用",挡不住"内部包互相乱引"——后者是方向问题。
2. 接口的位置与大小
Go 的隐式实现让接口可以定义在任何地方,这条自由正是污染来源。判据只有一句:接口定义在消费方,不是实现方。实现方导出接口,等于把"我需要什么"的决定权交给了"我能提供什么"。
// repo/mysql/user.go —— 实现方只导出具体类型
package mysql
type UserRepo struct{ db *sql.DB }
func (r *UserRepo) FindByID(ctx context.Context, id int64) (*domain.User, error) { /* ... */ }
// app/user_service.go —— 消费方声明自己需要的最小能力
package app
type UserReader interface {
FindByID(ctx context.Context, id int64) (*domain.User, error)
}
type UserService struct{ users UserReader }
接口大小的判据是调用点数量,不是方法数量:只被一处调用、只用一个方法,就写一个方法。方法越多越难替换,也越容易把两层绑死。
| 信号 | 含义 | 处理 |
|---|---|---|
| 一个实现、无替身需求 | 过早抽象 | 先删,等第二个实现 |
| 方法数远多于调用点数 | 抄了别人的契约 | 按调用点裁最小 |
出现 context、error 外的业务结构 |
抽象层级错位 | 下沉到领域类型 |
| 新增实现要改接口 | 接口在实现侧 | 移到消费方 |
3. 三种注入形态与手工装配
| 形态 | 写法 | 代价 | 适用 |
|---|---|---|---|
| 构造参数 | NewService(repo, log) |
显式、可读、编译期检查 | 默认选择 |
| 函数选项 | NewService(repo, WithTimeout(d)) |
可选参数友好,默认值集中 | 参数多、有默认值时 |
| 容器/反射库 | dig、fx |
运行期才能发现缺依赖,栈更深 | 大型应用、插件式注册 |
偏好手工装配的理由很实在:依赖图编译期可见,读一遍 main 就知道谁依赖谁;反射容器把依赖图推到运行期,缺 provider 的报错发生在启动时而非编译时。
func main() {
cfg := config.MustLoad()
db := mysql.MustOpen(cfg.DSN) // 谁创建,谁负责关
users := mysql.NewUserRepo(db)
svc := app.NewUserService(users, slog.Default())
h := httpapi.NewUserHandler(svc)
srv := &http.Server{Handler: h.Routes(), Addr: cfg.Addr}
graceful.Run(srv, db)
}
装配层只做三件事:创建、串联、登记关闭顺序。不该做:业务判断、数据转换、吞错误。
4. wire 类工具的收益门槛
google/wire 这类工具把装配改写成声明,wire gen 生成等价构造函数:收益是装配代码随依赖增长自动维护,代价是多一个生成步骤与一层阅读成本。
//go:build wireinject
func InitApp(cfg Config) (*App, error) {
wire.Build(mysql.NewDB, mysql.NewUserRepo, app.NewUserService, newApp)
return nil, nil
}
| 判据 | 手工装配更合适 | 生成器更合适 |
|---|---|---|
| 依赖数量 | 十几个以内 | 几十个且分层深 |
| 变更频率 | 装配稳定 | 频繁加 provider |
| 团队熟悉度 | 新人不熟生成链路 | 已统一构建流程 |
| 排错需求 | 启动即单步可断 | 可接受看生成的 wire_gen.go |
门槛大致是:手工 main 超过一屏半、并且每次加功能都要改三处以上,才值得引入生成器。低于这个量级,生成器带来的构建与阅读成本大于它省下的手写量。
5. 错误跨层:在哪里翻译
纪律分两段:跨层传递靠包装,对外暴露靠翻译。
// 适配层:把驱动错误翻译成领域错误,保留原错误链
if errors.Is(err, sql.ErrNoRows) {
return nil, fmt.Errorf("user %d: %w", id, domain.ErrNotFound)
}
// 入口层:只在这里决定 HTTP 状态码与响应体
switch {
case errors.Is(err, domain.ErrNotFound):
writeJSON(w, 404, apiErr("not_found"))
case errors.Is(err, domain.ErrConflict):
writeJSON(w, 409, apiErr("conflict"))
default:
writeJSON(w, 500, apiErr("internal"))
}
| 位置 | 该做 | 不该做 |
|---|---|---|
| 领域层 | 定义语义错误(ErrNotFound 这类哨兵) |
出现 http.Status* |
| 适配层 | %w 包装,保留上下文与原因 |
打印日志后返回空错误 |
| 入口层 | 一次性映射成状态码与响应体 | 在每层都 switch 一次 |
| 基础设施 | 只回传原始错误 | 提前翻译成领域语义 |
反例很常见:每层都 log.Error 一次再 return err,结果一个故障打五条日志、状态码在中间层被定死、调用方拿不到原始原因。
6. 初始化顺序与关闭责任
判据是依赖方向:被依赖的先创建,依赖它的后关闭。谁创建谁关闭,别在 main 里 defer db.Close() 又在别处再关一次。
func Run(srv *http.Server, db *sql.DB) error {
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt)
defer stop()
go func() { log.Print(srv.ListenAndServe()) }()
<-ctx.Done()
shutCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
_ = srv.Shutdown(shutCtx) // 先停止接流量
return db.Close() // 再释放下游
}
| 顺序问题 | 典型症状 | 判据 |
|---|---|---|
| 连接池在 handler 里创建 | 每个请求一个池,连接数爆 | 池只在装配层创建 |
| 关闭顺序反了 | 关闭时出现 sql: database is closed |
先停入口,再关下游 |
| 后台 worker 无退出信号 | 进程卡住不退出 | 所有 goroutine 受同一 ctx 控制 |
7. 测试替身与接口:值不值
| 情况 | 引入接口 | 理由 |
|---|---|---|
| 依赖外部网络、数据库 | 值得 | 测试要脱离真实依赖 |
| 依赖时钟、随机数 | 值得,函数类型即可 | 常见不确定性来源 |
| 纯内存计算 | 不值得 | 直接测,接口只增间接层 |
| 需要断言调用次数 | 视情况 | 先断言结果,再断言交互 |
判据是替身是否真会被写出来。给只有内存实现的类型造接口,通常只得到一份永远返回零值的假实现,不提高覆盖率,只提高阅读成本。func() time.Time 这类函数类型比接口更轻,能用函数就别造接口。
8. 循环依赖的三破法
| 破法 | 做法 | 代价 | 何时选 |
|---|---|---|---|
| 提取共享类型 | 把双方都用的类型下沉到第三个包 | 多一个包,命名要准 | 循环点只是数据结构 |
| 事件反转 | 一方发事件,另一方订阅 | 引入异步与最终一致 | 循环点是"通知"语义 |
| 合并包 | 承认它们是一个模块 | 包变大 | 二者确实共享不变量 |
选择顺序:先问"这是不是一个概念"——是就合并,不是就提取类型;只有当依赖本质是"回调"才用事件反转。它最贵:把编译期错误换成运行期不触发。
9. 可观测注入的位置
日志、指标、追踪应该在装配层统一注入,而不是在每个业务函数里 slog.Info。
func WithMetrics(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
rec := &statusRecorder{ResponseWriter: w, code: 200}
next.ServeHTTP(rec, r)
httpDuration.WithLabelValues(r.Method, r.URL.Path, strconv.Itoa(rec.code)).
Observe(time.Since(start).Seconds())
})
}
判据:同一类观测只在一个位置产生。业务层只把语义写进 context(请求 ID、业务键),传输层把它们变成 span 属性与日志字段。埋点散落各层时,改字段名要全局搜索,也容易出现同一指标两个口径。
10. 排错表
| 现象 | 最可能原因 | 处理 |
|---|---|---|
| 编译越来越慢 | 反向依赖、包粒度太细 | go list -deps 找反向边,合并或下沉 |
import cycle not allowed |
两个包互相需要对方的具体类型 | 提取共享类型或按语义合并 |
| 加了接口后测试仍要起数据库 | 接口定义在实现侧,消费方直接用了具体类型 | 把接口移到消费方 |
wire 报 provider 缺失 |
签名不匹配或未注册 | 对照 wire_gen.go 检查返回类型 |
| 启动后首个请求空指针 | 装配漏了一处,编译期发现不了 | 对齐构造参数,加启动自检 |
关闭时 database is closed |
关闭顺序与依赖方向相反 | 先 Shutdown 入口,再关下游 |
11. 边界:什么时候不必设计分层
| 场景 | 结论 | 理由 |
|---|---|---|
| 单包程序、脚本化工具 | 不分层 | 目录结构比逻辑还多 |
| 团队 1–2 人、代码万行以内 | 只守方向,不建接口 | 接口维护成本高于替换收益 |
| 只有一种存储、无替换预期 | 不定义仓储接口 | 替身永远用不上 |
| 周期性一次性任务 | 装配写在入口 | 生命周期短,抽象无收益 |
必须做的信号:多入口、两类以上外部依赖、或已出现过"改一字段重编全仓"。
12. 自测
- 判断"分层有没有被破坏",你会看目录名还是看哪条命令的输出?
- 接口应该定义在实现方还是消费方,为什么?
- 一个接口只有一个实现且没有外部依赖,该不该留着?
- 手工装配与代码生成注入的收益门槛大致在哪里?
- 错误跨层传递时,状态码该在哪一层决定?
- 循环依赖的三种破法里,哪一种把编译期错误换成了运行期风险?