nametake/protoc-gen-gohttpというprotocのプラグインを作ったのでその話。

Protocol Buffersとは Link to heading

Protocol Buffersはインターフェース定義言語の1つでGoogleによって開発されている(ざっくりいうと、JSONやXMLの仲間)。以下のように、構造を表すmessageとそのmessageを使ったRPCのインターフェースのserviceというものを定義できる。

syntax = "proto3";

package helloworld;

option go_package = "main";

message HelloRequest {
  string name = 1;
}

message HelloReply {
  string message = 1;
}

service Greeter {
  rpc SayHello(HelloRequest) returns (HelloReply) {}
}

上記の定義をprotocコマンドを使ってコンパイルすることで、対応した言語ならそのままコードを書き出せる。例えばGoなら以下のような構造体が書き出される(長くなるのでコードは削っている)。以下の構造体はProtocol Bufferが用意しているライブラリを利用すればバイナリに変換できる。

type HelloRequest struct {
	Name                 string   `protobuf:"bytes,1,opt,name=name" json:"name,omitempty"`
	XXX_NoUnkeyedLiteral struct{} `json:"-"`
	XXX_unrecognized     []byte   `json:"-"`
	XXX_sizecache        int32    `json:"-"`
}

type HelloReply struct {
	Message              string   `protobuf:"bytes,1,opt,name=message" json:"message,omitempty"`
	XXX_NoUnkeyedLiteral struct{} `json:"-"`
	XXX_unrecognized     []byte   `json:"-"`
	XXX_sizecache        int32    `json:"-"`
}

また、protocコマンドはプラグイン機構も持っている。例えばgRPCのためのコードを書き出したかったらprotoc --go_out=plugins=grpc:.のようにすることで以下のようにgRPC用のコードも同時に書き出してくれる(以下のコードも削っている)。

func NewGreeterClient(cc *grpc.ClientConn) GreeterClient {
	return &greeterClient{cc}
}

func (c *greeterClient) SayHello(ctx context.Context, in *HelloRequest, opts ...grpc.CallOption) (*HelloReply, error) {
	out := new(HelloReply)
	err := c.cc.Invoke(ctx, "/helloworld.Greeter/SayHello", in, out, opts...)
	if err != nil {
		return nil, err
	}
	return out, nil
}

// GreeterServer is the server API for Greeter service.
type GreeterServer interface {
	SayHello(context.Context, *HelloRequest) (*HelloReply, error)
}

func RegisterGreeterServer(s *grpc.Server, srv GreeterServer) {
	s.RegisterService(&_Greeter_serviceDesc, srv)
}

本題 Link to heading

Protocol Buffers自体はただのIDLでしかないので、書き出されるコードはただのデータ構造を表す構造体に過ぎない。よくgRPCと一緒に語られるため、gRPCと一緒に使わないといけないと勘違いされがちだが、gRPCを使わずとも書き出した構造体だけを利用できる。

書き出された構造体をバイナリに変換してHTTPのPOSTのBodyに乗せることで、通信プロトコルはHTTPのままでProtocol Buffersの恩恵を受けることもできる(構造体なのでJSONに変換して取り扱ったりもできる)。

クライアントとサーバ間で同じ定義を利用できて、かつその定義もシンプルなため、gRPCを使わないでProtocol Buffersだけを利用するだけでもかなり便利。

ただ、Go言語のコードを書き出した際に出力されるのはmessageに対応した構造体のみで、serviceの定義はgRPC等のプラグインを使わないと無視されてしまうため、HTTPの上で取り扱うには意味のない定義だった。

そこで「serviceからhttp.Handlerに対応できるコードを書き出せば良いのでは」という発想からprotoc-gen-gohttpというプラグインを作ってみた。

使い方 Link to heading

このプラグインはgRPCのプラグインと一緒に使うことを想定している。Protocol Buffersの定義にserviceを書いてこのプラグインを使うと、以下のようなコードが生成される。

type GreeterHTTPConverter struct {
	srv GreeterServer
}

func NewGreeterHTTPConverter(srv GreeterServer) *GreeterHTTPConverter {
	return &GreeterHTTPConverter{
		srv: srv,
	}
}

func (h *GreeterHTTPConverter) SayHello(cb func(ctx context.Context, w http.ResponseWriter, r *http.Request, arg, ret proto.Message, err error)) http.HandlerFunc {
	// ~~ 省略 ~~
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
	// ~~ Content-Typeに応じてRequestを読み込む処理 ~~
 		ret, err := h.srv.SayHello(ctx, arg)
		if err != nil {
			cb(ctx, w, r, arg, nil, err)
			return
		}
	// ~~ Responseを返す処理 ~~
  }
}

GreeterHTTPConverterという構造体がgRPCのプラグインを使ったときに生成されるGreeterServer interfaceを内部に持つ。GreeterHTTPConverterはCallbackを受け取ってhttp.HandlerFuncを返すSayHelloメソッドを実装している。

返されるhttp.HandlerFunc内部では、Bodyの情報をContent-Typeに合わせてJSONかバイナリかを判断して読み取り、GreeterServerinterfaceのSayHelloメソッドを呼び出すようになっている。

上記のGreeterHTTPConverterを使うことで、GreeterServer interfaceを実装している構造体を型を守りつつ以下のようにnet/httpのinterfaceに乗せることができる。

// GreeterServerを実装している構造体
srv := &EchoGreeterServer{}

// GreeterHTTPConverterのインスタンス作成
conv := NewGreeterHTTPConverter(srv)

// Callbackを渡してhttp.HandlerFuncに登録
http.Handle("/sayhello", conv.SayHello(logCallback))

SayHelloメソッドに渡すCallbackはhttp.HandlerFuncを抜けるときに必ず呼び出されるため、Callbackの引数をハンドリングすることで呼び出し時の挟み込む処理やエラーハンドリングができる。

細かい使い方や動かし方はREADMEに記載している。

コンセプト Link to heading

作成にあたって以下のことに気をつけてた。

  • 生成される物が標準パッケージとProtobuf関係のパッケージしかimportしない
  • 呼び出し毎に自由な処理を挟める(ログ等のため)
  • errorが発生したときに自由にハンドリングができる
  • RequestのBodyがJSONとProtobufのバイナリのどちらでも使えるようにする
  • 無駄にMarshal/Unmarshalをしない
  • できる限り型で守る(Contextに情報を詰めたりしない)

比較 Link to heading

このプラグインは実際にプロジェクトで使おうと思って作ったが、作る前に似たようなものがないか調べたのでそれとの比較も書いてみる。

twirp Link to heading

同じようにProtocol Buffersの定義からhttp.Handler対応するコードを生成するフレームワーク。Content-TypeをみてJSONかバイナリかを判断することもしている。ただ、以下の点が気になったので自分たちのプロジェクトでは採用を見送った。

  • 生成されるものがtwirpをimportしている
  • エラー発生時のハンドリングの余地が少ない
  • RPCの呼び出しに対するHookがContextに情報を詰めるようになっていて型で守るのが難しい
  • フレームワークなのでピンポイントで使いづらい

RussellLuo/protoc-go-plugins Link to heading

Protocol Buffersの定義からhttp.Handler対応の物を生成するプラグイン。JSONには対応していたが、バイナリのMarshal/Unmarshalには対応しておらず、エラーハンドリングにも余地がなかった。

moul/protoc-gen-gotemplate Link to heading

他の物とは毛色が違うがProtocol Buffersの定義からGoのテンプレート機能を使ってコードを生成するためのプラグイン。時間と労力をかけないなら、テンプレートを用意してこれを使うのが一番早かったとは思う。

プラグインの作成にめちゃくちゃ時間がかかるようだったらこれも検討していたが、そこまで時間がかかりそうでもなかったので勉強も兼ねて自作することにした。