grpc-gatewayの開発に学ぶ、ソフトウェアの設計手法~Yuguiが定めた、2つの基本設計方針
良いソフトウェアとはどのような方針のもとに設計されているのでしょうか。広く使われているOSSであるgrpc-gatewayの開発過程を作者のYuguiさんが振り返り、その設計手法を解説してもらいました。
こんにちは。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に変換して提供するためのミドルウェアです。 より正確には、このツールはコード生成器として機能し、ある種のリバースプロキシサーバーを生成します。下の図をご覧ください。
このリバースプロキシが、外部から来た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との間にある程度の互換性ができたため、ユーザーは比較的少ないコストで両者の間を行き来できるという強みが生まれました。
実装上の試行錯誤
最後のエピソードとして、コーディングのレベルにおける設計上の試行錯誤を簡単にお話しします。 この詳細度の設計はとりわけ発見的かつ段階的に行われ、トップダウンな設計があったわけではありません。
先のgrpc-gateway用のオプションが付いたサービス定義を再掲しましょう。
echo_service.proto:
service EchoService { rpc Echo(EchoProto) returns (EchoProto) { option (google.api.http) = { post: "/v1/example/echo" body: "*" }; } }
このような.proto
ファイルの記述を最終的にGoで書かれたHTTPハンドラに変換するのが目標ですが、入力データから具体的にどのような情報を抽出し、どのような処理を書くべきなのか必ずしも明らかではありません。頭の良い人であればすぐにそのような生成器を書き下せるのかもしれませんが、少なくとも私はそうではありませんでした。
そこで、まずは期待される変換後のHTTPハンドラを手書きし、最終的にユーザーが期待するリバースプロキシサーバーを実際に書いてみました。すると、次のようなことがすぐに判明します。
- まず、比較的大きな再利用可能コードの塊が、複数のハンドラに繰り返し現れます。そこで、この繰り返しコードを切り出してランタイムライブラリとすることに決めました。
- 次に、コード生成を実際に行うテンプレートファイル内で必要になるある種のビューモデルと、入力データの構文木との間にかなり乖離(かいり)があることが分かりました。そこで一度中間表現に変換してからコードを生成することにしました。 この抽象レイヤーをはさんだことで、その後の仕様拡張が随分楽に行えるようになりました。
- ストリーミングRPCをHTTP上でどのように表現するべきかは少し考えなければならないことが分かりました。そこで、以前読んだことがあったetcdと同様にJSON streamingを用いることにしました。 そしてこれらの学びを元にして、手書きしたコードと同じものを生成することを目標として生成器を書いたのです。
こうした設計はその後の開発を進めていく上でも有益に働きましたが、どれも実際のゴールである動作するリバースプロキシを書いてみないことには分からなかったものです。
まとめ
ここまで、最初の2つの方針から始まって、派生する幾つかの方針決定や公開を巡る決定を紹介してきました。期間としてはgrpc-gateway開発の最初の1カ月にあたります。 では、ここからある程度一般性のある教えを抽出するとすればどのようなものがあるでしょうか。
下記にいくつか挙げてみましたので、参考になれば幸いです。
技術的負債を恐れない
grpc-gatewayを公開した段階の3つの妥協のうち、最初の2つ「マッピング定義仕様の検討が不十分」「gojiへの依存」はある種の技術的負債です。これらは方針1を満たすために意識的に行なった設計上の妥協ですが、そのまま時間が経過すれば製品の適用可能範囲を狭めたりメンテナンスを困難にしたりしたかもしれません。
このため当初から、grpc-gatewayのコンセプトがユーザーに受け入れられるものであることを確認したらすぐにこれらの問題は解消する予定でした。具体的にはリクエストルーティングは自前の実装に置き換え、またマッピング定義スキーマはユーザーからのフィードバックに基づいて再設計する予定でした。
結果としてはGoogleからCloud Endpointsの仕様を提供された時点で、すぐに技術的負債を返済できましたが、そうでなくとも予定通りに返済したことでしょう。少なくともリクエストルーティングについては。
意図せざる技術的負債は厄介の種ですが、返済計画を立てた上で負債を選択すれば初期のアイディア検証段階にかけるコストを抑えられます。
良いコードをたくさん読む
何も設計上の決定を自分一人で発明する必要はなく、ライブラリの力を借りるように過去の信頼できる設計の力を借りればよいでしょう。 例えば、一時的妥協を検討するに当たってはRailsを読んだ経験が役に立ちましたし、HTTPでのストリーミング表現を考える際にはetcdを読んだ経験が役に立ちました。
1つの問題に集中する
全ての問題を解決する一枚岩のアプリケーションを作ろうとするのではなく、特定の問題のみを解決しようとする方が往々にして良い結果をもたらします。 実際にgrpc-gatewayはただgRPCのスキーマファイルを元にHTTPハンドラを生成するだけです。通信時の認証もペイロードの圧縮も呼び出しのトレースも、それ自体では行いません。
集中し、より早くソフトウェアを動くところまで持っていくことで、それがユーザーに受け入れられるかどうか、より早く検証できます。また、コードをシンプルにできますのでメンテナンスが容易になります。さらに、シンプルな方がユーザーに組み合わせの自由を提供できますから、適用可能な範囲が広がります。
例えば、grpc-gatewayの適用例の中でも最も広く使われていると思われるetcdの場合を考えると、重量級の一枚岩サーバーが軽量なetcdのコンポーネントとして好まれたはずもなく、組み合わせ可能なコンポーネントを生成するアプローチは正解だったと言えるでしょう。
問題を分割する
実装にあたっては、出力されるべきハンドラを手書きしてから生成器に取り組みました。 言い換えれば、生成器を書くという大きな問題に対して一度期待される出力を手書き実装するという中間マイルストーンを設け、その成果物からのフィードバックを元に次のような小問題を発見しました。
- ランタイムライブラリの開発
- 入力された.protoファイルから中間表現を構築する
- 手書き実装と一致する出力のテンプレートを書く
問題を分割して段階的に取り組むのは、解決を簡単にする上でも誤った問題を解いてしまわないためにも、一般的に有益な方針です。そしてまた、ソフトウェア開発ではとにかく動くコードを書き、そこから学ぶというのもしばしば役に立つ考え方です。
UNIXという考え方
以上、全ての背景にはUNIX哲学の影響があります。『UNIXという考え方』4で挙げられているUNIXの設計思想、「一つのプログラムには一つのことをうまくやらせる」「できるだけ早く試作する」「部分の総和は全体よりも大きい」「90%の解を目指す」といった要素はgrpc-gatewayにも当てはまりそうです。
まず「1つの問題に集中すること」は「一つのことをうまくやらせる」に通じます。その結果として「できるだけ早く試作」が実現し、ユーザーの声から学べましたし、Cloud Endpoints用のマッピング定義を提供もしてもらえました。 実装過程でも、最終的には捨てることになるHTTPハンドラ実装を手書きしたことが正しい設計を見出す助けになりました。「技術的負債を恐れない」のは「できるだけ早く試作」するためでもありました。
また、標準インターフェースのHTTPハンドラを生成する上に依存ライブラリを可能な限り絞っていますから、ユーザーは他のライブラリを豊富な選択肢から選択して自分の用途に合うようにハンドラを修飾できます。Cloud Endpointsとの互換性もユーザーが自由にソフトウェアを組み替えることを支援します(「部分の総和は全体よりも大きい」)。
それから、そもそも最初にgrpc-gatewayの想定用途を「リバースプロキシの実装を"手助けする"こと」だと定めています。全てのユースケースを完璧にサポートすることは考えておらず、うまく当てはまらない場合はユーザーが別のHTTPハンドラを手書きして組み合わせるべきだと考えています。このため、実装が無用に複雑化することもありませんでした。(「90%の解を目指す」)
こうしたUNIX的なミニマリズムは、UNIXそれ自体を筆頭に多くの成功例に見られるものです。皆さんのソフトウェア開発が、同じように素晴らしい成功を収めることを祈っています。
園田裕貴(そのだ・ゆうき)@yugui