この記事は、API通信を 基礎知識ゼロから実務で通用するところまで 学ぶシリーズ(全4回)の 第2回 です。同じ「ToDo API」を REST・gRPC・Connect の3方式で作りながら、仕組み・実装・実務での運用を順に解説します。
| 回 | 内容 | 章 |
|---|---|---|
| 第1回 | Web通信の基礎とREST | 00〜03 |
| 第2回 | gRPCとProtocol Buffers | 04〜06 |
| 第3回 | Connect | 07〜09 |
| 第4回 | 技術選定とミドルへのステップアップ | 10〜11・用語集 |
はじめに
第1回では REST API を作り、最後に「サーバーとクライアントの型がずれる」「JSON が重い」「ストリーミングが苦手」といった REST の限界を確認しました。第2回では、その解決策として生まれた gRPC と、その土台である Protocol Buffers を扱います。
HTTP/2 の多重化とトレーラーの話(第1回の00章)を前提にしているので、忘れていたらそこだけ読み返してから進んでください。
動作環境
コードはすべて TypeScript(Node.js 20 以上)で書いています。ライブラリはそれぞれ次のメジャーバージョンを前提にしています。
- Express 5 / zod
- @grpc/grpc-js / @grpc/proto-loader
- @connectrpc/connect v2 / @bufbuild/protobuf v2 / Buf CLI
メジャーバージョンが変わると API が変わることがあるので、実装するときは公式ドキュメントも併せて確認してください。
04 gRPCとProtocol Buffers
gRPC は Google が社内で使っていた通信基盤をもとに公開した RPC フレームワークです。「別のサーバーにある関数を、まるで手元の関数のように呼べる」ことを目指しています。その土台にあるのが Protocol Buffers というスキーマ言語とデータ形式です。
この章のゴール
- RPCとRESTの発想の違いを説明できる
- .proto ファイルを読み書きでき、フィールド番号の意味が分かる
- gRPCの通信が HTTP/2 の上で実際にどう流れているか説明できる
- 4種類の通信パターンを使い分けられる
RPCとは:リソースではなく「関数」で考える
REST は「モノ(リソース)に対して HTTP メソッドで操作する」考え方でした。RPC(Remote Procedure Call)は「遠くにある関数を呼ぶ」考え方です。
-
REST の発想
POST /todos
「todos というコレクションに新しい要素を作る」 -
RPC の発想
client.createTodo({ title })
「TodoService の CreateTodo 関数を呼ぶ」
RPC では URL 設計やメソッド選びに悩む必要がありません。関数名と、引数・戻り値の型を決めるだけです。この「引数・戻り値の型」を定義するのが Protocol Buffers です。
Protocol Buffers:型付きの契約書
Protocol Buffers(略して protobuf)は、データの形とサービスの関数を .proto ファイルに書く言語です。この1ファイルがサーバーとクライアントの唯一の契約書になり、ここから各言語のコードが自動生成されます。
syntax = "proto3"; // proto の文法バージョン
package todo.v1; // 名前空間。v1 はAPIのバージョン
// ---- データの形(message) ----
message Todo {
string id = 1; // 「= 1」は値ではなく「フィールド番号」
string title = 2;
bool done = 3;
}
// 各RPCは専用のリクエスト/レスポンス型を持つ(後から項目を足せるように)
message CreateTodoRequest { string title = 1; }
message CreateTodoResponse { Todo todo = 1; }
message GetTodoRequest { string id = 1; }
message GetTodoResponse { Todo todo = 1; }
message ListTodosRequest {
int32 page_size = 1;
string page_token = 2; // 03章のカーソルと同じ考え方
}
message ListTodosResponse {
repeated Todo todos = 1; // repeated = 配列
string next_page_token = 2;
}
message WatchTodosRequest {}
message WatchTodosResponse { Todo todo = 1; }
// ---- 関数の一覧(service) ----
service TodoService {
rpc CreateTodo(CreateTodoRequest) returns (CreateTodoResponse);
rpc GetTodo(GetTodoRequest) returns (GetTodoResponse) {
option idempotency_level = NO_SIDE_EFFECTS; // 副作用なし(=安全)と宣言
}
rpc ListTodos(ListTodosRequest) returns (ListTodosResponse) {
option idempotency_level = NO_SIDE_EFFECTS;
}
// stream = サーバーから何件も続けて送る(サーバーストリーミング)
rpc WatchTodos(WatchTodosRequest) returns (stream WatchTodosResponse);
}
このファイルは 05章(gRPC)と 08章(Connect)の両方でそのまま使い回します。同じ契約から違う方式のサーバーが作れる、というのがポイントです。
フィールド番号こそが本体
string title = 2; の 2 は初期値ではなくフィールド番号です。protobuf はデータを送るとき、"title" という名前は送らず、番号だけを送ります。受け取った側は .proto を見て「2番は title だな」と解釈します。
つまり、名前は変えても通信は壊れませんが、番号を変えると壊れます。これが protobuf の互換性ルールの根本です。
実際のバイト列を見てみよう
CreateTodoRequest{ title: "milk" } を protobuf でエンコードすると、たった 6バイトになります。同じ内容の JSON {"title":"milk"} は 16バイトです。
| タグ | 長さ=4 | m | i | l | k |
|---|---|---|---|---|---|
0A |
04 |
6D |
69 |
6C |
6B |
先頭の 0A は「フィールド番号」と「データの種類(ワイヤータイプ)」を1バイトに詰めたものです。計算式は (フィールド番号 << 3) | ワイヤータイプ。title は1番、文字列はワイヤータイプ2(長さ付きデータ)なので、(1 << 3) | 2 = 8 + 2 = 10 = 0x0A になります。
実験:自分でエンコードしてみる
次のスクリプトは、文字列を protobuf の「フィールド1番の string」としてエンコードし、gRPC で実際に送られるフレーム(先頭5バイト=圧縮フラグ+長さ)と JSON のサイズを並べて表示します。ライブラリを使わず、バイト列を手で組み立てているので仕組みがそのまま見えます。
// varint: 7ビットずつ区切り、続きがあるバイトは最上位ビットを1にする
const varint = (n: number): number[] => {
const out: number[] = [];
while (n > 127) { out.push((n & 127) | 128); n = Math.floor(n / 128); }
out.push(n);
return out;
};
const title = process.argv[2] ?? "milk";
const field = Number(process.argv[3] ?? 1);
const payload = [...new TextEncoder().encode(title)];
const tag = varint((field << 3) | 2); // ワイヤータイプ2 = 長さ付きデータ
const pb = [...tag, ...varint(payload.length), ...payload];
const frame = [0, (pb.length >>> 24) & 255, (pb.length >>> 16) & 255, (pb.length >>> 8) & 255, pb.length & 255];
const hex = (b: number[]) => b.map((x) => x.toString(16).toUpperCase().padStart(2, "0")).join(" ");
console.log("protobuf :", hex(pb), `(${pb.length} バイト)`);
console.log("gRPC枠付き:", hex([...frame, ...pb]));
console.log("JSON :", JSON.stringify({ title }), `(${new TextEncoder().encode(JSON.stringify({ title })).length} バイト)`);
npx tsx src/encode.ts milk # protobuf : 0A 04 6D 69 6C 6B (6 バイト)
npx tsx src/encode.ts 牛乳 # 日本語は1文字3バイト → 0A 06 E7 89 9B E4 B9 B3
npx tsx src/encode.ts milk 16 # フィールド番号16以上はタグが2バイトになる → 82 01 04 ...
フィールド番号は1〜15を優先して使う
タグは varint で表されるので、フィールド番号1〜15ならタグが1バイト、16以上だと2バイトになります。頻繁に使うフィールドほど若い番号を割り当てるのが定石です。
protobuf の型
| proto の型 | TypeScript での型 | メモ |
|---|---|---|
string |
string |
UTF-8 文字列 |
bool |
boolean |
|
int32 / uint32
|
number |
|
int64 / uint64
|
bigint(connect-es) |
精度落ちしないよう bigint や文字列で扱う |
double / float
|
number |
金額には使わない(整数の「円」や「銭」で持つ) |
repeated T |
T[] |
配列 |
map<K,V> |
オブジェクト / Map | |
enum |
enum | 0番は必ず XXX_UNSPECIFIED にする |
google.protobuf.Timestamp |
Timestamp(Date に変換可) | 日時は標準型を使う |
proto3 の落とし穴:「値なし」と「初期値」が区別できない
proto3 では bool done が false のとき、何も送りません(初期値は省略される)。受け取った側から見ると「false を送った」のか「送り忘れた」のか区別できません。区別が必要なフィールドには optional bool done = 3; と書きます。PATCH 的な部分更新 API を作るときに必ずぶつかる問題です。
互換性を守る3つのルール
-
使ったフィールド番号は二度と変えない・再利用しない
番号がデータの正体だからです。 -
削除したフィールドは reserved で封印する
reserved 3; reserved "done";と書いておけば、誰かがうっかり3番を再利用するとコンパイルエラーになります。 -
フィールドの追加は自由
古いクライアントは知らない番号を読み飛ばすので、追加は互換性を壊しません。
gRPCの通信のしくみ
gRPC は HTTP/2 の上で、次のようなリクエストを送っています。HTTP/2 はバイナリですが、分かりやすくテキストで表すとこうなります。
gRPC の1回の呼び出し(HTTP/2 をテキストで表現)
# ---- リクエスト ----
:method: POST ← gRPC は常に POST
:path: /todo.v1.TodoService/CreateTodo ← /パッケージ.サービス/メソッド
content-type: application/grpc+proto
te: trailers
grpc-timeout: 3S ← デッドライン(3秒以内に返して)
authorization: Bearer eyJhbGciOi... ← 「メタデータ」= HTTPヘッダー
[00][00 00 00 06][0A 04 6D 69 6C 6B] ← 5バイトの枠 + protobuf本体
# ---- レスポンス ----
:status: 200 ← 失敗しても基本は 200!
content-type: application/grpc+proto
[00][00 00 00 2E][0A 2C 0A 24 ...] ← protobuf本体
# ---- トレーラー(ボディの後に届くヘッダー) ----
grpc-status: 0 ← 本当の結果はここ(0 = OK)
grpc-message:
ここに gRPC の重要な特徴が詰まっています。
-
URL は機械的に決まる:
/todo.v1.TodoService/CreateTodo。設計の議論は不要です。 - 各メッセージの前に5バイトの枠(フレーム)が付く:1バイト目は圧縮しているか、残り4バイトがメッセージの長さ。長さが分かるので、1本のストリームに何個でもメッセージを連続で流せます。これがストリーミングの仕組みです。
-
結果はトレーラーの
grpc-statusで返る:HTTPステータスはほぼ常に 200 で、成功・失敗は最後に届くgrpc-statusで判定します。ストリーミングで途中までデータを送った後にエラーになっても伝えられるように、こういう設計になっています。
エンコード・デコード・HTTP/2 の処理は全部ライブラリと生成コードがやってくれます。開発者が書くのは「関数の中身」と「関数の呼び出し」だけです。
4種類の通信パターン
HTTP/2 のストリームと5バイト枠のおかげで、gRPC は4つの通信パターンを持っています。
| パターン | proto の書き方 | 向いている用途 |
|---|---|---|
| Unary(1対1) | rpc Get(Req) returns (Res); |
通常のAPI呼び出し。全体の9割以上はこれ。 |
| Server streaming | rpc Watch(Req) returns (stream Res); |
通知の配信、進捗表示、大きな結果の分割送信、LLMの逐次出力。 |
| Client streaming | rpc Upload(stream Req) returns (Res); |
大きなファイルやログの分割アップロード、集計。 |
| Bidirectional(双方向) | rpc Chat(stream Req) returns (stream Res); |
チャット、ゲーム、リアルタイム共同編集。 |
まずは Unary で考える
ストリーミングは強力ですが、ロードバランサーやタイムアウト、再接続の扱いが難しくなります。「本当に必要か」を検討してから使いましょう。
gRPCのステータスコード
HTTPのステータスの代わりに、gRPC は17種類の独自コードを持っています。よく使うものと、対応するHTTPステータスの目安です。
| コード | 番号 | 意味 | HTTP目安 | リトライ |
|---|---|---|---|---|
OK |
0 | 成功 | 200 | — |
INVALID_ARGUMENT |
3 | 引数が不正 | 400 | しない |
DEADLINE_EXCEEDED |
4 | 期限内に終わらなかった | 504 | 条件付き |
NOT_FOUND |
5 | 存在しない | 404 | しない |
ALREADY_EXISTS |
6 | 既に存在する | 409 | しない |
PERMISSION_DENIED |
7 | 権限なし | 403 | しない |
RESOURCE_EXHAUSTED |
8 | 呼びすぎ・容量不足 | 429 | 待ってから |
FAILED_PRECONDITION |
9 | 今の状態では実行できない | 400 / 422 | しない |
UNIMPLEMENTED |
12 | そのメソッドは無い | 501 | しない |
INTERNAL |
13 | サーバー内部のエラー | 500 | 基本しない |
UNAVAILABLE |
14 | 一時的に使えない | 503 | する |
UNAUTHENTICATED |
16 | 未認証 | 401 | しない |
理解度チェック
Q1. .proto でフィールド名 done を completed に変えた。通信は壊れる?
壊れません(バイナリ形式の場合)。protobuf は名前ではなく番号で送るため。ただし JSON 形式で通信している場合は名前が使われるので壊れます。Connect の JSON モードでは注意が必要です(07章)。
Q2. gRPC でエラーが起きたのに HTTP ステータスが 200 なのはなぜ?
gRPC は結果をボディの後に届くトレーラーの grpc-status で伝えるため。ストリーミングで途中までデータを送ってからエラーになるケースにも対応するための設計です。
Q3. 株価をリアルタイムに受け取り続けたい。どの通信パターン?
Server streaming。クライアントが1回リクエストし、サーバーが更新のたびにメッセージを流し続けます。
05 gRPCを実装する
Node.js の公式 gRPC 実装 @grpc/grpc-js を使い、04章の todo.proto からサーバーとクライアントを作ります。ここでは .proto を実行時に読み込む「動的ロード」方式を使い、仕組みが見えやすい形で書きます。
この章のゴール
- gRPC サーバーに Unary と Server streaming を実装できる
- デッドラインとメタデータ付きで呼び出せる
- gRPC のエラーを正しく返し、受け取れる
npm i @grpc/grpc-js @grpc/proto-loader
# プロジェクト構成
# ├── proto/todo/v1/todo.proto ← 04章のファイル
# └── src/grpc-server.ts, src/grpc-client.ts
サーバーのコード
import * as grpc from "@grpc/grpc-js";
import * as protoLoader from "@grpc/proto-loader";
import { randomUUID } from "node:crypto";
import { EventEmitter } from "node:events";
// ① .proto を読み込み、JavaScript から使える「サービス定義」に変換する
const packageDef = protoLoader.loadSync("proto/todo/v1/todo.proto", {
keepCase: false, // page_size → pageSize のように camelCase に変換
longs: String, // int64 を文字列で扱う(精度落ち対策)
defaults: true, // 省略された値を初期値で埋める
});
const proto = grpc.loadPackageDefinition(packageDef) as any; // 動的なので型は any
type Todo = { id: string; title: string; done: boolean };
const todos = new Map<string, Todo>();
const bus = new EventEmitter(); // 作成イベントをストリームに流すため
// ② .proto の service に書いた関数を1つずつ実装する
const handlers = {
// Unary: (call, callback)。call.request が引数、callback(エラー, 戻り値) で返す
createTodo(call: any, callback: grpc.sendUnaryData<unknown>) {
const title = String(call.request.title ?? "").trim();
if (!title) {
callback({ code: grpc.status.INVALID_ARGUMENT, details: "title は必須です" });
return;
}
const todo: Todo = { id: randomUUID(), title, done: false };
todos.set(todo.id, todo);
bus.emit("created", todo);
callback(null, { todo });
},
getTodo(call: any, callback: grpc.sendUnaryData<unknown>) {
const todo = todos.get(call.request.id);
if (!todo) {
callback({ code: grpc.status.NOT_FOUND, details: `todo ${call.request.id} は存在しません` });
return;
}
// メタデータ(=HTTPヘッダー)の読み方
console.log("x-request-id:", call.metadata.get("x-request-id")[0]);
callback(null, { todo });
},
listTodos(call: any, callback: grpc.sendUnaryData<unknown>) {
const size = call.request.pageSize || 20;
callback(null, { todos: [...todos.values()].slice(0, size), nextPageToken: "" });
},
// Server streaming: call.write() で何度でも送り、call.end() で終わる
watchTodos(call: grpc.ServerWritableStream<any, any>) {
const onCreated = (todo: Todo) => call.write({ todo });
bus.on("created", onCreated);
// クライアントが切断したら必ず後片付けする(しないとメモリリーク)
call.on("cancelled", () => bus.off("created", onCreated));
},
};
// ③ サーバーにサービスを登録して起動
const server = new grpc.Server();
server.addService(proto.todo.v1.TodoService.service, handlers);
server.bindAsync("0.0.0.0:50051", grpc.ServerCredentials.createInsecure(), (err, port) => {
if (err) throw err;
console.log(`gRPC server: localhost:${port}`);
});
クライアントのコード
grpc-js のクライアントはコールバック形式なので、実務では Promise に包んで await で使うのが一般的です。
import * as grpc from "@grpc/grpc-js";
import * as protoLoader from "@grpc/proto-loader";
import { randomUUID } from "node:crypto";
const packageDef = protoLoader.loadSync("proto/todo/v1/todo.proto", { keepCase: false, longs: String, defaults: true });
const proto = grpc.loadPackageDefinition(packageDef) as any;
// 接続先を指定してクライアントを作る。接続(HTTP/2)は内部で使い回される
const client = new proto.todo.v1.TodoService("localhost:50051", grpc.credentials.createInsecure());
// コールバックを Promise に変換する小さなヘルパー
function call<Res>(method: string, req: object, metadata = new grpc.Metadata(), timeoutMs = 3000) {
return new Promise<Res>((resolve, reject) => {
const deadline = new Date(Date.now() + timeoutMs); // gRPC では「締め切り時刻」で指定
client[method](req, metadata, { deadline }, (err: grpc.ServiceError | null, res: Res) =>
err ? reject(err) : resolve(res),
);
});
}
// --- Server streaming を購読(別のクライアントが作成すると通知が届く) ---
const stream = client.watchTodos({});
stream.on("data", (msg: any) => console.log("[通知] 作成:", msg.todo.title));
stream.on("error", (e: grpc.ServiceError) => {
if (e.code !== grpc.status.CANCELLED) console.error(e);
});
// --- Unary 呼び出し ---
const md = new grpc.Metadata();
md.set("x-request-id", randomUUID());
const { todo } = await call<any>("createTodo", { title: "牛乳を買う" }, md);
console.log("作成:", todo);
try {
await call("getTodo", { id: "nope" });
} catch (e) {
const err = e as grpc.ServiceError;
// 文字列ではなく「コード」で分岐するのが gRPC 流
if (err.code === grpc.status.NOT_FOUND) console.log("見つからない:", err.details);
else throw e;
}
setTimeout(() => { stream.cancel(); client.close(); }, 1000);
npx tsx src/grpc-server.ts # ターミナル1
npx tsx src/grpc-client.ts # ターミナル2
# curl の代わりに grpcurl を使うと手で叩ける(別途インストール)
grpcurl -plaintext -import-path proto -proto todo/v1/todo.proto \
-d '{"title":"パンを買う"}' localhost:50051 todo.v1.TodoService/CreateTodo
デッドライン:「いつまでに返して」を伝える
上のコードでは deadline を必ず付けています。gRPC にはデフォルトのタイムアウトがありません。付け忘れると、サーバーが固まったときにクライアントも永遠に待ち続け、リクエストが溜まってシステム全体が止まります。
デッドラインは grpc-timeout ヘッダーでサーバーにも伝わるので、サーバーは「どうせ間に合わない処理」を早めに打ち切れます。さらに、サーバーが別のサービスを呼ぶときに残り時間を引き継ぐデッドライン伝播ができると、ミドル以上の設計になります(06章)。
この実装の弱点:型が any
動的ロードは仕組みを学ぶには分かりやすいのですが、as any だらけで、REST のときと同じく型の恩恵を受けられていません。call.request.titel と typo しても気づけません。
実務の TypeScript では、.proto から型付きのコードを生成して使います。選択肢はいくつかあります。
-
@grpc/proto-loader付属のproto-loader-gen-typesで型定義だけ生成する -
ts-protoで grpc-js 用のコードを生成する - Connect(connect-es)を使う:型付きのコード生成に加え、ブラウザ対応や curl で叩ける手軽さも手に入る。TypeScript では現在これが最有力です(07〜09章)。
理解度チェック
Q1. Server streaming の実装で call.on("cancelled") を書かないとどうなる?
クライアントが切断してもイベントリスナーが残り続け、書き込み先のないストリームへの参照が溜まってメモリリークになります。ストリーミングでは「終了時の後片付け」が必須です。
Q2. デッドラインを指定しないと何が起きうる?
サーバーが応答しないとき、クライアントは無期限に待ち続けます。リクエストが溜まり、接続やメモリを使い果たして連鎖的に障害が広がります。
Q3. エラーを err.message の文字列で判定するのはなぜ良くない?
メッセージは人間向けで、文言変更や多言語化で簡単に変わります。プログラムの分岐は err.code(NOT_FOUND など)で行うべきです。
06 実務のgRPC運用
gRPC は「動かす」より「運用する」ほうが難しい技術です。本番導入時にハマりやすいポイントを押さえます。
この章のゴール
- gRPC がブラウザから直接使えない理由を説明できる
- ロードバランシングの落とし穴を知っている
- インターセプター・ヘルスチェック・リトライの役割が分かる
ブラウザ問題:gRPC はブラウザから直接呼べない
ブラウザの fetch API は HTTP/2 のフレームを細かく制御できず、トレーラーも読めません。gRPC は結果をトレーラー(grpc-status)で返すので、ブラウザでは成功か失敗かすら判定できないのです。
そこで生まれたのが gRPC-Web という派生プロトコルで、トレーラーをボディの末尾に埋め込みます。ただしサーバーは通常の gRPC しか話せないことが多いため、間に Envoy などの変換プロキシを置く必要があります。
インフラ部品が1つ増え、設定・監視・障害点も増えます。これが「gRPC をフロントエンドまで使う」ときの大きな負担でした。
ロードバランシングの落とし穴
gRPC は HTTP/2 の接続を張りっぱなしにして使い回すのが基本です。これは効率が良い反面、次の問題を起こします。
よくある障害
Kubernetes の通常の Service(L4 ロードバランサー)は「接続」単位で振り分けます。gRPC クライアントは接続を1本しか張らないので、サーバーを10台に増やしても、全リクエストが最初の1台に集中することがあります。
対策は2つです。
- L7(リクエスト単位)で振り分けるロードバランサーを使う:Envoy、Istio などのサービスメッシュ、HTTP/2 対応のクラウドLB。
-
クライアント側で振り分ける:DNS で全サーバーのアドレスを取得し、クライアントが
round_robinで分散する(Kubernetes では headless Service と組み合わせる)。
インターセプター:共通処理を1か所に
ログ出力・認証・計測など、全ての RPC に共通する処理は、各ハンドラーに書かずにインターセプター(Express のミドルウェアに相当)にまとめます。grpc-js にも仕組みはありますが、Connect のほうが書きやすいので、コード例は 08章で扱います。
リトライとバックオフ
04章の表で「リトライする」のは基本的に UNAVAILABLE だけです。リトライするときは次のルールを守ります。
- 指数バックオフ+ジッター:0.1秒、0.2秒、0.4秒…と待ち時間を倍にし、さらにランダムな揺らぎを加える。全クライアントが同時に再送して、復旧しかけたサーバーをまた倒す「サンダリングハード」を防ぎます。
- 回数の上限を必ず設ける(3回程度)。
- 冪等でない操作は、03章の冪等キーのような仕組みがない限りリトライしない。
- デッドラインの範囲内でリトライする。締め切りを過ぎたら諦める。
デッドライン伝播
呼び出し元が既に諦めた処理を下流で続けても無駄です。残り時間を引き継ぐことで、無駄な処理と連鎖的な遅延を防ぎます。
運用で使う標準機能
| 機能 | 内容 |
|---|---|
| ヘルスチェック |
grpc.health.v1.Health という標準サービスを実装すると、Kubernetes やロードバランサーが「このサーバーは生きているか」を確認できる。 |
| サーバーリフレクション | サーバーが自分の .proto 情報を公開する機能。grpcurl などで .proto ファイルなしに叩ける。本番では公開範囲に注意。 |
| TLS | 本番では createInsecure() ではなく TLS を使う。サービス間は mTLS(相互認証)にすることも多い。 |
| メッセージサイズ上限 | デフォルトで受信は 4MB まで。大きなデータはストリーミングで分割するか、上限を明示的に設定する。 |
ミドルへの視点:gRPC を選ぶ理由を言語化する
「速いから」だけでは導入理由として弱いです。実務で効いてくるのは、① .proto が唯一の契約になり、多言語(Go・Java・TypeScript…)のチームで型が共有される、② URL やメソッドの設計議論がなくなる、③ ストリーミングが標準で使える、の3点です。逆に、ブラウザ対応・LB・デバッグのしにくさというコストも説明できて初めて、技術選定の議論に参加できます。そしてこのコストの多くを解消するのが次の Connect です。
理解度チェック
Q1. gRPC サーバーを 3台から10台に増やしたのに、負荷が1台に偏っている。原因として考えられるのは?
L4 ロードバランサーが接続単位で振り分けており、クライアントが1本の HTTP/2 接続を使い回しているため。L7 のLBかクライアントサイド LB で解決します。
Q2. ブラウザから gRPC を直接呼べない一番の理由は?
ブラウザの fetch は HTTP/2 のトレーラーを読めないため、gRPC の結果(grpc-status)を受け取れないから。
Q3. INVALID_ARGUMENT をリトライしてはいけないのはなぜ?
入力が間違っているので、何度送っても同じ結果になるから。無駄な負荷を増やすだけです。
次回(第3回)は、gRPC と同じ .proto を使いながら、ブラウザや curl から普通の HTTP として叩ける Connect を扱います。