業務でgRPCを使う機会があるのですが、個人で軽く触ったことがある程度で正しく理解できているか不安だったので、改めてインプットし直しました。この記事はさくらインターネットさんが公開されている記事から特に重要だと感じた箇所を抽出して、自身の備忘録として短くまとめたものです。
実は前回も読んでいるはずなのですが、頭から抜けている箇所が多くありました...。
そもそもRPCって何
RPCとは通信プロトコルの1つで「遠隔手続き呼び出し」と訳せるようにクライント・サーバーモデルであり、どこかにあるサーバーに定義された関数をクライアントが呼び出すことで実現されます。
ここでいう通信プロトコルとは「HTTP
やHTTPS
のような通信に使用されるプロトコルよりも上位の概念」だと認識しておくと理解がしやすいと思います。私はプロトコル・通信プロトコルという言葉が同じように使用されていて、とても混乱しました。
意外にもRPCの歴史は古く1976年にはRFCが発表されていたそうです。
遠隔手続き呼出し (RPC) の考え方は、少なくともRFC 707が発表された1976年まで遡る。
最初にRPCを商用に実用化したのはゼロックスの「Courier」であり、1981年のことであった。
gRPCについて
本題のgRPCはRPC(Remote Procedure Call)を実現する方法の1つです。
元々はGoogleが自社向けに開発していたものを、改良・オープンソース化した技術であり、従来のXMLやJSONベースのRPCではプレーンのテキスト情報をやり取りするためにオーバーヘッドが大きくなるという欠点がありました。そこで、gRPCではバイナリデータをベースにしてRPCを実現することで、この問題を解消しています。
gPRCではデータのやり取り(表現・シリアライズ)にはProtocol Buffers
を使用します。
Protocol Buffers
もGoogleが開発したもので、特定の言語に依存しないプロトコル定義ファイルを.proto
拡張子を用いて定義します。JSONやYMLのような独自の文法をサポートしており、実際の通信時にはバイナリ形式のデータがやり取りされますが、プロトコル定義ファイルは人間でも簡単に読み書き可能なファイルです。
このプロトコル定義ファイルを専用のprotoc
というツールを使用して変換することで、各言語に必要なファイル(クラスや型)が出力されます。つまりプロトコル定義ファイルがgRPCの実装においてコアになるということです。
syntax = "proto3"; package user; message User { int32 id = 1; string name = 2; }
また、プロトコルにはHTTP/2
を使用します。
従来のXML・JSONベースのREST APIが使用することがあるHTTP/1
と比べて通信のパフォーマンスが良いです。HTTPの歴史が紹介されているページも合わせて読んでみたのですが、面白かったです。
プロトコル定義ファイルの書き方
ファイルトップにはsyntax
を記述して、使用するバージョンをします。
またプロトコル定義ファイルはパッケージング(階層化もOK)がサポートされており、名前衝突を防ぐことができます。
他の.proto
ファイルに定義されたメッセージなどをインポートすることができるので、積極的にファイルを分割していきたいところです。
syntax = "proto3"; package article; import "picture.proto";
プロトコル定義ファイルではデータはメッセージ(message
)という名前で定義されます。
TypeScriptでいうtype
に該当するもので、データの型をmessage 型名
を記述することでメッセージを定義します。
メッセージは複数のフィールドを持つことが可能であり型 フィールド名 フィールド番号
の順に記述します。他のメッセージをフィールドに定義することもできます。
/* メッセージ名はキャメルケース */ message Article { /* 型 フィールド名 = フィールド番号; */ uint32 id = 1; string title = 2; string body = 3; /* フィールド名はスネークケース */ } message PurchasedArticle { Article article = 1; string purchased_at = 2; }
フィールド番号はフィールドを特定するための識別子の役割を持つため、重複はできません。
また過去に定義したフィールド番号へ新たなフィールドを割り当てるようなことをすると、プロトコル定義ファイルの整合性がとれなくなるため、注意が必要です。互換性についてはトピックが長くなってしまうため、割愛します。
またフィールド番号は、15までは1バイト(0から15を表現可能)にエンコードされます。
16番以降は2バイト以上になるため、頻繁に使用するフィールドは15番までに定義しておくのが良いそうです。
You should use the field numbers 1 through 15 for the most-frequently-set fields.
Lower field number values take less space in the wire format.
For example, field numbers in the range 1 through 15 take one byte to encode.
Field numbers in the range 16 through 2047 take two bytes.
Language Guide (proto 3) | Protocol Buffers Documentation
enumと配列・map
メッセージ以外にもenum
を定義することができます。
フィールドは0始まりにする必要があり、0の数値を割り当てているフィールドがデフォルト値になります。
enum Status { Draft = 0; Private = 1; Public = 2; } message Article { uint32 id = 1; Status status = 2; }
他にも配列とmap(連想配列)がサポートされています。
Protocol Buffers
では直和型という表現はしないようですが、oneof
を使うことでAかBのフィールドのどちらかが存在することを定義できます。
/* 配列 */ message Tag { uint32 id = 1; string tag_name = 2; } message Article { uint32 id = 1; repeated Tag tags = 4; } /* 連想配列(map) */ message ArticleResponse { bool error = 1; oneof result { Article article = 2; string error_message = 3; } } /* oneof */ message UserResult { oneof result { User user = 1; string error = 2; } }
サービスの定義
ここではメッセージ(データ型)についての定義でした。
RPCではサーバーにどのようなサービス(プロシージャ群)が存在しているのか、どのようなリクエストを受けてレスポンスを返すのかを定義する必要があります。こちらもプロトコル定義ファイルに同じように定義することができます。サービスの定義にはservice
を記述してプロシージャの定義にはrpc
を記述します。
service Article { /* rpc プロシージャ名 (リクエスト型) returns (レスポンス型 */ rpc GetArticle (ArticleRequest) returns (ArticleResponse); }
記事情報を取得するRPCの一連の定義は以下のようになります。
一例ではありますが、1つのサービス・プロシージャ定義が完了しました。
syntax = "proto3"; package article; message Tag { uint32 id = 1; string tag_name = 2; } message Article { uint32 id = 1; string title = 2; string body = 3; repeated Tag tags = 4; enum Status { Draft = 0; Private = 1; Public = 2; } Status status = 5; } message ArticleRequest { uint32 id = 1; } message ArticleResponse { bool error = 1; oneof result { Article article = 2; string error_message = 3; } }
ファイルの出力
プロトコル定義ファイルが完成したらprotoc
を使って、各ファイルを出力します。
言語によって実行するコマンドが異なるので、詳細は公式サイトのLanguagesを参照してください。
自分の場合はkotlin
を使っているのでgradle
コマンドを実行することで各ファイルが出力されました。
コマンドの内部処理までは理解できていませんが、基本的にはprotoc
が内部で実行されているのだと思います。
あとはサーバーサイドの処理を記述すれば完成です。
internal class HelloWorldService : GreeterGrpcKt.GreeterCoroutineImplBase() { override suspend fun sayHello(request: HelloRequest) = helloReply { message = "Hello ${request.name}" } }
まとめ
- RPCは通信プロトコルの1つで、リモートに定義されたプロシージャを呼び出すための技術
- gRPCはRPCを実現する技術の1つでGoogleが社内向けに開発したものが改良・オープンソース化されたもの
- gRPCでは通信のオーバーヘッドを減らすために
Protocol Buffers
というバイナリ形式のデータをやり取りする Protocol Buffers
ではプロシージャ・データ型の情報をプロトコル定義ファイル(.proto
)に記述する- プロトコル定義ファイルにはメッセージ・サービスといった情報を定義する
protoc
というツールを使うことで、言語に対応したファイル(クラスや型)を出力する- 出力したファイルにサーバー側の処理などを実装する
今回は紹介しませんでしたが、gRPCはリクエスト・レスポンスといわれる1つのリクエストに対して1つのレスポンスを返す形式のみでなく、複数のリクエストに対して複数のレスポンスを返したり、リクエストをキャンセルさせたりといったことも可能です。
マイクロサービスでよく問題になるAへのリクエストは成功したけど、Bへのリクエストに失敗して、Cへのリクエストをキャンセルしたいようなケースに柔軟に対応することができそうです。
とはいえHTTP
のバージョン問題もあり、シンプルなサービスや外部公開向けのをAPIを作るようなケースであればREST APIを採用するのが適切だと思います。またやり取りされるデータ形式はバイナリになるため、直感的にデバッグをするのが難しくなるという欠点もあります。
少しでも「ええな〜」と思ったらはてなスター・はてなブックマーク・シェアを頂けると励みになります。