ちりもつもればミルキーウェイ

好奇心に可処分時間が奪われる

gRPCのメッセージをJSONシリアライズしたい

はじめに

最近こういう記事を書きました

convto.hatenablog.com

記事の内容としては、メッセージエンコーディングにはいろいろと種類があり、それぞれ特性があるのでいい感じに使い分けようねみたいな感じでした。

こんな記事を書くくらいなので最近メッセージエンコーディングについていろいろ考えてるんですが、gRPCって必ずしもメッセージエンコーディングwire formatじゃなくてもよくない?みたいなことを思いつきました。

というわけで、思考実験としてgrpc without wire formatみたいなのをやりたくなったので軽く試してみる会です。とりあえずいろいろサポートがありそうだったので今回はJSONで試します。

サクッとできそうだったgrpc-goでやってみてるので、他の言語で同様のことが簡単にできるかは不明です。

どうやるの

ざーーとgrpc-goのドキュメントみたらどうやらメッセージのエンコーディングはいい感じに調整できそうなことが判明

github.com

↑によると 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割で遊んでみましたが、世の中には多種多様な要求があるのでもしかしたら誰かの役にたつかもしれない...