Context 正确使用姿势

原文参考:https://juejin.cn/post/6844903929340231694

Context 是 immutable(不可变的)

context.Context API

基本上是两类操作:

  • 3个函数用于限定什么时候你的子节点退出
  • 1个函数用于设置请求范畴的变量
1
2
3
4
5
6
7
8
type Context interface {
  //  啥时候退出
  Deadline() (deadline time.Time, ok bool)
  Done() <-chan struct{}
  Err() error
  //  设置变量
  Value(key interface{}) interface{}
}

如何创建 Context?

  • 在 RPC 开始的时候,使用 context.Background()
    • 有些人把在 main() 里记录一个 context.Background(),然后把这个放到服务器的某个变量里,然后请求来了后从这个变量里继承 context。这么做是不对的。直接每个请求,源自自己的 context.Background() 即可。
  • 如果你没有 context,却需要调用一个 context 的函数的话,用 context.TODO()
  • 如果某步操作需要自己的超时设置的话,给它一个独立的 sub-context(如前面的例子)

Context 放哪?

  • 把 Context 想象为一条河流流过你的程序
  • 理想情况下,Context 存在于调用栈(Call Stack) 中
  • 不要把 Context 存储到一个 struct 里
    • 除非你使用的是像 http.Request 中的 request 结构体的方式
  • request 结构体应该以 Request 结束为生命终止
  • 当 RPC 请求处理结束后,应该去掉对 Context 变量的引用(Unreference)
  • Request 结束,Context 就应该结束。

Context 包的注意事项

  • 要养成关闭 Context 的习惯
    • 特别是 超时的 Contexts
  • 如果一个 context 被 GC 而不是 cancel 了,那一般是你做错了
1
2
ctx, cancel := context.WithTimeout(parentCtx, time.Second * 2)
		defer cancel()
  • 使用 Timeout 会导致内部使用 time.AfterFunc,从而会导致 context 在计时器到时之前都不会被垃圾回收。
  • 在建立之后,立即 defer cancel() 是一个好习惯。

终止请求 (Request Cancellation)

当你不再关心接下来获取的结果的时候,有可能会 Cancel 一个 Context?

golang.org/x/sync/errgroup 为例,errgroup 使用 Context 来提供 RPC 的终止行为。

1
2
3
4
5
6
type Group struct {
	cancel  func()
	wg      sync.WaitGroup
	errOnce sync.Once
	err     error
}

创建一个 group 和 context:

1
2
3
4
func WithContext(ctx context.Context) (*Group, context.Context) {
  ctx, cancel := context.WithCancel(ctx)
  return &Group{cancel: cancel}, ctx
}

这样就返回了一个可以被提前 cancel 的 group。

而调用的时候,并不是直接调用 go func(),而是调用 Go(),将函数作为参数传进去,用高阶函数的形式来调用,其内部才是 go func() 开启 goroutine。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
func (g *Group) Go(f func() error) {
  g.wg.Add(1)
  go func() {
    defer g.wg.Done()
    if err := f(); err != nil {
      g.errOnce.Do(func() {
        g.err = err
        if g.cancel != nil {
          g.cancel()
        }
      })
    }
  }()
}

当给入函数 f 返回错误,则使用 sync.Once 来 cancel context,而错误被保存于 g.err 之中,在随后的 Wait() 函数中返回。

1
2
3
4
5
6
7
func (g *Group) Wait() error {
  g.wg.Wait()
  if g.cancel != nil {
    g.cancel()
  }
  return g.err
}

注意:这里在 Wait() 结束后,调用了一次 cancel()。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
package main
func DoTwoRequestsAtOnce(ctx context.Context) error {
  eg, egCtx := errgroup.WithContext(ctx)
  var resp1, resp2 *http.Response
  f := func(loc string, respIn **http.Response) func() error {
    return func() error {
      reqCtx, cancel := context.WithTimeout(egCtx, time.Second)
      defer cancel()
      req, _ := http.NewRequest("GET", loc, nil)
      var err error
      *respIn, err = http.DefaultClient.Do(req.WithContext(reqCtx))
      if err == nil && (*respIn).StatusCode >= 500 {
        return errors.New("unexpected!")
      }
      return err
    }
  }
  eg.Go(f("<http://localhost:8080/fast_request>", &resp1))
  eg.Go(f("<http://localhost:8080/slow_request>", &resp2))
  return eg.Wait()
}

在这个例子中,同时发起了两个 RPC 调用,当任何一个调用超时或者出错后,会终止另一个 RPC 调用。这里就是利用前面讲到的 errgroup 来实现的,应对有很多并非请求,并需要集中处理超时、出错终止其它并发任务的时候,这个 pattern 使用起来很方便。

Context.Value - Request 范畴的值

context.Value API 的万金油(duct tape)

胶带(duct tape) 几乎可以修任何东西,从破箱子,到人的伤口,到汽车引擎,甚至到NASA登月任务中的阿波罗13号飞船(Yeah! True Story)。所以在西方文化里,胶带是个“万能”的东西。在中文里,恐怕万金油是更合适的对应词汇,从头疼、脑热,感冒发烧,到跌打损伤几乎无所不治。

当然,治标不治本,这点东西方文化中的潜台词都是一样的。这里提及的 context.Value 对于 API 而言,就是这类性质的东西,啥都可以干,但是治标不治本。

  • value 节点是 Context 链中的一个节点
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
package context
type valueCtx struct {
  Context
  key, val interface{}
}
func WithValue(parent Context, key, val interface{}) Context {
  //  ...
  return &valueCtx{parent, key, val}
}
func (c *valueCtx) Value(key interface{}) interface{} {
  if c.key == key {
    return c.val
  }
  return c.Context.Value(key)
}

可以看到,WithValue() 实际上就是在 Context 树形结构中,增加一个节点罢了。

约束 key 的空间

为了防止树形结构中出现重复的键,建议约束键的空间。比如使用私有类型,然后用 GetXxx() 和 WithXxxx() 来操作私有实体。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
type privateCtxType string
var (
  reqID = privateCtxType("req-id")
)
func GetRequestID(ctx context.Context) (int, bool) {
  id, exists := ctx.Value(reqID).(int)
  return id, exists
}
func WithRequestID(ctx context.Context, reqid int) context.Context {
  return context.WithValue(ctx, reqID, reqid)
}

这里使用 WithXxx 而不是 SetXxx 也是因为 Context 实际上是 immutable 的,所以不是修改 Context 里某个值,而是产生新的 Context 带某个值

Context.Value 是 immutable 的

再多次的强调 Context.Value 是 immutable 的也不过分。

  • context.Context 从设计上就是按照 immutable (不可变的)模式设计的
  • 同样,Context.Value 也是 immutable 的
  • 不要试图在 Context.Value 里存某个可变更的值,然后改变,期望别的 Context 可以看到这个改变
    • 更别指望着在 Context.Value 里存可变的值,最后多个 goroutine 并发访问没竞争冒险啥的,因为自始至终,就是按照不可变来设计的
    • 比如设置了超时,就别以为可以改变这个设置的超时值
  • 在使用 Context.Value 的时候,一定要记住这一点

应该把什么放到 Context.Value 里?

  • 应该保存 Request 范畴的值
    • 任何关于 Context 自身的都是 Request 范畴的(这俩同生共死)
    • 从 Request 数据衍生出来,并且随着 Request 的结束而终结

什么东西不属于 Request 范畴?

  • 在 Request 以外建立的,并且不随着 Request 改变而变化
    • 比如你 func main() 里建立的东西显然不属于 Request 范畴
  • 数据库连接
    • 如果 User ID 在连接里呢?(稍后会提及)
  • 全局 logger
    • 如果 logger 里需要有 User ID 呢?(稍后会提及)

那么用 Context.Value 有什么问题?

  • 不幸的是,好像所有东西都是由请求衍生出来的
  • 那么我们为什么还需要函数参数?然后干脆只来一个 Context 就完了?
1
2
3
func Add(ctx context.Context) int {
  return ctx.Value("first").(int) + ctx.Value("second").(int)
}

曾经看到过一个 API,就是这种形式:

1
2
3
4
func IsAdminUser(ctx context.Context) bool {
  userID := GetUser(ctx)
  return authSingleton.IsAdmin(userID)
}

这里API实现内部从 context 中取得 UserID,然后再进行权限判断。但是从函数签名看,则完全无法理解这个函数具体需要什么、以及做什么。

代码要以可读性为优先设计考虑。

别人拿到一个代码,一般不是掉进函数实现细节里去一行行的读代码,而是会先浏览一下函数接口。所以清晰的函数接口设计,会更加利于别人(或者是几个月后的你自己)理解这段代码。

一个良好的 API 设计,应该从函数签名就清晰的理解函数的逻辑。如果我们将上面的接口改为:

1
func IsAdminUser(ctx context.Context, userID string, authenticator auth.Service) bool

我们从这个函数签名就可以清楚的知道:

  • 这个函数很可能可以提前被 cancel
  • 这个函数需要 User ID
  • 这个函数需要一个authenticator来
  • 而且由于 authenticator 是传入参数,而不是依赖于隐式的某个东西,我们知道,测试的时候就很容易传入一个模拟认证函数来做测试
  • userID 是传入值,因此我们可以修改它,不用担心影响别的东西

所有这些信息,都是从函数签名得到的,而无需打开函数实现一行行去看。

那什么可以放到 Context.Value 里去?

现在知道 Context.Value 会让接口定义更加模糊,似乎不应该使用。那么又回到了原来的问题,到底什么可以放到 Context.Value 里去?换个角度去想,什么不是衍生于 Request?

  • Context.Value 应该是告知性质的东西,而不是控制性质的东西
  • 应该永远都不需要写进文档作为必须存在的输入数据
  • 如果你发现你的函数在某些 Context.Value 下无法正确工作,那就说明这个 Context.Value 里的信息不应该放在里面,而应该放在接口上。因为已经让接口太模糊了。

什么东西不是控制性质的东西?

  • Request ID
    • 只是给每个 RPC 调用一个 ID,而没有实际意义
    • 这就是个数字/字符串,反正你也不会用其作为逻辑判断
    • 一般也就是日志的时候需要记录一下
      • 而 logger 本身不是 Request 范畴,所以 logger 不应该在 Context 里
      • 非 Request 范畴的 logger 应该只是利用 Context 信息来修饰日志
  • User ID (如果仅仅是作为日志用)
  • Incoming Request ID

什么显然是控制性质的东西?

  • 数据库连接
    • 显然会非常严重的影响逻辑
    • 因此这应该在函数参数里,明确表示出来
  • 认证服务(Authentication)
    • 显然不同的认证服务导致的逻辑不同
    • 也应该放到函数参数里,明确表示出来

Context WithValue的常见使用场景

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
package main

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

/*
case1:
首先,我们创建一个空上下文并将其分配给ctx变量。
	1.ctx使用其键和值创建 3 个上下文作为父值。
	2.然后我们创建另一个ctx1作为父级的上下文并给它一个键和值。
	3.我们将尝试从中提取价值ctx1,ctx2,ctx3用正确的密钥。它将根据键返回给我们值。
	4.如果我们尝试从具有错误键的上下文中提取值,它将返回nil值。

case2:
	在http上下文中插入固值(中间件的方式),如 userID,Token等。
*/

func main() {
	//case1()
	http.Handle("/", middleware1(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		// After Insert Claim into Context

		fmt.Printf("%+v\\n", CtxClaim(r.Context()))
		fmt.Fprintf(w, "%+v\\n", CtxClaim(r.Context()))
	})))

	http.ListenAndServe(":8080", nil)
}

type Claims struct {
	ID int `json:"id"`
}

var contextKey = "ctx-key"

func middleware1(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		// Example: Get UserID From Token
		// ....
		// ....

		fmt.Println("Insert Claim into Context")
		newCtx := context.WithValue(r.Context(), contextKey, &Claims{
			ID: 1, // example User ID: 1
		})

		r = r.WithContext(newCtx)

		next.ServeHTTP(w, r)
	})
}

func CtxClaim(ctx context.Context) *Claims {
	raw, _ := ctx.Value(contextKey).(*Claims)
	return raw
}

func case1() {
	ctx := context.Background() // Empty Context

	ctx1 := context.WithValue(ctx, "key1", "value1") // parent: ctx
	ctx2 := context.WithValue(ctx, "key2", "value2") // parent: ctx
	ctx3 := context.WithValue(ctx, "key3", "value3") // parent: ctx

	ctx4 := context.WithValue(ctx1, "key4", "value4") // parent: ctx1

	fmt.Println(ctx1.Value("key1")) // value1
	fmt.Println(ctx2.Value("key2")) // value2
	fmt.Println(ctx3.Value("key3")) // value3

	fmt.Println(ctx4.Value("key4")) // value4
	fmt.Println(ctx4.Value("key1")) // value1

	fmt.Println(ctx3.Value("key1")) // nil
}

小结

个人不推荐在 context 中封装太多的东西向下传递是非常的不 “simple”,时刻记住context设计初衷从API就可以看出:1.超时控制。2.固值传递(如UserID等)。