Use Cases (更新: 2026/6/7)

gRPC入門:Protocol Buffersと4種のストリーミングを動かして理解する

gRPCを.protoの書き方からコード生成、単項・サーバー・クライアント・双方向ストリーミングまで、Nodeで動く最小例で解説。REST/GraphQLとの使い分けも。

gRPC入門:Protocol Buffersと4種のストリーミングを動かして理解する

「社内のサービス間、RESTでつないでるんですけど、なんか遅いし、フィールド名のtypoで本番が壊れたんですよね」

去年、知り合いのチームから相談されました。注文サービスが在庫サービスを叩く。在庫が請求を叩く。どれもJSONで会話していて、片方が userIduser_id に変えた瞬間、もう片方が静かに undefined を受け取る。テストはなぜか緑のまま。本番で初めて気づく。

僕も昔まったく同じ事故をやりました。だから言えるんですが、サービス同士の会話は、人間用のWeb APIとは別の道具を使ったほうがいい。その道具がgRPCです。

最初は「またgoogleの難しいやつでしょ」と身構えていました。でも、.proto という契約書を1枚先に書くだけ、と分かってから一気に楽になった。今日はその入口を、コピペで動くNodeのコードと一緒に解説します。

この記事の要点

  • gRPCは、離れたサービスの関数を「ローカル関数みたいに」呼ぶ仕組み。会話の形は .proto ファイルに先に書く。
  • Protocol Buffers(プロトコルバッファ)は、その契約書を表す言語であり、データをバイナリで小さく送る形式でもある。
  • RPCには4種類ある:単項、サーバーストリーミング、クライアントストリーミング、双方向。「1回ずつ」か「流し続ける」かで選ぶ。
  • 向くのはサービス間の内部通信。ブラウザに直接見せるAPIならRESTやGraphQLのほうが素直。
  • いちばん怖い事故はフィールド番号の再利用。消した番号は reserved で封印する。これだけは覚えて帰ってください。

gRPCって、結局なに?

ふだんREST APIを書くとき、頭の中はURL中心です。GET /tasks/123 を叩いてJSONが返る。エンドポイントとHTTPメソッドの組み合わせで世界を作ります。

gRPCは発想が逆で、関数中心です。GetTask(id) という関数を、別のサーバーにあるのに、自分の手元にあるかのように呼ぶ。RPC(Remote Procedure Call、遠隔手続き呼び出し)という名前そのままです。

その「呼べる関数の一覧」と「引数・戻り値の形」を、先に1枚の契約書にまとめる。それが .proto ファイルです。たとえばこう書きます。

syntax = "proto3";

package tasks.v1;

service TaskService {
  rpc GetTask(GetTaskRequest) returns (Task);
}

message Task {
  string id = 1;
  string title = 2;
}

message GetTaskRequest {
  string id = 1;
}

これを見ると、TaskService には GetTask という関数があって、GetTaskRequest(中身は id)を渡すと Taskidtitle)が返る、と一目で分かります。人間にも機械にも読める。ここがREST + JSONとの決定的な違いです。JSONは「実際にレスポンスを見るまで何が入っているか分からない」けれど、.proto は会話の前に形が確定しています。

message の中の = 1= 2 という数字は、フィールドの背番号です。名前ではなく、この番号でデータを識別します。あとで効いてくるので頭の隅に置いておいてください。

なぜバイナリで速いのか、ざっくりだけ

Protocol Buffersは、データを人間が読めないバイナリに詰めて送ります。{"title":"買い物"} のようにキー名(title)を毎回そのまま送るJSONと違って、gRPCは「背番号2番に文字列」とだけ送る。キー名の文字列を運ばないぶん、データが小さくなります。

しかもHTTP/2の上を走るので、1本の接続で複数のやり取りを同時に流せます。だから社内でサービスが何百回も通信するような場面で効いてくる。

逆に言うと、この「バイナリ」「契約書が必要」という性質は、ブラウザから手軽に叩きたい場面では足かせになります。そこは後半の使い分けで触れます。

RPCは4種類ある:「1回ずつ」か「流す」か

gRPCを理解する山場がここです。会話の形が4つあります。公式のCore conceptsの定義を、身近な例えに置き換えるとこうなります。

種類proto の書き方イメージ使う場面
単項(Unary)returns (Task)普通の関数呼び出し。1問1答ID指定で1件取得、1件作成
サーバーストリーミングreturns (stream Task)蛇口をひねると水が出続ける大量データのエクスポート、進捗通知
クライアントストリーミング(stream Chunk) returns (Result)荷物を小分けで送って最後に伝票ファイル分割アップロード、ログ送信
双方向(Bidirectional)(stream Msg) returns (stream Msg)電話。両方が好きに話すチャット、リアルタイム同期

公式の言葉だと、単項は「クライアントが1つのリクエストを送り、1つのレスポンスを受け取る、普通の関数呼び出しのよう」。サーバーストリーミングは「リクエストを送ると、連続したメッセージを読むストリームが返る」。双方向は「両側が読み書き両用のストリームでメッセージ列を送り合う」。

ここでひとつ正直な話を。僕は最初、面白がって双方向ストリーミングから手を出しました。チャット機能を作りたかったので。結果、メッセージの順序が前後する、片方が切断したときの後始末が抜ける、再接続のたびに状態がずれる、で1週間溶かしました。

教訓は単純で、迷ったら単項から始める。9割の用途は単項とサーバーストリーミングで足ります。クライアント・双方向は、順序とキャンセルと再接続という3つの宿題がついてくるので、本当に必要になってからで遅くない。

コピペで動く最小gRPCサンプル(Node)

説明より動かしたほうが早いです。protoc というコード生成ツールを使わず、Node.jsの @grpc/proto-loader.proto を起動時に読み込む構成にします。GoやJavaの本番では事前にコードを生成する方式(後述)が主流ですが、入門で契約を確かめる最初の一歩はこれが一番軽い。

まず作業フォルダを作って依存を入れます。

mkdir grpc-demo
cd grpc-demo
npm init -y
npm install @grpc/grpc-js @grpc/proto-loader
mkdir proto

package.json をこの形にします。

{
  "type": "commonjs",
  "scripts": {
    "server": "node server.js",
    "client": "node client.js"
  }
}

契約書 proto/task.proto を作ります。作成(単項)、1件取得(単項)、一覧(サーバーストリーミング)の3つを持たせます。

syntax = "proto3";

package tasks.v1;

service TaskService {
  rpc CreateTask(CreateTaskRequest) returns (Task);
  rpc GetTask(GetTaskRequest) returns (Task);
  rpc ListTasks(ListTasksRequest) returns (stream Task);
}

message Task {
  string id = 1;
  string title = 2;
  string status = 3;
  int64 created_at_unix = 4;
}

message CreateTaskRequest {
  string title = 1;
}

message GetTaskRequest {
  string id = 1;
}

message ListTasksRequest {
  int32 limit = 1;
}

次にサーバー server.js。ローカル検証なので暗号化なしの createInsecure() を使います。本番ではTLSに差し替えます(理由は後述)。認証メタデータのチェックも最小限だけ入れました。

const path = require("node:path");
const { randomUUID } = require("node:crypto");
const grpc = require("@grpc/grpc-js");
const protoLoader = require("@grpc/proto-loader");

// .proto を起動時に読み込む(コード生成なし)
const PROTO_PATH = path.join(__dirname, "proto", "task.proto");
const packageDefinition = protoLoader.loadSync(PROTO_PATH, {
  keepCase: false,
  longs: String,
  enums: String,
  defaults: true,
  oneofs: true,
});
const taskProto = grpc.loadPackageDefinition(packageDefinition).tasks.v1;
const token = process.env.DEMO_TOKEN || "dev-token";
const tasks = new Map();

// gRPCのエラーは「ステータスコード」を必ず添えるのが作法
function grpcError(code, message) {
  const error = new Error(message);
  error.code = code;
  return error;
}

// 門番:Bearerトークンが合わなければUNAUTHENTICATEDで弾く
function assertAuthenticated(call) {
  const value = call.metadata.get("authorization")[0];
  if (value !== `Bearer ${token}`) {
    throw grpcError(grpc.status.UNAUTHENTICATED, "認証に失敗しました");
  }
}

// 単項:1件作る
function createTask(call, callback) {
  try {
    assertAuthenticated(call);
    const title = String(call.request.title || "").trim();
    if (!title) {
      return callback(grpcError(grpc.status.INVALID_ARGUMENT, "titleは必須です"));
    }
    const task = {
      id: randomUUID(),
      title,
      status: "OPEN",
      createdAtUnix: String(Math.floor(Date.now() / 1000)),
    };
    tasks.set(task.id, task);
    callback(null, task);
  } catch (error) {
    callback(error);
  }
}

// 単項:1件取る。無ければNOT_FOUND
function getTask(call, callback) {
  try {
    assertAuthenticated(call);
    const task = tasks.get(call.request.id);
    if (!task) {
      return callback(grpcError(grpc.status.NOT_FOUND, "そのIDのタスクはありません"));
    }
    callback(null, task);
  } catch (error) {
    callback(error);
  }
}

// サーバーストリーミング:1件ずつ write して end で閉じる
function listTasks(call) {
  try {
    assertAuthenticated(call);
    const limit = Math.min(Math.max(Number(call.request.limit) || 10, 1), 100);
    for (const task of Array.from(tasks.values()).slice(0, limit)) {
      call.write(task);
    }
    call.end();
  } catch (error) {
    call.destroy(error);
  }
}

const server = new grpc.Server();
server.addService(taskProto.TaskService.service, { createTask, getTask, listTasks });
server.bindAsync("127.0.0.1:50051", grpc.ServerCredentials.createInsecure(), (error, port) => {
  if (error) throw error;
  console.log(`TaskService 起動 ポート ${port}`);
});

クライアント client.js。各呼び出しに1秒の**期限(deadline)**を付け、認証トークンをメタデータで送ります。

const path = require("node:path");
const grpc = require("@grpc/grpc-js");
const protoLoader = require("@grpc/proto-loader");

const PROTO_PATH = path.join(__dirname, "proto", "task.proto");
const packageDefinition = protoLoader.loadSync(PROTO_PATH, {
  keepCase: false,
  longs: String,
  enums: String,
  defaults: true,
  oneofs: true,
});
const taskProto = grpc.loadPackageDefinition(packageDefinition).tasks.v1;
const client = new taskProto.TaskService("127.0.0.1:50051", grpc.credentials.createInsecure());
const metadata = new grpc.Metadata();
metadata.set("authorization", `Bearer ${process.env.DEMO_TOKEN || "dev-token"}`);

// 「いつまで待つか」を絶対時刻で渡す
function deadline(ms) {
  return new Date(Date.now() + ms);
}

function createTask(title) {
  return new Promise((resolve, reject) => {
    client.createTask({ title }, metadata, { deadline: deadline(1000) }, (error, task) => {
      if (error) return reject(error);
      resolve(task);
    });
  });
}

function getTask(id) {
  return new Promise((resolve, reject) => {
    client.getTask({ id }, metadata, { deadline: deadline(1000) }, (error, task) => {
      if (error) return reject(error);
      resolve(task);
    });
  });
}

// サーバーストリーミングは data/end/error の3イベントで受ける
function listTasks(limit) {
  return new Promise((resolve, reject) => {
    const rows = [];
    const stream = client.listTasks({ limit }, metadata, { deadline: deadline(1000) });
    stream.on("data", (task) => rows.push(task));
    stream.on("error", reject);
    stream.on("end", () => resolve(rows));
  });
}

async function main() {
  const created = await createTask("はじめてのgRPC");
  const fetched = await getTask(created.id);
  const rows = await listTasks(10);
  console.log(JSON.stringify({ created, fetched, streamed: rows.length }, null, 2));
  client.close();
}

main().catch((error) => {
  // 失敗時はステータスコードを必ず出す。これがgRPCのデバッグの起点
  console.error(error.code, error.details || error.message);
  client.close();
  process.exitCode = 1;
});

実行はターミナルを2つ。まず1つ目でサーバーを起動します。

npm run server

別のターミナルでクライアントを叩きます。

npm run client

成功すると、作成したタスク・取得したタスク・ストリーミングで受け取った件数がJSONで出ます。streamed: 1 が出れば、単項とサーバーストリーミングの両方が動いた証拠です。

protocでコード生成する、ということ

上のサンプルは .proto を起動時に読む方式でした。これは手軽な一方、型の補完が効かず、.proto のtypoが実行時まで分かりません。

GoやJava、本格的なTypeScript開発では、protoc(プロトコルバッファのコンパイラ)で .proto から各言語のコードを事前生成します。たとえばGoならこんなコマンドです。

protoc --go_out=. --go-grpc_out=. proto/task.proto

これで task.pb.go(メッセージの構造体)と task_grpc.pb.go(サービスのインターフェース)が吐き出され、エディタの補完が効き、型のずれはコンパイル時に止まります。同じ .proto からGo・Python・Nodeの実装をそろえられるのが、多言語チームでgRPCが選ばれる最大の理由です。詳しい手順はgRPCの公式ドキュメントが言語別にそろっているので、そちらを基準にしてください。

入門の順番としては、まずNodeの動的読み込みで挙動をつかむ → 本番言語で protoc 生成に移る、が遠回りに見えて確実でした。

スキーマ進化:消した番号は必ず封印する

ここがgRPC運用でいちばん事故るポイントなので、太字で書きます。フィールド番号を再利用してはいけません。

背番号(= 1= 2)は、バイナリの中でデータを識別する唯一の手がかりです。owner_email = 6 を消して、後から priority = 6 と別の意味で6番を使い回すと、古いデータを読んだときに「メールアドレスのつもりだった文字列」を「優先度」として解釈してしまう。静かにデータが壊れます。

正しいやり方は、新しいフィールドには新しい番号を足し、消した番号と名前は reserved で封印することです。

message Task {
  string id = 1;
  string title = 2;
  string status = 3;
  int64 created_at_unix = 4;
  optional string assignee_email = 5;  // 追加は新しい番号で
  reserved 6, 7;                        // 消した番号は二度と使わない
  reserved "owner_email";              // 名前も封印
}

Protocol Buffersのproto3ガイドでも、使用中のフィールド番号は変更してはならず、削除した番号は reserved にする、と明記されています。あわせて、enum の0番は UNSPECIFIED(未指定)にしておくのが定石です。0は「値が入っていない」と区別がつかないので、意味のある値を割り当てないほうが安全です。

期限・認証・ストリーミングで手を抜かない

動くだけのコードと、運用に耐えるコードの差はここに出ます。

期限(deadline)。クライアントが「いつまで待つか」を決める仕組みです。公式は「期限・タイムアウトの指定は言語ごとに異なり、デフォルトがある場合とない場合がある」としています。つまり言語によっては無期限で待ち続ける。サンプルの deadline(1000)(1秒)はローカル用の値で、本番ではP95(95パーセンタイル、つまり「だいたいの処理が収まる時間」)の処理時間とネットワーク遅延を見て決めます。期限を入れないと、相手が固まったときに呼び出し元まで道連れになります。

認証とTLS。ローカル以外で createInsecure() のままにしないのが最低ライン。gRPCはSSL/TLSを組み込みで扱えるので、サーバーは grpc.ServerCredentials.createSsl(...)、クライアントは grpc.credentials.createSsl(rootCert) に差し替えます。ここを油断すると、メタデータでBearerトークンを送っていても通信が平文なので、トークンごと丸見えです。

ストリーミングのメモリ。サーバーストリーミングなのに全件を配列にためてから一気に call.write するなら、ストリーミングにした意味が薄く、メモリを圧迫します。1件読んで1件書く。そして、途中でクライアントが切断したときの後始末(ログ、リソース解放)を必ず書く。

REST・GraphQLとの使い分け

「速いから全部gRPC」は判断停止です。3つを役割で住み分けると、こう整理できます。

gRPCRESTGraphQL
主戦場サービス間の内部通信公開API・シンプルなCRUD画面が要求するデータを過不足なく
データ形式バイナリ(小さい・速い)JSON(人間が読める)JSON(クライアント主導で取捨選択)
契約.proto で厳格OpenAPI等で任意スキーマ(SDL)で厳格
ブラウザ直叩き苦手(プロキシが要る)得意得意
ストリーミング4種で強力苦手Subscriptionで一部対応

ざっくりの指針はこうです。バックエンド同士の高頻度通信ならgRPC。ブラウザやモバイルに直接見せるなら、過不足なくデータを取りたいときはGraphQL、素直なCRUDならREST。実際の現場では混在も普通で、外向きはREST/GraphQL、内向きはgRPC、という二段構えがよくあります。サービスの切り方そのものに迷っているなら、先にマイクロサービス分割の考え方を固めてからのほうが、どこをgRPCにすべきか見えてきます。

Claude Codeに任せるなら、契約から渡す

このサンプルをClaude Codeに拡張させるとき、いきなり「gRPCサーバー作って」と丸投げすると、正常系だけ作って失敗系の設計を飛ばします。順番を指定するのがコツでした。

このリポジトリでgRPCのTaskServiceを実装してください。
順番:
1. まず proto/task.proto を作る(CreateTask/GetTask/ListTasks)
2. Node.js + @grpc/grpc-js + @grpc/proto-loader で server.js を書く
3. 全RPCにdeadline付きの client.js を書く
4. authorization メタデータを検証する
5. npm run client で動かし、出力を貼って報告する
触ってよいのは proto/, server.js, client.js, package.json だけ。
本番ではTLSに差し替える注意をREADMEに書いてください。

レビューを頼むときは観点を具体的に。「フィールド番号の再利用がないか」「deadlineとキャンセルの扱いがあるか」「ステータスコードを使い分けているか」「ストリームでメモリをためていないか」「TLSなしの本番利用になっていないか」を、重大度順に指摘して、と投げます。検証の広げ方はテスト戦略のまとめも参考になります。

よくある質問

Q. gRPCとREST、結局どっちを使えばいいですか? A. ブラウザやモバイルから直接叩くなら原則REST(かGraphQL)。バックエンドのサービス同士が高頻度で通信するならgRPC。社内はgRPC、社外向けはREST、の二段構えが現実的です。

Q. Protocol BuffersとgRPCは別物ですか? A. 役割が違います。Protocol Buffersは「データの形を決めて小さく送る」仕組み、gRPCは「その形を使ってリモート関数を呼ぶ」仕組み。gRPCがProtocol Buffersを土台に使っている、という関係です。

Q. ブラウザから直接gRPCを呼べますか? A. 生のgRPCはそのままでは呼べません。gRPC-Webという仕組みとプロキシを挟めば可能ですが、手軽さではRESTやGraphQLに分があります。フロント直結が主目的なら無理にgRPCにしなくて大丈夫です。

Q. 双方向ストリーミングはいつ使う? A. チャットやリアルタイム同期のように、両側が好きなタイミングで送り合う場面です。ただし順序・キャンセル・再接続の難易度が一気に上がるので、本当に必要になるまでは単項とサーバーストリーミングで十分です。

Q. .proto を変更したら既存クライアントは壊れますか? A. ルールを守れば壊れません。フィールドの追加は新しい番号で行い、削除した番号と名前は reserved で封印する。番号を再利用しない限り、古いクライアントは知らないフィールドを無視して動き続けます。

実際に試した結果

この記事のNodeサンプルは、Node v24.14.1・npm 11.11.0の一時ディレクトリで npm installnode server.jsnode client.js まで通し、createdfetchedstreamed: 1 が出るところまで確認しています。一方この環境には goprotocprotoc-gen-go が無かったので、Goの静的生成は「こう書く」という説明にとどめ、実行確認できたNodeの動的読み込みに本体を絞りました。

正直に言うと、僕がgRPCで最初に得をしたのは速度ではありません。.proto を1枚先に書くことで、「このサービスは何ができて、何を受け取って、何を返すのか」がチームの共通言語になったことです。冒頭の user_id typo事故みたいなものは、契約書がある世界ではコンパイルかロードの時点で止まります。速いのはおまけ。最初に契約、それから実装。この順番さえ守れば、gRPCは思っているより怖くありません。

手元のClaude Codeをサービス開発の相棒として鍛えたいなら、日常コマンドとレビュー観点をまとめた無料チートシートから始めるのが手軽です。チームで .proto 運用やCI、レビューゲートまで設計したいときは研修・導入相談もあわせてどうぞ。

#gRPC #Protocol Buffers #ストリーミング #マイクロサービス #Claude Code
無料

無料PDF: Claude Code はじめてのチートシート

まずは無料PDFで基本コマンドと最初の使い方をまとめて確認してください。登録後はそのままテンプレート集や導入相談にも進めます。

スパムは送りません。登録情報は厳重に管理します。

Claude Codeを仕事で使える形にしませんか?

まず無料PDFで基本を固め、繰り返し使う作業はGumroad教材へ、チーム導入や権限設計は導入相談へ進めます。

Masa

この記事を書いた人

Masa

Claude Codeの実務活用、導入設計、収益導線改善を検証しているエンジニア。10言語の技術メディアを運営中。

PR

関連書籍・参考図書

この記事のテーマに関連する書籍を楽天ブックスで探せます。

※ 当サイトは楽天市場のアフィリエイトプログラムに参加しています。上記リンクから商品をご購入いただくと、運営者に紹介料が支払われる場合があります。