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かバイナリかを判断して読み取り、GreeterServer
interfaceの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のテンプレート機能を使ってコードを生成するためのプラグイン。時間と労力をかけないなら、テンプレートを用意してこれを使うのが一番早かったとは思う。
プラグインの作成にめちゃくちゃ時間がかかるようだったらこれも検討していたが、そこまで時間がかかりそうでもなかったので勉強も兼ねて自作することにした。