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 プロトコルのバージョン管理は一度実装すれば完了というものではなく、アプリケーションの成長に合わせて継続的に改善していく必要があるのです。
関連リンク
- article
WebSocket プロトコル設計:バージョン交渉・機能フラグ・後方互換のパターン
- article
WebSocket ハンドシェイク&ヘッダー チートシート:Upgrade/Sec-WebSocket-Key/Accept 一覧
- article
WebSocket を NGINX/HAProxy で終端する設定例:アップグレードヘッダーとタイムアウト完全ガイド
- article
WebSocket vs WebTransport vs SSE 徹底比較:遅延・帯域・安定性を実測レビュー
- article
WebSocket 導入判断ガイド:SSE・WebTransport・長輪講ポーリングとの適材適所を徹底解説
- article
WebSocket 技術の全体設計図:フレーム構造・サブプロトコル・拡張の要点を一気に理解
- article
NestJS クリーンアーキテクチャ:UseCase/Domain/Adapter を疎結合に保つ設計術
- article
WebSocket プロトコル設計:バージョン交渉・機能フラグ・後方互換のパターン
- article
MySQL 読み書き分離設計:ProxySQL で一貫性とスループットを両立
- article
Motion(旧 Framer Motion)アニメオーケストレーション設計:timeline・遅延・相互依存の整理術
- article
WebRTC で遠隔支援:画面注釈・ポインタ共有・低遅延音声の実装事例
- article
JavaScript パフォーマンス最適化大全:レイアウトスラッシングを潰す実践テク
- blog
iPhone 17シリーズの発表!全モデルiPhone 16から進化したポイントを見やすく整理
- blog
Googleストアから訂正案内!Pixel 10ポイント有効期限「1年」表示は誤りだった
- blog
【2025年8月】Googleストア「ストアポイント」は1年表記はミス?2年ルールとの整合性を検証
- blog
Googleストアの注文キャンセルはなぜ起きる?Pixel 10購入前に知るべき注意点
- blog
Pixcel 10シリーズの発表!全モデル Pixcel 9 から進化したポイントを見やすく整理
- blog
フロントエンドエンジニアの成長戦略:コーチングで最速スキルアップする方法
- review
今の自分に満足していますか?『持たざる者の逆襲 まだ何者でもない君へ』溝口勇児
- review
ついに語られた業界の裏側!『フジテレビの正体』堀江貴文が描くテレビ局の本当の姿
- review
愛する勇気を持てば人生が変わる!『幸せになる勇気』岸見一郎・古賀史健のアドラー実践編で真の幸福を手に入れる
- review
週末を変えれば年収も変わる!『世界の一流は「休日」に何をしているのか』越川慎司の一流週末メソッド
- review
新しい自分に会いに行こう!『自分の変え方』村岡大樹の認知科学コーチングで人生リセット
- review
科学革命から AI 時代へ!『サピエンス全史 下巻』ユヴァル・ノア・ハラリが予見する人類の未来