gRPCをTypeScriptで0から完全に理解したニキ
本記事のサマリ
この記事では、gRPCの本質的な理解から、実際にTypeScriptとNode.jsを使ってローカル環境で動かすまでの実践的な内容をお届けします!
gRPCは単なる新しいAPI技術ではなく、マイクロサービス間の通信を根本的に変える可能性を秘めた仕組みです。RESTful APIとは異なるアプローチで、型安全性とパフォーマンスの両立を実現できます。
今回は概念的な説明に留まらず、実際に手を動かしてgRPCサーバーとクライアントを構築し、Protocol Buffersの定義から動作確認まで一通り体験していただけます。モダンなWeb開発に携わるエンジニアの皆さんに、新たな技術選択肢として検討いただけるよう、実用的な視点で解説していきます。
gRPCとは何なのか
gRPCを一言で表現するなら「高性能で型安全な関数呼び出し」です。Remote Procedure Call(RPC)の進化形として、Googleが開発したオープンソースの通信フレームワークになります。
従来のREST APIでは、HTTPのGETやPOSTといったメソッドを使って「リソース」に対する操作を行いますが、gRPCでは「関数を呼び出す」という感覚で他のサービスとやり取りができます。まるで同一プロセス内の関数を呼び出すかのように、別のサーバーで動いている機能を利用できるのです。
gRPC公式サイトによると、gRPCの核となる特徴は以下の通りです。HTTP/2をベースとした高速通信、Protocol Buffersによる効率的なシリアライゼーション、そして多言語対応です。
特に注目すべきは、Protocol Buffersという独自の方式でデータを定義する点です。これにより、JSONのようなテキストベースのやり取りではなく、バイナリ形式での通信が可能になります。結果として、データサイズの削減と処理速度の向上を同時に実現できます。
GraphQLとgRPCの違いを理解する
gRPCを語る上で、同じくスキーマ駆動開発を特徴とするGraphQLとの違いを理解することも重要です。両者とも型安全性を重視している点では共通していますが、設計思想や適用場面が大きく異なります。
スキーマ定義の違いを見てみましょう。GraphQLではSDL(Schema Definition Language)でスキーマを定義し、柔軟なクエリが可能です。
# GraphQL Schema
type User {
id: ID!
name: String!
email: String!
}
type Query {
user(id: ID!): User
users: [User!]!
}
一方、gRPCではProtocol Buffersで厳密な関数シグネチャを定義し、RPC呼び出しの形で通信します。
// Protocol Buffers
service UserService {
rpc GetUser(GetUserRequest) returns (User);
rpc ListUsers(ListUsersRequest) returns (ListUsersResponse);
}
message User {
int32 id = 1;
string name = 2;
string email = 3;
}
GraphQLの強みは、クライアントが必要なデータを自由に組み合わせて取得できる点にあります。GraphQL公式サイトによると、Over-fetchingやUnder-fetchingの問題を解決し、フロントエンドの開発効率を大幅に向上させることができます。
対してgRPCは、マイクロサービス間の効率的な通信に特化しています。gRPC公式ドキュメントでも説明されているように、HTTP/2とProtocol Buffersによる高速通信と、ストリーミング機能が大きな特徴です。
技術選択の指針としては、以下のような使い分けが効果的です。
クライアント向けAPIでデータ取得の柔軟性を重視する場合はGraphQLが適しています。複数のリソースを組み合わせた複雑なクエリや、モバイルアプリでのデータ使用量最適化などの要件がある場合は、GraphQLの恩恵を受けやすいでしょう。
一方、サーバー間通信や高頻度な内部API呼び出しではgRPCが威力を発揮します。特に、レイテンシが重要な要件となるマイクロサービス環境や、リアルタイム通信が必要なシステムでは、gRPCの性能上の優位性が明確に現れます。
実際のプロジェクトでは、「外部向けAPIはGraphQL、内部サービス間はgRPC」という使い分けをしているケースも多く見られます。それぞれの特性を理解した上で、適材適所で活用することが重要です。
RESTとgRPCの根本的な違い
REST APIに慣れ親しんだ開発者にとって、gRPCは最初戸惑うかもしれません。しかし、その違いを理解すると、それぞれの適用場面が見えてきます。
RESTでは「ユーザー情報を取得する」場合、GET /users/123のようにリソース指向で考えます。一方、gRPCでは「GetUser(userID)」という関数を呼び出す感覚になります。この違いは単なる記法の問題ではなく、設計思想の根本的な差です。
RESTはHTTPの特性を活かし、キャッシュやプロキシとの相性が良好です。また、curlやブラウザから直接アクセスできる手軽さがあります。対してgRPCは、専用のクライアントライブラリが必要になる一方で、型安全性とパフォーマンスで優位に立ちます。
正直なところ、中小規模のサービスを開発している場合は、マイクロサービスを構築するほどの複雑さはないケースが多いでしょう。そんな環境では、通信内容がJSONで見やすく、バックエンドの変更に合わせてフロントエンドのビルドが不要なGraphQLの方がありがたいと感じる開発者も多いと思います。TypeScriptの型をGraphQLから自動生成するサービスも充実しているので、特に困らないというのが実情かもしれません。
ただ、いつか大規模なサービスに関わる機会が来たときには、gRPCという選択肢があることを知っておくと、きっと役立つ場面があるでしょう。
データ形式の面でも大きな違いがあります。RESTは通常JSONを使いますが、gRPCはProtocol Buffersによるバイナリ形式です。JSONは人間が読みやすく、デバッグしやすいメリットがある一方で、パースのオーバーヘッドとデータサイズの問題があります。Protocol Buffersはその逆で、効率性を重視した設計になっています。
通信方式の面では、gRPCがより柔軟です。RESTは基本的にリクエスト・レスポンス型ですが、gRPCではストリーミング通信も標準でサポートしています。リアルタイムでデータをやり取りする必要がある場合、gRPCの方が適している場合が多いでしょう。
Protocol Buffersの基礎理解
Protocol Buffers(protobuf)は、gRPCの心臓部とも言える技術です。この仕組みを理解することで、gRPCの威力を実感できるはずです。
Protocol Buffersの最大の特徴は「スキーマファースト」のアプローチです。.protoファイルでデータ構造とサービスインターフェースを定義すると、各言語向けのコードが自動生成されます。これにより、サーバーとクライアントの間で型の不整合が起きる心配がなくなります。
例えば、ユーザー情報を扱うサービスを考えてみましょう。protoファイルで一度定義すれば、TypeScript、Go、Python、Javaなど、どの言語でも同じ型定義を使えます。APIの仕様変更があった場合も、protoファイルを更新してコードを再生成するだけで、全ての言語のクライアントに変更が反映されます。
バイナリ形式での通信も大きなメリットです。JSONと比較して、データサイズは約3分の1から5分の1程度になることが多く、シリアライゼーションとデシリアライゼーションの処理速度も向上します。特にマイクロサービス間で頻繁に通信が発生する環境では、この効果は顕著に現れます。
Protocol Buffers公式ドキュメントでは、より詳細な仕様や最適化のテクニックが解説されています。
TypeScriptで実際に動かしてみよう
理論の説明はここまでにして、実際にコードを書いてgRPCの動作を確認してみましょう!今回は、簡単なユーザー管理サービスを例に、サーバーとクライアントの両方を実装します。
まずは、必要なパッケージのインストールから始めましょう〜
mkdir grpc-typescript-demo
cd grpc-typescript-demo
yarn init -y
yarn add @grpc/grpc-js @grpc/proto-loader
yarn add -D @types/node typescript ts-node
次に、Protocol Buffersの定義ファイルを作成します。これがgRPCサービスの設計図になります。
// proto/user.proto
syntax = "proto3";
package user;
service UserService {
rpc GetUser (GetUserRequest) returns (User);
rpc CreateUser (CreateUserRequest) returns (User);
rpc ListUsers (ListUsersRequest) returns (ListUsersResponse);
}
message User {
int32 id = 1;
string name = 2;
string email = 3;
int64 created_at = 4;
}
message GetUserRequest {
int32 user_id = 1;
}
message CreateUserRequest {
string name = 1;
string email = 2;
}
message ListUsersRequest {
int32 page_size = 1;
int32 page_token = 2;
}
message ListUsersResponse {
repeated User users = 1;
int32 next_page_token = 2;
}
この定義ファイルから、TypeScriptで利用できる型情報とサービスインターフェースを動的に読み込めます。@grpc/proto-loaderパッケージを使うことで、事前のコード生成なしに開発を進められます。
サーバーサイドの実装
続いて、gRPCサーバーを実装してみましょう!まず、ユーザーデータを保持するためのシンプルなインメモリストレージを用意します。
// src/server.ts
import * as grpc from '@grpc/grpc-js';
import * as protoLoader from '@grpc/proto-loader';
import * as path from 'path';
// Protocol Buffersの定義を読み込み
const PROTO_PATH = path.join(__dirname, '../proto/user.proto');
const packageDefinition = protoLoader.loadSync(PROTO_PATH, {
keepCase: true,
longs: String,
enums: String,
defaults: true,
oneofs: true,
});
const userProto = grpc.loadPackageDefinition(packageDefinition).user as any;
// インメモリでユーザーデータを管理
interface User {
id: number;
name: string;
email: string;
created_at: number;
}
let users: User[] = [
{ id: 1, name: 'Alice', email: 'alice@example.com', created_at: Date.now() },
{ id: 2, name: 'Bob', email: 'bob@example.com', created_at: Date.now() },
];
let nextUserId = 3;
// サービスの実装
const userService = {
getUser: (call: any, callback: any) => {
const userId = call.request.user_id;
const user = users.find(u => u.id === userId);
if (user) {
callback(null, user);
} else {
callback({
code: grpc.status.NOT_FOUND,
details: `User with ID ${userId} not found`
});
}
},
createUser: (call: any, callback: any) => {
const { name, email } = call.request;
// 簡単なバリデーション
if (!name || !email) {
callback({
code: grpc.status.INVALID_ARGUMENT,
details: 'Name and email are required'
});
return;
}
const newUser: User = {
id: nextUserId++,
name,
email,
created_at: Date.now()
};
users.push(newUser);
callback(null, newUser);
},
listUsers: (call: any, callback: any) => {
const { page_size = 10, page_token = 0 } = call.request;
const start = page_token;
const end = start + page_size;
const pageUsers = users.slice(start, end);
const response = {
users: pageUsers,
next_page_token: end < users.length ? end : 0
};
callback(null, response);
}
};
// サーバーの起動
const server = new grpc.Server();
server.addService(userProto.UserService.service, userService);
const PORT = '50051';
server.bindAsync(
`0.0.0.0:${PORT}`,
grpc.ServerCredentials.createInsecure(),
(error, port) => {
if (error) {
console.error('Failed to start server:', error);
return;
}
console.log(`gRPC server running on port ${port}`);
server.start();
}
);
このサーバー実装では、Protocol Buffersで定義した3つの操作(GetUser、CreateUser、ListUsers)に対応するハンドラーを実装しています。エラーハンドリングも含めて、実用的なサービスの基礎となる構造になっています。
クライアントサイドの実装
次に、作成したgRPCサーバーにアクセスするクライアントを実装してみましょう〜
// src/client.ts
import * as grpc from '@grpc/grpc-js';
import * as protoLoader from '@grpc/proto-loader';
import * as path from 'path';
const PROTO_PATH = path.join(__dirname, '../proto/user.proto');
const packageDefinition = protoLoader.loadSync(PROTO_PATH, {
keepCase: true,
longs: String,
enums: String,
defaults: true,
oneofs: true,
});
const userProto = grpc.loadPackageDefinition(packageDefinition).user as any;
// クライアントの作成
const client = new userProto.UserService(
'localhost:50051',
grpc.credentials.createInsecure()
);
// 使用例
async function demonstrateGrpcClient() {
console.log('=== gRPC Client Demo ===\n');
// 1. 既存ユーザーの取得
console.log('1. Getting user with ID 1...');
client.getUser({ user_id: 1 }, (error: any, user: any) => {
if (error) {
console.error('Error:', error.details);
} else {
console.log('User found:', user);
}
});
// 2. 新しいユーザーの作成
setTimeout(() => {
console.log('\n2. Creating new user...');
client.createUser({
name: 'Charlie',
email: 'charlie@example.com'
}, (error: any, user: any) => {
if (error) {
console.error('Error:', error.details);
} else {
console.log('User created:', user);
}
});
}, 1000);
// 3. ユーザー一覧の取得
setTimeout(() => {
console.log('\n3. Listing all users...');
client.listUsers({ page_size: 10, page_token: 0 }, (error: any, response: any) => {
if (error) {
console.error('Error:', error.details);
} else {
console.log('Users:', response.users);
console.log('Next page token:', response.next_page_token);
}
});
}, 2000);
// 4. Promise化した使い方の例
setTimeout(async () => {
console.log('\n4. Using Promise wrapper...');
try {
const user = await getUserAsync(2);
console.log('User retrieved with Promise:', user);
} catch (error) {
console.error('Promise error:', error);
}
}, 3000);
}
// Promise化のヘルパー関数
function getUserAsync(userId: number): Promise<any> {
return new Promise((resolve, reject) => {
client.getUser({ user_id: userId }, (error: any, user: any) => {
if (error) {
reject(error);
} else {
resolve(user);
}
});
});
}
demonstrateGrpcClient();
このクライアント実装では、コールバックベースとPromiseベースの両方のアプローチを示しています。実際のプロダクション環境では、async/awaitを使った方が読みやすいコードになることが多いでしょう。
実際に動かしてみる
実装が完了したら、実際にローカル環境でgRPCサービスを動かしてみましょう!
TypeScriptの設定ファイルを作成します。
// tsconfig.json
{
"compilerOptions": {
"target": "ES2020",
"module": "commonjs",
"lib": ["ES2020"],
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}
package.jsonにスクリプトを追加します。
{
"scripts": {
"build": "tsc",
"server": "ts-node src/server.ts",
"client": "ts-node src/client.ts"
}
}
まず、サーバーを起動します。
yarn server
ターミナルに「gRPC server running on port 50051」と表示されれば、サーバーの起動は成功です。
次に、別のターミナルでクライアントを実行します。
yarn client
正常に動作すると、以下のような出力が得られるはずです。
=== gRPC Client Demo ===
1. Getting user with ID 1...
User found: {
id: 1,
name: 'Alice',
email: 'alice@example.com',
created_at: '1640995200000'
}
2. Creating new user...
User created: {
id: 3,
name: 'Charlie',
email: 'charlie@example.com',
created_at: '1640995201000'
}
3. Listing all users...
Users: [
{ id: 1, name: 'Alice', email: 'alice@example.com', created_at: '1640995200000' },
{ id: 2, name: 'Bob', email: 'bob@example.com', created_at: '1640995200000' },
{ id: 3, name: 'Charlie', email: 'charlie@example.com', created_at: '1640995201000' }
]
Next page token: 0
4. Using Promise wrapper...
User retrieved with Promise: {
id: 2,
name: 'Bob',
email: 'bob@example.com',
created_at: '1640995200000'
}
gRPCの実用性と技術選択の判断基準
実際にgRPCを動かしてみると、その威力を実感できたのではないでしょうか。型安全性、パフォーマンス、そして開発体験の向上は、特にマイクロサービスアーキテクチャにおいて大きなメリットをもたらします。
マイクロサービスでgRPCが効果的な理由を整理すると、以下の点が挙げられます。
サービス間通信の効率化が最も重要な要素です。Stack Overflow Blogによると、Protocol BuffersとHTTP/2の組み合わせにより、JSONベースの通信と比較してレイテンシとデータサイズの両面で優位性があることが示されています。マイクロサービスでは内部通信が頻発するため、この効率化の恩恵は累積的に大きくなります。
契約ベースの開発も重要なメリットです。複数のチームが異なるサービスを並行開発する場合、.protoファイルによる明確なインターフェース定義により、サービス間の結合を適切に管理できます。また、多言語環境での相互運用性も、マイクロサービスアーキテクチャでは重要な要素となります。
0から1のフェーズでの検討点も現実的に考える必要があります。
初期開発段階では、要件が流動的でAPIの仕様変更が頻繁に発生します。このような状況では、gRPCの厳密な型定義がかえって足枷になる可能性があります。Postman Blogでも指摘されているように、プロトタイピングや要件検証の段階では、より柔軟なAPIアプローチの方が適している場合があります。
また、デバッグやモニタリングの観点でも注意が必要です。バイナリ通信であるため、ログの可視性やデバッグツールの充実度はREST APIに劣る部分があります。チームの技術レベルや運用体制も考慮して導入を検討すべきでしょう。
実用的な導入戦略としては、ハイブリッドアプローチが効果的です。外部向けAPIや初期のプロトタイピングではGraphQLやRESTを使用し、内部サービス間通信や性能要件が厳しい部分でgRPCを採用する、という段階的な導入が現実的な選択肢となります。
gRPC公式ドキュメントには、より高度な機能やベストプラクティスが豊富に記載されているので、実際のプロジェクトに導入する際は、ぜひ参考にしてください。
まとめ
今回は、gRPCの本質的な理解から、TypeScriptでの実装、ローカル環境での動作確認まで一通り体験していただきました。
gRPCは単なる技術トレンドではなく、マイクロサービス時代における通信の課題を解決する実用的なソリューションです。RESTful APIとは異なるアプローチで、パフォーマンスと開発体験の両立を実現できます。
今回のデモコードは基本的な機能に留めていますが、ストリーミング通信、認証・認可、エラーハンドリングなど、実用的な機能も豊富に用意されています。
モダンなWeb開発において、適切な技術選択は成功の鍵となります。gRPCという選択肢を持つことで、プロジェクトの要件に応じて、より最適なアーキテクチャを設計できるようになるでしょう。
この記事が、皆さんのgRPC探求の出発点となれば幸いです。実際のプロジェクトでの採用を検討される際は、チームの技術レベル、既存システムとの兼ね合い、運用面の課題なども含めて総合的に判断されることをお勧めします。
株式会社StellarCreate(stellar-create.co.jp)のエンジニアブログです。 プロダクト指向のフルスタックエンジニアを目指す方募集中です! カジュアル面談で気軽に雑談しましょう!→ recruit.stellar-create.co.jp/
Discussion