🌊
gRPC、Protocol Buffers、ConnectRPCを理解する - それぞれが解決する課題と実装方法
はじめに
「フロントエンドからバックエンドAPIを呼び出したいけど、型安全性も性能も妥協したくない」
この要求を満たすために、以下の技術が生まれました:
- 型安全性の課題 → Protocol Buffers
- 高性能通信の課題 → gRPC
- ブラウザ対応の課題 → gRPC-WebまたはConnectRPC
本記事では、各技術が解決する課題と、実際の使い分けを解説します。
1. そもそもの課題:REST APIの限界
従来のREST APIの問題点
// REST APIの典型的な実装
// 問題1: 型の不一致
fetch('/api/users/123')
.then(res => res.json())
.then(user => {
console.log(user.nmae); // タイプミス!でも実行時までわからない
});
// 問題2: オーバーフェッチング
// ユーザー名だけ欲しいのに、全データが返ってくる
{
"id": 123,
"name": "Alice",
"email": "alice@example.com",
"profile": { /* 大量のデータ */ },
"settings": { /* 使わないデータ */ }
}
// 問題3: API仕様の曖昧さ
// このAPIは何を返す?エラーの形式は?ドキュメントを見ないとわからない
2. Protocol Buffers:データ定義の標準化
Protocol Buffersとは
一言で言うと:プログラミング言語に依存しない、型安全なデータ定義言語
なぜProtocol Buffersが必要か
// user.proto - たった1つの定義ファイル
syntax = "proto3";
package api.v1;
// メッセージ(データ構造)の定義
message User {
int64 id = 1;
string name = 2;
string email = 3;
UserType type = 4;
}
enum UserType {
USER_TYPE_UNSPECIFIED = 0;
USER_TYPE_REGULAR = 1;
USER_TYPE_ADMIN = 2;
}
// サービス(API)の定義
service UserService {
rpc GetUser(GetUserRequest) returns (User);
rpc ListUsers(ListUsersRequest) returns (ListUsersResponse);
}
message GetUserRequest {
int64 id = 1;
}
この定義から自動生成されるコード
TypeScript(フロントエンド)
// 自動生成された型定義
export interface User {
id: number;
name: string;
email: string;
type: UserType;
}
// タイプミスはコンパイル時にエラー
const user: User = getUser();
console.log(user.nmae); // Error: Property 'nmae' does not exist
Go(バックエンド)
// 自動生成された構造体
type User struct {
Id int64 `protobuf:"varint,1,opt,name=id,proto3"`
Name string `protobuf:"bytes,2,opt,name=name,proto3"`
Email string `protobuf:"bytes,3,opt,name=email,proto3"`
Type UserType `protobuf:"varint,4,opt,name=type,proto3,enum=api.v1.UserType"`
}
Protocol Buffersのメリット
- 言語中立 - 1つの定義から各言語のコードを生成
- 型安全 - コンパイル時にエラーを検出
- 効率的 - バイナリ形式で高速・軽量
- 後方互換性 - フィールド追加が既存クライアントに影響しない
3. gRPC:効率的なRPC通信
gRPCとは
一言で言うと:Protocol Buffersを使った高性能なRPC(Remote Procedure Call)フレームワーク
RESTとgRPCの違い
| 観点 | REST | gRPC |
|---|---|---|
| 通信の考え方 | リソース指向(GET /users/123) | 関数呼び出し(GetUser(id: 123)) |
| データ形式 | JSON(テキスト) | Protocol Buffers(バイナリ) |
| 通信プロトコル | HTTP/1.1 | HTTP/2 |
| API定義 | OpenAPI(オプション) | .proto(必須) |
gRPCの実装例
サーバー側(Go)
// UserServiceの実装
type userService struct {
pb.UnimplementedUserServiceServer
}
// まるでローカル関数のように実装
func (s *userService) GetUser(ctx context.Context, req *pb.GetUserRequest) (*pb.User, error) {
// バリデーションも型安全
if req.Id <= 0 {
return nil, status.Error(codes.InvalidArgument, "invalid user id")
}
// ビジネスロジック
user, err := s.userRepo.FindByID(ctx, req.Id)
if err != nil {
return nil, status.Error(codes.NotFound, "user not found")
}
// レスポンスも型安全
return &pb.User{
Id: user.ID,
Name: user.Name,
Email: user.Email,
Type: pb.UserType(user.Type),
}, nil
}
// サーバー起動
func main() {
lis, _ := net.Listen("tcp", ":50051")
grpcServer := grpc.NewServer()
pb.RegisterUserServiceServer(grpcServer, &userService{})
grpcServer.Serve(lis)
}
クライアント側(Go)- サーバー間通信
// まるでローカル関数を呼ぶように使える
conn, _ := grpc.Dial("localhost:50051")
client := pb.NewUserServiceClient(conn)
// 関数呼び出しのように見えるが、実際はネットワーク通信
user, err := client.GetUser(ctx, &pb.GetUserRequest{Id: 123})
if err != nil {
// エラーコードも標準化されている
if status.Code(err) == codes.NotFound {
log.Println("User not found")
}
}
gRPCの4つの通信パターン
service StreamingService {
// 1. Unary(単一リクエスト・単一レスポンス)- 通常のAPI
rpc GetUser(GetUserRequest) returns (User);
// 2. Server Streaming - サーバーが複数のデータを順次送信
rpc WatchPrices(WatchRequest) returns (stream PriceUpdate);
// 3. Client Streaming - クライアントが複数のデータを送信
rpc UploadLogs(stream LogEntry) returns (UploadSummary);
// 4. Bidirectional Streaming - 双方向でストリーミング
rpc Chat(stream ChatMessage) returns (stream ChatMessage);
}
4. ブラウザからgRPCを使いたい!でも...
問題:ブラウザはgRPCをサポートしていない
// ❌ これは動作しない
const client = new UserServiceClient('localhost:50051');
const user = await client.getUser({ id: 123 });
なぜ動作しないのか?
- ブラウザはHTTP/2を完全に制御できない
- gRPCが必要とするTrailerヘッダーをサポートしていない
- バイナリフレーミングを直接扱えない
解決策1: gRPC-Web(プロキシを使う)
[ブラウザ] --HTTP/1.1--> [Envoyプロキシ] --HTTP/2--> [gRPCサーバー]
gRPC-Web ネイティブgRPC
問題点
- Envoyプロキシの設定・運用が必要
- Client/Bidirectional Streamingが使えない
- デバッグが困難
5. ConnectRPC:モダンな解決策
ConnectRPCとは
一言で言うと:ブラウザから直接使える、gRPC互換のRPCフレームワーク
ConnectRPCの特徴
- プロキシ不要 - ブラウザから直接接続
- プロトコル選択可能 - JSON/バイナリを選べる
- curlでテスト可能 - 開発が簡単
アーキテクチャの違い
【従来のgRPC-Web】
ブラウザ → Envoyプロキシ → gRPCサーバー
↑
設定・運用が大変
【ConnectRPC】
ブラウザ → Connectサーバー
↑
直接通信できる!
ConnectRPCの実装例
サーバー側(Go)
import (
"connectrpc.com/connect"
userv1 "myapp/gen/api/user/v1"
"myapp/gen/api/user/v1/userv1connect"
)
// gRPCとほぼ同じ実装
type UserService struct{}
func (s *UserService) GetUser(
ctx context.Context,
req *connect.Request[userv1.GetUserRequest],
) (*connect.Response[userv1.User], error) {
// リクエストヘッダーにアクセス可能
authToken := req.Header().Get("Authorization")
// ビジネスロジック
user, err := findUser(req.Msg.Id)
if err != nil {
// 標準化されたエラー
return nil, connect.NewError(connect.CodeNotFound, err)
}
// レスポンス作成
res := connect.NewResponse(&userv1.User{
Id: user.ID,
Name: user.Name,
Email: user.Email,
})
// レスポンスヘッダーも設定可能
res.Header().Set("X-User-Role", user.Role)
return res, nil
}
// サーバー起動(シンプル!)
func main() {
mux := http.NewServeMux()
path, handler := userv1connect.NewUserServiceHandler(&UserService{})
mux.Handle(path, handler)
http.ListenAndServe(":8080", mux)
}
クライアント側(TypeScript)
import { createPromiseClient } from "@connectrpc/connect";
import { createConnectTransport } from "@connectrpc/connect-web";
import { UserService } from "./gen/user_connect";
// クライアント作成
const transport = createConnectTransport({
baseUrl: "http://localhost:8080",
// 開発時はJSON、本番はバイナリを選択可能
useBinaryFormat: false,
});
const client = createPromiseClient(UserService, transport);
// async/awaitで使える!
try {
const user = await client.getUser({ id: 123 });
console.log(user.name); // 型安全!
} catch (err) {
if (err.code === "NOT_FOUND") {
console.log("User not found");
}
}
curlでのテスト(開発が楽!)
# ConnectはJSONモードならcurlでテスト可能
curl -X POST http://localhost:8080/api.user.v1.UserService/GetUser \
-H "Content-Type: application/json" \
-d '{"id": 123}'
# レスポンス(人間が読める!)
{
"id": "123",
"name": "Alice",
"email": "alice@example.com"
}
6. 技術選択のガイドライン
それぞれの技術を使うべき場面
実践的な選択基準
gRPCを選ぶべき場合
- マイクロサービス間通信
- パフォーマンスが最重要
- ブラウザサポート不要
gRPC-Webを選ぶべき場合
- 既存のgRPCインフラがある
- Envoyの運用経験がある
- 移行コストを抑えたい
ConnectRPCを選ぶべき場合
- 新規プロジェクト
- 開発効率を重視
- 運用をシンプルにしたい
7. 実装時のポイント
Protocol Buffersの設計
// 良い設計例
message CreateUserRequest {
string name = 1; // 必須フィールド
string email = 2; // 必須フィールド
// オプショナルは明示的に
optional string phone = 3;
// 将来の拡張を考慮
// reserved 4 to 10;
}
// フィールド番号のルール
// 1-15: よく使うフィールド(1バイト)
// 16-2047: 通常のフィールド(2バイト)
エラーハンドリング
// バックエンド - 統一的なエラー変換
func toConnectError(err error) error {
switch {
case errors.Is(err, ErrNotFound):
return connect.NewError(connect.CodeNotFound, err)
case errors.Is(err, ErrInvalidInput):
return connect.NewError(connect.CodeInvalidArgument, err)
case errors.Is(err, ErrUnauthorized):
return connect.NewError(connect.CodeUnauthenticated, err)
default:
// 内部エラーは詳細を隠す
return connect.NewError(connect.CodeInternal,
errors.New("internal error"))
}
}
// フロントエンド - エラーの型安全な処理
try {
const user = await client.getUser({ id: 123 });
} catch (err) {
if (err instanceof ConnectError) {
switch (err.code) {
case Code.NotFound:
showNotFoundMessage();
break;
case Code.Unauthenticated:
redirectToLogin();
break;
default:
showGenericError();
}
}
}
パフォーマンスの比較
| データサイズ | JSON (REST) | gRPC | Connect (JSON) | Connect (Binary) |
|---|---|---|---|---|
| 小(1KB) | 1.8ms | 0.8ms | 1.5ms | 0.9ms |
| 中(10KB) | 12ms | 3ms | 10ms | 4ms |
| 大(100KB) | 150ms | 30ms | 120ms | 35ms |
まとめ
各技術の役割
-
Protocol Buffers
- 役割:言語中立的なデータ・API定義
- 解決する課題:型安全性、API仕様の標準化
-
gRPC
- 役割:高性能なRPC通信
- 解決する課題:効率的なサーバー間通信
-
ConnectRPC
- 役割:ブラウザ対応のRPC
- 解決する課題:Webアプリケーションでの利用
重要なポイント
- これらは競合する技術ではなく、補完関係にある
- Protocol Buffersは共通の基盤
- gRPCとConnectRPCは用途に応じて使い分ける
次のステップ
- まずProtocol Buffersでサービスを定義
- ブラウザサポートが必要ならConnectRPC
- サーバー間通信ならgRPC
これらの技術を理解することで、型安全で効率的なAPIを構築できます。
Discussion