Protocol Buffers (protobuf.js)

GoogleのProtocol BuffersのJavaScript/TypeScript実装

serializationprotobufschemabinaryperformancerpc

Protocol Buffers (protobuf.js)

Protocol Buffers(protobuf)は、Googleが開発した言語中立、プラットフォーム中立な構造化データのシリアライゼーション形式です。protobuf.jsはその公式JavaScript/TypeScript実装です。

主な特徴

  • 高いパフォーマンス: JSONよりも高速でコンパクト
  • 強力な型システム: スキーマベースの型安全性
  • クロスプラットフォーム: 多言語間でのデータ交換
  • 後方互換性: スキーマの進化をサポート
  • コード生成: 静的型付きクライアント生成
  • RPCサポート: gRPCとの統合が容易

主なユースケース

  • gRPCサービス: 高速なRPC通信
  • マイクロサービス: サービス間のデータ交換
  • API通信: 効率的なRESTful API
  • データストレージ: 構造化データの永続化
  • ゲーム開発: リアルタイムデータ同期
  • IoTシステム: デバイス間通信

インストール

# メインライブラリ
npm install protobufjs

# 開発時のCLIツール
npm install protobufjs-cli --save-dev

CDNから直接利用:

<script src="//cdn.jsdelivr.net/npm/[email protected]/dist/protobuf.min.js"></script>

基本的な使い方

1. プロトコルバッファの定義

user.protoファイルでスキーマを定義:

// user.proto
syntax = "proto3";

package userpackage;

enum UserStatus {
  INACTIVE = 0;
  ACTIVE = 1;
  SUSPENDED = 2;
}

message Address {
  string street = 1;
  string city = 2;
  string country = 3;
  string postal_code = 4;
}

message User {
  int32 id = 1;
  string name = 2;
  string email = 3;
  UserStatus status = 4;
  Address address = 5;
  repeated string tags = 6;
  map<string, string> metadata = 7;
  int64 created_at = 8;
}

2. 動的読み込み(Runtime)

import protobuf from 'protobufjs';

// .protoファイルの読み込み
protobuf.load('user.proto', function(err, root) {
  if (err) throw err;

  // メッセージ型の取得
  const User = root.lookupType('userpackage.User');
  
  // データの作成
  const userData = {
    id: 1,
    name: '田中太郎',
    email: '[email protected]',
    status: 'ACTIVE',
    address: {
      street: '1-1-1 新宿',
      city: '東京',
      country: '日本',
      postal_code: '160-0022'
    },
    tags: ['premium', 'verified'],
    metadata: {
      source: 'web',
      campaign: 'summer2024'
    },
    created_at: Date.now()
  };

  // バリデーション
  const errMsg = User.verify(userData);
  if (errMsg) throw Error(errMsg);

  // メッセージの作成
  const message = User.create(userData);
  
  // エンコード
  const buffer = User.encode(message).finish();
  console.log('エンコード後のサイズ:', buffer.length);

  // デコード
  const decoded = User.decode(buffer);
  console.log('デコード結果:', decoded);

  // オブジェクトへの変換
  const object = User.toObject(decoded, {
    longs: String,
    enums: String,
    bytes: String,
    defaults: true
  });
  console.log('JavaScript Object:', object);
});

3. Promiseベースの読み込み

import protobuf from 'protobufjs';

async function loadSchema() {
  try {
    const root = await protobuf.load('user.proto');
    const User = root.lookupType('userpackage.User');
    
    // メッセージの処理
    const message = User.create({
      id: 1,
      name: '田中太郎',
      email: '[email protected]'
    });
    
    return User.encode(message).finish();
  } catch (error) {
    console.error('スキーマ読み込みエラー:', error);
    throw error;
  }
}

静的コード生成

1. コード生成コマンド

# JavaScript生成
pbjs -t static-module -w commonjs -o user_pb.js user.proto

# TypeScript定義生成
pbts -o user_pb.d.ts user_pb.js

2. 生成されたコードの使用

// 生成されたコードをインポート
import { User, UserStatus } from './user_pb.js';

// 静的メソッドの使用
const user = User.create({
  id: 1,
  name: '田中太郎',
  email: '[email protected]',
  status: UserStatus.ACTIVE,
  address: {
    street: '1-1-1 新宿',
    city: '東京',
    country: '日本',
    postal_code: '160-0022'
  },
  tags: ['premium', 'verified'],
  metadata: {
    source: 'web',
    campaign: 'summer2024'
  },
  created_at: Date.now()
});

// エンコード・デコード
const buffer = User.encode(user).finish();
const decoded = User.decode(buffer);

console.log('User ID:', decoded.id);
console.log('User Name:', decoded.name);
console.log('User Status:', decoded.status);
console.log('Address:', decoded.address);

TypeScriptでの使用

1. 型定義付きコード生成

import { User, UserStatus, Address } from './user_pb';

// 型安全なメッセージ作成
const createUser = (userData: Partial<User>): User => {
  const user = User.create(userData);
  
  // TypeScriptの型チェックが有効
  if (!user.name || !user.email) {
    throw new Error('必須フィールドが不足しています');
  }
  
  return user;
};

// 使用例
const user = createUser({
  id: 1,
  name: '田中太郎',
  email: '[email protected]',
  status: UserStatus.ACTIVE
});

// エンコード・デコード
const buffer: Uint8Array = User.encode(user).finish();
const decoded: User = User.decode(buffer);

2. デコレーターを使った定義

import { Message, Type, Field, OneOf } from 'protobufjs/light';

@Type.d('UserMessage')
export class UserMessage extends Message<UserMessage> {
  @Field.d(1, 'int32')
  public id: number;

  @Field.d(2, 'string')
  public name: string;

  @Field.d(3, 'string')
  public email: string;

  @Field.d(4, 'string', 'optional')
  public phone?: string;

  @OneOf.d('email', 'phone')
  public contact: string;
}

// 使用例
const message = new UserMessage({
  id: 1,
  name: '田中太郎',
  email: '[email protected]'
});

const buffer = UserMessage.encode(message).finish();
const decoded = UserMessage.decode(buffer);

高度な使用例

1. カスタムクラスの登録

import protobuf from 'protobufjs';

// カスタムクラスの定義
class CustomUser {
  constructor(properties) {
    this.id = properties.id;
    this.name = properties.name;
    this.email = properties.email;
    this.createdAt = new Date(properties.created_at);
  }

  // カスタムメソッド
  getDisplayName() {
    return `${this.name} (${this.email})`;
  }

  isActive() {
    return this.status === 'ACTIVE';
  }

  // 静的メソッド
  static fromEmail(email) {
    return new CustomUser({
      id: 0,
      name: email.split('@')[0],
      email: email,
      created_at: Date.now()
    });
  }
}

// スキーマ読み込み後にカスタムクラスを登録
protobuf.load('user.proto', function(err, root) {
  if (err) throw err;

  const UserType = root.lookupType('userpackage.User');
  
  // カスタムクラスを登録
  UserType.ctor = CustomUser;
  
  // カスタム機能の追加
  CustomUser.prototype.validate = function() {
    if (!this.email.includes('@')) {
      throw new Error('無効なメールアドレス');
    }
  };

  // 使用例
  const buffer = /* エンコードされたデータ */;
  const user = UserType.decode(buffer);
  
  console.log(user.getDisplayName()); // カスタムメソッドが利用可能
  console.log(user.isActive());
  user.validate();
});

2. gRPCサービスの実装

// greeter.proto
/*
syntax = "proto3";

service Greeter {
  rpc SayHello (HelloRequest) returns (HelloReply) {}
  rpc SayGoodbye (HelloRequest) returns (HelloReply) {}
}

message HelloRequest {
  string name = 1;
}

message HelloReply {
  string message = 1;
}
*/

import protobuf from 'protobufjs';

// RPCトランスポート実装
function createRpcImpl(baseUrl) {
  return function(method, requestData, callback) {
    fetch(`${baseUrl}/${method.name}`, {
      method: 'POST',
      headers: {
        'Content-Type': 'application/x-protobuf'
      },
      body: requestData
    })
    .then(response => response.arrayBuffer())
    .then(responseData => {
      callback(null, new Uint8Array(responseData));
    })
    .catch(error => {
      callback(error);
    });
  };
}

// gRPCクライアントの使用
protobuf.load('greeter.proto', function(err, root) {
  if (err) throw err;

  const Greeter = root.lookupService('Greeter');
  const rpcImpl = createRpcImpl('https://api.example.com/grpc');
  
  // サービスインスタンスの作成
  const greeter = Greeter.create(rpcImpl);

  // RPCコール(コールバック)
  greeter.sayHello({ name: '田中太郎' }, function(err, response) {
    if (err) {
      console.error('RPC エラー:', err);
      return;
    }
    console.log('応答:', response.message);
  });

  // RPCコール(Promise)
  greeter.sayHello({ name: '田中太郎' })
    .then(response => {
      console.log('応答:', response.message);
    })
    .catch(error => {
      console.error('RPC エラー:', error);
    });
});

3. ストリーミング処理

import protobuf from 'protobufjs';

class ProtobufStreamer {
  constructor(schema) {
    this.schema = schema;
    this.buffer = new Uint8Array();
  }

  // 複数のメッセージを連続してエンコード
  encodeStream(messages) {
    const encodedMessages = messages.map(msg => {
      const encoded = this.schema.encode(msg).finish();
      const length = encoded.length;
      
      // 長さ区切りプロトコル(Length-Delimited)
      const lengthBuffer = new Uint8Array(4);
      const view = new DataView(lengthBuffer.buffer);
      view.setUint32(0, length, true); // リトルエンディアン
      
      // 長さ + データ
      const result = new Uint8Array(4 + length);
      result.set(lengthBuffer);
      result.set(encoded, 4);
      
      return result;
    });

    // 全てのメッセージを結合
    const totalLength = encodedMessages.reduce((sum, msg) => sum + msg.length, 0);
    const result = new Uint8Array(totalLength);
    let offset = 0;
    
    for (const msg of encodedMessages) {
      result.set(msg, offset);
      offset += msg.length;
    }

    return result;
  }

  // ストリームからメッセージをデコード
  decodeStream(streamData) {
    const messages = [];
    let offset = 0;

    while (offset < streamData.length) {
      if (offset + 4 > streamData.length) {
        break; // 長さ情報が不完全
      }

      // 長さを読み取り
      const lengthBuffer = streamData.slice(offset, offset + 4);
      const view = new DataView(lengthBuffer.buffer);
      const length = view.getUint32(0, true);

      if (offset + 4 + length > streamData.length) {
        break; // データが不完全
      }

      // メッセージデータを読み取り
      const messageData = streamData.slice(offset + 4, offset + 4 + length);
      const message = this.schema.decode(messageData);
      messages.push(message);

      offset += 4 + length;
    }

    return messages;
  }
}

// 使用例
protobuf.load('user.proto', function(err, root) {
  if (err) throw err;

  const User = root.lookupType('userpackage.User');
  const streamer = new ProtobufStreamer(User);

  // 複数のユーザーをストリーム形式でエンコード
  const users = [
    { id: 1, name: '田中太郎', email: '[email protected]' },
    { id: 2, name: '佐藤花子', email: '[email protected]' },
    { id: 3, name: '山田次郎', email: '[email protected]' }
  ];

  const streamData = streamer.encodeStream(users);
  console.log('ストリームサイズ:', streamData.length);

  // ストリームからデコード
  const decodedUsers = streamer.decodeStream(streamData);
  console.log('デコードされたユーザー数:', decodedUsers.length);
  
  decodedUsers.forEach(user => {
    console.log(`ID: ${user.id}, Name: ${user.name}`);
  });
});

パフォーマンスベンチマーク

2024年のベンチマーク結果

// パフォーマンステスト
function performanceTest() {
  const testData = {
    users: Array.from({ length: 1000 }, (_, i) => ({
      id: i,
      name: `ユーザー${i}`,
      email: `user${i}@example.com`,
      status: 'ACTIVE',
      tags: ['tag1', 'tag2', 'tag3'],
      metadata: {
        source: 'test',
        version: '1.0',
        created: Date.now()
      }
    }))
  };

  console.log('パフォーマンステスト開始');

  // JSON テスト
  let start = performance.now();
  const jsonString = JSON.stringify(testData);
  const jsonParsed = JSON.parse(jsonString);
  const jsonTime = performance.now() - start;

  // Protocol Buffers テスト
  start = performance.now();
  const messages = testData.users.map(user => User.create(user));
  const encoded = messages.map(msg => User.encode(msg).finish());
  const decoded = encoded.map(buf => User.decode(buf));
  const protobufTime = performance.now() - start;

  console.log('結果:');
  console.log(`JSON: ${jsonTime.toFixed(2)}ms, サイズ: ${jsonString.length} bytes`);
  console.log(`Protocol Buffers: ${protobufTime.toFixed(2)}ms, サイズ: ${encoded.reduce((sum, buf) => sum + buf.length, 0)} bytes`);
  console.log(`Protocol Buffers高速化: ${(jsonTime / protobufTime).toFixed(2)}倍`);
}

// 実際のベンチマーク結果(Node.js 18.x)
/*
Protocol Buffers (static) x 290,096 ops/sec
JSON (string) x 129,381 ops/sec (55.3% slower)
Google Protobuf x 42,050 ops/sec (85.5% slower)
*/

実用的な例

1. WebSocketでの使用

import protobuf from 'protobufjs';

class ProtobufWebSocket {
  constructor(url, schema) {
    this.ws = new WebSocket(url);
    this.ws.binaryType = 'arraybuffer';
    this.schema = schema;
    this.messageHandlers = new Map();
    
    this.ws.onmessage = (event) => {
      this.handleMessage(event.data);
    };
  }

  handleMessage(data) {
    try {
      const buffer = new Uint8Array(data);
      const message = this.schema.decode(buffer);
      
      const handler = this.messageHandlers.get(message.constructor.name);
      if (handler) {
        handler(message);
      }
    } catch (error) {
      console.error('メッセージ処理エラー:', error);
    }
  }

  send(message) {
    if (this.ws.readyState === WebSocket.OPEN) {
      const buffer = this.schema.encode(message).finish();
      this.ws.send(buffer);
    }
  }

  onMessage(messageType, handler) {
    this.messageHandlers.set(messageType, handler);
  }
}

// 使用例
protobuf.load('chat.proto', function(err, root) {
  if (err) throw err;

  const ChatMessage = root.lookupType('chat.ChatMessage');
  const ws = new ProtobufWebSocket('wss://chat.example.com', ChatMessage);

  ws.onMessage('ChatMessage', (message) => {
    console.log(`${message.user}: ${message.content}`);
  });

  // メッセージ送信
  ws.send(ChatMessage.create({
    user: '田中太郎',
    content: 'こんにちは!',
    timestamp: Date.now()
  }));
});

2. Node.jsでのファイル処理

import fs from 'fs';
import protobuf from 'protobufjs';

class ProtobufFileHandler {
  constructor(schemaPath) {
    this.root = null;
    this.loadSchema(schemaPath);
  }

  async loadSchema(schemaPath) {
    try {
      this.root = await protobuf.load(schemaPath);
      console.log('スキーマが読み込まれました');
    } catch (error) {
      console.error('スキーマ読み込みエラー:', error);
    }
  }

  writeToFile(filename, messages, messageType) {
    const MessageType = this.root.lookupType(messageType);
    const encodedMessages = messages.map(msg => {
      const message = MessageType.create(msg);
      return MessageType.encode(message).finish();
    });

    // ファイルヘッダー(メッセージ数)
    const header = new Uint8Array(4);
    const view = new DataView(header.buffer);
    view.setUint32(0, encodedMessages.length, true);

    // 全データの結合
    const totalSize = header.length + encodedMessages.reduce((sum, msg) => sum + msg.length + 4, 0);
    const fileData = new Uint8Array(totalSize);
    let offset = 0;

    // ヘッダーを書き込み
    fileData.set(header, offset);
    offset += header.length;

    // 各メッセージを書き込み
    encodedMessages.forEach(msg => {
      // メッセージサイズ
      const sizeBuffer = new Uint8Array(4);
      const sizeView = new DataView(sizeBuffer.buffer);
      sizeView.setUint32(0, msg.length, true);
      
      fileData.set(sizeBuffer, offset);
      offset += 4;
      
      // メッセージデータ
      fileData.set(msg, offset);
      offset += msg.length;
    });

    fs.writeFileSync(filename, fileData);
    console.log(`${messages.length}個のメッセージを${filename}に保存しました`);
  }

  readFromFile(filename, messageType) {
    const MessageType = this.root.lookupType(messageType);
    const fileData = fs.readFileSync(filename);
    const messages = [];

    // ヘッダーを読み込み
    const headerView = new DataView(fileData.buffer, 0, 4);
    const messageCount = headerView.getUint32(0, true);
    let offset = 4;

    // 各メッセージを読み込み
    for (let i = 0; i < messageCount; i++) {
      const sizeView = new DataView(fileData.buffer, offset, 4);
      const messageSize = sizeView.getUint32(0, true);
      offset += 4;

      const messageData = fileData.slice(offset, offset + messageSize);
      const message = MessageType.decode(messageData);
      messages.push(message);
      offset += messageSize;
    }

    console.log(`${filename}から${messages.length}個のメッセージを読み込みました`);
    return messages;
  }
}

// 使用例
const handler = new ProtobufFileHandler('user.proto');

// データの保存
const users = [
  { id: 1, name: '田中太郎', email: '[email protected]' },
  { id: 2, name: '佐藤花子', email: '[email protected]' },
  { id: 3, name: '山田次郎', email: '[email protected]' }
];

handler.writeToFile('users.pb', users, 'userpackage.User');

// データの読み込み
const loadedUsers = handler.readFromFile('users.pb', 'userpackage.User');
loadedUsers.forEach(user => {
  console.log(`ID: ${user.id}, Name: ${user.name}`);
});

他のライブラリとの比較

特徴Protocol BuffersJSONMessagePackFlatBuffers
パフォーマンス最高
サイズ
可読性
スキーマ必要不要不要必要
型安全性
後方互換性優秀制限的制限的優秀

トラブルシューティング

よくある問題と解決策

  1. スキーマの進化と互換性

    // 古いバージョンとの互換性を保つ
    const message = User.create({
      id: 1,
      name: '田中太郎',
      // 新しいフィールドは省略可能
      // phone: '090-1234-5678'  // 新しいフィールド
    });
    
  2. 大きなメッセージの処理

    // メッセージサイズの制限
    const MAX_MESSAGE_SIZE = 1024 * 1024; // 1MB
    
    function safeEncode(message) {
      const buffer = MessageType.encode(message).finish();
      if (buffer.length > MAX_MESSAGE_SIZE) {
        throw new Error('メッセージサイズが大きすぎます');
      }
      return buffer;
    }
    
  3. エラーハンドリング

    function safeDecode(buffer) {
      try {
        return MessageType.decode(buffer);
      } catch (error) {
        if (error instanceof protobuf.util.ProtocolError) {
          console.error('必須フィールドが不足:', error.instance);
        } else {
          console.error('データ形式エラー:', error);
        }
        throw error;
      }
    }
    

まとめ

Protocol Buffers は、高性能で型安全なデータシリアライゼーションを提供する優れたライブラリです。特に以下の場合に効果的です:

利点

  • 高いパフォーマンス: JSONより高速でコンパクト
  • 強力な型システム: スキーマベースの型安全性
  • クロスプラットフォーム: 多言語間での互換性
  • 後方互換性: スキーマの進化をサポート

推奨用途

  • gRPCサービスでのRPC通信
  • マイクロサービス間のデータ交換
  • 高頻度のAPI通信でパフォーマンスが重要な場合
  • 型安全性が重要なアプリケーション

Protocol Buffers は、JSONの柔軟性を犠牲にしてパフォーマンスと型安全性を得る、エンタープライズレベルのアプリケーションに最適なソリューションです。