GoでgRPCのserviceのinterfaceを実装したことにする


GoでgRPCを使って開発するときの課題

protoから生成されたコードを一緒のリポジトリで管理するか別のリポジトリで管理して取り込んで使うかという話がありますが、別のリポジトリのものを取り込む場合によくあるのがいつのまにかgRPCのserviceに新しいmethodが追加されていて自分の手元でビルドできない!という問題です。

例えば以下ような proto があった場合、

service EchoService {
    rpc Echo(Message) returns (Message) {}
}

このprotoからgRPCのコードを生成すると以下のようなものができます

type EchoServiceServer interface {
    Echo(context.Context, *Message) (*Message, error)
}

サーバとして使うにはこれを実装したstructを用意する必要があります

type echoServer struct {}

func newEchoServer() pb.EchoServiceServer {
    return new(echoServer)
}

func (s *echoServer) Echo(ctx context.Context, msg *pb.Message) (*pb.Message, error) {
    return msg, nil
}

ある日 EchoService に新しいmethodを誰かが追加しました

service EchoService {
    rpc Echo(Message) returns (Message) {}
    rpc Echo2(Message) returns (Message) {}
}

その結果生成されるコードのinterfaceも以下のようになります

type EchoServiceServer interface {
    Echo(context.Context, *Message) (*Message, error)
    Echo2(context.Context, *Message) (*Message, error)
}

この変更を取り込んでしまうと、現状の echoServer structは EchoServiceServer interfaceを満たしていないのでビルド時にエラーになります

$ go build
# github.com/kazegusuri/grpc-service-hack
./echo.go:13:12: cannot use new(echoServer) (type *echoServer) as type "github.com/kazegusuri/grpc-service-hack/proto".EchoServiceServer in return argument:
    *echoServer does not implement "github.com/kazegusuri/grpc-service-hack/proto".EchoServiceServer (missing Echo2 method)

普通に考えれば「足せばいいじゃん」で終わりだと思います。自分もそう思います。

とはいえ、ちょっとした修正をしようと思ったときにこの状況になったら気になるということもあると思うのでどうにかする方法を考えてみました。

interfaceを無理やり満たす

とくにすごいハックでもなんでもないですが、先程の echoServer のstructを以下のようにするだけです

type echoServer struct {
    pb.EchoServiceServer // embedded
}

func newEchoServer() pb.EchoServiceServer {
    return new(echoServer)
}

func (s *echoServer) Echo(ctx context.Context, msg *pb.Message) (*pb.Message, error) {
    return msg, nil
}

echoServer のstructにgRPCのserviceのinterfaceを埋め込みます。こうすることで echoServer は何も関数を実装していなくても常に EchoServiceServer のinterfaceを満たした状態になります。
これで誰かが新しいmethodを追加してinterfaceが変わったとしてもビルドエラーになることはないです。

当たり前ですが、この状態で仮に新しく追加された Echo2 を呼び出そうとすると echoServer には Echo2 が実装されていないので panic になります。つまりビルド時から実行時のエラーになるわけです。あまり良くないですよね。そもそもinterface埋め込みはGoのコードではなかなか見ることはないのでなんだれこれはという気持ちにもなります。

実行時エラーの問題を緩和させる

おそらく最終的な問題は本番環境でいざリクエストなげたらpanicになった!というのを防ぎたいということだと思います。そもそもエンドポイントのテストはしてないのかという問題はおいといて、ビルド時に解決できる問題はビルド時に解決するべきです。

正直あまり良い方法でもないですが、この問題を緩和するためにビルドタグを使うという手を考えました。開発時のみ development をビルドタグとして指定するようにして、このビルドタグで interface を埋め込むかどうかを制御しようという感じです。

以下のように echo_dev.goecho_prod.go でそれぞれ echoServerInterface interfaceを定義して、開発用の環境のみ EchoServiceServer を埋め込みます。

// echo_dev.go
// +build development

...

type echoServerInterface interface {
    pb.EchoServiceServer
}
// echo_prod.go
// +build !development

...

type echoServerInterface interface {
}

実際の echoServer struct は以下のように定義します

type echoServer struct {
    pb.EchoServiceServerInterface // embedded
}

struct自体をビルドタグ毎に定義するという方法もありますが、このstructには実装に必要なフィールドをどんどん足していくと思うので interface 部分だけ分けています。
こうすることでビルド時に -tags=development を指定するとinterfaceのチェックが事実上なくなりますが、タグがついていない場合は通常通りのチェックが行われるようになります。

この仕組みを用意して、CIなどではfeatureブランチ間ではdevelopmentタグを指定してビルドして、masterへマージするときなどにはつけないという運用をすることで事前にチェックすることができるようになります。

ここまでの結果言えることは、こんなハックをせずに普通にすぐに実装を直しましょうということです。methodの追加によるinterfaceの変更というのは他の開発と比較すればものすごく頻度が少ないです。そのためだけに別のもっと大きな問題を生み出すのはやめましょう。

蛇足ですが、serviceを(細かく)分ければ良いというのも何も解決にはなっていません。serviceを分けてもそのserviceに追加することがあれば同じ問題が発生します。この方法で解決するなら1 service 1 methodで実装するという制約をいれるしかないです。