grpc-gatewayの開発に学ぶ、ソフトウェアの設計手法~Yuguiが定めた、2つの基本設計方針
良いソフトウェアとはどのような方針のもとに設計されているのでしょうか。広く使われているOSSであるgrpc-gatewayの開発過程を作者のYuguiさんが振り返り、その設計手法を解説してもらいました。
こんにちは。
本記事では読者がより良いソフトウェア設計を行うための参考として、筆者が経験してきた設計上の決定をご紹介します。
筆者はこれまでRuby 1.9のリリースマネジメントを担当したり、Google Mapsの日本向け地理データ処理やgrpc-gatewayの開発などをしてきました。そしてこれらを通じて、広く長く使われて拡張されていくソフトウェアを設計するための方針決定に携わったり、方針に関わる良い議論を目にしたりする機会に恵まれてきました。中でも本記事では、grpc-gatewayを開発した際の比較的初期段階において行った設計を説明するつもりです。
さて、いきなり主題を否定するようにも聞こえるかもしれませんが、実のところgrpc-gatewayを書き始めた際にそれほどのグランドデザインがあったわけではありません。確かにごく少数の要点だけは最初に決めたものが今でも残っています。しかし、設計上の決定の多くはむしろ、コードを書きながら他の人からのフィードバックを受けながら発見的に行われてきました。
ただし、こうした動的な設計サイクルこそは実のところ最初期から狙ったものでした。幸運にもgrpc-gatewayではこの狙いがとても有効に働きました。
以下では、最初に簡単にgrpc-gatewayを紹介し、その後におおよその時系列に沿って設計過程を説明していきます。
grpc-gatewayとは
grpc-gatewayはgRPCで書かれたAPIを古典的なJSON over HTTPのAPIに変換して提供するためのミドルウェアです。 より正確には、このツールはコード生成器として機能し、ある種のリバースプロキシサーバーを生成します。下の図をご覧ください。

1つのProtocol Buffers定義ファイルからprotocがAPIサーバースタブを、grpc-gatewayがリバースプロキシを生成する。実行時にはJSON over HTTPのリクエストをリバースプロキシがそのリクエストパスやペイロードをもとにgRPCに変換して、APIサーバープロセスに転送する。画像はGengo Inc.より
このリバースプロキシが、外部から来たHTTPリクエストをgRPCメソッド呼び出しに変換してバックエンドとなるgRPCサーバーに転送します。そしてまた、呼び出し結果をHTTPレスポンスに変換してクライアントに転送します。
なお多少不正確な表現ではありますが1、以下ではこうした(主に)JSONをペイロードとしてHTTP(主にHTTP 1.1)のリクエストおよびレスポンスとしてAPI呼び出しを実現する方式を、簡潔にREST APIとも呼びます。
簡単な適用例を見てみましょう。次のようなgRPCサービスがあったとします。
echo_service.proto:
syntax = "proto3"; package example; message EchoProto { string value = 1; } service EchoService { rpc Echo(EchoProto) returns (EchoProto) {} }
ここで、次のようにEchoService.Echo
メソッドにオプションを追加して、gRPCとREST APIとの対応関係を定義します。
service EchoService { rpc Echo(EchoProto) returns (EchoProto) { option (google.api.http) = { post: "/v1/example/echo" body: "*" }; } }
するとgrpc-gatewayはこの定義を読んで、EchoProto
メッセージのJSON表現をHTTPリクエストボディから読み取ってバックエンドに転送するようなREST API「POST /v1/example/echo
」のハンドラを生成します。
以上がgrpc-gatewayの簡単な紹介でした。より詳しくはgRPC自体のドキュメントやgrpc-gatewayのドキュメントまたはCoreOS社による解説などを参照してください。
さて、後に説明する設計判断の話をよく理解できるように、ここでgrpc-gatewayの用途についても触れておきます。grpc-gatewayが主に想定する用途は次のようなものです。
- システム内部でgRPCによるAPIを提供しているマイクロサービスを材料として開発者が外部向けAPIを実装する際、それを手助けする
- システム内部のマイクロサービス間通信方式をRESTからgRPCに移行する際、元のREST APIとの互換レイヤーを提供する
- システム内のwebフロントエンド部分からgRPC APIを呼び出すのを仲立ちする
- 初めからREST APIの提供を意図していたが、Protocol BuffersとgRPCによるスキーマ記述とサーバーコード生成の恩恵を得ることを狙い、あえてgRPCを元にRESTに変換する
1と2は筆者がGengo社在職中にgrpc-gatewayを開発した直接的な動機でもあります。社内にgRPCを導入するにあたって、将来的に発生し得るであろう問題を先行して解決しておきたかったのです。しかしまた、当初の想定用途からは外れますが、3と4のような用途でもgrpc-gatewayが便利に使われることがあるようです。
4に関しては筆者による外部記事「今さらProtocol Buffersと、手に馴染む道具の話」なども参考になるでしょう。gRPCのスキーマ記述言語であるProtocol Buffersは、単にJSONのスキーマを記述する目的でも便利に利用できるので、そのときにもgrpc-gatewayが役に立ちます。
優先事項から、基本指針を決定する
では、具体的にgrpc-gateway開発における設計判断を説明していきましょう。
最初期の設計方針として考えたのは次の2点でした。
- 全ての問題を解決するのではなく、gRPCとRESTとの変換という問題に集中すること
- 実行時のパフォーマンスをある程度重視すること
社内で「必要なとき」「必要なところ」にgRPCを適用できるように備えることが目的であるため、最も差し障る問題を中心に素早く解決する必要があります。この点に方針1が従います。
また変換レイヤーをはさむ以上、パフォーマンス劣化が懸念されるのが当然であり、少なくともスループットに悪影響をもたらさないことも重要です。方針2はこの点に従います。
2つの基本方針に基づき、実装方式決定
2つの基本方針から、標準的なHTTPハンドラの実装を生成するコード生成器であることを決定しました。
他のあり得る実装としては、変換定義データを読み込んで動的に振る舞いを変えるようなサーバーを書くこともできました。実際にGoogle Cloud Endpointsにおける同種の機能はそのような設計になっています。 ただ、次のような理由からこの方式は選択しませんでした。
- 主問題に集中するためには、認証やその他の個別のニーズは利用者自身が既存のミドルウェアを組み合わせて解決できる方が一枚岩のサーバーを提供するよりも良い(方針1)
- Cloud Endpoints方式ではProtocol Buffersのリフレクション機能2が必要になる。ところが、これは特定のProtocol Buffersスキーマに特化したコードを生成するよりもパフォーマンスが劣化しがちである。(方針2)
また、この段階で生成するコードの言語をGoにすることも決定します。
- HTTPハンドラの仕様が標準ライブラリに含まれているため、この仕様に従えば他のどのHTTPミドルウェアライブラリとも互換性があると期待できる(方針1)
- goroutineなどの言語特性が効率の良い並行処理サーバーを書くのに適している(方針2)
- gRPCの公式サポート言語である
- 筆者がGoに慣れている
そして、なんとなく生成されるコードに合わせたいという気分および筆者が慣れているという理由により、コード生成器自体もGoで書くことに決定しました。
公開とフィードバック
基本方針とは別に、その後に大きな影響を与えた初期の決定としてはもう1点、「早期に公開する」と決めたことがあります。
実装方式を決定してから3日目に一通り動作するバージョンのgrpc-gatewayができたものの、これは現在の実装とはいくつかの点で大きく異なっていました。 私としては最初期の実装に満足していませんでしたが、この段階で一度外部に公開してフィードバックを得ることにしました。
これにはいくつか理由があります。第一に、grpc-gatewayのような変換レイヤーのニーズは社内に留まらず普遍的なものだと考えたものの、それは仮説にすぎないので早期の検証を必要としていました。仮説が外れていたら、あまり汎用化を目指さずに社内のニーズにだけ集中する方針に転換でき、無駄な汎用化の労力を避けられます。一方、仮説が正しく、ニーズが多かった場合、できるだけ早く公開すればその分だけみんなが幸せになります。そして、実際のユースケースを反映したり他の人から助言を受けたりして、grpc-gatewayをよりよいものにしていけるでしょう。 後で紹介するように、これらの狙いは想像以上にうまくいきました。
ちなみに、公開段階で私が不満に思っていた点は下記です。
- REST APIとのマッピング定義用オプションが現在のものとは異なり、私がとりあえず定義してみた程度の設計だった
- やってきたHTTPリクエストを見て適切な内部ハンドラを呼び出したりリクエストからパラメータを取り出す処理(リクエストルーティング)を他のフレームワーク(goji)に頼っていた
- HTTPヘッダをgRPCメタデータに変換するような付加的な機能を欠いていた
これらはみな、方針1に基づいて主たる問題に集中し、早期に動く実装を作り上げるための妥協でした。
まず、さまざまなユースケースを満たす良い設計のマッピング定義用オプションのスキーマやマッピング仕様を短期間の内に考えるのが困難であることは理解していました。そこで、見た人が「このツールは使えそうだ」と思う程度のユースケースを一通り扱えるスキーマだけを作りました。最終的なスキーマが初期とはだいぶ異なった設計となっていくのは想定内であり、社内に残った初期のコードを移行する必要が生じる可能性は覚悟していました。
次に、リクエストルーティングはそれなりに面倒な実装になるのは分かっていたため、とりあえず既存のフレームワークの力を借りました。方針2を考えれば依存ライブラリが増えれば増えるほどユーザーの自由なミドルウェア選択が制約される可能性があるので望ましくありません。しかしここでは早期に動くものを作ることを優先しました。
かつてRailsの初期バージョンから次第にHTTPリクエストのルーティング実装が複雑化していくのを見ていたことも、ここでの判断の助けになりました。Railsの例を考えれば、実用的で効率的な実装がややこしいのは見当が付くと同時に、その気になれば後から自分で書き直せることも分かります。
そして最後に、付加的機能は後に回す判断をしました。ユーザーに期待してもらえる程度には一通りの機能を実装したつもりでしたし、初期ユーザーが何か有意義な機能を要求したらすぐに提供する自信もあったためです。ここでは、プロトタイプであるとはいえ実装レベルの設計もコーディングもそれなりにクリーンに行ったことが早期に公開する判断を後押しもしました。
こうして開発7日目にgrpc-gatewayを公開し、grpc-ioメーリングリストにて紹介したのです。
すると、公開の翌日にはGoogle内部のチームから協力したいという連絡があり、程なくCloud Endpoints用の3マッピング仕様とマッピング定義スキーマを提供してくれました。 当時はまだCloud EndpointsのgRPCサポート自体が公開前でしたが、内部システム用に定義されていたマッピング仕様を急きょ整理して提供してくれたようです。
その後、提供されたマッピング仕様を採用し、ユーザーからのフィードバックも反映し、またそれらを実現するためにリクエストのルーティング実装も書き下ろして、新しいバージョンをリリースしました。
この段階で、1つ重要な設計指針が加わったのです。つまり、遠からずCloud EndpointsのgRPCサポートのようなものがGoogle Cloud Platformで提供されることが想像できたため、Googleから提供を受けたマッピング仕様からむやみに逸脱しないように決めました。
振り返ってみると、これらの出来事やCloud Endpointsのマッピング仕様を採用したことによっていくつもの良い結果が生まれています。
- 第一に、私が感じていた自作マッピング仕様への不満は、Googleが長年にわたって練ってきた仕様が提供されたことにより、速やかに解消されたのです。私が少し心配していた自社内コードを移行するコストについても、自社内でgrpc-gatewayの利用が広まり始める前に完成度の高い仕様がやってきたので、杞憂(きゆう)に終わりました。
- 第二に、Cloud Endpointsのマッピング仕様はgojiの挙動とは異なるため、ルーティング実装を入れ替えて依存ライブラリを減らす良いきっかけになりました。
- 第三に、この完成度の高い仕様への準拠はユーザーに信頼してもらう役に立ちました。
- 最後に、Cloud Endpointsとの間にある程度の互換性ができたため、ユーザーは比較的少ないコストで両者の間を行き来できるという強みが生まれました。
実装上の試行錯誤
続きをお読みいただけます(無料)。

- すべての過去記事を読める
- 過去のウェビナー動画を
視聴できる - 企業やエージェントから
スカウトが届く