はじめに
最近こういう記事を書きました
記事の内容としては、メッセージエンコーディングにはいろいろと種類があり、それぞれ特性があるのでいい感じに使い分けようねみたいな感じでした。
こんな記事を書くくらいなので最近メッセージエンコーディングについていろいろ考えてるんですが、gRPCって必ずしもメッセージエンコーディングwire formatじゃなくてもよくない?みたいなことを思いつきました。
protobuf周辺技術みたいなコード生成を前提とするエコシステムにおいて、メッセージエンコーディングがself describingであるないしは構造化メタデータを含んでいるメリットってあんま無い気がするなー。どうせメッセージ単体から構造情報取り出さずに生成された決め打ちの構造としてパースしてるので
— convto (@convto) 2022年12月27日
まあwire formatはrepeatableの表現がかなりきれいで変更に対して柔軟なので、そういうのはメタデータがないとできないのはそう。
— convto (@convto) 2022年12月27日
Apache Avro みたいなスキーマをよそに依存してバイナリは一切スキーマ持ってないみたいなアプローチでもいいと思うんだけどなー。コード生成してるんだし。互換についての対策は考える必要あるけど
— convto (@convto) 2022年12月27日
というわけで、思考実験としてgrpc without wire formatみたいなのをやりたくなったので軽く試してみる会です。とりあえずいろいろサポートがありそうだったので今回はJSONで試します。
サクッとできそうだったgrpc-goでやってみてるので、他の言語で同様のことが簡単にできるかは不明です。
どうやるの
ざーーとgrpc-goのドキュメントみたらどうやらメッセージのエンコーディングはいい感じに調整できそうなことが判明
↑によると grpc/encoding.Codec
interface を実装していい感じにすればよきようにできるっぽい。
type Codec interface { // Marshal returns the wire format of v. Marshal(v interface{}) ([]byte, error) // Unmarshal parses the wire format into v. Unmarshal(data []byte, v interface{}) error // Name returns the name of the Codec implementation. The returned string // will be used as part of content type in transmission. The result must be // static; the result cannot change between calls. Name() string }
Codec interfaceを実装すればclient/serverともに任意のencodingを使うことが可能なよう。ちなみにエンコーディング情報をどう渡すかというと、ヘッダが content-type: application/grpc+json
みたいになるとのことです。
ちなみに、コードをさらっと読む限りCodec interfaceで言及されてる v interface{}
の実体は proto.Message
ぽいので、Codec実装するときはそのつもりで処理を書く感じになりそうです。
json用の grpc/encoding/Codec を実装してみる
じつは このへんで言及されてる 通り proto.Message
を受け取ってなんやかやjsonにする処理はすでに protobuf/encoding/protojson で実装されてます!
こいつは grpc/encoding.Codec
interfaceを実装してないので、シュッとそのcodecだけ実装します。
package jsoncodec import ( "fmt" "google.golang.org/grpc/encoding" "google.golang.org/protobuf/encoding/protojson" "google.golang.org/protobuf/proto" ) const Name = "json" // codec implements encoding.Codec. type codec struct{} func (c *codec) Marshal(v interface{}) ([]byte, error) { msg, ok := v.(proto.Message) if !ok { return nil, fmt.Errorf("not a proto message but %T: %v", v, v) } b, err := protojson.Marshal(msg) if err != nil { return nil, err } return b, nil } func (c *codec) Unmarshal(b []byte, v interface{}) error { msg, ok := v.(proto.Message) if !ok { return fmt.Errorf("not a proto message but %T: %v", v, v) } return protojson.Unmarshal(b, msg) } func (c *codec) Name() string { return Name } func init() { encoding.RegisterCodec(new(codec)) }
initで encoding.RegisterCodec
しておくとserverはcontent-typeヘッダからcodecが特定できるようになるし、clientもcodec名の指定をoptionで追加すれば送れるようになります。
client/serverを書く
適当なRPCが itempb
パッケージに出力されている体でいきます
client
package main import ( "context" "fmt" "github.com/convto/playground/itempb" "github.com/convto/playground/jsoncodec" "google.golang.org/grpc" "google.golang.org/grpc/credentials/insecure" ) func main() { address := "localhost:8080" conn, err := grpc.Dial( address, grpc.WithTransportCredentials(insecure.NewCredentials()), grpc.WithBlock(), grpc.WithDefaultCallOptions(grpc.CallContentSubtype(jsoncodec.Name)), ) if err != nil { panic(err) } defer conn.Close() client := itempb.NewItemServiceClient(conn) res, err := client.PostItem( context.Background(), &itempb.PostItemRequest{ Name: "apple", Price: 100, }, ) if err != nil { panic(err) } fmt.Println(res.Id, res.Name, res.Price) }
今回のjsonつかっちゃおう作戦で重要なのは grpc.WithDefaultCallOptions(grpc.CallContentSubtype(jsoncodec.Name))
の部分だけで、それ以外は適当に設定してるだけです。initで RegisterCodec
を通してjson codecが登録済みなので、CallOptionにcodec nameを指定するだけでうまいこといきます。
server
package main import ( "context" "fmt" "log" "net" "os" "os/signal" "github.com/convto/playground/itempb" _ "github.com/convto/playground/jsoncodec" "google.golang.org/grpc" ) type server struct { itempb.UnimplementedItemServiceServer } func (s *server) PostItem(_ context.Context, in *itempb.PostItemRequest) (*itempb.PostItemResponse, error) { return &itempb.PostItemResponse{ Id: 1, Name: in.GetName(), Price: in.GetPrice(), }, nil } func main() { listener, err := net.Listen("tcp", fmt.Sprintf(":%d", 8080)) if err != nil { panic(err) } s := grpc.NewServer() itempb.RegisterItemServiceServer(s, &server{}) go func() { log.Printf("start server, port: %v", 8080) s.Serve(listener) }() q := make(chan os.Signal, 1) signal.Notify(q, os.Interrupt) <-q log.Println("shutdown server...") s.GracefulStop() }
なんとここで重要なのは _ "github.com/convto/playground/jsoncodec"
のblank importしてる部分だけです!それ以外は検証用の適当なコードです。
initで RegisterCodec()
が実行されればserverはcontent-typeからいい感じに情報を特定できます。
いざ試してみる
server起動しつつclientコードを実行して、wiresharkでパケットとっ捕まえて中身を見ます。
req
JSONになってるね〜〜えらいね〜〜
res
こっちもJSONになっててえらいね〜〜〜
ちなみにDecompressed Headerはこんなかんじでした
content-typeに詰まってるのも確認できた〜〜えらいね〜〜
感想
他の言語がどうかはしらんですが、grpc-goは比較的いい感じにエンコーディングの差し替えが可能っぽいです。
wire format自体かなり良い感じのエンコーディングなので基本的に変える必要はない寄りの気持ちですが、protocはエコシステムからコード生成支援を受けれる前提があるのでApache Avroとか試すのも面白そうだし、MessagePackとかを使いたいなら使ってもいいかもしれません。やりたくなったときにやれるっていうのは大事な要素ですよね!みんなで遊んでいきましょう〜
それでは今日はこれにておしまい!今回は興味本位9割で遊んでみましたが、世の中には多種多様な要求があるのでもしかしたら誰かの役にたつかもしれない...