Using Context Package in GO (Golang) – Complete Guide

60 阅读9分钟

Introduction

Definitions

context 是 GO 提供的一个非常重要的包。

假设您启动了一个函数,并且需要将一些公共参数传递给下游函数。不能将这些公共参数分别作为参数传递给所有下游函数。

Problem Statement

  • 假设您启动了一个函数,需要向下游函数传递一些通用参数,但不能将这些通用参数分别作为参数传递给所有下游函数。
  • 你启动了一个 goroutine,它又会启动更多的 goroutine,如此循环。假设你正在执行的任务不再需要了。那么如何通知所有子程序优雅地退出,以便释放资源?
  • 任务应在指定的超时时间内完成,例如2秒。如果不是,它应该优雅地退出或返回。
  • 任务应在规定期限内完成,例如应在下午 5 点前结束。如果未完成,则应优雅地退出并返回

上述所有问题都非常适用于 HTTP 请求,而且这些问题也同样适用于许多不同的领域。

对于网络 HTTP 请求,需要在客户端断开连接时取消请求,或在指定超时内完成请求,还需要向所有下游函数提供请求范围值(如 request_id)。

When to Use (Some Use Cases)

  • 向下游传递数据。例如,HTTP 请求会创建 request_id、request_user,需要将其传递给所有下游函数,以便进行分布式跟踪。
  • 当你想在中途停止操作时,比如客户端提前断开了连接,那么对应的 HTTP 请求应该被停止
  • 如果想要在指定的时间内停止操作,比如一个操作要么 2s 内完成,要么 2s 超时提前终止

Context Interface

理解上下文的核心是了解上下文接口

type Context interface {
    // 当上下文被取消或超时(达到截止时间或超时时间已过)时,监听 Done 通道的 case 被选中
    Done() <-chan struct{}
    // Err 将说明取消上下文的原因, 取消上下文有三种情况:
    // 1. 有明确的取消信号(显示调用 cancel 方法)
    // 2. 已达到超时
    // 3. 截止日期已到
    Err() error
    // 用于处理中断和超时
    Deadline() (deadline time.Time, ok bool)
    // 用于传递请求范围值
    Value(key interface{}) interface{}
}

Creating New Context

context.Background()

context 包函数 Background() 返回一个实现 Context 接口的空 Context

  1. It has no values
  2. It is never canceled
  3. It has no deadline

context.Background() 是由它派生的所有 context 的根。

context.ToDo()

  • context包中的 ToDo 函数返回一个空上下文。当周围的函数没有传递上下文,而我们希望在当前函数中使用上下文作为占位符,并计划在不久的将来添加实际上下文时,就会使用该上下文。将其添加为占位符的一个用途是,它有助于静态代码分析工具中的验证。
  • 它也是一个空的上下文,与 context.Background() 相同

上述两种方法描述了一种创建新上下文的方法,从这些上下文可以衍生出更多上下文,这就是上下文树的作用。

Context Tree

在了解上下文树之前,请确保在使用上下文时它是在后台隐式创建的。在 go context 软件包本身中没有提及。

无论何时使用上下文,从 context.Background() 中得到的空上下文都是所有上下文的根。context.ToDo() 也是根上下文,但如上所述,它更像是一个上下文占位符,供将来使用。这个空上下文没有任何功能,我们可以从中派生出一个新的上下文来增加功能。基本上,一个新的上下文是通过封装一个已存在的不可变上下文并添加额外信息而创建的。让我们看看创建上下文树的示例

Two Level Tree

rootCtx := context.Backgroud()
childCtx := context.WithValue(rootCtx, "msgId", "someMsgId")

In above

  • rootCtx 是没有任何功能的空 Context
  • childCtx 派生自 rootCtx,具有存储请求作用域值的功能。在上例中,它存储的键值对是 {"msgId" : "someMsgId"}

Three level tree

rootCtx := context.Background()
childCtx := context.WithValue(rootCtx, "msgId", "someMsgId")
childOfChildCtx, cancelFunc := context.WithCancel(childCtx)
  • rootCtx 是没有任何功能的空 Context
  • childCtx 从 rootCtx 派生,具有存储请求作用域值的功能。在上面的示例中,它存储的键值对是 {"msgId" : "someMsgId"}
  • childOfChildCtx 从 childCtx 派生。它具有存储请求作用域值的功能,还具有触发取消信号的功能。 cancelFunc 可用于触发取消信号

Multi-level tree

rootCtx := context.Background()
childCtx1 := context.WithValue(rootCtx, "msgId", "someMsgId")
childCtx2, cancelFunc := context.WithCancel(childCtx1)
childCtx3 := context.WithValue(rootCtx, "user_id", "some_user_id")
  • rootCtx 是没有任何功能的空 Context
  • childCtx1 从 rootCtx 派生,具有存储请求作用域值的功能。在上例中,它存储的键值对是 {"msgId" : "someMsgId"}
  • childCtx2 源自 childCtx1。它具有触发取消信号的功能。cancelFunc 可用于触发取消信号
  • childCtx3 源自 rootCtx。它具有存储当前用户信息的功能。

以上三级树的结构如下

image.png

由于它是一棵树,因此也可以为某个节点创建更多的子节点。例如,我们可以从 childCtx1 派生一个新的上下文 childCtx4

childCtx4 := context.WithValue(childCtx1, "current_time", "some_time")

添加了上述节点的树就像下面这样:

image.png

此时此刻,我们可能还不清楚 WithValue() 或 WithCancel() 函数是如何使用的。现在只需明白,只要使用上下文,就会创建一棵以根为 emptyCtx 的上下文树。

Deriving From Context

创建派生上下文的方法有 4 种

  • 传递请求作用域的值--使用上下文包的 WithValue() 函数
  • 使用取消信号 - 使用上下文软件包的 WithCancel() 函数
  • 使用截止日期 - 使用上下文包中的 WithDeadine() 函数
  • 使用超时-使用上下文包的 WithTimeout ()函数

context.WithValue()

用于传递请求作用域的值,该函数的完整签名为

withValue(parent Context, key val interface{}) (ctx Context)

它接收父上下文、键、值作为参数,并返回一个派生上下文。这里的父上下文可以是 context.Background() 或任何其他上下文。

此外,从该上下文派生的任何上下文都将具有该值。

// root Context
ctxRoot := context.Background() 

// Below ctxChild has access to only one pair {"a" : "x"}
ctxChild := context.WithValue(ctxRoot, "a", "x")

// Below ctxChlidOfChild has access to both pair {"a" : "x", "b" : "y"} as it is derived from ctxChild
ctxxChildOfChild := context.WithValue(ctxChild, "b", "y")

Example

withValue() 的完整工作示例。在下面的示例中,我们为每个传入请求注入了 msgId。请注意,在下面的程序中

  • inejctMsgID是一个网络HTTP中间件函数,用于填充上下文中的 "msgID" 字段
  • HelloWorld 是应用程序 "localhost:8080/welcome" 的处理函数,它从上下文中获取 msgID,并将其作为响应标头发送回来
package main

import (
    "context"
    "net/http"
    "github.com/google/uuid"
)

func main() {
    helloWorldHandler := http.HandlerFunc(HelloWorld)
    http.Handle("/welcome", injectMsgID(helloWorldHandler))
    http.ListenAndServe(":8080", nil)
}

// HelloWorld hello world handler
func HelloWorld(w http.ResponseWriter, r *http.Request) {
    msgID := ""
    if m := r.Context().Value("msgId"); m != nil {
        if value, ok := m.(string); ok {
            msgID = value
        }
    }
    w.Header().Add("msgId", msgID)
    w.Write([]byte("hello, world")
}

func injectMsgID(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        msgID := uuid.New().String()
        ctx := context.WithValue(r.Context(), "msgId", msgID)
        req := r.WithContext(ctx)
        next.ServeHTTP(w, req)
    })
}

运行上述程序后,只需对上述请求进行 curl 调用即可

curl http://localhost:8080/welcome

下面是响应,请注意响应头中填充的 MsgId,injectMsgId 函数充当中间件,向请求上下文注入唯一的 msgId。

*   Trying [::1]:8080...
* Connected to localhost (::1) port 8080
> GET /welcome HTTP/1.1
> Host: localhost:8080
> User-Agent: curl/8.4.0
> Accept: */*
> 
< HTTP/1.1 200 OK
< Msgid: 5845d18c-a811-4e89-b98b-210fe59eaf9b
< Date: Wed, 24 Jan 2024 17:49:28 GMT
< Content-Length: 12
< Content-Type: text/plain; charset=utf-8
< 
* Connection #0 to host localhost left intact
hello, world% 

context.WithCancel()

用于取消信号,以下是 WithCancel() 函数的签名

func WithCancel(parent Context) (ctx Context, cancel CancelFunc)

context.WithCancel() 函数返回两个结果

  • 是 parentContext 的副本,但具有新的 done 通道
  • 一个取消函数,调用该函数时会关闭已完成的通道

只有该上下文的创建者才能调用 cancel 函数。强烈建议不要随意传递 cancel 函数。让我们通过一个例子来了解 withCancel。

遵循谁创建,谁取消

Example

func main() {
    ctx := contxt.Background()
    cancelCtx, cancelFunc := context.WithCancel(ctx)
    go task(cancelCtx)
    time.Sleep(time.Second * 3)
    cancelFunc()
    time.Sleep(time.Second * 1)
}

func task(ctx context.Context) {
    i := 1
    for {
        select {
            case <-ctx.Done():
                fmt.Println("Gracefully exit")
                fmt.Println(ctx.Err())
                return
            default:
                fmt.Println(i)
                time.Sleep(time.Second * 1)
                i++
        }
    }
}

Output

1
2
3
Gracefully exit
context canceled

在上述程序中

一旦 cancelFunc 被调用,task goroutine 将优雅地退出。

而且一旦调用了 cancelFunc,错误字符串(ctx.Err() 方法返回的字符串)就会被上下文包设置为 "context canceled"。

context.WithTimeout()

用于基于时间的取消。函数的签名是

func WithTimeout(parent Context, timeout time.Duration) (Context, CancelFunc) {}

context.WithTimeout() 函数将

  • 将返回带有新 done 通道的 parentContext 副本。
  • 接受超时持续时间,超时后将关闭已完成通道并取消上下文
  • 取消函数,用于在超时前取消上下文

Example

func main() {
    ctx := context.Background()
    cancelCtx, cancel := context.WithTimeout(ctx, time.Second * 3)
    defer cancel()
    go task1(cancelCtx)
    time.Sleep(time.Second * 4)
}

func task1(ctx context.Context) {
    i := 1
    for {
        select {
            case <-ctx.Done():
                fmt.Println("Gracefully exit")
                fmt.Println(ctx.Err())
                return 
            default:
                fmt.Println(i)
                time.Sleep(time.Second * 1)
                i++
        }
    }
}

Output

1
2
3
Gracefully exit
context deadline exceeded

在上述程序中

任务函数将在 3 秒超时结束后优雅退出。上下文包会将错误字符串设置为 "context deadline exceeded"。

context.WithDeadline()

用于基于截止日期的取消。函数的签名是

func WithDeadline(parent Context, d time.Time) (Context, CancelFunc)

context.WithDeadline() 函数

  • 将返回带有新完成通道的 parentContext 副本。
  • 接受截止日期,在该截止日期之后,此 done channel 将被关闭,上下文将被取消
  • 取消函数,用于在截止日期前取消上下文。

Example

func main() {
    ctx := context.Background()
    cancelCtx, cancel := context.WithDeadline(ctx, time.Now().Add(time.Second*5))
    defer cancel()
    go task(cancelCtx)
    time.Sleep(time.Second * 6)
}

func task(ctx context.Context) {
    i := 1
    for {
        select {
            case <-ctx.Done():
                fmt.Println("Gracefully exit")
                fmt.Println(ctx.Err())
                return
            default:
                fmt.Println(i)
                time.Sleep(time.Second * 1)
                i++
        }
    }
}
1
2
3
4
5
Gracefully exit
context deadline exceeded

由于我们给出的截止时间是 Time.now() + 5 秒,因此一旦超时 5 秒,任务函数就会优雅地退出。上下文包将错误字符串设置为 "context deadline exceeded"。

What We Learned

How to create the context:

  • Using context.Backgroun()
  • Using context.Todo()

Context Tree

Deriving a new context

  • context.WithValue()
  • context.WithCancel()
  • context.WithTimeout()
  • contxt.WithDeadline()

BestPractices and Caveats

以下是在使用上下文时可以遵循的最佳实践。

  • 最好不要将 Context 类型放在结构体字段中,应该作为函数的第一个参数传入
  • 上下文应在程序中流动。例如,在 HTTP 请求的情况下,可以为每个传入的请求创建一个新的上下文,该上下文可用于保存 request_id 或在上下文中放置一些常用信息,如当前登录的用户,这些信息可能对该特定请求有用。(比如 gin 框架中使用 JWT 校验 handler 时,一旦校验成功就将 user_id 导入到 context 上下文中供责任链中后续的 handler 继续使用)
  • 始终将上下文作为函数的第一个参数传递。
  • 在不确定是否使用上下文时,最好使用 context.ToDo() 作为占位符。(以便未来替换)
  • 只有父程序或函数才能使用 cancel 上下文。因此,不要将 cancelFunc 传递给下游的程序或函数。Golang 允许将 cancelFunc 传递给子程序,但不推荐这样做(如果子 goroutine 可以自己决定自己的生命周期,那么很容易出现问题)