T-CREATOR

WebSocket プロトコル設計:バージョン交渉・機能フラグ・後方互換のパターン

WebSocket プロトコル設計:バージョン交渉・機能フラグ・後方互換のパターン

WebSocket を使ったアプリケーションが成長するにつれ、プロトコルのバージョン管理や機能追加への対応が重要になります。クライアントとサーバーのバージョンが異なる環境でもスムーズに通信できるようにするには、どのような設計が求められるのでしょうか。

この記事では、WebSocket プロトコルにおけるバージョン交渉、機能フラグ、後方互換性の実現パターンについて、具体的なコード例と図解を交えて解説します。これらの仕組みを理解することで、長期運用可能な WebSocket システムを構築できるようになるでしょう。

背景

WebSocket プロトコルの進化と課題

WebSocket アプリケーションは、リリース後も機能追加や仕様変更が繰り返されます。しかし、クライアントとサーバーが常に同じバージョンで動作するとは限りません。

例えば、以下のような状況が発生します:

  • モバイルアプリのクライアントが旧バージョンのまま
  • サーバーだけが新機能を実装して先行リリース
  • 段階的なロールアウトによるバージョンの混在

こうした状況でも通信を維持し、可能な範囲で新機能を提供する仕組みが必要です。

プロトコル設計で考慮すべき要素

WebSocket プロトコルの設計では、以下の要素を考慮する必要があります。

まず、バージョン情報の伝達です。クライアントとサーバーがお互いのバージョンを認識し、対応可能な機能を判断しなければなりません。

次に、機能の段階的な有効化です。新機能を追加する際、それをサポートしないクライアントにも影響を与えない設計が求められます。

最後に、後方互換性の維持です。旧バージョンのクライアントが新バージョンのサーバーと通信できるよう、メッセージ形式や処理フローに互換性を持たせる必要があります。

以下の図は、バージョンが異なるクライアントとサーバー間の通信シナリオを示しています。

mermaidflowchart TB
  client_v1["クライアント v1.0"] -->|接続要求| server["サーバー v2.0"]
  client_v2["クライアント v2.0"] -->|接続要求| server

  server -->|バージョン確認| check{対応可能?}
  check -->|v1.0 互換| compatible["基本機能のみ提供"]
  check -->|v2.0 互換| full["全機能提供"]

  compatible -->|レスポンス| client_v1
  full -->|レスポンス| client_v2

このように、サーバーはクライアントのバージョンに応じて提供する機能を調整します。旧バージョンのクライアントには基本機能のみ、新バージョンには全機能を提供することで、柔軟な対応が可能になるのです。

課題

バージョン不一致による通信エラー

バージョン管理が不十分な場合、以下のような問題が発生します。

メッセージ形式の不一致により、クライアントが解析できないメッセージを受信してパースエラーが起きることがあります。例えば、サーバーが新しいフィールドを追加したメッセージを送信すると、旧クライアントでは想定外の構造として扱われてしまいます。

未対応機能の呼び出しも問題です。クライアントが新機能を使おうとしても、サーバーがその機能を実装していない場合、エラーが返されます。

接続の切断も深刻な課題です。プロトコルの互換性がないと判断された場合、接続自体が確立できなくなります。

機能フラグの管理コスト

新機能を段階的にリリースする際、機能フラグの管理が複雑になります。

どのクライアントにどの機能を有効にするか、フラグの組み合わせパターンが増えるとテストケースが爆発的に増加します。また、古い機能フラグを削除するタイミングの判断も難しく、コードベースに不要なフラグが残り続けることがあるでしょう。

後方互換性の維持コスト

後方互換性を維持しようとすると、以下のコストが発生します。

コードの複雑化です。バージョンごとの分岐処理が増え、コードが読みにくくなります。パフォーマンスの低下も無視できません。互換性チェックや変換処理によって、オーバーヘッドが増加するのです。

テストの負担増加も課題です。複数バージョンの組み合わせをテストする必要があり、テストケース数が膨大になります。

以下の図は、バージョン管理における課題の関係性を示しています。

mermaidflowchart LR
  version_mismatch["バージョン不一致"] --> parse_error["パースエラー"]
  version_mismatch --> unsupported["未対応機能"]
  version_mismatch --> disconnect["接続切断"]

  feature_flags["機能フラグ管理"] --> test_explosion["テストケース爆発"]
  feature_flags --> code_debt["技術的負債"]

  backward_compat["後方互換性"] --> code_complex["コード複雑化"]
  backward_compat --> perf_overhead["パフォーマンス低下"]
  backward_compat --> test_burden["テスト負担増"]

  parse_error --> failure["通信失敗"]
  unsupported --> failure
  disconnect --> failure
  test_explosion --> maintenance["保守コスト増大"]
  code_debt --> maintenance
  code_complex --> maintenance
  test_burden --> maintenance

これらの課題に対処するには、適切な設計パターンとベストプラクティスを適用することが不可欠です。

解決策

バージョン交渉の実装パターン

バージョン交渉では、接続開始時にクライアントとサーバーがサポートするバージョン情報を交換し、互いに対応可能なバージョンで通信します。

ハンドシェイクフェーズでのバージョン情報交換

WebSocket の接続確立時、最初のメッセージでバージョン情報を送信します。クライアントは対応可能なバージョンのリストを送り、サーバーは使用するバージョンを決定して応答するのです。

以下のコードは、クライアント側でのバージョン情報送信を示しています。

typescript// クライアント側:バージョン情報を含む接続メッセージ
interface HandshakeMessage {
  type: 'handshake';
  supportedVersions: string[];
  clientId: string;
}

class WebSocketClient {
  private ws: WebSocket;
  private negotiatedVersion: string | null = null;

  constructor(url: string) {
    this.ws = new WebSocket(url);
    this.setupHandlers();
  }

  private setupHandlers(): void {
    this.ws.onopen = () => {
      // 接続確立後、サポートするバージョンを送信
      const handshake: HandshakeMessage = {
        type: 'handshake',
        supportedVersions: ['2.0', '1.5', '1.0'],
        clientId: this.generateClientId(),
      };
      this.ws.send(JSON.stringify(handshake));
    };

    this.ws.onmessage = (event) => {
      this.handleMessage(event.data);
    };
  }

  private generateClientId(): string {
    return `client_${Date.now()}_${Math.random()
      .toString(36)
      .substr(2, 9)}`;
  }
}

クライアントは supportedVersions として対応可能なバージョンのリストを降順(新しい順)で送信します。これにより、サーバーは最適なバージョンを選択できます。

次に、サーバー側でのバージョン選択処理を見てみましょう。

typescript// サーバー側:バージョン選択と応答
interface HandshakeResponse {
  type: 'handshake_ack';
  selectedVersion: string;
  serverCapabilities: string[];
}

class WebSocketServer {
  private supportedVersions = ['2.0', '1.5', '1.0'];
  private minSupportedVersion = '1.0';

  handleHandshake(
    ws: WebSocket,
    message: HandshakeMessage
  ): void {
    // クライアントとサーバーの共通バージョンを選択
    const selectedVersion = this.negotiateVersion(
      message.supportedVersions
    );

    if (!selectedVersion) {
      // 互換性のあるバージョンがない場合は切断
      ws.send(
        JSON.stringify({
          type: 'error',
          code: 'VERSION_MISMATCH',
          message: `サポートされるバージョンがありません。最低バージョン: ${this.minSupportedVersion}`,
        })
      );
      ws.close(1003, 'Version mismatch');
      return;
    }

    // バージョン交渉成功
    const response: HandshakeResponse = {
      type: 'handshake_ack',
      selectedVersion,
      serverCapabilities:
        this.getCapabilitiesForVersion(selectedVersion),
    };

    ws.send(JSON.stringify(response));
  }

  private negotiateVersion(
    clientVersions: string[]
  ): string | null {
    // クライアントとサーバーの共通バージョンから最新を選択
    for (const version of clientVersions) {
      if (this.supportedVersions.includes(version)) {
        return version;
      }
    }
    return null;
  }
}

サーバーは negotiateVersion メソッドで、クライアントが提示したバージョンの中から自身がサポートする最新のものを選択します。互換性がない場合はエラーを返して接続を切断するのです。

バージョンに応じた機能の提供

バージョンが決定したら、そのバージョンでサポートされる機能(Capabilities)をクライアントに伝えます。

typescript// サーバー側:バージョンごとの機能定義
interface VersionCapabilities {
  version: string;
  features: string[];
}

class CapabilityManager {
  private capabilitiesMap: Map<string, string[]> = new Map([
    [
      '2.0',
      [
        'streaming',
        'binary',
        'compression',
        'multiplexing',
      ],
    ],
    ['1.5', ['streaming', 'binary', 'compression']],
    ['1.0', ['streaming']],
  ]);

  getCapabilitiesForVersion(version: string): string[] {
    return this.capabilitiesMap.get(version) || [];
  }

  hasCapability(
    version: string,
    capability: string
  ): boolean {
    const capabilities = this.capabilitiesMap.get(version);
    return capabilities
      ? capabilities.includes(capability)
      : false;
  }
}

この CapabilityManager により、バージョンごとに提供可能な機能を一元管理できます。新しいバージョンでは機能が追加され、古いバージョンでは基本機能のみが提供されるのです。

機能フラグによる段階的リリース

機能フラグ(Feature Flag)は、コードの変更なしに特定の機能を有効・無効にする仕組みです。WebSocket プロトコルでは、この仕組みを使って新機能を段階的にリリースできます。

機能フラグの設計

機能フラグは、クライアントやユーザーの属性に基づいて動的に制御します。

typescript// 機能フラグの定義
interface FeatureFlag {
  name: string;
  enabled: boolean;
  enabledForVersions?: string[];
  enabledForClients?: string[];
  rolloutPercentage?: number;
}

class FeatureFlagManager {
  private flags: Map<string, FeatureFlag> = new Map();

  constructor() {
    // 機能フラグの初期化
    this.flags.set('compression', {
      name: 'compression',
      enabled: true,
      enabledForVersions: ['2.0', '1.5'],
    });

    this.flags.set('multiplexing', {
      name: 'multiplexing',
      enabled: true,
      enabledForVersions: ['2.0'],
      rolloutPercentage: 50, // 段階的リリース:50%のユーザーに提供
    });
  }

  isFeatureEnabled(
    featureName: string,
    version: string,
    clientId: string
  ): boolean {
    const flag = this.flags.get(featureName);

    if (!flag || !flag.enabled) {
      return false;
    }

    // バージョンチェック
    if (
      flag.enabledForVersions &&
      !flag.enabledForVersions.includes(version)
    ) {
      return false;
    }

    // 特定クライアントのチェック
    if (
      flag.enabledForClients &&
      !flag.enabledForClients.includes(clientId)
    ) {
      return false;
    }

    // ロールアウト割合のチェック
    if (flag.rolloutPercentage !== undefined) {
      return this.isInRolloutPercentage(
        clientId,
        flag.rolloutPercentage
      );
    }

    return true;
  }
}

FeatureFlagManager は、バージョン、クライアント ID、ロールアウト割合などの条件に基づいて機能の有効・無効を判定します。これにより、特定の条件下でのみ新機能を提供できるのです。

ロールアウト割合の計算処理も実装してみましょう。

typescript// ロールアウト割合の計算
class FeatureFlagManager {
  // ... 前述のコード

  private isInRolloutPercentage(
    clientId: string,
    percentage: number
  ): boolean {
    // クライアントIDのハッシュ値から決定論的に判定
    const hash = this.hashString(clientId);
    const bucketValue = hash % 100;
    return bucketValue < percentage;
  }

  private hashString(str: string): number {
    let hash = 0;
    for (let i = 0; i < str.length; i++) {
      const char = str.charCodeAt(i);
      hash = (hash << 5) - hash + char;
      hash = hash & hash; // 32bit整数に変換
    }
    return Math.abs(hash);
  }
}

このロジックでは、クライアント ID をハッシュ化して 0〜99 のバケットに振り分け、ロールアウト割合と比較します。同じクライアント ID に対しては常に同じ結果が返されるため、一貫した動作が保証されます。

メッセージ送信時の機能フラグ適用

実際のメッセージ送信時に、機能フラグを適用してデータを加工します。

typescript// メッセージ送信時の機能フラグ適用
class MessageSender {
  constructor(
    private ws: WebSocket,
    private version: string,
    private clientId: string,
    private featureFlagManager: FeatureFlagManager
  ) {}

  send(data: any): void {
    let messageData = JSON.stringify(data);

    // 圧縮機能が有効な場合
    if (
      this.featureFlagManager.isFeatureEnabled(
        'compression',
        this.version,
        this.clientId
      )
    ) {
      messageData = this.compress(messageData);
    }

    // バイナリ転送が有効な場合
    if (
      this.featureFlagManager.isFeatureEnabled(
        'binary',
        this.version,
        this.clientId
      )
    ) {
      this.ws.send(this.toBinary(messageData));
    } else {
      this.ws.send(messageData);
    }
  }

  private compress(data: string): string {
    // 圧縮処理の実装(例:pako、zlib など)
    return data; // 簡略化のため省略
  }

  private toBinary(data: string): ArrayBuffer {
    const encoder = new TextEncoder();
    return encoder.encode(data).buffer;
  }
}

このように、送信前に機能フラグをチェックし、有効な場合のみ圧縮やバイナリ変換を行います。機能が無効な場合は従来の方法で送信されるため、後方互換性が保たれるのです。

後方互換性を保つメッセージ設計

後方互換性を維持するには、メッセージ構造の設計が重要です。新しいフィールドを追加しても、旧バージョンのクライアントが影響を受けないようにします。

拡張可能なメッセージ形式

メッセージには必須フィールドとオプショナルフィールドを明確に分離します。

typescript// 後方互換性を持つメッセージ型定義
interface BaseMessage {
  type: string;
  timestamp: number;
  version: string;
}

interface ChatMessage extends BaseMessage {
  type: 'chat';
  userId: string;
  text: string;

  // v1.5 で追加されたフィールド
  threadId?: string;
  replyTo?: string;

  // v2.0 で追加されたフィールド
  attachments?: Attachment[];
  reactions?: Reaction[];
}

interface Attachment {
  id: string;
  type: 'image' | 'file';
  url: string;
}

interface Reaction {
  emoji: string;
  userId: string;
}

オプショナルフィールド(? 付き)として新機能を追加することで、旧バージョンはこれらのフィールドを無視できます。必須フィールドのみを参照すれば、基本的な動作が保証されるのです。

メッセージのバージョン別処理

受信したメッセージをバージョンに応じて処理します。

typescript// バージョン別のメッセージ処理
class MessageHandler {
  constructor(private version: string) {}

  handleChatMessage(message: ChatMessage): void {
    // 全バージョン共通の処理
    this.displayMessage(message.userId, message.text);

    // v1.5 以降でスレッド機能をサポート
    if (this.isVersionAtLeast('1.5') && message.threadId) {
      this.updateThread(message.threadId, message);
    }

    // v2.0 以降で添付ファイルとリアクションをサポート
    if (this.isVersionAtLeast('2.0')) {
      if (message.attachments) {
        this.displayAttachments(message.attachments);
      }
      if (message.reactions) {
        this.displayReactions(message.reactions);
      }
    }
  }

  private isVersionAtLeast(
    requiredVersion: string
  ): boolean {
    return (
      this.compareVersions(this.version, requiredVersion) >=
      0
    );
  }

  private compareVersions(v1: string, v2: string): number {
    const parts1 = v1.split('.').map(Number);
    const parts2 = v2.split('.').map(Number);

    for (
      let i = 0;
      i < Math.max(parts1.length, parts2.length);
      i++
    ) {
      const num1 = parts1[i] || 0;
      const num2 = parts2[i] || 0;

      if (num1 > num2) return 1;
      if (num1 < num2) return -1;
    }

    return 0;
  }

  private displayMessage(
    userId: string,
    text: string
  ): void {
    console.log(`[${userId}]: ${text}`);
  }

  private updateThread(
    threadId: string,
    message: ChatMessage
  ): void {
    console.log(`スレッド ${threadId} を更新`);
  }

  private displayAttachments(
    attachments: Attachment[]
  ): void {
    attachments.forEach((att) => {
      console.log(`添付ファイル: ${att.url}`);
    });
  }

  private displayReactions(reactions: Reaction[]): void {
    console.log(
      `リアクション: ${reactions
        .map((r) => r.emoji)
        .join(' ')}`
    );
  }
}

バージョン比較メソッド isVersionAtLeast を使って、現在のバージョンが機能をサポートしているかを判定します。サポートしている場合のみ、該当の処理を実行するのです。

以下の図は、バージョン別のメッセージ処理フローを示しています。

mermaidflowchart TB
  receive["メッセージ受信"] --> parse["JSON パース"]
  parse --> check_type{メッセージタイプ}

  check_type -->|chat| base["基本表示<br/>userId, text"]

  base --> check_v15{バージョン<br/>1.5 以上?}
  check_v15 -->|Yes| thread_check{threadId<br/>存在?}
  check_v15 -->|No| check_v20
  thread_check -->|Yes| thread_proc["スレッド処理"]
  thread_check -->|No| check_v20
  thread_proc --> check_v20

  check_v20{バージョン<br/>2.0 以上?}
  check_v20 -->|Yes| attach_check{attachments<br/>存在?}
  check_v20 -->|No| done["処理完了"]
  attach_check -->|Yes| attach_proc["添付ファイル表示"]
  attach_check -->|No| reaction_check
  attach_proc --> reaction_check{reactions<br/>存在?}
  reaction_check -->|Yes| reaction_proc["リアクション表示"]
  reaction_check -->|No| done
  reaction_proc --> done

この図から、バージョンとフィールドの存在チェックを段階的に行い、対応可能な処理のみを実行する流れが理解できます。旧バージョンは途中で処理を完了し、新バージョンは追加機能まで処理を進めるのです。

デフォルト値とフォールバック処理

新しいフィールドがない場合のデフォルト値やフォールバック処理を実装します。

typescript// デフォルト値とフォールバック
class MessageNormalizer {
  normalizeChatMessage(
    message: Partial<ChatMessage>
  ): ChatMessage {
    return {
      type: 'chat',
      timestamp: message.timestamp || Date.now(),
      version: message.version || '1.0',
      userId: message.userId || 'unknown',
      text: message.text || '',

      // オプショナルフィールドはそのまま
      threadId: message.threadId,
      replyTo: message.replyTo,
      attachments: message.attachments,
      reactions: message.reactions,
    };
  }

  // 旧バージョンメッセージを新フォーマットに変換
  upgradeMessage(
    message: any,
    fromVersion: string
  ): ChatMessage {
    if (fromVersion === '1.0') {
      // v1.0 のメッセージには threadId や attachments がない
      return {
        ...message,
        version: '2.0',
        threadId: undefined,
        attachments: undefined,
        reactions: undefined,
      };
    }

    if (fromVersion === '1.5') {
      // v1.5 には attachments と reactions がない
      return {
        ...message,
        version: '2.0',
        attachments: undefined,
        reactions: undefined,
      };
    }

    return message;
  }
}

MessageNormalizer は、不完全なメッセージを正規化し、必須フィールドにデフォルト値を設定します。また、旧バージョンのメッセージを新フォーマットに変換する機能も提供するのです。

具体例

リアルタイムチャットアプリケーションでの実装

実際のチャットアプリケーションで、バージョン交渉から機能フラグ、後方互換性までを統合した実装例を見てみましょう。

サーバー側の統合実装

まず、すべての仕組みを統合したサーバークラスを作成します。

typescript// サーバー側の統合実装
import WebSocket from 'ws';

interface Client {
  ws: WebSocket;
  id: string;
  version: string;
  capabilities: string[];
}

class ChatWebSocketServer {
  private wss: WebSocket.Server;
  private clients: Map<string, Client> = new Map();
  private capabilityManager: CapabilityManager;
  private featureFlagManager: FeatureFlagManager;
  private messageNormalizer: MessageNormalizer;

  constructor(port: number) {
    this.wss = new WebSocket.Server({ port });
    this.capabilityManager = new CapabilityManager();
    this.featureFlagManager = new FeatureFlagManager();
    this.messageNormalizer = new MessageNormalizer();

    this.setupServer();
  }

  private setupServer(): void {
    this.wss.on('connection', (ws: WebSocket) => {
      let clientId: string | null = null;

      ws.on('message', (data: WebSocket.Data) => {
        const message = JSON.parse(data.toString());

        // ハンドシェイク処理
        if (message.type === 'handshake') {
          clientId = this.handleHandshake(ws, message);
        } else if (clientId) {
          this.handleClientMessage(clientId, message);
        }
      });

      ws.on('close', () => {
        if (clientId) {
          this.clients.delete(clientId);
          console.log(
            `クライアント ${clientId} が切断しました`
          );
        }
      });
    });

    console.log(
      `WebSocket サーバーがポート ${this.wss.options.port} で起動しました`
    );
  }
}

このクラスは、接続時にハンドシェイクを実行し、クライアント情報を管理します。メッセージ受信時は、ハンドシェイク完了後のクライアントのみ処理を行うのです。

次に、ハンドシェイク処理の詳細を実装します。

typescript// ハンドシェイク処理の実装
class ChatWebSocketServer {
  // ... 前述のコード

  private handleHandshake(
    ws: WebSocket,
    message: HandshakeMessage
  ): string | null {
    const selectedVersion = this.negotiateVersion(
      message.supportedVersions
    );

    if (!selectedVersion) {
      ws.send(
        JSON.stringify({
          type: 'error',
          code: 'VERSION_MISMATCH',
          message: 'サポートされるバージョンがありません',
        })
      );
      ws.close();
      return null;
    }

    const clientId = message.clientId;
    const capabilities =
      this.capabilityManager.getCapabilitiesForVersion(
        selectedVersion
      );

    // クライアント情報を保存
    this.clients.set(clientId, {
      ws,
      id: clientId,
      version: selectedVersion,
      capabilities,
    });

    // ハンドシェイク応答を送信
    const response: HandshakeResponse = {
      type: 'handshake_ack',
      selectedVersion,
      serverCapabilities: capabilities,
    };

    ws.send(JSON.stringify(response));
    console.log(
      `クライアント ${clientId} がバージョン ${selectedVersion} で接続しました`
    );

    return clientId;
  }

  private negotiateVersion(
    clientVersions: string[]
  ): string | null {
    const supportedVersions = ['2.0', '1.5', '1.0'];
    for (const version of clientVersions) {
      if (supportedVersions.includes(version)) {
        return version;
      }
    }
    return null;
  }
}

ハンドシェイクでは、バージョン交渉を行い、成功した場合にクライアント情報を保存します。その後、選択されたバージョンとサポート機能をクライアントに通知するのです。

クライアントからのメッセージ処理も実装しましょう。

typescript// クライアントメッセージの処理
class ChatWebSocketServer {
  // ... 前述のコード

  private handleClientMessage(
    clientId: string,
    message: any
  ): void {
    const client = this.clients.get(clientId);
    if (!client) return;

    // メッセージの正規化
    const normalized =
      this.messageNormalizer.normalizeChatMessage(message);

    // メッセージタイプ別の処理
    switch (normalized.type) {
      case 'chat':
        this.broadcastChatMessage(client, normalized);
        break;
      default:
        console.warn(
          `未知のメッセージタイプ: ${normalized.type}`
        );
    }
  }

  private broadcastChatMessage(
    sender: Client,
    message: ChatMessage
  ): void {
    // 全クライアントにメッセージを配信
    this.clients.forEach((client, clientId) => {
      if (clientId === sender.id) return; // 送信者には送らない

      // クライアントのバージョンに応じてメッセージを調整
      const adjustedMessage = this.adjustMessageForClient(
        message,
        client
      );

      client.ws.send(JSON.stringify(adjustedMessage));
    });
  }
}

メッセージ受信時は、まず正規化を行い、その後タイプに応じた処理を実行します。チャットメッセージの場合は、全クライアントに配信するのです。

バージョンに応じたメッセージ調整処理も追加します。

typescript// クライアントバージョンに応じたメッセージ調整
class ChatWebSocketServer {
  // ... 前述のコード

  private adjustMessageForClient(
    message: ChatMessage,
    client: Client
  ): Partial<ChatMessage> {
    const adjusted: Partial<ChatMessage> = { ...message };

    // v1.0 クライアントには基本フィールドのみ
    if (client.version === '1.0') {
      delete adjusted.threadId;
      delete adjusted.replyTo;
      delete adjusted.attachments;
      delete adjusted.reactions;
    }

    // v1.5 クライアントには attachments と reactions を除外
    if (client.version === '1.5') {
      delete adjusted.attachments;
      delete adjusted.reactions;
    }

    // 機能フラグによる調整
    if (
      !this.featureFlagManager.isFeatureEnabled(
        'compression',
        client.version,
        client.id
      )
    ) {
      // 圧縮を無効化(実装は省略)
    }

    return adjusted;
  }
}

この処理により、各クライアントのバージョンに応じて、サポートされないフィールドを削除します。旧バージョンのクライアントは新しいフィールドを受け取らないため、パースエラーを防げるのです。

クライアント側の統合実装

クライアント側でも、バージョン交渉と機能管理を実装します。

typescript// クライアント側の統合実装
class ChatWebSocketClient {
  private ws: WebSocket | null = null;
  private clientId: string;
  private supportedVersions = ['2.0', '1.5', '1.0'];
  private negotiatedVersion: string | null = null;
  private serverCapabilities: string[] = [];
  private messageHandler: MessageHandler | null = null;

  constructor(private url: string) {
    this.clientId = this.generateClientId();
  }

  connect(): Promise<void> {
    return new Promise((resolve, reject) => {
      this.ws = new WebSocket(this.url);

      this.ws.onopen = () => {
        // ハンドシェイク開始
        this.sendHandshake();
      };

      this.ws.onmessage = (event) => {
        const message = JSON.parse(event.data);

        if (message.type === 'handshake_ack') {
          this.handleHandshakeAck(message);
          resolve();
        } else if (message.type === 'error') {
          reject(new Error(message.message));
        } else if (this.messageHandler) {
          this.messageHandler.handleChatMessage(message);
        }
      };

      this.ws.onerror = (error) => {
        reject(error);
      };
    });
  }

  private generateClientId(): string {
    return `client_${Date.now()}_${Math.random()
      .toString(36)
      .substr(2, 9)}`;
  }

  private sendHandshake(): void {
    if (!this.ws) return;

    const handshake: HandshakeMessage = {
      type: 'handshake',
      supportedVersions: this.supportedVersions,
      clientId: this.clientId,
    };

    this.ws.send(JSON.stringify(handshake));
  }
}

クライアントは接続時にハンドシェイクを送信し、サーバーからの応答を待ちます。ハンドシェイク完了後、通常のメッセージ処理が開始されるのです。

ハンドシェイク応答の処理を実装します。

typescript// ハンドシェイク応答の処理
class ChatWebSocketClient {
  // ... 前述のコード

  private handleHandshakeAck(
    message: HandshakeResponse
  ): void {
    this.negotiatedVersion = message.selectedVersion;
    this.serverCapabilities = message.serverCapabilities;

    // バージョンに応じたメッセージハンドラを初期化
    this.messageHandler = new MessageHandler(
      this.negotiatedVersion
    );

    console.log(
      `接続成功: バージョン ${this.negotiatedVersion}`
    );
    console.log(
      `サーバー機能: ${this.serverCapabilities.join(', ')}`
    );
  }

  sendChatMessage(
    text: string,
    options?: {
      threadId?: string;
      attachments?: Attachment[];
    }
  ): void {
    if (!this.ws || !this.negotiatedVersion) {
      console.error('WebSocket が接続されていません');
      return;
    }

    const message: ChatMessage = {
      type: 'chat',
      timestamp: Date.now(),
      version: this.negotiatedVersion,
      userId: this.clientId,
      text,
    };

    // バージョンが対応している場合のみオプションを追加
    if (this.isVersionAtLeast('1.5') && options?.threadId) {
      message.threadId = options.threadId;
    }

    if (
      this.isVersionAtLeast('2.0') &&
      options?.attachments
    ) {
      message.attachments = options.attachments;
    }

    this.ws.send(JSON.stringify(message));
  }

  private isVersionAtLeast(
    requiredVersion: string
  ): boolean {
    if (!this.negotiatedVersion) return false;
    return (
      this.compareVersions(
        this.negotiatedVersion,
        requiredVersion
      ) >= 0
    );
  }

  private compareVersions(v1: string, v2: string): number {
    const parts1 = v1.split('.').map(Number);
    const parts2 = v2.split('.').map(Number);

    for (
      let i = 0;
      i < Math.max(parts1.length, parts2.length);
      i++
    ) {
      const num1 = parts1[i] || 0;
      const num2 = parts2[i] || 0;

      if (num1 > num2) return 1;
      if (num1 < num2) return -1;
    }

    return 0;
  }
}

クライアントは、ハンドシェイク応答で受け取ったバージョンとサーバー機能を保存します。メッセージ送信時は、交渉済みのバージョンでサポートされる機能のみを使用するのです。

以下の図は、クライアントとサーバー間の通信フロー全体を示しています。

mermaidsequenceDiagram
  participant Client as クライアント
  participant Server as サーバー

  Client->>Server: 接続要求
  Server-->>Client: 接続確立

  Client->>Server: handshake<br/>(supportedVersions)
  Server->>Server: バージョン交渉
  Server->>Server: 機能リスト生成
  Server-->>Client: handshake_ack<br/>(selectedVersion, capabilities)

  Client->>Client: メッセージハンドラ初期化

  Note over Client,Server: 通常通信開始

  Client->>Server: chat メッセージ<br/>(バージョン対応フィールドのみ)
  Server->>Server: メッセージ正規化
  Server->>Server: 各クライアントのバージョン確認
  Server-->>Client: 調整済みメッセージ<br/>(v1.0 用)
  Server-->>Client: 調整済みメッセージ<br/>(v2.0 用)

  Client->>Client: メッセージ処理<br/>(バージョン別分岐)

この図から、ハンドシェイクから通常通信、メッセージ配信までの一連の流れが把握できます。各段階でバージョンと機能のチェックが行われ、適切な処理が実行されるのです。

エラーハンドリングとロギング

バージョン管理においては、エラーハンドリングとロギングも重要です。

typescript// エラーハンドリングとロギング
class ProtocolLogger {
  logVersionNegotiation(
    clientId: string,
    clientVersions: string[],
    selectedVersion: string | null
  ): void {
    if (selectedVersion) {
      console.log(
        `[VERSION_NEGOTIATION] クライアント ${clientId}: ` +
          `サポートバージョン [${clientVersions.join(
            ', '
          )}] -> ` +
          `選択バージョン ${selectedVersion}`
      );
    } else {
      console.error(
        `[VERSION_MISMATCH] クライアント ${clientId}: ` +
          `サポートバージョン [${clientVersions.join(
            ', '
          )}] に互換性なし`
      );
    }
  }

  logFeatureUsage(
    clientId: string,
    version: string,
    feature: string,
    enabled: boolean
  ): void {
    console.log(
      `[FEATURE_FLAG] クライアント ${clientId} (v${version}): ` +
        `機能 "${feature}" は ${enabled ? '有効' : '無効'}`
    );
  }

  logMessageAdjustment(
    clientId: string,
    originalFields: string[],
    adjustedFields: string[]
  ): void {
    const removed = originalFields.filter(
      (f) => !adjustedFields.includes(f)
    );
    if (removed.length > 0) {
      console.log(
        `[MESSAGE_ADJUSTMENT] クライアント ${clientId}: ` +
          `削除されたフィールド [${removed.join(', ')}]`
      );
    }
  }
}

このロガーにより、バージョン交渉、機能フラグの判定、メッセージ調整の状況を記録できます。問題が発生した際、ログを追跡することで原因を特定しやすくなるのです。

エラーコードの定義も行いましょう。

typescript// エラーコードの定義
enum ProtocolErrorCode {
  VERSION_MISMATCH = 'VERSION_MISMATCH',
  UNSUPPORTED_FEATURE = 'UNSUPPORTED_FEATURE',
  INVALID_MESSAGE_FORMAT = 'INVALID_MESSAGE_FORMAT',
  CAPABILITY_NOT_AVAILABLE = 'CAPABILITY_NOT_AVAILABLE',
}

interface ProtocolError {
  type: 'error';
  code: ProtocolErrorCode;
  message: string;
  details?: any;
}

class ProtocolErrorHandler {
  handleError(ws: WebSocket, error: ProtocolError): void {
    ws.send(JSON.stringify(error));

    // エラーコードに応じた処理
    switch (error.code) {
      case ProtocolErrorCode.VERSION_MISMATCH:
        // バージョン不一致の場合は接続を切断
        ws.close(1003, 'Version mismatch');
        break;

      case ProtocolErrorCode.UNSUPPORTED_FEATURE:
        // 未サポート機能の場合は警告のみ
        console.warn(
          `未サポート機能が使用されました: ${error.message}`
        );
        break;

      case ProtocolErrorCode.INVALID_MESSAGE_FORMAT:
        // 不正なメッセージ形式の場合は無視
        console.error(
          `不正なメッセージ形式: ${error.message}`
        );
        break;

      default:
        console.error(
          `プロトコルエラー: ${error.code} - ${error.message}`
        );
    }
  }

  createError(
    code: ProtocolErrorCode,
    message: string,
    details?: any
  ): ProtocolError {
    return {
      type: 'error',
      code,
      message,
      details,
    };
  }
}

エラーコードを体系化することで、問題の種類を明確に区別できます。バージョン不一致のような致命的なエラーは接続を切断し、軽微なエラーは警告として記録するのです。

テスト戦略

バージョン管理機能の品質を保証するため、包括的なテストを実装します。

typescript// バージョン交渉のテスト
describe('バージョン交渉', () => {
  let server: ChatWebSocketServer;

  beforeEach(() => {
    server = new ChatWebSocketServer(8080);
  });

  afterEach(() => {
    server.close();
  });

  test('最新の共通バージョンを選択する', () => {
    const clientVersions = ['2.0', '1.5', '1.0'];
    const result =
      server['negotiateVersion'](clientVersions);

    expect(result).toBe('2.0');
  });

  test('一部のバージョンのみ互換性がある場合', () => {
    const clientVersions = ['3.0', '1.5', '1.0'];
    const result =
      server['negotiateVersion'](clientVersions);

    expect(result).toBe('1.5');
  });

  test('互換性のあるバージョンがない場合', () => {
    const clientVersions = ['3.0', '2.5'];
    const result =
      server['negotiateVersion'](clientVersions);

    expect(result).toBeNull();
  });
});

テストでは、バージョン交渉のさまざまなシナリオをカバーします。正常系だけでなく、互換性がない場合の動作も確認するのです。

機能フラグのテストも重要です。

typescript// 機能フラグのテスト
describe('機能フラグ', () => {
  let featureFlagManager: FeatureFlagManager;

  beforeEach(() => {
    featureFlagManager = new FeatureFlagManager();
  });

  test('バージョンに応じた機能の有効化', () => {
    expect(
      featureFlagManager.isFeatureEnabled(
        'compression',
        '2.0',
        'client1'
      )
    ).toBe(true);

    expect(
      featureFlagManager.isFeatureEnabled(
        'compression',
        '1.0',
        'client1'
      )
    ).toBe(false);
  });

  test('ロールアウト割合による段階的リリース', () => {
    // 同じクライアントIDは常に同じ結果
    const clientId = 'test_client_123';
    const result1 = featureFlagManager.isFeatureEnabled(
      'multiplexing',
      '2.0',
      clientId
    );
    const result2 = featureFlagManager.isFeatureEnabled(
      'multiplexing',
      '2.0',
      clientId
    );

    expect(result1).toBe(result2);
  });

  test('ロールアウト割合の分布', () => {
    const results = new Map<boolean, number>();
    results.set(true, 0);
    results.set(false, 0);

    // 1000 個のクライアントIDでテスト
    for (let i = 0; i < 1000; i++) {
      const clientId = `client_${i}`;
      const enabled = featureFlagManager.isFeatureEnabled(
        'multiplexing',
        '2.0',
        clientId
      );
      results.set(enabled, (results.get(enabled) || 0) + 1);
    }

    // 50% ロールアウトなので、有効化率は 40-60% の範囲内であるべき
    const enabledCount = results.get(true) || 0;
    const enabledPercentage = (enabledCount / 1000) * 100;

    expect(enabledPercentage).toBeGreaterThan(40);
    expect(enabledPercentage).toBeLessThan(60);
  });
});

機能フラグのテストでは、バージョンチェック、ロールアウト割合の一貫性、分布の適切性を検証します。これにより、機能が意図通りに制御されることが保証されるのです。

まとめ

WebSocket プロトコルのバージョン管理は、長期運用可能なリアルタイムアプリケーションを構築する上で不可欠な要素です。

バージョン交渉により、クライアントとサーバーが互いにサポートするバージョンを確認し、最適なバージョンで通信できます。ハンドシェイクフェーズでバージョン情報を交換することで、接続開始時に互換性を確立できるのです。

機能フラグを使えば、コードの変更なしに特定の機能を有効・無効にできます。バージョン、クライアント ID、ロールアウト割合などの条件に基づいて、段階的に新機能をリリースできるでしょう。

後方互換性を保つメッセージ設計では、オプショナルフィールドとして新機能を追加し、旧バージョンのクライアントに影響を与えません。バージョンに応じたメッセージ調整により、各クライアントが理解できる形式でデータを提供できるのです。

これらの仕組みを組み合わせることで、以下のメリットが得られます:

#メリット説明
1柔軟なリリース管理段階的なロールアウトやカナリアリリースが可能
2リスクの低減新機能の影響範囲を限定し、問題発生時の影響を最小化
3長期運用性の向上旧バージョンのクライアントも継続利用可能
4開発効率の向上バージョン別の分岐処理を体系化し、保守性が向上
5ユーザー体験の向上バージョン不一致による接続エラーを防止

実装時は、エラーハンドリング、ロギング、テストを適切に行うことが重要です。バージョン交渉の失敗、機能フラグの誤作動、メッセージ形式の不一致など、さまざまなエッジケースに対応できる設計が求められます。

WebSocket プロトコルのバージョン管理は一度実装すれば完了というものではなく、アプリケーションの成長に合わせて継続的に改善していく必要があるのです。

関連リンク