Go context 值入门:请求范围数据可以放,业务参数不要乱塞

讲 context.WithValue 的适用边界:请求 ID、用户 ID、trace 信息可以放,业务参数、可选配置和依赖对象不该乱放。

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 用得克制,代码边界会清楚很多。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页