context.Context 是 Go 服务开发里绕不开的类型。它主要负责取消、超时和请求范围的数据。前两者很好理解,第三个“请求范围的数据”最容易被滥用。很多初学者会把业务参数、配置、数据库连接甚至 service 都塞进 context,最后代码变得像隐形全局变量。
本文只讨论 context.WithValue 的边界。它不是不能用,而是要用在合适的地方:请求 ID、当前用户 ID、trace 信息、语言环境这类横切信息可以放;明确的业务参数应该通过函数参数传递。
一个合适场景:请求 ID
中间件生成请求 ID:
type requestIDKey struct{}
func WithRequestID(ctx context.Context, id string) context.Context {
return context.WithValue(ctx, requestIDKey{}, id)
}
func RequestID(ctx context.Context) string {
id, _ := ctx.Value(requestIDKey{}).(string)
return id
}
HTTP 中间件:
func RequestIDMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
id := r.Header.Get("X-Request-ID")
if id == "" {
id = newRequestID()
}
ctx := WithRequestID(r.Context(), id)
w.Header().Set("X-Request-ID", id)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
日志里取:
slog.Info("create order", "request_id", RequestID(ctx))
请求 ID 是横切信息,不属于某个业务函数的核心输入。放 context 合理。
key 不要用普通字符串
不要这样:
context.WithValue(ctx, "user_id", 123)
不同包可能使用同一个字符串 key,产生冲突。更推荐使用未导出的自定义类型:
type userIDKey struct{}
func WithUserID(ctx context.Context, id int64) context.Context {
return context.WithValue(ctx, userIDKey{}, id)
}
func UserID(ctx context.Context) (int64, bool) {
id, ok := ctx.Value(userIDKey{}).(int64)
return id, ok
}
把读写封装成函数,调用方不用知道 key 的细节。
不要把业务参数放进 context
不推荐:
func CreateOrder(ctx context.Context) error {
productID := ctx.Value("product_id").(string)
quantity := ctx.Value("quantity").(int)
// ...
}
推荐:
func CreateOrder(ctx context.Context, input CreateOrderInput) error {
// input.ProductID, input.Quantity
return nil
}
业务参数是函数的显式输入,应该出现在函数签名里。这样代码更容易读、测试更容易写、编译器也能帮你发现遗漏。context 里的值没有类型约束,读错 key 或断言失败都是运行时问题。
不要把依赖对象放进 context
不推荐:
db := ctx.Value("db").(*sql.DB)
数据库、缓存、客户端、service 应该通过结构体字段或构造函数注入:
type OrderService struct {
db *sql.DB
}
func (s *OrderService) Create(ctx context.Context, input CreateOrderInput) error {
_, err := s.db.ExecContext(ctx, "INSERT ...")
return err
}
context 不是依赖注入容器。把依赖藏进去会让函数签名看起来很简单,实际依赖却变得不可见。
读取值要能处理不存在
即使你认为中间件一定设置了用户 ID,读取时也要处理不存在:
func RequireUserID(ctx context.Context) (int64, error) {
id, ok := UserID(ctx)
if !ok {
return 0, errors.New("missing user id in context")
}
return id, nil
}
测试、后台任务、CLI 调用可能没有经过 HTTP 中间件。不要到处直接 type assertion:
id := ctx.Value(userIDKey{}).(int64)
这会在缺值时 panic。服务端代码里,缺少上下文值通常应该变成明确错误。
测试 context helper
func TestUserIDContext(t *testing.T) {
ctx := WithUserID(context.Background(), 42)
id, ok := UserID(ctx)
if !ok || id != 42 {
t.Fatalf("id = %d, ok = %v", id, ok)
}
}
测试 handler 时,也可以直接构造带用户 ID 的 request context:
req := httptest.NewRequest(http.MethodGet, "/me", nil)
req = req.WithContext(WithUserID(req.Context(), 42))
这样测试不需要完整跑登录中间件。helper 函数让测试更干净。
context 值不要跨进程理解
context 里的值只存在当前进程、当前调用链。它不会自动传给消息队列、数据库、外部 HTTP 服务。比如请求 ID 如果要传给下游服务,你必须显式写到请求头:
func addRequestIDHeader(ctx context.Context, req *http.Request) {
if id := RequestID(ctx); id != "" {
req.Header.Set("X-Request-ID", id)
}
}
如果要把用户 ID 写进后台任务,也应该放到任务 payload 里,而不是假设 worker 能拿到原来的 context。后台任务通常在另一个 goroutine、另一个进程甚至另一台机器上执行,context 不是持久化载体。
值越少越好
一个健康的 context 通常只带少量横切信息。看到代码里有十几个 WithValue,就应该警觉:是不是把参数、配置和依赖都藏进去了?隐藏依赖会让函数看起来干净,实际调试时却要沿着调用链找谁设置了值。
实践里可以约定:只有中间件、认证层、追踪层这类边界代码能写 context value;业务函数尽量只读少数 helper。规则明确后,context 会成为清晰的链路载体,而不是杂物箱。
小结
context.WithValue 适合传递请求范围的横切信息,比如请求 ID、用户 ID、trace 信息和语言环境。key 应该使用自定义类型,并通过 helper 函数读写。
业务参数、配置和依赖对象不要塞进 context。它们应该通过函数参数、结构体字段或构造函数显式传递。context 用得克制,代码边界会清楚很多。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。