跟Codex学习go(一)Context

下面用几个逐步升级的例子,系统理解 Go 里的 context.Context

context 主要解决四类问题:

  1. 取消任务
  2. 设置超时
  3. 设置截止时间
  4. 在调用链中传递请求级数据

核心原则:

context 不是用来传普通业务参数的,而是用来控制一条调用链的生命周期。


一、最基础:手动取消任务

先看最简单的例子。

css 复制代码
package main

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

func worker(ctx context.Context) {
	for {
		select {
		case <-ctx.Done():
			fmt.Println("worker 收到取消信号:", ctx.Err())
			return

		default:
			fmt.Println("worker 正在工作...")
			time.Sleep(time.Second)
		}
	}
}

func main() {
	ctx, cancel := context.WithCancel(context.Background())

	go worker(ctx)

	time.Sleep(3 * time.Second)

	fmt.Println("main 主动取消任务")
	cancel()

	time.Sleep(time.Second)
}

运行效果大概是:

erlang 复制代码
worker 正在工作...
worker 正在工作...
worker 正在工作...
main 主动取消任务
worker 收到取消信号: context canceled

1. context.Background()

css 复制代码
ctx := context.Background()

它是一个空的根上下文,一般用于:

  • main 函数
  • 后台任务
  • 测试代码
  • 调用链的最顶层

它不会被取消,也没有超时。

2. context.WithCancel

css 复制代码
ctx, cancel := context.WithCancel(context.Background())

返回两个值:

复制代码
ctx
cancel

调用:

scss 复制代码
cancel()

之后:

scss 复制代码
ctx.Done()

对应的 channel 会被关闭。

因此下面的代码会被触发:

arduino 复制代码
case <-ctx.Done():

3. ctx.Err()

取消后:

scss 复制代码
ctx.Err()

会返回:

复制代码
context.Canceled

完整判断也可以这样写:

css 复制代码
if err := ctx.Err(); err != nil {
	fmt.Println(err)
}

二、为什么不用普通的 bool 取消

你可能会想到:

arduino 复制代码
stop := false

然后 goroutine 不断检查:

arduino 复制代码
if stop {
	return
}

这种写法有几个问题:

  • 有并发读写风险
  • 需要加锁或使用原子变量
  • 不方便向多个 goroutine 广播
  • 不方便形成父子调用链
  • 没有统一的超时机制

context 的取消信号本质上是关闭一个 channel。

一个 channel 被关闭后,所有监听者都能同时收到通知。


三、一个 Context 取消多个 goroutine

go 复制代码
package main

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

func worker(ctx context.Context, name string) {
	for {
		select {
		case <-ctx.Done():
			fmt.Printf("%s 停止工作,原因:%v\n", name, ctx.Err())
			return

		case <-time.After(time.Second):
			fmt.Printf("%s 正在工作\n", name)
		}
	}
}

func main() {
	ctx, cancel := context.WithCancel(context.Background())

	go worker(ctx, "worker-1")
	go worker(ctx, "worker-2")
	go worker(ctx, "worker-3")

	time.Sleep(3 * time.Second)

	cancel()

	time.Sleep(time.Second)
}

调用一次:

scss 复制代码
cancel()

三个 goroutine 都会停止。

这就是 context 很重要的一个特性:

广播式取消。


四、使用 WithTimeout 设置超时

很多时候,我们不希望任务无限执行。

例如:

  • 查询数据库最多允许 2 秒
  • 调用第三方接口最多允许 5 秒
  • 文件处理最多允许 10 秒

可以使用:

复制代码
context.WithTimeout

示例:

go 复制代码
package main

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

func slowTask(ctx context.Context) error {
	select {
	case <-time.After(5 * time.Second):
		fmt.Println("任务执行完成")
		return nil

	case <-ctx.Done():
		return ctx.Err()
	}
}

func main() {
	ctx, cancel := context.WithTimeout(
		context.Background(),
		2*time.Second,
	)
	defer cancel()

	err := slowTask(ctx)
	if err != nil {
		fmt.Println("任务失败:", err)
	}
}

输出:

makefile 复制代码
任务失败: context deadline exceeded

因为:

  • 任务本身需要 5 秒
  • context 只允许执行 2 秒
  • 2 秒后自动取消

此时:

scss 复制代码
ctx.Err()

返回:

复制代码
context.DeadlineExceeded

五、为什么一定要 defer cancel()

常见写法:

css 复制代码
ctx, cancel := context.WithTimeout(
	context.Background(),
	2*time.Second,
)
defer cancel()

即使 context 会自动超时,也仍然建议调用:

scss 复制代码
cancel()

因为 context 内部可能维护:

  • timer
  • 子 context
  • 取消通知
  • 相关资源

如果函数提前结束,调用 cancel() 可以尽早释放资源。

正确模式:

css 复制代码
ctx, cancel := context.WithTimeout(parentCtx, time.Second)
defer cancel()

六、WithDeadline:设置具体截止时间

WithTimeout 表示:

复制代码
从现在开始,最多运行多久

WithDeadline 表示:

复制代码
运行到某个具体时间点

示例:

css 复制代码
deadline := time.Now().Add(3 * time.Second)

ctx, cancel := context.WithDeadline(
	context.Background(),
	deadline,
)
defer cancel()

完整示例:

css 复制代码
package main

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

func main() {
	deadline := time.Now().Add(3 * time.Second)

	ctx, cancel := context.WithDeadline(
		context.Background(),
		deadline,
	)
	defer cancel()

	fmt.Println("截止时间:", deadline.Format("15:04:05"))

	<-ctx.Done()

	fmt.Println("context 结束:", ctx.Err())
}

三秒后:

复制代码
context 结束: context deadline exceeded

实际项目中,WithTimeout 使用得更多。


七、Context 在函数调用链中传递

真实项目不会只有一个函数,通常是:

markdown 复制代码
Controller
    ↓
Service
    ↓
Repository
    ↓
Database

context 应该沿着整条调用链传递。

示例:

go 复制代码
package main

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

func controller(ctx context.Context) {
	err := service(ctx)
	if err != nil {
		fmt.Println("controller:", err)
	}
}

func service(ctx context.Context) error {
	return repository(ctx)
}

func repository(ctx context.Context) error {
	select {
	case <-time.After(5 * time.Second):
		fmt.Println("数据库查询完成")
		return nil

	case <-ctx.Done():
		return ctx.Err()
	}
}

func main() {
	ctx, cancel := context.WithTimeout(
		context.Background(),
		2*time.Second,
	)
	defer cancel()

	controller(ctx)
}

执行过程:

css 复制代码
main 创建 context
    ↓
controller 接收 context
    ↓
service 继续传递
    ↓
repository 监听 context

两秒后,最底层的 repository 也能收到取消信号。

这就是 context 的核心价值:

在整个调用链中传递取消和超时信号。


八、HTTP 请求中的 Context

在 Go HTTP 服务中,每个请求本身就带有一个 context:

scss 复制代码
r.Context()

示例:

go 复制代码
package main

import (
	"context"
	"fmt"
	"net/http"
	"time"
)

func queryData(ctx context.Context) error {
	select {
	case <-time.After(5 * time.Second):
		return nil

	case <-ctx.Done():
		return ctx.Err()
	}
}

func handler(w http.ResponseWriter, r *http.Request) {
	ctx := r.Context()

	err := queryData(ctx)
	if err != nil {
		http.Error(w, err.Error(), http.StatusRequestTimeout)
		return
	}

	fmt.Fprintln(w, "查询成功")
}

func main() {
	http.HandleFunc("/data", handler)
	http.ListenAndServe(":8080", nil)
}

当发生以下情况时,请求 context 可能被取消:

  • 客户端主动断开连接
  • 请求被取消
  • HTTP Server 超时
  • 上游 context 被取消

因此,业务函数应该优先使用:

scss 复制代码
r.Context()

而不是重新创建:

scss 复制代码
context.Background()

错误写法:

scss 复制代码
func handler(w http.ResponseWriter, r *http.Request) {
	// 错误:丢失了 HTTP 请求原本的取消信号
	ctx := context.Background()

	queryData(ctx)
}

正确写法:

css 复制代码
ctx := r.Context()

九、Gin 中如何使用 Context

你使用的是 Gin,需要注意两个不同的 context:

复制代码
*gin.Context

和:

复制代码
context.Context

它们不是同一个类型。

Gin Controller 中可以这样获取标准 context:

go 复制代码
func GetUser(c *gin.Context) {
	ctx := c.Request.Context()

	user, err := userService.GetUser(ctx, 1)
	if err != nil {
		c.JSON(500, gin.H{
			"message": err.Error(),
		})
		return
	}

	c.JSON(200, user)
}

Service:

go 复制代码
func (s *UserService) GetUser(
	ctx context.Context,
	id uint,
) (*User, error) {
	return s.repo.GetUser(ctx, id)
}

Repository:

go 复制代码
func (r *UserRepository) GetUser(
	ctx context.Context,
	id uint,
) (*User, error) {
	var user User

	err := r.db.
		WithContext(ctx).
		First(&user, id).
		Error

	if err != nil {
		return nil, err
	}

	return &user, nil
}

这里最关键的是:

scss 复制代码
db.WithContext(ctx)

这样,当 HTTP 请求被取消时,数据库操作也有机会停止。


十、GORM 中的 Context

你现在经常使用 GORM。

普通查询:

sql 复制代码
db.Where("id = ?", id).First(&user)

带 context:

scss 复制代码
db.WithContext(ctx).
	Where("id = ?", id).
	First(&user)

完整例子:

go 复制代码
func GetUserByID(
	ctx context.Context,
	db *gorm.DB,
	id uint,
) (*User, error) {
	var user User

	err := db.
		WithContext(ctx).
		Where("id = ?", id).
		First(&user).
		Error

	if err != nil {
		return nil, err
	}

	return &user, nil
}

还可以额外设置数据库查询超时:

go 复制代码
func GetUserByID(
	parentCtx context.Context,
	db *gorm.DB,
	id uint,
) (*User, error) {
	ctx, cancel := context.WithTimeout(
		parentCtx,
		2*time.Second,
	)
	defer cancel()

	var user User

	err := db.
		WithContext(ctx).
		Where("id = ?", id).
		First(&user).
		Error

	if err != nil {
		return nil, err
	}

	return &user, nil
}

这样即使 HTTP 请求没有取消,数据库查询超过两秒也会被取消。


十一、WithValue:传递请求级数据

Context 还可以传递少量请求级数据。

例如:

  • request ID
  • trace ID
  • 当前用户 ID
  • 链路追踪信息
  • 日志字段

示例:

go 复制代码
package main

import (
	"context"
	"fmt"
)

type contextKey string

const userIDKey contextKey = "userID"

func service(ctx context.Context) {
	userID, ok := ctx.Value(userIDKey).(uint)
	if !ok {
		fmt.Println("没有找到 userID")
		return
	}

	fmt.Println("当前用户 ID:", userID)
}

func main() {
	ctx := context.WithValue(
		context.Background(),
		userIDKey,
		uint(1001),
	)

	service(ctx)
}

输出:

yaml 复制代码
当前用户 ID: 1001

不要直接使用字符串作为 key

不推荐:

css 复制代码
ctx := context.WithValue(ctx, "userID", 1001)

因为不同包可能使用同样的字符串,发生冲突。

推荐自定义类型:

go 复制代码
type contextKey string

const userIDKey contextKey = "userID"

更严谨的写法:

go 复制代码
type contextKey struct {
	name string
}

var userIDKey = contextKey{name: "userID"}

十二、封装 Context Value

不要在业务代码里到处写:

scss 复制代码
ctx.Value(userIDKey).(uint)

建议封装。

go 复制代码
package requestctx

import "context"

type key struct {
	name string
}

var userIDKey = key{name: "userID"}

func WithUserID(ctx context.Context, userID uint) context.Context {
	return context.WithValue(ctx, userIDKey, userID)
}

func UserID(ctx context.Context) (uint, bool) {
	userID, ok := ctx.Value(userIDKey).(uint)
	return userID, ok
}

使用:

ini 复制代码
ctx = requestctx.WithUserID(ctx, 1001)

userID, ok := requestctx.UserID(ctx)

这样可以避免:

  • key 冲突
  • 类型断言散落
  • 不同模块使用不同 key
  • 后续修改困难

十三、Gin 中间件传递用户信息

假设 JWT 中间件解析出用户 ID。

可以把用户 ID 放到标准 context 中。

go 复制代码
package middleware

import (
	"context"

	"github.com/gin-gonic/gin"
)

type contextKey struct {
	name string
}

var userIDKey = contextKey{name: "userID"}

func JWTAuth() gin.HandlerFunc {
	return func(c *gin.Context) {
		// 示例:实际项目中从 JWT 解析
		userID := uint(1001)

		ctx := context.WithValue(
			c.Request.Context(),
			userIDKey,
			userID,
		)

		c.Request = c.Request.WithContext(ctx)

		c.Next()
	}
}

Controller:

go 复制代码
func GetProfile(c *gin.Context) {
	ctx := c.Request.Context()

	userID, ok := ctx.Value(userIDKey).(uint)
	if !ok {
		c.JSON(401, gin.H{
			"message": "未获取到用户信息",
		})
		return
	}

	c.JSON(200, gin.H{
		"user_id": userID,
	})
}

不过在 Gin 项目里,简单数据也经常使用:

javascript 复制代码
c.Set("userID", userID)

然后:

arduino 复制代码
value, exists := c.Get("userID")

二者区别:

  • gin.Context 数据主要在 Controller 和中间件之间使用
  • context.Context 数据可以继续传到 Service、Repository、数据库和外部请求

如果用户信息需要深入调用链,标准 context.Context 更合适。


十四、父子 Context 的传播关系

Context 可以形成树结构。

markdown 复制代码
rootCtx
   ├── childCtx1
   └── childCtx2
          └── grandChildCtx

示例:

erlang 复制代码
package main

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

func watch(ctx context.Context, name string) {
	<-ctx.Done()
	fmt.Printf("%s 被取消: %v\n", name, ctx.Err())
}

func main() {
	parentCtx, parentCancel := context.WithCancel(
		context.Background(),
	)

	childCtx, childCancel := context.WithCancel(parentCtx)

	go watch(parentCtx, "parent")
	go watch(childCtx, "child")

	time.Sleep(time.Second)

	fmt.Println("取消 parent")
	parentCancel()

	time.Sleep(time.Second)

	childCancel()
}

取消父 context:

scss 复制代码
parentCancel()

会同时取消:

  • parent
  • child
  • child 的所有后代

但取消 child:

scss 复制代码
childCancel()

不会影响 parent。

规则是:

父 context 取消,会向下传播;子 context 取消,不会向上传播。


十五、子 Context 的超时不能超过父 Context

示例:

css 复制代码
parentCtx, parentCancel := context.WithTimeout(
	context.Background(),
	2*time.Second,
)
defer parentCancel()

childCtx, childCancel := context.WithTimeout(
	parentCtx,
	10*time.Second,
)
defer childCancel()

虽然 child 设置了 10 秒,但父 context 只有 2 秒。

最终 child 最多只能活 2 秒。

可以理解为:

复制代码
实际截止时间 = 父截止时间和子截止时间中更早的那个

十六、可取消的循环任务

这是项目中非常常见的写法。

css 复制代码
func runMonitor(ctx context.Context) {
	ticker := time.NewTicker(time.Second)
	defer ticker.Stop()

	for {
		select {
		case <-ticker.C:
			fmt.Println("采集设备数据")

		case <-ctx.Done():
			fmt.Println("停止采集:", ctx.Err())
			return
		}
	}
}

启动:

css 复制代码
ctx, cancel := context.WithCancel(context.Background())

go runMonitor(ctx)

time.Sleep(5 * time.Second)

cancel()

这适合:

  • MQTT 数据采集
  • 设备监控
  • 定时同步
  • WebSocket 心跳
  • 后台轮询
  • 消息消费

十七、错误示例:在循环里使用 time.Sleep

下面代码虽然可以工作,但取消响应不及时:

scss 复制代码
func worker(ctx context.Context) {
	for {
		if ctx.Err() != nil {
			return
		}

		doWork()

		time.Sleep(30 * time.Second)
	}
}

假设刚进入 Sleep 后调用了:

scss 复制代码
cancel()

任务仍然要等待最多 30 秒才能退出。

更好的写法:

go 复制代码
func worker(ctx context.Context) {
	ticker := time.NewTicker(30 * time.Second)
	defer ticker.Stop()

	for {
		select {
		case <-ctx.Done():
			return

		case <-ticker.C:
			doWork()
		}
	}
}

这样收到取消后,可以立即退出。


十八、HTTP 客户端请求中使用 Context

调用第三方接口时,也应该传入 context。

go 复制代码
package main

import (
	"context"
	"fmt"
	"io"
	"net/http"
	"time"
)

func main() {
	ctx, cancel := context.WithTimeout(
		context.Background(),
		2*time.Second,
	)
	defer cancel()

	req, err := http.NewRequestWithContext(
		ctx,
		http.MethodGet,
		"https://example.com",
		nil,
	)
	if err != nil {
		panic(err)
	}

	resp, err := http.DefaultClient.Do(req)
	if err != nil {
		fmt.Println("请求失败:", err)
		return
	}
	defer resp.Body.Close()

	body, err := io.ReadAll(resp.Body)
	if err != nil {
		fmt.Println("读取响应失败:", err)
		return
	}

	fmt.Println(string(body))
}

关键代码:

复制代码
http.NewRequestWithContext

当 context 超时或取消时,HTTP 请求也会终止。


十九、并发任务:任意一个失败,取消其他任务

假设同时执行三个任务:

  • 查询用户
  • 查询订单
  • 查询权限

只要一个失败,其余任务就没有继续执行的必要。

基础写法:

go 复制代码
package main

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

func task(ctx context.Context, name string, duration time.Duration) error {
	select {
	case <-time.After(duration):
		fmt.Println(name, "完成")
		return nil

	case <-ctx.Done():
		fmt.Println(name, "被取消")
		return ctx.Err()
	}
}

func main() {
	ctx, cancel := context.WithCancel(context.Background())
	defer cancel()

	var wg sync.WaitGroup

	errChan := make(chan error, 3)

	wg.Add(3)

	go func() {
		defer wg.Done()

		err := task(ctx, "用户查询", 2*time.Second)
		if err != nil {
			errChan <- err
			cancel()
		}
	}()

	go func() {
		defer wg.Done()

		err := task(ctx, "订单查询", 5*time.Second)
		if err != nil {
			errChan <- err
			cancel()
		}
	}()

	go func() {
		defer wg.Done()

		time.Sleep(time.Second)

		errChan <- errors.New("权限查询失败")
		cancel()
	}()

	wg.Wait()
	close(errChan)

	for err := range errChan {
		fmt.Println("错误:", err)
	}
}

不过在生产项目里,更推荐使用:

bash 复制代码
golang.org/x/sync/errgroup

二十、errgroup:并发任务的标准写法

go 复制代码
package main

import (
	"context"
	"fmt"
	"time"

	"golang.org/x/sync/errgroup"
)

func task(
	ctx context.Context,
	name string,
	duration time.Duration,
	shouldFail bool,
) error {
	select {
	case <-time.After(duration):
		if shouldFail {
			return fmt.Errorf("%s 执行失败", name)
		}

		fmt.Println(name, "执行成功")
		return nil

	case <-ctx.Done():
		fmt.Println(name, "被取消")
		return ctx.Err()
	}
}

func main() {
	ctx := context.Background()

	group, ctx := errgroup.WithContext(ctx)

	group.Go(func() error {
		return task(ctx, "任务A", 2*time.Second, false)
	})

	group.Go(func() error {
		return task(ctx, "任务B", time.Second, true)
	})

	group.Go(func() error {
		return task(ctx, "任务C", 5*time.Second, false)
	})

	if err := group.Wait(); err != nil {
		fmt.Println("任务组失败:", err)
	}
}

当任务 B 失败后:

css 复制代码
任务B 执行失败

errgroup 会自动取消关联的 context。

任务 A、任务 C 如果还没完成,会收到取消信号。

这是非常实用的并发模式。


二十一、Context 和 Channel 如何配合

Context 负责取消,channel 负责传输数据。

示例:

go 复制代码
package main

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

func producer(
	ctx context.Context,
	resultChan chan<- int,
) {
	defer close(resultChan)

	for i := 1; i <= 100; i++ {
		select {
		case <-ctx.Done():
			fmt.Println("生产者退出:", ctx.Err())
			return

		case resultChan <- i:
			time.Sleep(200 * time.Millisecond)
		}
	}
}

func main() {
	ctx, cancel := context.WithTimeout(
		context.Background(),
		2*time.Second,
	)
	defer cancel()

	resultChan := make(chan int)

	go producer(ctx, resultChan)

	for value := range resultChan {
		fmt.Println("收到数据:", value)
	}
}

注意生产者发送数据时也要监听取消:

csharp 复制代码
select {
case <-ctx.Done():
	return

case resultChan <- value:
}

否则如果没有消费者,生产者可能永久阻塞在:

r 复制代码
resultChan <- value

即使 context 已经取消,也无法退出。


二十二、典型错误:创建 goroutine 后不监听 Context

错误写法:

scss 复制代码
func worker(ctx context.Context) {
	for {
		doWork()
	}
}

即使上层调用:

scss 复制代码
cancel()

这个 goroutine 也不会停止。

Context 不会强制杀死 goroutine。

它只是发出一个取消信号。

任务必须主动监听:

csharp 复制代码
select {
case <-ctx.Done():
	return
default:
}

这是理解 context 最重要的一点:

Context 是协作式取消,不是强制中断。


二十三、典型错误:把 Context 放到结构体里

一般不推荐:

go 复制代码
type UserService struct {
	ctx context.Context
}

然后:

复制代码
service.ctx

因为 context 通常对应某一次请求。

如果把它存进长期存在的 Service 结构体里,可能导致:

  • 不同请求共用 context
  • 请求数据串扰
  • 旧 context 已取消
  • 生命周期混乱

推荐作为函数第一个参数传递:

go 复制代码
func (s *UserService) GetUser(
	ctx context.Context,
	id uint,
) (*User, error)

标准约定:

go 复制代码
func DoSomething(ctx context.Context, ...)

二十四、典型错误:传递 nil Context

不要这样:

go 复制代码
service.GetUser(nil, 1)

标准库很多函数不接受 nil context。

没有合适的 context 时使用:

scss 复制代码
context.Background()

或者:

scss 复制代码
context.TODO()

二十五、Background 和 TODO 的区别

context.Background()

表示你明确知道这是一个根 context。

css 复制代码
ctx := context.Background()

context.TODO()

表示这里未来应该传入一个合适的 context,但目前还没有完成改造。

css 复制代码
ctx := context.TODO()

例如旧代码迁移:

scss 复制代码
func oldFunction() {
	ctx := context.TODO()

	newFunction(ctx)
}

生产代码中不要长期大量使用 TODO()

它更像一个标记:

这里以后还需要完善。


二十六、Context 错误判断

常见错误有两个:

复制代码
context.Canceled
context.DeadlineExceeded

建议使用:

vbnet 复制代码
errors.Is

示例:

css 复制代码
if err != nil {
	switch {
	case errors.Is(err, context.Canceled):
		fmt.Println("任务被主动取消")

	case errors.Is(err, context.DeadlineExceeded):
		fmt.Println("任务执行超时")

	default:
		fmt.Println("其他错误:", err)
	}
}

不要只判断字符串:

javascript 复制代码
if err.Error() == "context canceled"

字符串判断不可靠。


二十七、在 Service 中如何正确处理 Context 错误

假设数据库返回:

复制代码
context deadline exceeded

一般不应该把它包装得完全无法识别。

可以这样:

go 复制代码
func (s *UserService) GetUser(
	ctx context.Context,
	id uint,
) (*User, error) {
	user, err := s.repo.GetUser(ctx, id)
	if err != nil {
		return nil, fmt.Errorf("查询用户失败: %w", err)
	}

	return user, nil
}

这里使用:

perl 复制代码
%w

保留原始错误链。

上层依然可以判断:

scss 复制代码
errors.Is(err, context.DeadlineExceeded)

二十八、Gin + GORM 完整示例

下面给一个比较接近你实际项目的写法。

Model

lua 复制代码
package model

import "time"

type User struct {
	ID        uint      `gorm:"primaryKey" json:"id"`
	Username  string    `gorm:"size:64;not null" json:"username"`
	Nickname  string    `gorm:"size:64" json:"nickname"`
	Status    uint8     `json:"status"`
	CreatedAt time.Time `json:"created_at"`
	UpdatedAt time.Time `json:"updated_at"`
}

Repository

go 复制代码
package repository

import (
	"context"
	"errors"

	"gorm.io/gorm"

	"your-project/model"
)

type UserRepository struct {
	db *gorm.DB
}

func NewUserRepository(db *gorm.DB) *UserRepository {
	return &UserRepository{
		db: db,
	}
}

func (r *UserRepository) GetByID(
	ctx context.Context,
	id uint,
) (*model.User, error) {
	var user model.User

	err := r.db.
		WithContext(ctx).
		First(&user, id).
		Error

	if err != nil {
		if errors.Is(err, gorm.ErrRecordNotFound) {
			return nil, nil
		}

		return nil, err
	}

	return &user, nil
}

Service

go 复制代码
package service

import (
	"context"
	"errors"
	"fmt"
	"time"

	"your-project/model"
	"your-project/repository"
)

var ErrUserNotFound = errors.New("用户不存在")

type UserService struct {
	userRepo *repository.UserRepository
}

func NewUserService(
	userRepo *repository.UserRepository,
) *UserService {
	return &UserService{
		userRepo: userRepo,
	}
}

func (s *UserService) GetByID(
	parentCtx context.Context,
	id uint,
) (*model.User, error) {
	ctx, cancel := context.WithTimeout(
		parentCtx,
		3*time.Second,
	)
	defer cancel()

	user, err := s.userRepo.GetByID(ctx, id)
	if err != nil {
		return nil, fmt.Errorf("查询用户失败: %w", err)
	}

	if user == nil {
		return nil, ErrUserNotFound
	}

	return user, nil
}

Controller

go 复制代码
package controller

import (
	"context"
	"errors"
	"net/http"
	"strconv"

	"github.com/gin-gonic/gin"

	"your-project/service"
)

type UserController struct {
	userService *service.UserService
}

func NewUserController(
	userService *service.UserService,
) *UserController {
	return &UserController{
		userService: userService,
	}
}

func (ctl *UserController) GetByID(c *gin.Context) {
	idValue := c.Param("id")

	id64, err := strconv.ParseUint(idValue, 10, 64)
	if err != nil {
		c.JSON(http.StatusBadRequest, gin.H{
			"message": "用户 ID 不合法",
		})
		return
	}

	ctx := c.Request.Context()

	user, err := ctl.userService.GetByID(ctx, uint(id64))
	if err != nil {
		switch {
		case errors.Is(err, service.ErrUserNotFound):
			c.JSON(http.StatusNotFound, gin.H{
				"message": "用户不存在",
			})

		case errors.Is(err, context.Canceled):
			c.JSON(499, gin.H{
				"message": "请求已取消",
			})

		case errors.Is(err, context.DeadlineExceeded):
			c.JSON(http.StatusGatewayTimeout, gin.H{
				"message": "查询超时",
			})

		default:
			c.JSON(http.StatusInternalServerError, gin.H{
				"message": "服务器内部错误",
			})
		}

		return
	}

	c.JSON(http.StatusOK, gin.H{
		"data": user,
	})
}

这条链路是:

markdown 复制代码
Gin Request Context
        ↓
Controller
        ↓
Service 增加 3 秒超时
        ↓
Repository
        ↓
GORM WithContext
        ↓
数据库驱动

二十九、后台任务与请求 Context 的区别

假设用户调用接口后,你启动一个需要长期执行的任务:

css 复制代码
func handler(c *gin.Context) {
	ctx := c.Request.Context()

	go generateReport(ctx)

	c.JSON(200, gin.H{
		"message": "任务已启动",
	})
}

这里可能有问题。

HTTP 请求结束后:

scss 复制代码
c.Request.Context()

通常会被取消。

于是后台任务可能立刻停止。

如果这个任务必须脱离 HTTP 请求继续执行,就不应该直接使用请求 context。

可以使用:

scss 复制代码
go generateReport(context.Background())

但这样又失去了统一关闭能力。

更合理的是,应用启动时创建应用级 context:

css 复制代码
appCtx, appCancel := context.WithCancel(context.Background())

后台任务使用:

scss 复制代码
go generateReport(appCtx)

服务关闭时:

scss 复制代码
appCancel()

判断标准:

  • 任务属于当前请求:使用 r.Context()
  • 任务属于整个应用:使用应用级 context
  • 任务属于某个批次:为该批次创建专用 context

三十、优雅关闭服务器

Context 也常用于服务优雅关闭。

go 复制代码
package main

import (
	"context"
	"errors"
	"fmt"
	"net/http"
	"os"
	"os/signal"
	"syscall"
	"time"
)

func main() {
	server := &http.Server{
		Addr: ":8080",
	}

	go func() {
		fmt.Println("服务器启动:", server.Addr)

		err := server.ListenAndServe()
		if err != nil && !errors.Is(err, http.ErrServerClosed) {
			panic(err)
		}
	}()

	signalCtx, stop := signal.NotifyContext(
		context.Background(),
		os.Interrupt,
		syscall.SIGTERM,
	)
	defer stop()

	<-signalCtx.Done()

	fmt.Println("收到退出信号,开始关闭服务")

	shutdownCtx, cancel := context.WithTimeout(
		context.Background(),
		10*time.Second,
	)
	defer cancel()

	if err := server.Shutdown(shutdownCtx); err != nil {
		fmt.Println("关闭服务失败:", err)
		return
	}

	fmt.Println("服务器已安全关闭")
}

这里有两个 context:

复制代码
signalCtx

用于等待系统信号:

  • Ctrl+C

  • SIGTERM

    shutdownCtx

用于限制服务器关闭时间,最多等待 10 秒。


三十一、Context 的四个核心方法

context.Context 接口本身非常简单:

scss 复制代码
type Context interface {
	Deadline() (deadline time.Time, ok bool)

	Done() <-chan struct{}

	Err() error

	Value(key any) any
}

Deadline

获取截止时间:

css 复制代码
deadline, ok := ctx.Deadline()

Done

返回取消通知 channel:

scss 复制代码
<-ctx.Done()

Err

返回取消原因:

scss 复制代码
ctx.Err()

Value

获取请求级数据:

scss 复制代码
ctx.Value(key)

三十二、什么时候需要在函数中检查 Context

如果函数调用的是支持 context 的底层 API,例如:

scss 复制代码
db.WithContext(ctx)
http.NewRequestWithContext(ctx, ...)

底层通常会帮你监听。

但如果你自己写的是 CPU 密集型循环,需要主动检查。

例如:

go 复制代码
func calculate(ctx context.Context, count int) error {
	for i := 0; i < count; i++ {
		if i%1000 == 0 {
			select {
			case <-ctx.Done():
				return ctx.Err()

			default:
			}
		}

		// 执行计算
	}

	return nil
}

不要每次循环都检查,否则极端情况下会增加开销。

可以每隔一定次数检查一次。


三十三、生产级最佳实践

1. Context 放在第一个参数

推荐:

go 复制代码
func QueryUser(
	ctx context.Context,
	id uint,
) error

不推荐:

go 复制代码
func QueryUser(
	id uint,
	ctx context.Context,
) error

2. 不要把 Context 设为可选参数

不推荐:

go 复制代码
func QueryUser(id uint, ctx ...context.Context)

直接明确传入。

3. 不要传 nil

使用:

scss 复制代码
context.Background()

4. 创建子 Context 后及时 cancel

css 复制代码
ctx, cancel := context.WithTimeout(parent, time.Second)
defer cancel()

5. 不要用 Context 传业务参数

不推荐:

arduino 复制代码
context.WithValue(ctx, "page", 1)
context.WithValue(ctx, "pageSize", 20)
context.WithValue(ctx, "username", "admin")

这些应该使用结构体:

go 复制代码
type UserQuery struct {
	Page     int
	PageSize int
	Username string
}

然后:

go 复制代码
func ListUsers(
	ctx context.Context,
	query UserQuery,
) error

6. Value 只传请求级元数据

适合:

  • user ID
  • trace ID
  • request ID
  • tenant ID
  • 日志字段

不适合:

  • 数据库连接
  • Service 对象
  • 页面参数
  • 大结构体
  • 可选配置集合

7. 不要主动调用 ctx.Done() 对应 channel 的 close

你只能调用:

scss 复制代码
cancel()

不能关闭:

scss 复制代码
close(ctx.Done())

因为 Done() 返回的是只读 channel:

go 复制代码
<-chan struct{}

三十四、用一句话理解 Context

可以把 context 想象成一张"任务通行证"。

这张通行证上可能包含:

复制代码
任务什么时候到期
任务是否已取消
为什么被取消
当前请求属于谁
当前请求的追踪编号

它会从上层一直传到下层:

复制代码
HTTP 请求
  ↓
Controller
  ↓
Service
  ↓
Repository
  ↓
数据库 / 第三方 API

任何一层发现通行证失效,都应该尽快停止工作。


三十五、建议你按这个顺序练习

先掌握这五个例子:

markdown 复制代码
1. WithCancel 手动取消 goroutine
2. WithTimeout 自动超时
3. 父 Context 取消多个子任务
4. Gin Request.Context 传到 GORM
5. errgroup 并发任务失败后统一取消

最终记住这个标准模板:

go 复制代码
func DoSomething(
	ctx context.Context,
) error {
	select {
	case <-ctx.Done():
		return ctx.Err()

	default:
	}

	return nil
}

以及数据库模板:

scss 复制代码
func Query(
	ctx context.Context,
	db *gorm.DB,
) error {
	return db.
		WithContext(ctx).
		Find(&result).
		Error
}

以及超时模板:

css 复制代码
ctx, cancel := context.WithTimeout(
	parentCtx,
	3*time.Second,
)
defer cancel()

掌握这三个模板,已经能覆盖大部分 Go 项目中的 context 使用场景。

相关推荐
运维开发笔记21 小时前
5.2 Go 数组进阶学习笔记(多维、排序、搜索)
go
newerp1 天前
Redis 操作与缓存策略
后端·程序员·go
newerp1 天前
GORM ORM 基础
后端·程序员·go
newerp1 天前
CRUD 操作与预处理语句
后端·程序员·go
程序员爱钓鱼1 天前
Go 编程实战:闭包 Closure——函数如何记住外部变量
后端·google·go
橘bird2 天前
Go 语言学习教程 万字精讲(新手完整版)
go
用户330144867632 天前
08. mcentral:中心缓存的 span 管理
go
用户330144867632 天前
07. mcache.refill:缓存补充与 span 归还
go
程序员爱钓鱼2 天前
Go 编程实战:匿名函数 Anonymous Function——没有名字的函数与灵活回调
后端·面试·go
用户330144867633 天前
03. Size Class 分级机制
go