2827 字
约 9 分钟
18
S09-A08 · Go 项目分层与装配:接口边界与手工注入
2024-12-31
2026-11-09

项目长到十几个包后,最初那几次"随手 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. 自测

  1. 判断"分层有没有被破坏",你会看目录名还是看哪条命令的输出?
  2. 接口应该定义在实现方还是消费方,为什么?
  3. 一个接口只有一个实现且没有外部依赖,该不该留着?
  4. 手工装配与代码生成注入的收益门槛大致在哪里?
  5. 错误跨层传递时,状态码该在哪一层决定?
  6. 循环依赖的三种破法里,哪一种把编译期错误换成了运行期风险?
S09-A08 · Go 项目分层与装配:接口边界与手工注入
https://www.clxhxhhr.top/posts/6205/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。