gRPC とは何か?
gRPC は Google がオープンソースで公開している RPC(Remote Procedure Call)フレームワークです。核心となるアイデアはシンプルで、別のサービスのメソッドを呼び出すとき、ローカルのメソッドを呼ぶのと同じ感覚で書けるようにする、というものです。ネットワーク通信やシリアライズ・デシリアライズといった処理は、すべてフレームワークが引き受けてくれます。
従来、サービス間でデータをやり取りする場合、だいたい次のような形になります。
// curlバージョン
curl -X POST localhost:8080/api/orders \
-H 'Content-Type: application/json' \
-d '{"userId":"u123","amount":3000}'
// javaコード
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create("https://httpbin.org/get"))
.GET()
.build();
HttpResponse<String> response = client.send(
request,
HttpResponse.BodyHandlers.ofString()
);
これは私たちにとって馴染み深いやり方で、リクエストを送る前に相手のドメイン、パス、HTTPメソッド、さらに仕様として必要なHTTPヘッダーやリクエストボディなどを、あらかじめ把握しておく必要があります。
しかし gRPC はそういうやり方をしません。
gRPC を使う前に、まずインターフェースを定義する .proto ファイルを書きます。これはリクエスト側とレスポンス側の間の仕様書のようなもので、Interface Definition Language(IDL)とも呼ばれます。内容は次のような感じです。
syntax = "proto3";
service WalletService {
rpc GetBalance (BalanceRequest) returns (BalanceResponse);
}
message BalanceRequest {
string user_id = 1;
}
message BalanceResponse {
int64 balance = 1;
string currency = 2;
}
Java バックエンドの感覚で理解するなら、こんな感じです。
syntax→ 使用する言語・仕様のバージョンを指定するservice→ 双方のやり取りの窓口。見た目はローカルのClassのようですが、実体は別のサーバー上にあります。通信フレームワークのためのinterfaceだと考えるとイメージしやすいです。rpc→ 一つの gRPC サービスの中に複数のメソッドを持てます。ちょうどinterfaceで定義するメソッドのようなものです。message→ いわゆる DTO / POJO で、リクエストとレスポンスのデータ形式を定義します。field = 1→ フィールド番号です。通信時に送られるのは名前ではなく、この番号だけです。
ここまで書けたら、protobuf-maven-plugin のようなツールを使ってコードを生成します(ここでは Java を例にしています)。pom.xml に追加して compile を実行すると、次のようなファイルが自動生成されます。
このうち WalletServiceGrpc.java は、サーバー側で使う ImplBase と、クライアント側で使う Stub を提供します。BalanceRequest と BalanceResponse は、リクエストとレスポンスの DTO です。
次に、サーバー側では WalletServiceGrpc.WalletServiceImplBase を継承した WalletServiceImpl を書きます。ビジネスロジックはこのサブクラスの中に書けばよく、通信の細かい部分はすべてフレームワークに任せて構いません。サンプルコードは以下の通りです。
public class WalletServiceImpl extends WalletServiceGrpc.WalletServiceImplBase {
@Override
public void getBalance(BalanceRequest request, StreamObserver<BalanceResponse> responseObserver) {
BalanceResponse response = BalanceResponse.newBuilder()
.setBalance(3000L)
.setCurrency("TWD")
.build();
responseObserver.onNext(response);
responseObserver.onCompleted();
}
}
サーバー側の実装ができたら、gRPC のリクエストを受け付ける専用のポートを立てる必要があります。サンプルコードは以下の通りです。
int port = 9090;
Server server = ServerBuilder.forPort(port)
.addService(new WalletServiceImpl())
.build();
server.start();
System.out.println("gRPC server started, listening on port " + port);
Runtime.getRuntime().addShutdownHook(new Thread(server::shutdown));
server.awaitTermination();
これでサーバー側の宣言は完成です。次はクライアント側を見ていきましょう。
クライアント側でやることはごくわずかです。コネクションを張って Stub を取得すれば、あとはローカルのメソッドを呼ぶのと同じように使えます。
ManagedChannel channel = ManagedChannelBuilder
.forAddress("localhost", 9090)
.usePlaintext() // ローカルテストなので暗号化しない
.build();
WalletServiceGrpc.WalletServiceBlockingStub stub = WalletServiceGrpc.newBlockingStub(channel);
BalanceResponse response = stub.getBalance(
BalanceRequest.newBuilder().setUserId("u123").build());
最初の HTTP 版と比べると、こちらにはパスも、HTTPメソッドも、ヘッダーもなく、自分で DTO を書く必要すらありません。私たちはただ getBalance() を呼び出しただけで、残りは全部フレームワークが処理してくれています。
まとめ
現状、gRPC が主に使われているのはバックエンドのマイクロサービス間の通信です。レイテンシに敏感でスループットが大きいシステムに向いていますし、言語をまたいで使えるのも利点です。たとえば Python チームと同じ .proto ファイルを共有すれば、両者の間での認識のズレを減らすことができます。
一方で向いていない点もあります。外部公開向きではなく、対外サービスの主流は依然として REST API です。また、ブラウザから直接呼び出すのにも向いていません。たとえば HTTP キャッシュが必要な場面では、gRPC は常に POST メソッドを使うため、ブラウザのキャッシュが効かず、扱いづらくなります。
もう一つ注意すべきなのがロードバランシングです。gRPC が使う HTTP/2 は長期間維持されるコネクションなので、従来の L4 ロードバランサーだと、そのコネクション上のリクエストは全部同じサーバーに送られ続けてしまいます。つまり、gRPC の通信では L4 LB が事実上機能しなくなるケースがあるということで、実務では特に注意が必要です。L7 LB(Envoy、Istio)やクライアントサイドロードバランシングを使う必要があります。
さらに、gRPC はデバッグの難易度も上げます。リクエストをバイナリ形式で送るため、そのバイナリ通信を確認するためのツール自体にも学習コストがかかります。
さて、これで gRPC について一通りの基礎知識は身についたと思います。次はいよいよ実際に検証していきます——可読性を犠牲にして性能を得るこの仕組みが、実際どれだけの効果をもたらすのかをテストしていきます。
今回の記事はここまでです。それでは、また巡り会える日まで。