Go で gRPC の metadata を使う
この記事は Magic Moment Advent Calendar 2024 10 日目の記事です。
Magic Moment ソフトウェアエンジニアの scent-y です。
弊社ではサービス間通信の手段のひとつとして gRPC を採用しており、キックするイベントを伝播するのに gRPC の metadata を利用したりしてます。
metadata を扱う中で挙動としてどうなっているのか気になった点がいくつかあり、少しまとめてみたライトな内容の記事になります。
利用するパッケージは google.golang.org/grpc/metadata です。 本記事では Unary RPC のケースのみ扱います。
IncomingContext と OutgoingContext の使い分け
パッケージで IncomingContext と OutgoingContext と、異なるキーが存在します。どのような使い分けになっているのでしょうか?
クライアントサイドから metadata を送る場合は OutgoingContext を利用し、サーバーサイドで metadata を受け取る場合は IncomingContext を利用します。
下記のように実装することでクライアントから metadata を送信し、サーバーで受け取ることができます。
// client
ctx = metadata.AppendToOutgoingContext(ctx, "custom-header", "val1")
responsePb, err := serviceClient.Get(ctx, requestPb)
if err != nil {
return nil, errors.Wrap(err)
}
// server
md, ok := metadata.FromIncomingContext(ctx)
if ok {
val := md.Get("custom-header")
logger.Infof(ctx, "custom-header: %s", val) // custom-header: [val1]
}
metadata を取得するのにサーバーで FromOutgoingContext は利用しないので注意が必要です。
FromOutgoingContext はクライアントサイドでのみ利用します。
// server
md, ok := metadata.FromOutgoingContext(ctx)
if ok {
val := md.Get("custom-header")
logger.Infof(ctx, "custom-header: %s", val)
} else {
logger.Info(ctx, "custom-header not found") // custom-header not found
}
キーで大文字と小文字は区別されるのか
metadata では キーは全て小文字に変換されるため、区別されません。なので {"custom-Header":"val1"} と {"CUSTOM-header":"val2"} を metadata に追加した場合、キーが custom-header に変換され、val1 と val2 は同じキーの値として扱われます。
// client
ctx = metadata.AppendToOutgoingContext(ctx, "custom-Header", "val1")
ctx = metadata.AppendToOutgoingContext(ctx, "CUSTOM-header", "val2")
responsePb, err := serviceClient.Get(ctx, requestPb)
if err != nil {
return nil, errors.Wrap(err)
}
// server
md, ok := metadata.FromIncomingContext(ctx)
if ok {
val := md.Get("custom-header")
logger.Infof(ctx, "custom-header: %s", strings.Join(val, ",")) // custom-header: val1,val2
}
上書きではなく値をマージする挙動になるんですね。
AppendToOutgoingContext と NewOutgoingContext の違い
クライアントから metadata を送信する方法として AppendToOutgoingContext と NewOutgoingContext があります。どのような違いがあるのでしょうか?
AppendToOutgoingContext は context に metadata が既に存在する場合、既存の metadata を保持したまま metadata を追加します。
対して、NewOutgoingContext は metadata が既に存在する場合、既存の metadata を上書きします。
// client
ctx = metadata.AppendToOutgoingContext(ctx, "custom-header", "val1")
ctx = metadata.NewOutgoingContext(ctx, metadata.Pairs("custom-header", "val2"))
responsePb, err := serviceClient.Get(ctx, requestPb)
if err != nil {
return nil, errors.Wrap(err)
}
// server
md, ok := metadata.FromIncomingContext(ctx)
if ok {
val := md.Get("custom-header")
logger.Infof(ctx, "custom-header: %s", val) // custom-header: [val2]
}
NewOutgoingContext で上書きされたため、val1 がいませんね。
両者間にパフォーマンスの差はあるのでしょうか。ベンチマークテストで確認してみます。
package metadata_test
import (
"context"
"testing"
"google.golang.org/grpc/metadata"
)
func BenchmarkMetadataOperations(b *testing.B) {
b.Run("AppendToOutgoingContext", func(b *testing.B) {
ctx := context.Background()
b.ResetTimer()
for i := 0; i < b.N; i++ {
_ = metadata.AppendToOutgoingContext(ctx, "k1", "v1", "k2", "v2", "k3", "v3")
}
})
b.Run("NewOutgoingContext", func(b *testing.B) {
ctx := context.Background()
b.ResetTimer()
for i := 0; i < b.N; i++ {
md := metadata.Pairs("k1", "v1", "k2", "v2", "k3", "v3")
_ = metadata.NewOutgoingContext(ctx, md)
}
})
}
結果は下記でした。
goos: darwin
goarch: arm64
pkg: benchmark-test
cpu: Apple M1
BenchmarkMetadataOperations/AppendToOutgoingContext-8 9838526 122.0 ns/op
BenchmarkMetadataOperations/NewOutgoingContext-8 5098166 234.8 ns/op
PASS
ok benchmark-test 4.020s
AppendToOutgoingContext の方が約2倍高速なことが分かりました。
意図しない metadata の上書きを防ぐ観点でも、どちらを利用するか迷った際は AppendToOutgoingContext を利用するのが無難かなと思いました。
最後に
簡単な内容でしたが、Go での gRPC metadata に関して少しまとめてみました。
metadata はトレース情報や認証情報やその他の追加情報など、.proto ファイルで厳密に定義されてないが故に柔軟に活用できるなと感じてます。
最近急に寒くなったので、体調に気をつけてお過ごしください⛄️ここまで読んでいただいてありがとうございました。
次回のアドベントカレンダーは @yano の 「Cloud Run jobs を Cloud Deploy でデプロイしてみた」 です。お楽しみに!
Discussion