protobuf を使った、最近の開発スタンダード

Apr 9, 2023 00:07 · 539 words · 3 minute read

protocol buffers は、DSL としての使い勝手が良い。
最近のプロジェクトでは、API 定義は専ら protocol buffers を使っている。

特にサーバーサイドの開発をするときによく使う構成を取り上げる。

API スキーマの記述 🔗

protocol buffers とくれば、gRPC。
しかし、gRPC は HTTP/2 が使われており、ブラウザ対応などはまだ完全ではない。
また、プロジェクトの性質上、比較的枯れた技術が好まれる場面の少ない。

その場合、API には REST が採用され、スキーマは依然として OpenAPI として記述する必要のある場面も多い。
protocol buffers の表現力と比較してしまうと、OpenAPI は、JSON にしろ YAML にしろ、決して書き易くはない。

そんなケースでは、protocol buffers でスキーマを記述しつつ、クライアントチームには protobuf から生成した OpenAPI を提供するような構成を採用している。

syntax = "proto3";

// Merging Services
//
// This is an example of merging two proto files.
package grpc.gateway.examples.internal.examplepb;

import "google/api/annotations.proto";

option go_package = "github.com/grpc-ecosystem/grpc-gateway/v2/examples/internal/proto/examplepb";

// InMessageA represents a message to ServiceA and ServiceC.
message InMessageA {
  // Here is the explanation about InMessageA.values
  repeated string values = 1;
}

// OutMessageA represents a message returned from ServiceA.
message OutMessageA {
  // Here is the explanation about OutMessageA.value
  string value = 1;
}

// OutMessageC represents a message returned from ServiceC.
message OutMessageC {
  // Here is the explanation about OutMessageC.value
  string value = 1;
}

// ServiceA provices MethodOne and MethodTwo
service ServiceA {
  // ServiceA.MethodOne receives InMessageA and returns OutMessageA
  //
  // Here is the detail explanation about ServiceA.MethodOne.
  rpc MethodOne(InMessageA) returns (OutMessageA) {
    option (google.api.http) = {
      post: "/v1/example/a/1"
      body: "*"
    };
  }
  // ServiceA.MethodTwo receives OutMessageA and returns InMessageA
  //
  // Here is the detail explanation about ServiceA.MethodTwo.
  rpc MethodTwo(OutMessageA) returns (InMessageA) {
    option (google.api.http) = {
      post: "/v1/example/a/2"
      body: "*"
    };
  }
}

https://github.com/grpc-ecosystem/grpc-gateway

google/api/annotations.proto を使用して、MethodOption を記述することで、rpc と RESTful API のルーティングの対応づけを protocol buffer の中に記述することができる。

これを protoc-gen-openapiv2 に渡すと、OpenAPI の定義ファイルを生成してくれる。

protoc -I . --openapiv2_out ./gen/openapiv2 \
    --openapiv2_opt logtostderr=true \
    your/service/v1/your_service.proto

スキーマの置き場所 🔗

大きくわけると、

  • 実装と同じ場所で管理する。
  • 実装と別の場所で管理する。

のどちらか。

最近では、チームの規模が比較的小さいプロジェクトにおいては、フロントエンド/バックエンドをモノレポで管理する構成を選択することも少ない。
その場合は、スキーマの実装と同じ場所で管理することになるので、あまり迷う余地はない。

チームの規模が大きい場合、モノレポだと取り回しが難しいケースもあり、フロントエンド/バックエンドそれぞれでレポジトリを分けるケースもある。

この場合、スキーマの管理方法が悩ましい。   サーバー側がスキーマ設計を主導ケースは、サーバー側のレポジトリに組み込んでしまって、OAS などの生成物だけを配布する場合もある。

スキーマの設計に関わるのがサーバーチームに限らない場合は、スキーマ管理用のレポジトリを設けて、依存する実装がそれぞれ submodule として取り込むケースが多い。

この場合、スキーマ管理レポジトリには必要最小限のファイルだけをチェックインする形にしておくと、CIパイプラインなどから取り回しがし易くなる。

コード生成 🔗

コード生成も大きく2つの選択肢がある。

  • protoc コマンドで記述する (+Make/Bazel などのビルドツールと併用)。
  • buf を利用する

特に理由がなければ、buf を使っておくのが初手としては見通しがよい所感がある。

  • サードパーティの proto ファイル( grpc-gateway option googleapis )を管理しなくてよくなる。
  • protoc plugin の依存管理もしなくてよくなる。
  • 生成コマンドの記述が楽。

あたりが大きな恩恵として受けられる。

# buf.yaml
version: v1
deps:
  - buf.build/googleapis/googleapis
  - buf.build/grpc-ecosystem/grpc-gateway
breaking:
  use:
    - FILE
lint:
  use:
    - DEFAULT
# buf.gen.yaml
version: v1
plugins:
  - plugin: buf.build/grpc-ecosystem/openapiv2
    opt:
      - allow_merge=true
      - merge_file_name=backend
    out: gen/swagger

buf には linter や formatter も含まれているので、protolintclang-format を導入して設定ファイルを管理していく必要がなくなる。

vscode extension もある。

{
  "[proto3]": {
    "editor.defaultFormatter": "bufbuild.vscode-buf",
  }
}

一点不満があるとすれば、スタイルについて opinionated なので、たとえば alignment の調整などで formatter に介入することができない。

スキーマ管理レポジトリを独立させている場合は、go_package の管理に工夫がいる。

良し悪しではあるが、依存の向きとしては schema <- implementation であってほしいので、schema ファイルに go_package を書きづらい。

アプローチとしては2つあって、buf.gen.yaml managed を使うこと。

version: v1
plugins:
  - plugin: go
    out: gen/xxx-schema
    opt: &go_opt
      - module=github.com/org/xxx/gen/xxx-schema
  - plugin: go-grpc
    out: gen/xxx-schema
    opt: *go_opt
  - plugin: grpc-gateway
    out: gen/xxx-schema
    opt: *go_opt
managed:
  enabled: true
  go_package_prefix:
    default: github.com/org/xxx/gen/xxx-schema
    except:
      - buf.build/googleapis/googleapis
      - buf.build/grpc-ecosystem/grpc-gateway

これで生成対象の proto ファイルに option go_package = {managed.go_package_prefix.default} + {proto file の dir} が付与されたのと同じになる。

もう一つのアプローチは、生成側で M オプションを渡して指定すること。

version: v1
plugins:
  - plugin: go
    out: gen/xxx-schema
    opt: &go_opt
      - module=github.com/org/xxx/gen/xxx-schema
      - Mxxx-schema/backend/v1/foo.proto=github.com/org/xxx/gen/xxx-schema/backend/v1;backend
  - plugin: go-grpc
    out: gen/xxx-schema
    opt: *go_opt
  - plugin: grpc-gateway
    out: gen/xxx-schema
    opt: *go_opt

基本的には、前者の managed を使って設定しておくのが見通しが良さそう。
ただ前者の場合、生成される go の package は、protobuf の package 構成を踏襲する形になるので、たとえば「スキーマの管理は別のチームが行なっているため、自チームの生成ファイルの都合で構造変更を通すのがキツイ」みたいな場合に後者は使える。

後者の場合 M フラグが多くなるので、protobuf ファイルが追加されるたびに opt を追加していく必要がある。
例のように protoc-gen-go, protoc-gen-go-grpc など複数の plugin を使う場合には、yaml のアンカーを使うことで苦痛を軽減できる。

※ 上記の設定ファイルは、以下のようなプロジェクト構成を想定している。

.
├── xxx-schema
│   ├── backend/v1/foo.proto
│   ├── buf.gen.yaml
│   ├── buf.lock
│   └── buf.yaml
├── buf.gen.yaml
├── gen
│   └── xxx/backend/v1
├── go.mod
└── go.sum

生成は、実装側のプロジェクトルートで buf generate xxx-schema として実行すればよい。

サーバー実装 🔗

gRPC-gateway は、gRPC サーバーに対するリバースプロキシとして REST API サーバーを実装するのが基本ではあるが、gRPC のインターフェースが不要な場合は、 grpc-gateway/runtime を使って直接 REST API サーバーを実装してしまっている。

書き心地としては gRPC サーバーを実装している時と大きくは変わらない。
ロガーや認証処理などは、interceptor がそのままでは使えないので、一般的な middleware pattern で実装する必要がある。

package main

import (
	"context"
	"log"
	"net/http"

	"github.com/grpc-ecosystem/grpc-gateway/v2/runtime"
  "github.com/org/xxx/gen/protobuf/backend/echo/v1"
)

func main() {
	ctx := context.Background()
	mux := runtime.NewServeMux(runtime.WithErrorHandler(middleware.NewGrpcGatewayErrorHandler()))
	echo.RegisterEchoServiceHandlerServer(ctx, mux, initializeEchoServer())
	// TODO: graceful shutdown
	if err := http.ListenAndServe(":8080", mux); err != nil {
		log.Fatal(err)
	}
}

これで protoc-gen-go-grpc で生成する gRPC サーバーの実装を、そのまま (google.api.http) アノテーションをつけたパスのルーティングとしてプロキシなしで立ち上げることができる。

DependencyInjection は wire を使うことが多いが、DI の管理が辛くなってからで十分間に合うので、初手は入れない方がよい所感を持っている。