gRPC Metadata的误解

最近在处理线上问题时,我遇到一个与 gRPC Metadata 相关的困惑。起初,我以为在 gRPC 请求中,metadata 中相同的 key 会发生覆盖,但实际情况并非如此。相同的 key 并不会覆盖前一个值,反而会形成一个数组,就像 HTTP header 的设计一样。这一现象在初次遇到时并不明显,为了弄清楚其中的原理,我决定深入源码进行分析,最终发现了其中的细节并排查出了导致问题的根源。

问题背景

先看下发生问题的代码

import (
    "context"
    mmd "google.golang.org/grpc/metadata"
    "google.golang.org/protobuf/types/known/emptypb"
)

func metadataPass(ctx context.Context) {
	var i int
	for {
		i++
		ctx = mmd.AppendToOutgoingContext(ctx, "test", i)
		res, _ := client.SayHello(ctx, &emptypb.Empty{})
	}
	return
}

代码很简单,就是在一个循环中,每次发起gRPC请求前都调用AppendToOutgoingContext添加键值对, 看起来没啥问题,因为metadata的go源码实现是依靠于context,而context如果是相同key则会覆盖,这就导致写这段代码时顺利成章的认为这样使用是没问题的。

排查过程

定位问题其实是比较快的,出于好奇,决定看下metadata的源码实现。 先看下AppendToOutgoingContext具体做了什么

type rawMD struct {
    md    MD
    added [][]string
}

// AppendToOutgoingContext returns a new context with the provided kv merged
// with any existing metadata in the context. Please refer to the documentation
// of Pairs for a description of kv.
func AppendToOutgoingContext(ctx context.Context, kv ...string) context.Context {
	if len(kv)%2 == 1 {
		panic(fmt.Sprintf("metadata: AppendToOutgoingContext got an odd number of input pairs for metadata: %d", len(kv)))
	}
	md, _ := ctx.Value(mdOutgoingKey{}).(rawMD)
	added := make([][]string, len(md.added)+1)
	copy(added, md.added)
	kvCopy := make([]string, 0, len(kv))
	for i := 0; i < len(kv); i += 2 {
		kvCopy = append(kvCopy, strings.ToLower(kv[i]), kv[i+1])
	}
	added[len(added)-1] = kvCopy
	return context.WithValue(ctx, mdOutgoingKey{}, rawMD{md: md.md, added: added})
}

源码比较简单,把rawMDctx取出来,这里并没有做判断是否有key的情况,而是直接把传入的键值对继续往added上面追加。

接着看下FromOutgoingContext的实现

// FromOutgoingContext returns the outgoing metadata in ctx if it exists.
//
// All keys in the returned MD are lowercase.
func FromOutgoingContext(ctx context.Context) (MD, bool) {
	raw, ok := ctx.Value(mdOutgoingKey{}).(rawMD)
	if !ok {
		return nil, false
	}

	mdSize := len(raw.md)
	for i := range raw.added {
		mdSize += len(raw.added[i]) / 2
	}

	out := make(MD, mdSize)
	for k, v := range raw.md {
		// We need to manually convert all keys to lower case, because MD is a
		// map, and there's no guarantee that the MD attached to the context is
		// created using our helper functions.
		key := strings.ToLower(k)
		out[key] = copyOf(v)
	}
	for _, added := range raw.added {
		if len(added)%2 == 1 {
			panic(fmt.Sprintf("metadata: FromOutgoingContext got an odd number of input pairs for metadata: %d", len(added)))
		}

		for i := 0; i < len(added); i += 2 {
			key := strings.ToLower(added[i])
			out[key] = append(out[key], added[i+1])
		}
	}
	return out, ok
}

在返回metadata的时候,如果是相同key值,value是一个数组,顺序跟你写入metadata的顺序一致。 好吧,看到MD定义的时候突然想起来value是一个数组,太久这都忘了,grpc的metadata设计其实跟HTTPheader的设计是类似的,value都是数组。

// MD is a mapping from metadata keys to values. Users should use the following
// two convenience functions New and Pairs to generate MD.
type MD map[string][]string

// Get obtains the values for a given key.
//
// k is converted to lowercase before searching in md.
func (md MD) Get(k string) []string {
    k = strings.ToLower(k)
    return md[k]
}
// A Header represents the key-value pairs in an HTTP header.
//
// The keys should be in canonical form, as returned by
// CanonicalHeaderKey.
type Header map[string][]string

而最终由于在项目中服务端使用的是kratos框架,默认取的也是第一个元素,所以拿到的还是第一次写入的值

// Get returns the value associated with the passed key.
func (m Metadata) Get(key string) string {
	v := m[strings.ToLower(key)]
	if len(v) == 0 {
		return ""
	}
	return v[0]
}

解决方案

第一种解决方案是最简单的,直接用空的context

ctx = mmd.AppendToOutgoingContext(context.Background(), "test", i)

第二种是取出metadata,重新设置键值,并重新初始化context,这种是推荐的方法(因为context之前携带的其他metadata key值,以及ctx里面的超时信息都能携带过去)

    md, ok := mmd.FromOutgoingContext(ctx)
    if !ok{
        md = make(mmd.MD)
    }
    md.Set("test", i)
    ctx = mmd.NewOutgoingContext(ctx, md)

总结

这次 gRPC Metadata 相同 key 的问题让我深刻体会到,在开发和调试过程中,深入理解底层实现的重要性。我们往往习惯于依赖文档和现有的经验来解决问题,但面对复杂且不常见的情况时,文档可能并不够用。这时,直接阅读源码就是一种非常有效的手段。

通过这次的排查过程,我不仅解决了具体的问题,还对 gRPC 的内部机制有了更深入的了解。这种理解不仅能帮助我们编写出更加健壮的代码,还能让我们在遇到问题时有更高的洞察力和更快的解决速度。

因此,我强烈建议大家在工作中遇到类似疑惑时,不妨多去看看源码。源码是最权威的参考资料,也是理解框架和工具设计理念的最佳途径。它不仅能帮助我们解决眼前的问题,还能提升我们作为开发者的整体能力。