こんにちは、技術戦略部 SREグループのカンタンです。
MoTが提供しているサービスを成長させるために様々なマイクロサービスを次から次と開発しています。マイクロサービスの増加に伴って全体のシステムが複雑になり、前回の記事で話た共通ログフォーマットも含めて様々な機能の共通化がとても大事になってきています。
API開発のハードルを下げるため、サービス間の通信をgRPCに統一しようとしていて、本記事ではMoTで採用しているProtocol Buffers (PB)の一元管理方法を紹介させていただきます。
gPRCとProtocol Buffers (PB)
マイクロサービス間のAPI通信をgRPCに統一しようとしている理由がいくつかあります:
- パフォーマンス:Protocol Buffersを使ってリクエストとレスポンスがバイナリ形式としてシリアル化されるためサイズが小さくなって効率的です。また、gRPCがHTTP2を利用しているため通信のオーバヘッドも減っています
- 厳密な仕様:gRPCの仕様が厳密に決まっているため、どの言語やプラットフォームを使っても一貫性があります
- コード生成:Protocol Buffersが定義されている
.protoファイルからGolang, Ruby, Pythonなど様々な言語のコードを自動生成できます。コード生成することで、サーバとクライアントでのコード重複が無くなったり、APIクライアントを実装する必要もなくなるため開発の効率が上がります。また、プラグインを簡単に作成できるため、生成されるコードは拡張しやすいです - ストリーミング:gRPCは双方向ストリーミングを対応しているため、Pub/Subの実装が楽になります
gRPCのデメリットもいくつかあります:
- 動作確認がしにくい:curlなどを使って簡単に動作確認できるRESTと違って、gRPCはバイナリ形式のため簡単に動作確認できないです。grpcurlなどを使えばcurlと似たような体験が得られますが、サーバ側でリフレクションを対応する必要があって、簡単にできない言語もあるため一手間かかります
- ブラウザーからのサポートがまだ浅い:gRPC Webを使えばブラウザーからgRPC通信できますがサポートがまだ限定的です。その理由で、MoTではgRPCをマイクロサービス間の通信にしか使っていなくて、クライアントからの通信はGraphQLやRESTにしています。gRPCサービスを外部会社のサーバなど、gRPCを対応しないクライアントからアクセスする必要がある場合はgrpc-gatewayを使ってgRPCサービスをREST APIとして提供しています
全体的にgRPCのメリットがデメリットを大きく上回っているため、MoTではマイクロサービス間の通信をgRPCに統一しようとしています。現在はGolang、Python、Ruby、ScalaなどのgRPCサービスが十数個稼働しています。
gRPCサーバの作り方
gRPCサーバを作るのに必要なものを説明していきたいと思います。説明のためGolangを使いますがgRPCを対応している言語であればどの言語でも同じような流れになります。
(1) .proto ファイルを作成します。例えば下記のように SayHello というメソッドを持っている HelloWorldService サービスを定義します。PBの書き方はこちらにご参考ください。
syntax = "proto3";
// Package
package mypackage;
option go_package = "github.com/MyOrg/myrepo";
// サービス
service HelloWorldService {
// SayHello API: 引数としてもらったユーザ名に挨拶する
rpc SayHello(SayHelloRequest) returns (SayHelloResponse);
}
// SayHello APIのリクエスト
message SayHelloRequest {
// ユーザ名
string name = 1;
}
// SayHello APIのレスポンス
message SayHelloResponse {
// 挨拶メッセージ
string message = 1;
}
(2) protocというツールを使って、 .proto ファイルからコードを自動生成します。Macの場合はbrewでインストールできます。
# protoc brew install protobuf # protoc-gen-go: Go protocol buffers plugin # protoc-gen-go-grpc: Go protocol buffers gRPC plugin go install google.golang.org/protobuf/cmd/protoc-gen-go@latest go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@latest # protocがprotoc-gen-goを見つけるため、GOPATHの設定が必要 export GOPATH=... # 必要に応じて、設定する export PATH=$GOPATH/bin:$PATH # フォルダー準備 mkdir output
# .protoファイルからGoコードを生成する
protoc \
-I./ \
--go_out=./output \
--go-grpc_out=./output \
--go-grpc_opt=require_unimplemented_servers=false \
./hello_world_service.proto
# 成果物確認
$ tree
.
├── hello_world_service.proto
└── output
└── github.com
└── MyOrg
└── myrepo
├── hello_world_service.pb.go
└── hello_world_service_grpc.pb.go
(3) 自動生成されたコードを使ってgRPCサーバを実装します。
cd prototest
# フォルダー構造
$ tree
├── go.mod
├── go.sum
├── main.go
└── pb # 生成されたコードをこのフォルダーに置く
├── hello_world_service.pb.go
└── hello_world_service_grpc.pb.go
package main import ( "context" "fmt" "net" "github.com/labstack/gommon/log" "google.golang.org/grpc" "google.golang.org/grpc/reflection" pb "github.com/MobilityTechnologies/prototest/pb" ) // -------------------- // HelloWorldServiceServerの実装 // -------------------- type HelloWorldServiceServer struct {} func (s *HelloWorldServiceServer) SayHello(ctx context.Context, request *pb.SayHelloRequest) (*pb.SayHelloResponse, error) { name := request.GetName() message := fmt.Sprintf("Hello %s!", name) return &pb.SayHelloResponse{ Message: message, }, nil } func newServer() *HelloWorldServiceServer { return &HelloWorldServiceServer{} } // -------------------- // gRPCサーバを起動する // -------------------- func main() { port := 50052 listen, err := net.Listen("tcp", fmt.Sprintf(":%v", port)) if err != nil { log.Fatalf("failed to listen: %v", err) } // Server server := grpc.NewServer() // Register HelloWorldService pb.RegisterHelloWorldServiceServer(server, newServer()) // Add server reflection reflection.Register(server) log.Infof("listening on port %v ...", port) if err := server.Serve(listen); err != nil { log.Fatalf("failed to serve: %v", err) } }
(4) サーバを起動して、grpcurlを使って動作確認します。
# サーバをビルドして起動する
$ go build && ./prototest
{"time":"2021-11-30T10:58:06.853145+09:00","level":"INFO","prefix":"-","file":"main.go","line":"57","message":"listening on port 50052 ..."}
$ grpcurl -plaintext localhost:50052 list 156ms Tue Nov 30 11:00:54 2021
grpc.reflection.v1alpha.ServerReflection
mypackage.HelloWorldService
$ grpcurl -plaintext -d '{"name": "John"}' localhost:50052 mypackage.HelloWorldService/SayHello Tue Nov 30 11:06:06 2021
{
"message": "Hello John!"
}
Protocol Buffersの一般的な管理方法
API仕様が定義されている.proto Protocol Buffersファイルを管理する時にいくつか気をつけないことがあります:
.protoファイルから生成されたコードを利用するのはサーバだけではなくてクライアントも- サーバは一つしかないものの、クライアントはいくつかありえるし、クライアントによって違う言語を使う可能性もある
- ある
.protoファイルが別の.protoファイルやパッケージに依存する可能性がある - 生成されたコードのバージョン管理を行った方がサーバ開発者とクライアント開発者の認識合わせとPBのアップデートがやりやすくなる
- PBのコード生成プラグインを使ってコードを拡張したい可能性がある
上記のことを頭の片隅に置いてPB管理のいくつかのアプローチを比較してみたいと思います。
(A) PBをサーバ側で管理して、サーバとクライアントで生成する
このアプローチでは .proto ファイルをサーバ側で定義して管理します。サーバとクライアントはそれぞれ .proto ファイルから自分の言語のコードを生成します。

メリット
- サーバとクライアントは自分の言語だけのことを考えれば良い
デメリット
- PBから生成されたコードはバージョン管理されていないため、クライアントとサーバが使っているコードの差分が把握しづらい(特にコミットレベルで差分を把握したい場合)
.protoからの生成の仕組みを各サーバとクライアントリポジトリで設ける必要がある- 開発者の環境に依存しない仕組み
- PBの依存関係解決できる仕組み
- PBコード生成を拡張できる仕組み
- サービスを跨ぐ、会社全体のPBのポリシーを施行しづらい (linting、設計方針)
- クライアント側でコードを生成する時に、サーバのリポジトリを参照する必要がある
(B) PBの保存とコード生成をサーバリポジトリで行う
このアプローチでは .proto ファイルの管理とコード生成を全てサーバリポジトリで行います。サーバは全てのクライアントの言語でコードを生成します。

メリット
- クライアントリポジトリ側で生成する仕組みの必要がない
- PBから生成されたコードはバージョン管理可能
デメリット
.protoからの生成の仕組みを各サーバリポジトリで設ける必要がある- 開発者の環境に依存しない仕組み
- PBの依存関係解決できる仕組み
- PBコード生成を拡張できる仕組み
- サービスを跨ぐ、会社全体のPBのポリシーを施行しづらい (linting、設計方針)
- クライアント側でコードをビルドする時に、サーバのリポジトリを参照する必要がある
- サーバは全てのクライアントの言語を把握しないといけない。新しい言語が現れた時に対応する必要がある
(C) 全てのサービスのPBを一元管理する
このアプローチでは、全てのサービスの .proto ファイルを同じリポジトリでmonorepo形式で一元管理します。

メリット
- コード生成は一箇所だけで行うため、サーバとクライアントリポジトリ側で生成の仕組みを設ける必要がない。仕組みを一回だけ用意すればよくて、拡張したい時も一回だけ改修すれば良い
- PBから生成されたコードはバージョン管理可能
- サービスを跨ぐ、会社全体のPBのポリシーを施行しやすい (linting、設計方針)
- クライアントがサーバリポジトリを参照する必要がない
- サーバとクライアントは自分の言語のことだけを考えれば良い
- API仕様が一箇所に集約されるため、他のサービスの仕様を確認しやすいし参考にしやすい
- レビューや他のチームとのコラボレーションがやりやすい
デメリット
- 複数のサービスや言語のことを考える必要があるため、仕組みを改修したい時に様々なパターンを考慮する必要がある。例えば、
- grpc-gatewayを導入したい時、様々な言語で検証する必要がある
- lintingルールを変更する時に、影響範囲が広い
- 生成の仕組みに問題が出ると、様々なサービスの開発に影響する
MoTのProtocol Buffers管理方法
MoTでは上記の(C)アプローチを採用してPBをmonorepoで一元管理しています。仕組みを作った時に一番参考になった「How We Build gRPC Services At Namely」という優秀な記事はおすすめです。
リポジトリ構造
protorepo というGitHubリポジトリで全てのサービスの .proto ファイルを管理しています。リポジトリ構造は以下のようになっています。
. ├── .ci --> CI用の設定 │ ├── run.sh ├── CODEOWNERS ├── Makefile ├── README.md ├── docs --> 自動生成されたDocumentationの保存フォルダー │ ├── pb_md │ │ ├── business.md │ │ ├── core.md │ │ ├── delivery.md │ │ └── payment.md │ ├── pb_html │ │ ├── business.html │ │ ├── core.html │ │ ├── delivery.html │ │ └── payment.html │ └── swagger │ ├── business.html │ ├── core.html │ ├── delivery.html │ └── payment.html ├── motpb --> .protoファイルの保存フォルダー │ ├── business │ │ ├── .protolangs │ │ ├── package.json │ │ ├── file1.proto │ │ └── file2.proto │ ├── core │ │ ├── .protolangs │ │ ├── package.json │ │ ├── coordinates.proto │ │ ├── money.proto │ │ └── package.json │ ├── delivery │ │ ├── .protolangs │ │ ├── package.json │ │ ├── file1.proto │ │ ├── file2.proto │ │ └── file3.proto │ └── payment │ ├── .protolangs │ ├── package.json │ ├── file1.proto │ ├── file2.proto │ └── file3.proto └── prototool.yaml --> lintingルール
motpb フォルダーの下にパッケージごとにフォルダーを分けて .protoファイルを保存しています。パッケージフォルダーの中に .protolangs と package.json という2つの特別ファイルを保存しています:
.protolangsにコード生成対象の言語のリストを記入しています。例:
ruby go scala
package.jsonにパッケージのバージョンと依存関係のバージョンを管理しています。例:
{ "name": "delivery", "version": "2.0.0", "description": "Delivery Protocol Buffers", "dependencies": { "core": "^1.3.0" } }
docs フォルダーにPBから生成された様々な形式のDocumentationを保存しています (Markdown, HTML, Swagger/Open API)。
コード生成の仕組み
PBからのコード生成はCIで行うようにしています。そうすることで、開発者が何もインストールせずにGitHubにプルリクエストを出して、レビューしてもらって、マージすることだけで必要な成果物が生成されて利用可能になります。
CI処理の流れは以下のようになります。
- PRを作成する
- [CI PR ビルド]
- prototoolを使ってlintを確認する
- protocを使って変更されたパッケージのビルドができることを確認する
- PRをマージする
- [CI マージビルド] 変更されたパッケージごと、
- ドキュメンテーションを生成してコミットする
- パッケージの
package.jsonのバージョンを自動的にバンプしてコミットする package@a.b.cタグを作る:packageは変更されたパッケージで、a.b.cはバンプ後のバージョンprotorepoのoriginにプッシュする
- [CI タグビルド]
package@a.b.cタグ作成をトリガーにビルドが走る- 対象パッケージの
.protolangsファイルを読み込んで生成対象言語を抽出する - protocを使って言語ごとにコードを生成する
- 言語によって
go.mod,gemspecなど、パッケージをライブラリとして提供するための追加ファイルを生成する - 成果物を言語ごとに別のリポジトリにプッシュしてタグつける (
protorepo-go-artifacts、protorepo-ruby-artifacts、protorepo-scala-artifactsなど)
- 対象パッケージの

PBから生成されたコードは言語ごとに別のリポジトリに保存されていてます。生成時にタグを付けることで生成されたコードのバージョン管理も行っています。生成されたコードをライブラリとして取得できるため、サーバとクライアント側での利用も楽です。
補足
- grpc-gatewayなどプラグインを対応するために
protocを直接使わず、 必要なものを持っているprotocのラッパーをDockerイメージとして作って生成に使っている package.jsonに記載されていた依存関係がgo.modなどに入っているため依存関係の解決も自動的に行われている- grpc-gatewayを対応するサービスのREST APIドキュメンテーションをSwagger/Open API形式で生成している
- ドキュメンテーションをMoTのエンジニアがアクセスできる開発者ポータルにアップロードしていて、URLで直接ブラウザーで見えるようになっている
サンプル:Golangの成果物を管理する protorepo-go-artifactsリポジトリ
├── README.md
├── business
│ ├── file1.pb.go
│ ├── file2.pb.go
│ ├── go.mod
│ └── go.sum
├── core
│ ├── coordinates.pb.go
│ ├── go.mod
│ ├── go.sum
│ └── money.pb.go
├── delivery
│ ├── file1.pb.go
│ ├── file2.pb.go
│ ├── file3.pb.go
│ ├── go.mod
│ └── go.sum
└── payment
├── file1.pb.go
├── file2.pb.go
├── file3.pb.go
├── go.mod
└── go.sum
サンプル: coreに依存しているdeliveryパッケージの go.mod
module github.com/MobilityTechnologies/protorepo-go-artifacts/delivery
go 1.17
require (
github.com/MobilityTechnologies/protorepo-go-artifacts/core v1.3.0
google.golang.org/grpc v1.42.0
google.golang.org/protobuf v1.27.1
)
MoTのアプローチのメリットとデメリット
MoTがgRPCを採用し始めた時と合わせてこの仕組みを用意していて、2年以上の実績があります。いくつか調整が必要でしたが全体的に満足しています。
- 開発者がコード生成のことを考える必要がなくて、普段から慣れているGitHubでPRを出せば良い
- 全てのサービスのAPI仕様が一箇所に集約されていて、他のチームとのコミュニケーションコストが減っている
- 他のサービスのAPI仕様に参照しやすいため、全てのAPI仕様を統一しやすい(ページネーションのやり方、メッセージやフィールドの命名規則、基本的な設計など)。他のサービスと統一すれば悩むところも減って効率が上がるでしょう
- API仕様と実装を分けてレビューできる。物理的に別のリポジトリに対してPRを出す必要があるため、実装の細かいところを気にしないで仕様だけのレビューができて、設計ミスを防ぎやすくなっている
- 新しい言語を対応したい時、protorepoのCI設定を変更すれば良い
メリットの方が圧倒的に多いですが、デメリットもいくつかあります
- CI設定や仕組みに問題が出ると、様々なサービスの開発が止まる可能性があるため速やかな対応が必要
- 新しい言語やプラグインを追加したい時、全てのパッケージを考慮する必要があるため検証に時間がかかる
おわりに
MoTのProtocol Buffersの管理方法を紹介させていただきました。gRPCとProtocol Buffersを使うことで開発の効率が上がってビジネスロジックに集中できます。様々なサービスのAPI仕様を一元管理することで、悩む時間が減って設計ミスが防ぎやすくなっていてシステム全体の品質も上がっています。
gRPCの導入を検討されている方、またはPBの管理方法に悩んでいる方へ、この記事がご参考になれば幸いです!