2581 字
约 8 分钟
14
S09-A05 · Go 服务架构:包结构、错误处理与部署形态
2024-12-31
2026-11-09

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. 自测

  1. 什么信号说明包是按层切而不是按功能切?
  2. internal/ 能挡住哪种依赖,挡不住哪种?
  3. 只有一个实现时,该不该抽接口?
  4. 为什么不能用 err.Error() 做分支判断?
  5. 限流与鉴权谁在前,为什么?
  6. 关闭服务时为什么先停止接收新请求再等在途请求?
S09-A05 · Go 服务架构:包结构、错误处理与部署形态
https://www.clxhxhhr.top/posts/6105/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。