MoTLab -GO Inc. Engineering Blog-MoTLab -GO Inc. Engineering Blog-

Protocol Buffersの一元管理方法

gRPCSREServerSide
November 30, 2021

こんにちは、技術戦略部 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 ファイルから自分の言語のコードを生成します。

An image from Notion

メリット

  • サーバとクライアントは自分の言語だけのことを考えれば良い

デメリット

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

(B) PBの保存とコード生成をサーバリポジトリで行う

このアプローチでは .proto ファイルの管理とコード生成を全てサーバリポジトリで行います。サーバは全てのクライアントの言語でコードを生成します。

An image from Notion

メリット

  • クライアントリポジトリ側で生成する仕組みの必要がない
  • PBから生成されたコードはバージョン管理可能

デメリット

  • .proto からの生成の仕組みを各サーバリポジトリで設ける必要がある
    • 開発者の環境に依存しない仕組み
    • PBの依存関係解決できる仕組み
    • PBコード生成を拡張できる仕組み
  • サービスを跨ぐ、会社全体のPBのポリシーを施行しづらい (linting、設計方針)
  • クライアント側でコードをビルドする時に、サーバのリポジトリを参照する必要がある
  • サーバは全てのクライアントの言語を把握しないといけない。新しい言語が現れた時に対応する必要がある

(C) 全てのサービスのPBを一元管理する

このアプローチでは、全てのサービスの .proto ファイルを同じリポジトリでmonorepo形式で一元管理します。

An image from Notion

メリット

  • コード生成は一箇所だけで行うため、サーバとクライアントリポジトリ側で生成の仕組みを設ける必要がない。仕組みを一回だけ用意すればよくて、拡張したい時も一回だけ改修すれば良い
  • 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ファイルを保存しています。パッケージフォルダーの中に .protolangspackage.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-artifactsprotorepo-ruby-artifactsprotorepo-scala-artifactsなど)

An image from Notion

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の管理方法に悩んでいる方へ、この記事がご参考になれば幸いです!


We're Hiring!

📢
Mobility Technologies ではともに働くエンジニアを募集しています。

興味のある方は 採用ページ も見ていただけると嬉しいです。

Twitter @mot_techtalk のフォローもよろしくお願いします!