go-zero 的 ctx 传递规范

「go-zero 里 context 怎么传?规范是啥?」—— 这篇把 ctx 扮演的角色、从哪来、该装什么不该装什么、以及一套必须遵守的传递规范一次讲清。

一句话结论 ctx 扮演的角色 从哪来、怎么传 该装啥 / 不该装啥 传递规范清单 常见用法 其他框架 结论

ctx 是「一次请求的身份与生命线」,靠第一个参数一路传到底

go-zero 严格遵守 Go 官方约定:context.Context 永远作为函数第一个参数显式传递,不存进结构体字段、不做全局变量。它承载请求级数据(userId / traceId)与取消/超时信号,和 svc(进程级依赖)各管一段。

记得住的一句话:请求级的「这是谁的请求、这次调用要多久、链路 ID 是多少」放 ctx,靠第一个参数从 handler → logic → 下游 RPC/DB 一路传;连接池、客户端这类「进程级共享资源」放 svc,二者绝不混用

在 go-zero 里,ctx 扮演什么角色

一个 context.Context 同时是三样东西:请求身份证、超时/取消开关、链路透传通道。

🪪 请求身份证(value)
中间件从 JWT 里解出 userId / 租户 ID,放进 ctx;之后 logic 里不用再解析 token,直接从 ctx 取。一次请求内共享,请求间隔离。
⏱️ 超时 / 取消开关(deadline/cancel)
上游超时或客户端断开,ctx 被取消,下游所有基于同一个 ctx 的调用会立即收到信号、及时释放资源。go-zero 的 httpx / zrpc 默认就带超时。
🔗 链路透传通道(trace)
go-zero 内置 trace,把 traceId 写进 ctx 并经 RPC 元数据自动透传到下游服务,串起整条调用链(配合 jaeger/otel 看全链路)。
🚫 它不是什么
不是「万能口袋」——不放数据库连接、不放可变业务状态、不放大对象。这些要么在 svc,要么当显式参数传。

ctx 从哪来、怎么一路传到下游

HTTP 入口的 ctx 来自 http.Request,框架把它塞进生成的 logic 结构;之后你只要用 l.ctx 往下传。

HTTP 请求 r.Context() handler 中间件塞 userId logic l.ctx 取数据 下游 RPC / DB ctx 一起带过去 go-zero 生成代码已经帮你把 ctx 贯穿好了: handler 里 ctx := r.Context()NewXxxLogic(ctx, svcCtx) 存进 logic.ctx 你写的 logic 只要拿 l.ctx 继续往下游传,不用自己 new ctx
// 生成的 handler 里(你不用改)
func LoginHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
    return func(w http.ResponseWriter, r *http.Request) {
        ctx := r.Context()                       // ① 入口 ctx,来自请求
        l := logic.NewLoginLogic(ctx, svcCtx)       // ② 存进 logic.ctx
        resp, err := l.Login(&types.LoginReq{...})
        httpx.OkJson(w, resp)
    }
}

// 你写的 logic 里
func (l *LoginLogic) Login(req *types.LoginReq) (*types.LoginResp, error) {
    userId := ctxdata.Get(l.ctx, "userId")        // ③ 从 ctx 取请求身份
    // ④ 调用下游也带同一个 ctx —— 超时/取消/链路一起透传
    u, err := l.svcCtx.UserRpc.GetUser(l.ctx, &user.GetUserReq{Id: userId})
    ...
}

ctx 该装什么、不该装什么

这是最容易被搞混的地方,和 svc 的边界划清楚。

类别放 ctx(请求级)✅放 svc(进程级)✅放参数 / 其他
用户身份userId、tenantId(本次请求是谁)
链路 / 诊断traceId、spanId、自定义 metadata
数据库句柄DB / Redis 连接池
下游客户端RPC stub / httpc
本次请求参数只想「随调用飘」的小标量可放主请求体当显式参数传最清晰
大对象 / 可变状态❌ 别放(ctx 取用有成本、易误改)并发安全组件(Redis/sync.Map)需要长期跨请求共享的放 svc
铁律:「这是谁的请求 / 这次要多久 / 链路 ID」→ ctx;「进程级共享的连接与客户端」→ svc。把 DB 句柄塞进 ctx、或把 userId 塞进 svc,都是把两个维度搞混了。详见「svc 深度解析」「全局变量」两篇。

go-zero 的 ctx 传递规范清单

这套规范一半来自 Go 官方、一半被 go-zero 的代码生成强制落地。

✅ ctx 永远第一个参数
所有接收/发起调用的函数,ctx 放最前:func(ctx, req)。go-zero 生成的 logic 构造器 NewXxxLogic(ctx, svcCtx) 已强制此形。
✅ 下游调用必须带同一个 ctx
调 RPC / DB / HTTP 一律传 l.ctx,这样超时、取消、traceId 才能一路透传下去。
✅ 不要 new 一个空 ctx 自己造
入口 ctx 是框架给的(带超时/链路),别用 context.Background() 替换它,否则链路和超时全断。后台 goroutine 需要更长生命周期时,才用 context.WithoutCancel / 派生。
✅ 用类型安全的 key 取自定义值
不要 context.WithValue(ctx, "k", v) 用裸字符串 key。定义私有类型常量 key,或用 go-zero 内置 ctxdata 包(专门取 JWT 里的字段)。
🚫 绝不把 ctx 存进结构体字段
Go 官方明确反对。ctx 应随调用流动,存字段会让它的「取消/超时」语义失效、难追踪。logic 里是用 l.ctx(生成器给的),但那是参数传递的产物,不是你自己 new 了存进去。
🚫 别往 ctx 塞大对象 / 可变状态
ctx 每次 WithValue 都复制,放大对象有性能成本;放可变状态会让并发安全失控、且难测试。

最常见的几种用法

从「取登录用户」到「链路透传」的落地代码。

① 中间件把 JWT 里的 userId 注入 ctx(内置 ctxdata)

go-zero 推荐用 jwt: Auth + ctxdata:鉴权中间件自动把 token claims 解析后写进 ctx,业务侧直接取。

// 中间件 / 或框架自带:校验后写入
ctx := ctxdata.ContextWithToken(r.Context(), token)

// logic 里取(不用自己解 token)
userId := ctxdata.Get(l.ctx, "userId")   // 返回 any,按需转 string/int64

② 自定义类型安全的 key

自己加的请求级字段,用私有类型避免 key 冲突。

type ctxKey string
const tenantKey ctxKey = "tenant"

// 中间件注入
ctx = context.WithValue(ctx, tenantKey, "t-001")
// logic 取
tenant, _ := ctx.Value(tenantKey).(string)

③ 下游调用的超时 / 取消透传

只要带 l.ctx,上游配置的超时(rest/zrpc 的 Timeout)会自动生效,下游超时被取消时这里立刻返回。

// 带同一个 ctx,超时与链路自动透传
order, err := l.svcCtx.OrderRpc.GetOrder(l.ctx, &pb.GetOrderReq{Id: id})
if errors.Is(err, context.DeadlineExceeded) {
    return nil, errs.New(504, "下游超时")   // 优雅处理取消
}

④ 后台 goroutine 要脱离请求生命周期

若请求结束后还想继续干活,别让 ctx 随请求取消——派生一个不被取消的 ctx。

// 否则主请求 cancel,后台 goroutine 也跟着死
bg := context.WithoutCancel(l.ctx)   // 保留 value,但不继承取消
go asyncAudit(bg, orderId)

其他框架也这么传 ctx 吗?

「第一个参数显式传 ctx」是 Go 官方与几乎所有 Go Web 框架的共识。

🔹 Gin / Echo
c *gin.Context / c echo.Context 既当请求载体又当数据袋,把 ctx 存成了结构体字段。go-zero 刻意不用这种做法(避免 ctx 当字段),而是把数据从 c 取出来、放进标准 context.Context 再传——更贴合官方规范。
🔹 gRPC(裸)
handler 第一个参数就是 ctx context.Context,和 go-zero zrpc 完全一致;go-zero 只是把它和生成的 logic 串起来。链路透传靠 metadata,go-zero 内置帮做了。
🔸 其他语言(Spring / FastAPI)
多用「隐式上下文」:ThreadLocal(Java)、request.state / 依赖注入(Python)。Go 选择显式第一个参数,可读性更强、无隐式魔法,但写起来更啰嗦——go-zero 用 codegen 把啰嗦的部分自动化了。
🔸 小结
目标一样(携带请求身份 + 取消信号 + 链路),机制上 Go 系统一用「显式 ctx 第一参数」,go-zero 再用生成器把这套规范强制到每个 logic 上,新人不会走偏。

一句话记住 go-zero 的 ctx 传递

🧠 记忆口诀

· ctx = 请求身份证 + 超时/取消开关 + 链路透传通道;
· 永远第一个参数,从 handler → logic → 下游 RPC/DB 一路显式传;
· 入口 ctx 来自 r.Context(),生成器帮你存进 l.ctx,你不用自己 new;
· 身份/链路数据放 ctx,连接/客户端放 svc,二者绝不混
· 下游调用带 l.ctx(超时/取消/链路自动透传);
· 存结构体字段、塞大对象/可变状态、用裸字符串 key。