一起养成写作习惯!这是我参与「掘金日新计划 · 4 月更文挑战」的第15天,点击查看活动详情。
开篇
在 Golang RPC 调用的时候,我们需要通过 IDL 或 PB 的形式,定义接口字段和方法。但总有一些信息是业务域内很基础的元信息,我们不希望针对每个接口都去增加字段,对于框架来说亦是如此。
那么,有没有一种通用的办法可以实现元信息的透传,并且方便框架接入呢?
字节开源的 gopkg 项目提供了 metainfo 的能力,它定义的服务间元信息传递的规范,接入也很容易。目前字节使用的 RPC 框架 Kitex,以及即将开源的 HTTP 框架 Hertz 都进行了支持。
今天这篇文章,我们一起来看下,metainfo 是怎么基于 context.Context 实现这一点的。
(如果对 context.Context 不太熟悉,可以参照:Golang context.Context 原理,实战用法,问题)
整体定位
Github 仓库:github.com/bytedance/g…
该库提供了一种在 go 语言的 context.Context 中保存用于跨服务传递的元信息的统一接口。
元信息被设计为字符串的键值对,并且键是大小写敏感的。
根据数据传递的场景,元信息被分为两种类别,transient 和 persistent —— 前者只会传递一跳,从客户端传递到下游服务端,然后消失;后者需要在整个服务调用链上一直传递,直到被丢弃。
由于传递过程使用了 go 语言的 context.Context,因此,为了避免服务端的 Context 实例直接传递给用于调用下游服务的客户端时造成从更上游服务传递来的 transient 数据被继续透传,需要引入一个中间态:transient-upstream,以和当前服务自己设置的 transient 数据作区分。该类别仅用于实现 transient 的语义,在接口上,transient 和 transient-upstream 并没有区别。
该库被设计成针对 context.Context 进行操作的接口集合,而具体的元数据在网络上传输的形式和方式,应由支持该库的框架来实现。通常,终端用户不应该关注其具体传输形式,而应该仅依赖该库提供的抽象接口。
框架支持指南
如果要在某个框架里引入 metainfo 并对框架的用户提供支持,需要满足如下的条件:
- 框架使用的传输协议应该支持元信息的传递(例如 HTTP header、thrift 的 header transport 等)。
- 当框架作为服务端接收到元信息后,需要将元信息添加到
context.Context对象里。随后,在进入用户的代码逻辑(和其他可能要使用元信息的代码)之前,需要用metainfo.TransferForward转化客户端传递过来的 transient 信息为 transient-upstream。 - 在框架作为客户端发起对服务端的请求,需要将元信息传递出去之前,需要调用一次
metainfo.TransferForward将context.Context里的 transient-upstream 信息丢弃掉,使得 transient 信息符合“只传递一跳”的语义。
API 参考
- 出于兼容性和普适性,元信息的形式为字符串的 key value 对。
- 空串作为 key 或者 value 都是无效的。
- 由于 context 的特性,程序对 metainfo 的增删改只会对拥有相同的 contetxt 或者其子 context 的代码可见。
常量
metainfo 包提供了几个常量字符串前缀,用于无 context(例如网络传输)的场景下标记元信息的类型。
典型的业务代码通常不需要用到这些前缀。支持 metainfo 的框架也可以自行选择在传输时用于区分元信息类别的方式。
PrefixPersistentPrefixTransientPrefixTransientUpstream
方法
转化 transient
TransferForward(ctx context.Context) context.Context- 向前传递,用于将上游传来的 transient 数据转化为 transient-upstream 数据,并过滤掉原有的 transient-upstream 数据。
transient
GetValue(ctx context.Context, k string) (string, bool)- 从 context 里获取指定 key 的 transient 数据(包括 transient-upstream 数据)。
GetAllValues(ctx context.Context) map[string]string- 从 context 里获取所有 transient 数据(包括 transient-upstream 数据)。
WithValue(ctx context.Context, k string, v string) context.Context- 向 context 里添加一个 transient 数据。
DelValue(ctx context.Context, k string) context.Context- 从 context 里删除指定的 transient 数据。
persistent
GetPersistentValue(ctx context.Context, k string) (string, bool)- 从 context 里获取指定 key 的 persistent 数据。
GetAllPersistentValues(ctx context.Context) map[string]string- 从 context 里获取所有 persistent 数据。
WithPersistentValue(ctx context.Context, k string, v string) context.Context- 向 context 里添加一个 persistent 数据。
DelPersistentValue(ctx context.Context, k string) context.Context- 从 context 里删除指定的 persistent 数据。
源码分析
前面的系列我们曾经提到过,很多时候我们希望在 context 里存数据,其实不太关心 key 的类型,很多时候直接放了个 string,这样有好有坏。
string 作为内置的类型,容易发生碰撞,一旦不同服务都定义了同一个字符串,可能会拿到意想不到的结果(如果语义上并不期望复用的话)。
我们通常会建议利用空结构体来作为 context 的key,这样避免了撞车,同时也可以最大程度减小内存消耗。详情可以参照前一篇 Golang context.Context 原理,实战用法,问题) 对于空结构体的描述。
在 metainfo 库中,我们也能看到很多这样的案例。
metainfo 声明的 key 类型定义如下
type ctxKeyType struct{} // 空结构体
var ctxKey ctxKeyType
对应的 value 则是三个数组结构:
type node struct {
persistent []kv
transient []kv
stale []kv
}
type kv struct {
key string
val string
}
顾名思义,persistent 和 transient 其实和前一节提到的两个概念语义一一对应,persistent 存储的是希望在整条链路上一直传递的 kv 数组,transient 则是 client 到 server 一跳传递的数据。
那么 stale 是干什么呢?我们来看看底层处理 transient-upstream 是怎么被识别出来,并处理掉的。
我们会发现,每次实际获取不管是 transient 还是 persistent 的数据,本质都是调用了 getNode 这个函数,按照我们此前声明的 ctxKey 来获取对应的节点数据(包含三个kv数组)。而与之对应的,withNode 函数则将一个现成的 node 存入到 context 中。
func getNode(ctx context.Context) *node {
if ctx != nil {
if val, ok := ctx.Value(ctxKey).(*node); ok {
return val
}
}
return nil
}
func withNode(ctx context.Context, n *node) context.Context {
if ctx == nil {
return ctx
}
return context.WithValue(ctx, ctxKey, n)
}
下面来看暴露的 TransferForward 接口,会发现本质上依赖的是 node.transferForward() 方法。
// TransferForward converts transient values to transient-upstream values and filters out original transient-upstream values.
// It should be used before the context is passing from server to client.
func TransferForward(ctx context.Context) context.Context {
if n := getNode(ctx); n != nil {
return withNode(ctx, n.transferForward())
}
return ctx
}
func (n *node) transferForward() (r *node) {
r = &node{
persistent: n.persistent,
stale: n.transient,
}
return
}
而 transferForward 的逻辑也很简单,persistent 原样赋值,将 transient 赋值给 stale,抛弃 node 原有的 stale。
这个设计也非常简洁明了,我们知道,上一个服务传过来的 node 同样包含这三个kv数组。语义上来说,persistent 自然是需要保持不变,因为我们希望一直传递下去。但是上一个服务node的 transient,由于到当前服务时已经经过了一跳,此时预期是要停止往下传递了,所以赋值给了 stale。而上一个服务 node 对应的 stale,当然完全无需关心了。
我们来看几个典型案例:
// GetValue retrieves the value set into the context by the given key.
func GetValue(ctx context.Context, k string) (v string, ok bool) {
if n := getNode(ctx); n != nil {
if idx, ok := search(n.transient, k); ok {
return n.transient[idx].val, true
}
if idx, ok := search(n.stale, k); ok {
return n.stale[idx].val, true
}
}
return
}
func search(kvs []kv, key string) (idx int, ok bool) {
for i := range kvs {
if kvs[i].key == key {
return i, true
}
}
return
}
GetValue 的语义在上一节已经提到,是从 context 里获取指定 key 的 transient 数据(包括 transient-upstream 数据)。我们看到,这里就直接在 node 对应的 transient 和 stale 两个数组中进行寻找。
- transient 是要传递给下一个服务,完成一跳的数据。
- stale 代表不需要传递,只停留在当前节点的数据。
// WithValue sets the value into the context by the given key.
// This value will be propagated to the next service/endpoint through an RPC call.
//
// Notice that it will not propagate any further beyond the next service/endpoint,
// Use WithPersistentValue if you want to pass a key/value pair all the way.
func WithValue(ctx context.Context, k, v string) context.Context {
if len(k) == 0 || len(v) == 0 {
return ctx
}
if n := getNode(ctx); n != nil {
if m := n.addTransient(k, v); m != n {
return withNode(ctx, m)
}
} else {
return withNode(ctx, &node{
transient: []kv{{key: k, val: v}},
})
}
return ctx
}
func (n *node) addTransient(k, v string) *node {
if res, ok := remove(n.stale, k); ok {
return &node{
persistent: n.persistent,
transient: append(n.transient, kv{
key: k,
val: v,
}),
stale: res,
}
}
if idx, ok := search(n.transient, k); ok {
if n.transient[idx].val == v {
return n
}
r := *n
r.transient = make([]kv, len(n.transient))
copy(r.transient, n.transient)
r.transient[idx].val = v
return &r
}
r := *n
r.transient = append(r.transient, kv{
key: k,
val: v,
})
return &r
}
func remove(kvs []kv, key string) (res []kv, removed bool) {
if idx, ok := search(kvs, key); ok {
res = append(res, kvs[:idx]...)
res = append(res, kvs[idx+1:]...)
return res, true
}
return kvs, false
}
WithValue 的语义是向 context 里添加一个 transient 数据。这里逻辑相对简单。
此处先校验 k,v 是否有空字符串,直接返回。
若 ctx 中已经有 node 了,就直接取出 node,添加到 transient 即可。若ctx无node,则新创建一个。
addTransient 方法流程如下:
- 先判断 stale 中有没有这次要添加的 key,如果有,那么直接替换返回;
- 继续到 transient 中寻找是否撞key,若有,更新对应 value 的值返回;
- 若前两步都没发现撞 key,就直接往 transient 数组中加上新的 k,v。
backward
官方README 中目前没有提到的一个feature是 backward,这里我们也看一下。
大家知道 context 本质是一个链表,查找数据其实是比较慢的,如果能够把 context 底层存储的结构换成 map,直接就能做到 O(1) 级别的查找。当然,虽然 context 是并发安全的,它存储的值可不一定,如果你在里面放了个 map,记得一定用锁保证不会出现 race condition,否则一样会抛 panic。
metainfo 也提供了一个方便上手使用的基于 map 存储数据的实现。我们直接看源码
type bwCtxKeyType int
const (
bwCtxKeySend bwCtxKeyType = iota
bwCtxKeyRecv
)
type bwCtxValue struct {
sync.RWMutex
kvs map[string]string
}
func newBackwardCtxValues() *bwCtxValue {
return &bwCtxValue{
kvs: make(map[string]string),
}
}
func (p *bwCtxValue) get(k string) (v string, ok bool) {
p.RLock()
v, ok = p.kvs[k]
p.RUnlock()
return
}
func (p *bwCtxValue) getAll() (m map[string]string) {
p.RLock()
if cnt := len(p.kvs); cnt > 0 {
m = make(map[string]string, cnt)
for k, v := range p.kvs {
m[k] = v
}
}
p.RUnlock()
return
}
func (p *bwCtxValue) set(k, v string) {
p.Lock()
p.kvs[k] = v
p.Unlock()
}
func (p *bwCtxValue) setMany(kvs []string) {
p.Lock()
for i := 0; i < len(kvs); i += 2 {
p.kvs[kvs[i]] = kvs[i+1]
}
p.Unlock()
}
func (p *bwCtxValue) setMap(kvs map[string]string) {
p.Lock()
for k, v := range kvs {
p.kvs[k] = v
}
p.Unlock()
}
这里 backward 的key被声明为了 int,用 iota 进行生成。
bwCtxValue 结构是实现的精髓,其实很简单,为了方便扩展,保持了 k,v 都是字符串的设计,加了一把读写锁。 由于读写锁开箱即用,初始化 bwCtxValue 时只是创建了一个空map。
后面的读写其实就相对简单了,读场景用读锁 RLock, RUnlock 控制粒度稍微小一些。
这里可能有人会问,那 backward 机制支持的是 persistent 还是 transient 呢?
其实从源码实现看,二者是完全独立的两套实现,backward 并没有区分一跳,还是全链路这么细。
transient 的设计为一跳的场景做了很好的支撑。
而 backward 目前的设计是全部作为 persistent 来实现,只需要引用同一个包里的bwCtxKeyType,甚至直接用同样的 int 值都可以获取。
目前匹配 bwCtxKeyType 的变量有两个。分别代表 send 和 recv 语义,对应到不同的 API,能看出来作者是希望针对接受请求和发送请求进行隔离开。
type bwCtxKeyType int
const (
bwCtxKeySend bwCtxKeyType = iota
bwCtxKeyRecv
)
下面我们看下 backward 支持的 API:
注意,下面两个分组不能用串,否则无法得到预期效果:
- 底层使用
bwCtxKeyRecv的接口:func WithBackwardValues(ctx context.Context) context.Context- 初始化一个 bwCtxValue 放入 context
RecvBackwardValue(ctx context.Context, key string) (val string, ok bool)- Get 方法,根据 key 获取 value, ok 代表 key 是否存在。
RecvAllBackwardValues(ctx context.Context) (m map[string]string)- GetAll,获取到 bwCtxValue 包含的 map
SetBackwardValue(ctx context.Context, key, val string) (ok bool)- Set 方法,写入 kv。ok 为 false 时说明当前 ctx 中不包含一个 bwCtxValue,写入失败
SetBackwardValues(ctx context.Context, kvs ...string) (ok bool)- MSet 方法,写入多个 kv,注意是字符串形式,k 和 v 要隔开传入。
SetBackwardValuesFromMap(ctx context.Context, kvs map[string]string) (ok bool)- MSet 方法,入参变成了直接一个 map,更加开发者友好。
- 底层使用
bwCtxKeySend的接口:func WithBackwardValuesToSend(ctx context.Context) context.Context- 与 WithBackwardValues一致,只是换了 ctxKey
SendBackwardValue(ctx context.Context, key, val string) (ok bool)- Set 方法,写入 kv
SendBackwardValues(ctx context.Context, kvs ...string) (ok bool)- MSet 方法,这里还是要注意 k,v 间隔传入
SendBackwardValuesFromMap(ctx context.Context, kvs map[string]string) (ok bool)- MSet 方法,传入 map
AllBackwardValuesToSend(ctx context.Context) (m map[string]string)-
GetAll 方法,获取 send 对应的 map
-
整体来看,代码还是非常简洁的。作为接入的开发者,我们只需要关注 persistent, transient 这套基于 node 的实现即可。backward的部分目前看主要是针对于框架内部实现。