1. Context 值传递 - 源码解析(1)
如果在 公众号 文章发现状态为 已更新, 建议点击 查看原文 查看最新内容。
状态: 未更新
原文链接: https://typonotes.com/posts/2023/03/01/devopscamp-context-sample/
上下文 Context
应该是 Go语言 中一个极其重要的 基石 概念了。 本文将通过一个案例 着重 说明 值传递 的过程、用法和注意事项。
本文会通过 案例分析, 扩展到 源码讲解、使用方式 等多方面进行 Context 讲解。
阅读完本文后, 你能
- 掌握标准库中的 Context 是如何实现存取值的。
- 掌握开源库中, 对于 Context 的封装使用。
这里有一篇 Go 语言设计与实现 - 上下文 Context , 是目前我学习的资料中 完成度 和 友善度 都很高的一篇文章。
不管你愿不愿意, 用 Go 都绕不过 Context。不管用不用, 在所有 公共方法或函数 中强迫自己自己使用 context 作为入参。 虽然有点武断,但是…(我也没有想到好的理由)
从上文中我们可以确认, context
有两个核心作用, 值传递 与 信号传递。
- 值传递: 将上文的中的值传递到下文。 最直观的用法可能应该链路追踪。
- 信号传递: 应该算 值传递 的一种特殊情况。 通过捕获信号、处理信息, 可以控制调用链流程。
就用 context 实现一个 曹操打新野 的值传递游戏, 要求如下
main -> Lubei(ctx context.Context, n int)
-> Guanyu(ctx context.Context)
-> Zhangfei(ctx context.Context)
- 给刘备传递 任意数字
- 刘备拿到数字, 并输出 “曹操来了 n 万人”
- 刘备把数字传递给关羽。
- a. 如果数字为偶数, 直接传递给张飞
- b. 如果数字为奇数, 数字扩大10倍后传递给张飞。
- c. 输出 “曹操来了 n 万人”。 (注意 n 的值)
- 张飞拿到数字, 直接输出 “曹操来了 n 万人”。
代码已经放到了 Github 上: https://github.com/tangx-labs/golang-context-with-from-demo
为什么要单独把这个拿出来说呢? 因为最开始我以为 Conetxt 是一个 struct, 所有的 Context 都是一样的。 指导我在 gonic-gin/gin
那边踩了坑(挖个坑,以后有机会再说)。
type Context interface {
Deadline() (deadline time.Time, ok bool)
Done() <-chan struct{}
Err() error
Value(key any) any
}
而我们常用的 标准库 Conetxt, 其实背后基本上是 4种 基础 struct 构成。
emptyCtx
valueCtx
: 这个是我们今天的重点。cancelCtx
timerCtx
如果你常用他们, 应该能马上想起对应他们几个的方法或函数。
通常我们使用 context.WithValue
函数 进行变量值注入。
// 定义曹军的 key , 用于 context 的值传递
type enemy int // 定义一个新类型
var keyCaojun = enemy(0)
// 将曹军数量注入到 context 中
func WithEnemyContext(ctx context.Context, number int) context.Context {
return context.WithValue(ctx, keyCaojun, number)
}
最后的返回值 context.Context
就是一个 valueCtx
。 也就是不管你传入的 ctx
是什么, 最后得到的都是 valueCtx
。 这个是无法控制的, 除非你不用这个函数而自己实现。
从代码中可以看到, 我定义了一个 函数 叫做 WithEnemyContext
专门用作 值注入, 而 keyCaojun
并不是作为参数传递进来的, 而是在定义在 包级别 的常量。
这是因为:作为一个 包, 所提供功能相对固定, 不需要用户自己定义。 这样可以减少了由于输入带来的不必要问题。
这也是开源库中常见的用法。
从代码中可以看到, 我为这个 key 定义了一个 新类型, 其基础类型是 int
。
// 定义曹军的 key , 用于 context 的值传递
type enemy int // 定义一个新类型
const keyCaojun = enemy(0)
掌握了基础的你应该知道, 类型 enemy
和 int
可以互相转换, 但他们并不相同。
事实上, 在 context 注入值的时候, 应该 尽量避免 使用 基础类型。 就是因为基础类型太过于常用, 在做 key 的时候, 如果其值很简单, 很可能不知不觉就发生冲突被覆盖了。
如果一定要使用基础类型怎么办? 那就把值设置复杂一点吧。
同样的, 为了从 context 中取值, 也封装了一个函数 FromEnemyContext
, 同样也用到了 keyCaojun
这个常量。 这样 WithEnemyConetxt
一起, 组成了 一组 用于存取的函数。
// 从 context 中取出曹军数量
func FromEnemyContext(ctx context.Context) int {
val := ctx.Value(keyCaojun)
n, ok := val.(int) // 类型断言
if !ok {
return 0
}
return n
}
在代码中, 通过 ctx.Value
取出来的值是 any
类型, 在返回之前, 我们需要将其断言成 int
类型。
不过, 由于我们这是 一组 函数, 所以 val
的类型肯定是 int
, 而其 零值 就是 0
。 综上这么多 约束条件, 此处代码可以简写, 不需要在判断类型断言是否成功。
// 从 context 中取出曹军数量
func FromEnemyContext(ctx context.Context) int {
val := ctx.Value(keyCaojun)
n := val.(int) // 类型断言
return n
}
下面这段代码, 就是 “曹操打新野” 的故事的代码实现。
func main() {
ctx := context.Background()
Liubei(ctx, 9)
}
func Liubei(ctx context.Context, n int) {
// (1) 注入到 context
ctx = info.WithEnemyContext(ctx, n)
fmt.Printf("刘备: 曹操来了 %d 万人\n", n)
Guanyu(ctx)
}
func Guanyu(ctx context.Context) {
n := info.FromEnemyContext(ctx)
// (2) 获取消息
fmt.Printf("关羽(1) <-: 曹操来了 %d 万人\n", n)
if n%2 == 1 {
// 扩大数量
n = n * 10
// (1) 重新注入到 context
ctx = info.WithEnemyContext(ctx, n)
}
fmt.Printf("关羽(2) ->: 曹操来了 %d 万人\n", n)
Zhangfei(ctx)
}
func Zhangfei(ctx context.Context) {
// (2) 获取消息
n := info.FromEnemyContext(ctx)
fmt.Printf("张飞: 曹操来了 %d 万人\n", n)
}
在代码中, 可以注意到
- 在 (1) 处: 刘备和关羽都通过
WithEnemyContext
传递了军情信息。 - 在 (2) 处: 关羽和张飞都通过
FromEnemyContext
获得了军情信息。
虽然他们都使用了相同的函数, 相同的 key, 但是 传递或得到 的军情却是不同的。
我们现在从上到下, 一步步来理清楚。
虽然他们都是 emptyCtx
的实例, 他们 不能被取消、 没有值、 也没有到期时间
var (
background = new(emptyCtx)
todo = new(emptyCtx)
)
但是他们用处不同(字面赋予的意义)。
Background
: 用于main
函数、 初始化、 测试以及最顶层的Context。TODO
: 作为一个占位符, 表示这里还不清楚的初始化, 或待替换成其他 context。
在传值使用 WithValue
, 省略其他安全边界检查, 可以看到核心代码如下, 每次都创建了一个新的 valueCtx
对象
func WithValue(parent Context, key, val any) Context {
// ... 省略安全边界
return &valueCtx{parent, key, val}
}
我们再来看看 valueCtx
的结构, 非常简单, 就是三个字段。
type valueCtx struct {
Context
key, val any
}
- 不管你传入的 context 底层是什么数据结构, 出来的一定是
valueCtx
。 - 每一次调用都是一次
valueCtx
的封装。 调用的的越多, 层就越多。 就像俄罗斯套娃一样。
所以就案例来讲, 画图大概如下。
刘备在 main 上套了一层, 关羽在刘备上套了一层。 由于关羽创建新 context 是有条件的, 所以使用虚线表示。
这里需要注意, 跟踪 ctx.Value()
其实是到了 Context
接口的定义的 Value
方法字段。
type Context interface {
// 省略
Value(key any) any
}
因此, 要了解 Value
的实际行为逻辑, 还必须的到清楚 ctx
底层到底是什么结构体?
我们这里开了天眼, 知道是 valueCtx
, 所以直接转到这部分代码
func (c *valueCtx) Value(key any) any {
if c.key == key {
return c.val
}
return value(c.Context, key)
}
func value(c Context, key any) any {
for { // (1) 循环查找
switch ctx := c.(type) {
case *valueCtx:
if key == ctx.key {
return ctx.val
}
c = ctx.Context // (2) 找到 valueCtx, 但 key 不对, 同变量复制继续循环
// 省略
case *emptyCtx: // (3) 如果底层是 emtpyCtx
return nil
default:
return c.Value(key) // (3) 如果最底层是其他自定 Context
}
}
}
可以看到, valueCtx.Value
的实现简单粗暴
- 先看自己这一层的 key 是否匹配, 匹配则返回 val 值。
- 如果不匹配, 那我就不管了。 派遣下面小弟
value()
函数去处理, 自己只需要对外统一返回结果就行。
在 value()
函数中, 做的事情就比多了。
- (1) 首先就创建了一个
for {}
死循环, 准备死磕, 非要找到不可。 - (2) 如果当前的是 valueCtx, 就使用 key 进行比对, 匹配就返回, 不匹配就就把
c
替换成 父Context, 继续死磕。 - (3) 不断的循环迭代, 到最后找到最底层的 Context。
- 如果是
emptyCtx
, 就直接返回nil
- 如果是 用户自定义 的 Context ,
value()
也不干了, 直接返回用户自定义的方法。 至于后面是什么, 怎么实现的,全看用户。
- 如果是
回到案例中, 情况就比较简单了。
- 关羽得到军情, 一定是刘备给的。 用绿线表示。
- 而张飞得到军情, 可能是关羽给的、 也可能是刘备给的。从上往下获取。 用黑线表示。
本文中, 着力于 valueCtx
的讨论, 而跳过了 timerCtx
和 cancelCtx
。
其一, valueCtx
只有值的操作, 用来说明 Context 的 层级结构 更简单。
其二, timerCtx
和 cancelCtx
可以看作 valueCtx
的扩展。 当后者掌握了之后, 前两者就只需要在补充一点个字特色知识就能掌握。