前几天在工作中使用 grpc 和 context 的时候,对于 grpc 的 metadata 和 context 的 context.Value 有一些疑惑,顺便探索了一下 go 的 context,在这里分享一下。

现在是 2018-04-29 19:49,正在北京开往家的动车上,窗外已经是黑黑的一片了,11 点到家,写一会文章吧

context 包

这里说的 context 包指的是 golang(1.7+)中的标准库 context,文档在这里:https://pkg.go.dev/context。

context 包定义了 Context 类型,他可以在携带 deadlines 和 cancelatin 信号,也可以携带其他的请求值。

发送到 server 的请求应该创建一个 context,服务器处理请求的时候也应该接受一个 context。在程序的函数调用链中,必须传递 context,也可以基于一个 context,使用 WithCancel, WithDeadline, WithTimeout, or WithValue 等函数创建一个新的 context 传递下去。当一个 context 被 canceled 的时候,那么所有通过改 context 派生的 context 都应该被 cancel

WithCancel, WithDeadline, and WithTimeout 这三个函数使用一个 context(the parent) 作为参数,然后创建一个新的 context(the child)和 CancelFunc 函数。调用 CancelFunc 函数将会取消该 child 和他的 children,移除 parent 到 child 的应用,然后停止任何相关的 timeers。吐过调用这个函数失败的话,会导致 child 和他的 children 知道 parent cancel 的时候或者 timer fired 的时候才会取消。go vet 工具会检查 CancelFuncs 对否在所有的控制分支上使用了。

context 的使用规则

  • context 应该作为函数参数传递,而不是 struct 的一个 field
  • context 应该是函数的第一个参数
  • 不要传递 nil context,即使是不使用,如果不知道用啥,用 context.TODO()
  • 只使用 context 传递请求上下文,而不是为了传递可选参数
  • context 可能会被同一个函数在不同的 goroutine 中使用,他是并发安全的

context 接口

以下是 context 的接口定义

type Context interface {
    // 返回当前context应该被cancel的时间
    // 如果 ok==false 的话,那么当前context没有deadline
    Deadline() (deadline time.Time, ok bool)

    // 返回了一个chan,当 当前context被cancel的时候,这个chan就会被close
    // 如果这个context永远也不会被cancel的时候,会返回一个nil
    //
    // WithCancel会在cancel的遍历Done
    // WithDeadline
    // WithTimeout
    //
    // Done是为了在select中使用提供的
    //
    //  // Stream generates values with DoSomething and sends them to out
    //  // until DoSomething returns an error or ctx.Done is closed.
    //  func Stream(ctx context.Context, out chan<- Value) error {
    //          for {
    //                  v, err := DoSomething(ctx)
    //                  if err != nil {
    //                          return err
    //                  }
    //                  select {
    //                  case <-ctx.Done():
    //                          return ctx.Err()
    //                  case out <- v:
    //                  }
    //          }
    //  }
    //
    // See https://blog.golang.org/pipelines for more examples of how to use
    // a Done channel for cancelation.
    Done() <-chan struct{}

    // 如果Done还没有被close,返回nil
    // 如果Done已经被clode了,返回一个non-nil的err,有几个err:
    // Canceled: context是被cancel的
    // DeadlineExceeded: context的deadline过了
    // 如果Err返回了一个non-nil,那么以后返回的结果也都是一样的
    Err() error

    // 返回指定的key的值,如果没有,返回nil
    //
    // 一个key指定了context中唯一的value。
    // key可以是任何可以比较的类型,(golint不允许使用string)
    // 各个包应该定义自己的非导出的类型所谓key,以避免重叠
    Value(key interface{}) interface{}
}

context 是如何实现 k-v 键值对存储的

什么是 contextk-v 存储

通过下面这样的代码可以在 goroutine 之间安全的传递数据

type key = struct{}
ctx := context.Background()
ctx = context.WithValue(ctx, key{}, "this is value")
fmt.Printf("value: %v\n", ctx.Value(key{}))

简析

context 值的实现和 cancel 等的实现是基于不同的 struct 的,这些 struct 都和接口 Context 组合了,所以都实现了接口 Context(参见这篇我的博文

这种思想是可以借鉴的,即先定义一个 interface,然后不同的 struct 组合这个 interface,然后实现不同的方法

context 的 k-v 对存储是一个树状的结构,每个节点都存储一对 k-v,并指向父 context

代码解析

下面结合 k-v 实现的代码看一下

// 定义存储k-v的struct,是一个树结构
type valueCtx struct {
    Context
    key, val interface{}
}

// String接口
// 其中的c.Context是通过`%v`格式化的,所以这个函数实际上是一个递归函数
// 这里使用%v作为递归的方法而不是c.Context.String(),是因为Context接口没有定义String方法,而valueCtx没有定义,这一点值得学习
func (c *valueCtx) String() string {
    return fmt.Sprintf("%v.WithValue(%#v, %#v)\n", c.Context, c.key, c.val)
}

// 取值方法
// 先判断当前context中是否有该存储的值
// 没有就使用父context取,也是一个递归函数
func (c *valueCtx) Value(key interface{}) interface{} {
    if c.key == key {
        return c.val
    }
    return c.Context.Value(key)
}

// 赋值方法
// 实际上是返回一个struct,这个struct里面有key和val的field,作为k-v的存储载体
// 然后为了保留意见设置的数据,需要把老context传下去
// 所以大概是这么个格式:{{{context.TODO(), key1, val1}, key2, val2}, key3, val3}
// 所以如果在同一个key上设置了两个值,那么旧的值永远不会取出来,因为会先取到后设置的值;所以也可以直接理解为一个map(实际上复杂度不是O(1)的)
func WithValue(parent Context, key, val interface{}) Context {
    if key == nil {
        panic("nil key")
    }

    if !reflect.TypeOf(key).Comparable() {
        panic("key is not comparable")
    }

    return &valueCtx{parent, key, val}
}

context 中的 value 存储与 grpc medatada

在 grpc 中,client 与 server 之间通过 context 传递上下文数据的时候,不能使用 context.WithValue。

因为 context.Context 是没有办法跨网络传输的, grpc 需要定义自己的 Context 实现,并为这个 Context 实现基于网络的数据传输、序列化和反序列化方法。

那么如何在 client 与 server 之间传递数据呢?可以使用 grpc 提供的 medadata 接口:

package main

import (
    "context"
    "fmt"

    "google.golang.org/grpc/metadata"
)

func main() {
    // client
    md := metadata.Pairs(
        "k1", "v1",
        "k2", "v2",
    )
    ctx := metadata.NewOutgoingContext(context.Background(), md)

    // server
    md2, ok := metadata.FromIncomingContext(ctx)
    if !ok {
        panic("no metadata")
    }

    fmt.Printf("k1 %v\n", md2["k1"])
    fmt.Printf("k2 %v\n", md2["k2"])
}

这里使用了metadata.NewOutgoingContext传递数据,metadata.FromIncomingContext获取数据。

这里其实就是通过 context.WithValue 实现的,只不过 key 分别是指定的mdOutgoingKey{}mdIncomingKey{}

mdOutgoingKey{}mdIncomingKey{}之间的区别就是,在 client 端发送数据的时候选用metadata.NewOutgoingContext,在 server 接受数据的时候,选用metadata.FromIncomingContext

参考文章