5. context 实战应用

前面四篇讲了机制和模式。这一篇落到两个实际场景:怎么在测试里用 context,以及一个完整请求链路的综合示例,把所有知识点串起来。

测试中的五种 context 模式

1. Background 作为默认 context

绝大多数单元测试不需要复杂的 context,直接用 Background()

func TestProcess(t *testing.T) {
    result := process(context.Background())
    if result != "ok" {
        t.Errorf("expected ok, got %s", result)
    }
}

被测函数接收 context.Context 参数,测试时就传 Background(),简单清晰。

2. WithValue 注入 mock 数据

被测函数依赖 ctx 里的某些值(比如认证信息),测试时用 WithValue 注入:

type authInfo struct {
    UserID int
    Role   string
}

func getUserRole(ctx context.Context) string {
    auth, ok := ctx.Value("auth").(*authInfo)
    if !ok {
        return "anonymous"
    }
    return auth.Role
}

func TestGetUserRole(t *testing.T) {
    // 注入管理员角色
    ctx := context.WithValue(context.Background(), "auth", &authInfo{
        UserID: 1,
        Role:   "admin",
    })
    if role := getUserRole(ctx); role != "admin" {
        t.Errorf("expected admin, got %s", role)
    }

    // 测试无认证场景
    if role := getUserRole(context.Background()); role != "anonymous" {
        t.Errorf("expected anonymous, got %s", role)
    }
}

3. WithCancel 模拟取消

测试”被取消后函数返回什么错误”:

func TestLongProcessCancel(t *testing.T) {
    ctx, cancel := context.WithCancel(context.Background())
    go func() {
        time.Sleep(50 * time.Millisecond)
        cancel() // 主动触发取消
    }()

    result, err := longProcess(ctx)
    if err != context.Canceled {
        t.Errorf("expected Canceled, got %v", err)
    }
}

4. WithTimeout 防止测试卡住

测试里调用一个可能永远阻塞的函数,用 WithTimeout 兜底,防止 CI 永远挂住

func TestProcessWithTimeout(t *testing.T) {
    ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
    defer cancel()

    if _, err := process(ctx); err != nil {
        t.Fatalf("process failed: %v", err)
    }
}

5 秒后自动失败,比单元测试卡死 30 分钟强得多。

5. t.Context()(Go 1.24+)

Go 1.24 引入了 t.Context()自动随测试结束取消

func TestWorker(t *testing.T) {
    ctx := t.Context() // 测试结束自动取消
    if err := worker(ctx); err != nil {
        t.Fatal(err)
    }
}

省掉手动 cancel() 的样板代码。新项目用 Go 1.24+ 的话,这是首选。

综合示例:完整请求处理链路

把前四篇的所有知识点串成一个真实场景:一次 HTTP 请求从入口到响应,经历中间件、认证、超时控制、并发调用、清理回调。

链路结构

1. 入口       → 创建基础 context
2. Tracing 中间件 → WithValue 注入 trace_id
3. Auth 中间件   → WithValue 注入 user_id
4. Handler     → WithTimeout 给整个请求设超时
5. Service     → 继承 context
6. 并发调用     → Fan-out 调三个服务,WaitGroup 收集
7. 响应       → 返回聚合结果
8. AfterFunc   → context 取消时记录日志

完整代码

package main

import (
    "context"
    "fmt"
    "sync"
    "time"
)

type Request struct {
    Path     string
    ClientIP string
}

type Response struct {
    Data     string
    TraceID  string
    UserID   string
    Duration time.Duration
}

type Middleware func(ctx context.Context) context.Context

func handleRequest(req *Request) *Response {
    start := time.Now()

    // 1. 基础 context
    baseCtx := context.Background()

    // 2. 中间件:注入 TraceID
    ctx := tracingMiddleware(baseCtx)

    // 3. 中间件:认证,注入 UserID
    ctx = authMiddleware(ctx)

    // 4. 设置请求超时
    ctx, cancel := context.WithTimeout(ctx, 500*time.Millisecond)
    defer cancel()

    // 5. 注册取消回调(用于日志记录)
    context.AfterFunc(ctx, func() {
        traceID, _ := ctx.Value("trace_id").(string)
        fmt.Printf("[AfterFunc] context 取消, traceID=%s\n", traceID)
    })

    // 6. 并发调用多个服务
    result, err := fanOutCall(ctx)
    if err != nil {
        return &Response{
            Data:     fmt.Sprintf("错误: %v", err),
            TraceID:  ctx.Value("trace_id").(string),
            Duration: time.Since(start),
        }
    }

    return &Response{
        Data:     result,
        TraceID:  ctx.Value("trace_id").(string),
        UserID:   ctx.Value("user_id").(string),
        Duration: time.Since(start),
    }
}

func tracingMiddleware(ctx context.Context) context.Context {
    traceID := fmt.Sprintf("trace-%d", time.Now().UnixNano()%10000)
    return context.WithValue(ctx, "trace_id", traceID)
}

func authMiddleware(ctx context.Context) context.Context {
    return context.WithValue(ctx, "user_id", "user-42")
}

func fanOutCall(ctx context.Context) (string, error) {
    services := []struct {
        name  string
        delay time.Duration
    }{
        {"UserService", 100 * time.Millisecond},
        {"OrderService", 200 * time.Millisecond},
        {"PaymentService", 300 * time.Millisecond},
    }

    var wg sync.WaitGroup
    results := make(chan string, len(services))
    errors := make(chan error, len(services))

    for _, svc := range services {
        wg.Add(1)
        go func(name string, delay time.Duration) {
            defer wg.Done()
            if r, err := callService(ctx, name, delay); err != nil {
                errors <- err
            } else {
                results <- r
            }
        }(svc.name, svc.delay)
    }

    go func() {
        wg.Wait()
        close(results)
        close(errors)
    }()

    var collected []string
    for r := range results {
        collected = append(collected, r)
    }

    select {
    case err := <-errors:
        return "", err
    default:
        return fmt.Sprintf("聚合 %d 个服务: %v", len(collected), collected), nil
    }
}

func callService(ctx context.Context, name string, delay time.Duration) (string, error) {
    traceID, _ := ctx.Value("trace_id").(string)
    select {
    case <-time.After(delay):
        return fmt.Sprintf("%s OK (trace=%s)", name, traceID), nil
    case <-ctx.Done():
        return "", fmt.Errorf("%s 超时: %w", name, ctx.Err())
    }
}

用到的知识点

这段代码把前面四篇的核心知识点全部串起来:

知识点在代码里的体现
Background 作为根baseCtx := context.Background()
WithValue 链式传递中间件依次注入 trace_id、user_id
WithTimeoutctx, cancel := context.WithTimeout(ctx, 500*time.Millisecond)
defer cancel()创建后立即 defer
AfterFunc注册取消后的日志回调
Fan-out 并发三个 goroutine 同时调三个服务
WaitGroup + 关闭 channel收集所有结果
select + ctx.Done()callService 里二选一
errors.Is 兼容的错误处理%w 包装错误
Value 向上查找子 context 能拿到祖先所有值

模拟超时场景

把整体超时从 500ms 改成 250ms,会看到 PaymentService(300ms)超时:

ctx, cancel := context.WithTimeout(ctx, 250*time.Millisecond)
// UserService(100ms) → 成功
// OrderService(200ms) → 成功
// PaymentService(300ms) → 失败: PaymentService 超时: context deadline exceeded

这正是 context 的核心价值:用一行 WithTimeout,让整个调用链都有超时保护,单个服务慢不会拖垮整个请求

知识体系总览

整个 context 包的体系可以用一棵树概括:

context 包
├── 创建函数
│   ├── Background()              空 context,用作根
│   ├── TODO()                    占位 context
│   ├── WithCancel(parent)        可手动取消
│   ├── WithTimeout(parent, d)    超时自动取消
│   ├── WithDeadline(parent, t)   截止时间取消
│   └── WithValue(parent, k, v)   携带键值对

├── Context 接口
│   ├── Deadline()    返回截止时间
│   ├── Done()        返回取消信号 channel
│   ├── Err()         返回取消原因
│   └── Value(key)    返回 key 对应的值

├── 传播规则
│   ├── 取消:父 → 子(单向)
│   ├── 值:  子 → 父(向上查找)
│   └── Deadline:取最早的

├── 错误类型
│   ├── Canceled            主动取消
│   └── DeadlineExceeded    超时取消

└── Go 1.21+ 新特性
    ├── AfterFunc(ctx, f)      取消后执行回调
    └── WithoutCancel(parent)  不受父取消影响

最重要的两条原则,再强调一次:

  • select + ctx.Done() 是 context 的灵魂。任何 goroutine,只要它的生命周期长于一次函数调用,都要监听 ctx.Done()
  • defer cancel() 是底线。任何创建可取消 context 的地方,立即 defer cancel,不依赖”我知道这里要取消”