🌊

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. 言語中立 - 1つの定義から各言語のコードを生成
  2. 型安全 - コンパイル時にエラーを検出
  3. 効率的 - バイナリ形式で高速・軽量
  4. 後方互換性 - フィールド追加が既存クライアントに影響しない

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 });

なぜ動作しないのか?

  1. ブラウザはHTTP/2を完全に制御できない
  2. gRPCが必要とするTrailerヘッダーをサポートしていない
  3. バイナリフレーミングを直接扱えない

解決策1: gRPC-Web(プロキシを使う)

[ブラウザ] --HTTP/1.1--> [Envoyプロキシ] --HTTP/2--> [gRPCサーバー]
           gRPC-Web                      ネイティブgRPC

問題点

  • Envoyプロキシの設定・運用が必要
  • Client/Bidirectional Streamingが使えない
  • デバッグが困難

5. ConnectRPC:モダンな解決策

ConnectRPCとは

一言で言うと:ブラウザから直接使える、gRPC互換のRPCフレームワーク

ConnectRPCの特徴

  1. プロキシ不要 - ブラウザから直接接続
  2. プロトコル選択可能 - JSON/バイナリを選べる
  3. 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

まとめ

各技術の役割

  1. Protocol Buffers

    • 役割:言語中立的なデータ・API定義
    • 解決する課題:型安全性、API仕様の標準化
  2. gRPC

    • 役割:高性能なRPC通信
    • 解決する課題:効率的なサーバー間通信
  3. ConnectRPC

    • 役割:ブラウザ対応のRPC
    • 解決する課題:Webアプリケーションでの利用

重要なポイント

  • これらは競合する技術ではなく、補完関係にある
  • Protocol Buffersは共通の基盤
  • gRPCとConnectRPCは用途に応じて使い分ける

次のステップ

  1. まずProtocol Buffersでサービスを定義
  2. ブラウザサポートが必要ならConnectRPC
  3. サーバー間通信ならgRPC

これらの技術を理解することで、型安全で効率的なAPIを構築できます。

参考リンク

Discussion