sgcWebSockets でよくある 10 の間違い(と修正方法)

· コンポーネント

長年サポートチケットに回答してきた経験から、「WebSocket 接続が変な動きをする」報告の大多数は同じ少数の問題に起因します。どれもライブラリのバグではなく — すべて存在を知れば 5 分で修正できる構成ミスです。本稿は最もよくある 10 個を、症状、原因、それぞれを解決するワンライナーと共にリストします。

このリストから 1 つだけ読むなら、#2 にしてください。OnMessage イベントのブロッキングは、他のすべてのカテゴリを合計するより「ライブラリが遅い」チケットの原因になっており、数百の同時クライアントを超えてスケールするまで見えません。深夜の本番デバッグセッションを避けるため、出荷前に修正してください。

間違い 1: WatchDog 再接続が無効

症状: クライアントは起動時に接続し、何時間も問題なく動作し、Wi-Fi の途切れ、VPN 再接続、サーバー再起動の後で黙ってメッセージ受信を止める。

原因: WatchDog プロパティはデフォルトでオフです。一部のアプリはフェイルファスト動作を望むため、ライブラリは自動再接続を望んでいると仮定しません。ほとんどは望みます。

oClient.WatchDog.Enabled  := True;
oClient.WatchDog.Interval := 10;   // try every 10 seconds
oClient.WatchDog.Attempts := 0;    // 0 = unlimited

設定して忘れる方式。OnDisconnect のロギングと組み合わせれば、現場で再接続がどのくらい発火するかを見られます。ユーザーがネットワーク間をローミングするモバイルやラップトップのデプロイでは、次のインターバルティックを待つ代わりに即座の再接続をトリガーするため、OS のネットワーク変更通知(.NET の SystemEvents.NetworkAvailabilityChanged、Windows-Delphi では WMI ルート)も結線してください。

間違い 2: OnMessage イベントのブロッキング

症状: 負荷下でスループットが崩壊。サーバーが「詰まっている」感覚。CPU は低いが、メッセージが詰まる。

原因: OnMessage は I/O ワーカースレッドで実行されます。その中で遅いデータベース、外部 HTTP API、長時間実行パーサーを呼び出すと、そのワーカーを共有する他のすべての接続を飢えさせます。

// BAD: blocks the worker
procedure TForm1.ServerMessage(Connection: TsgcWSConnection; const Text: string);
begin
  oDb.Query('INSERT INTO events VALUES(?)', [Text]);     // 50 ms
  oHttp.Get('https://billing/track?event=' + Text);      // 200 ms
end;

// GOOD: hand off to a worker queue
procedure TForm1.ServerMessage(Connection: TsgcWSConnection; const Text: string);
begin
  oWorkQueue.Push(TWorkItem.Create(Connection.Guid, Text));
end;

間違い 3: HeartBeat の無視

症状: ゴースト接続が蓄積。クライアントが死んでいることが分かっているのに Connections.Count が増え続ける。

原因: アプリケーション層のハートビートなしでは、OS TCP keepalive タイマーは約 2 時間後に発火します。そこまでにメモリを食い荒らすゾンビソケットを数万持つことになります。

oServer.HeartBeat.Enabled  := True;
oServer.HeartBeat.Interval := 30;
oServer.HeartBeat.Timeout  := 90;

間違い 4: TLS バージョン不一致

症状: クライアントが「handshake failed」または「SSL_ERROR_SYSCALL」を受け取る。開発マシンでは動作するが、企業プロキシ越しの本番では失敗。

原因: クライアントの SSL オプションは後方互換性のためデフォルトで TLS 1.0–1.2。モダンなサーバーは TLS 1.2/1.3 のみを要求します。または OpenSSL DLL が古いか。

oClient.TLSOptions.Version := tlsTLSv1_2;     // or tlsTLSv1_3
oClient.TLSOptions.OpenSSL_Options.LibPath := ExtractFilePath(ParamStr(0));
// Bundle the latest libcrypto-3.dll / libssl-3.dll with your installer.

間違い 5: フラグメント化されたメッセージを処理していない

症状: サーバーが大きな JSON ドキュメントの最初の部分を受信し、パーサーが失敗し、接続が閉じられる。

原因: デフォルトで、sgcWebSockets はフラグメントを自動で再構成し、完全なペイロードでのみ OnMessage を発火します。しかしメモリ理由で ReadOptions.FragmentMode := frgPartial を設定したなら、自分で再構成しなければなりません。

// Default (recommended for most apps)
oServer.ReadOptions.FragmentMode := frgComplete;

// If you opted into partial delivery, reassemble manually
procedure TForm1.OnFragment(Connection: TsgcWSConnection;
  const aPartial: TBytes; aIsFinal: Boolean);
begin
  Buffers[Connection.Guid].Append(aPartial);
  if aIsFinal then
    Process(Buffers[Connection.Guid].ToBytes);
end;

間違い 6: GUI スレッドで同期 API を使用

症状: 接続時、または遅いリンクで WriteData を呼ぶときに UI が数秒間フリーズ。

原因: メインスレッドでのブロッキング呼び出し。VCL/FMX アプリでは常に非同期パターンを使うか、ワーカースレッドから呼び出してください。

// BAD: blocks the UI
oClient.Active := True;

// GOOD: connect asynchronously
TTask.Run(procedure
  begin
    oClient.Active := True;
  end);

// Or use the event-driven API
oClient.OnConnect := DoConnected;
oClient.Connect;  // returns immediately

間違い 7: サブプロトコルの選択を忘れる

症状: 接続は成功するがピアがすべてのフレームを拒否、または予期と異なる形式で応答。

原因: 多くの WebSocket サーバー(MQTT-over-WS、STOMP、GraphQL-WS、Phoenix)はハンドシェイクで特定のサブプロトコルを要求します。なしでは、サーバーは別のプロトコルにデフォルト設定するか、切断します。

// MQTT over WebSocket
oClient.Specifications.Hixie76    := False;
oClient.Specifications.RFC6455    := True;
oClient.HeadersRequest.Add('Sec-WebSocket-Protocol: mqttv3.1');

// GraphQL over WS
oClient.HeadersRequest.Add('Sec-WebSocket-Protocol: graphql-ws');

間違い 8: バッファサイズが小さすぎる(または大きすぎる)

症状: 多くの小さなメッセージを押し出すサーバーで CPU が高い、または大きなメッセージを押し出すサーバーで OOM。

原因: デフォルトの送受信バッファは汎用向けにサイズ設定されています。トラフィック形状に合わせて調整してください。

// Small messages (chat, telemetry): smaller buffers reduce per-conn memory
oServer.IOHandler.RecvBufferSize := 4096;
oServer.IOHandler.SendBufferSize := 4096;

// Big messages (file transfer, media): larger buffers reduce syscalls
oServer.IOHandler.RecvBufferSize := 65536;
oServer.IOHandler.SendBufferSize := 65536;

間違い 9: 公開サーバーで Origin チェックなし

症状: セキュリティ監査が「任意のウェブサイトがユーザーのブラウザ経由で WebSocket に接続できる」とフラグ。

原因: WebSocket プロトコルは同一オリジンを強制しません。サーバーは Origin ヘッダーを検証しなければなりません。

procedure TForm1.ServerBeforeConnect(Connection: TsgcWSConnection;
  var Continue: Boolean);
var
  vOrigin: string;
begin
  vOrigin := Connection.Headers.Values['Origin'];
  Continue := (vOrigin = 'https://app.example.com')
           or (vOrigin = 'https://admin.example.com');
end;

間違い 10: OnMessage で機密データを記録

症状: 監査員がログファイルに API キー、JWT、PII を発見。コンプライアンス問題。

原因: OnMessage の簡単な LogMemo.Lines.Add(Text) がすべてのペイロードを永遠にディスクに書きます。

procedure TForm1.ServerMessage(Connection: TsgcWSConnection; const Text: string);
begin
  // Hash / redact before logging
  LogMemo.Lines.Add(Format('[%s] %d bytes (sha=%s)',
    [Connection.Guid, Length(Text), ShortHash(Text)]));
  Process(Text);
end;

ボーナス: history.txt を読まない

すべてのリリースには 2013 年以来のすべての変更、修正、破壊的注意事項をリストする history.txt が付属します。アップグレード後に 5 分間目を通すことで、後で「なぜ動かなくなったか」のデバッグ時間が何時間も節約されます。

ボーナス 2: プロジェクト間でコンポーネントバージョンを混在

Delphi 開発者はときどき、新しい sgcWebSockets リリースから 1 つの .pas を「このファイルだけ」古いプロジェクトにコピーします。それは動作する — 動作しなくなるまで。ファイルは 2 リリース前に変わった型に依存しており、リンカーが謎に失敗するか、悪化すればリンクして実行時にクラッシュします。常にライブラリ全体を一緒にアップグレードしてください。1 ファイルコピーで節約される 30 秒は、下流で何かが壊れたときの 4 時間のデバッグの価値がありません。

ボーナス 3: WebSocket を fire-and-forget として扱う

WebSocket はメッセージキューではありません。TCP 上の双方向バイトストリームです。ネットワークがメッセージ途中で切れると、フレームは失われ、自動的に再配送されません。ビジネスクリティカルなメッセージには、独自の確認応答プロトコルを上に追加する必要があります — 通常はメッセージごとの UUID、受信側からの明示 ACK、タイムアウト後の送信側での再送。この層をスキップするのは「ユーザーが入力中」通知には問題ありませんが、「ユーザーがカートで支払い済み」には致命的です。

ボーナス 4: メモにメモリを漏らさせる

症状: デバッグフォームの診断メモが、トラフィックの数時間後にクライアントを OOM させる最初のもの。sgcWebSockets を責めますが、sgcWebSockets は無実です。

原因: TMemo はこれまで追加されたすべての行を保持します。1 秒に 100 行で 1 時間あたり 360,000 行。各行は文字列を割り当て。VCL は WM_PAINT のたびに数千の見えない行を描画。ライブラリは何も悪いことをしていないのに、プロセスは停止します。

// Cap the diagnostic memo
procedure TForm1.LogLine(const aText: string);
const
  cMaxLines = 500;
begin
  TThread.Queue(nil, procedure
    begin
      while Memo1.Lines.Count > cMaxLines do
        Memo1.Lines.Delete(0);
      Memo1.Lines.Add(aText);
    end);
end;

さらに良い: LoggerPro でローリングファイルにログし、視覚的デバッグのために最後の 200 行だけをメモにミラーする。本番コードはネットワークスレッドから UI コントロールに決して書き込むべきではありません。

ボーナス 5: Application.Terminate 前にサーバーを閉じない

症状: アプリのシャットダウン時にプロセスが 30 秒ハングする、または接続が優雅でなく切り取られたため OS がクライアントログで未処理例外を報告。

原因: サーバーデストラクタは各接続にクローズフレームを送り、OS がリスニングポートを解放するのを待ちます。oServer.Active := False の前に Application.Terminate を呼ぶと、接続はハンドシェイク途中で死に、OS ポートは TIME_WAIT に留まり、迅速な再起動をブロックします。

procedure TForm1.FormCloseQuery(Sender: TObject; var CanClose: Boolean);
begin
  if oServer.Active then
  begin
    oServer.Broadcast('{"event":"shutdown","reconnect_after":5}');
    Sleep(200);                  // let frames flush
    oServer.Active := False;     // graceful close
  end;
end;

コンソールサーバーでは、Windows では SetConsoleCtrlHandler または Linux では SIGTERM をフックし、同じシャットダウンシーケンスを実行してください。サービスマネージャーの HUP/再起動ループと組み合わせれば、接続ドロップゼロのデプロイが手に入ります。

パターンの背後のパターン

これらの間違いのほとんどには共通の根があります: ネットワークが信頼できると仮定すること。それは違います。半開 TCP 接続は起こります。モバイルネットワークは切れます。企業プロキシは TLS を壊します。Wi-Fi はローミングします。サーバーは再起動します。クラウドロードバランサーは 60 秒後にアイドル接続を終端します。これらの条件で生き残らない WebSocket アプリは完成していません — ハッピーパスのデモです。良い知らせ: ライブラリはこれらのシナリオすべてにコントロールを公開します。悪い知らせ: 後方互換性のためほとんどがデフォルトでオフです。ドキュメントの WatchDog、HeartBeat、Reconnect、TLS オプションページを読むのに費やす 2 時間は、買える中で最も安い保険です。

2 次パターン: I/O スレッドを尊重する。1 ミリ秒以上かかるもの — データベースクエリ、ファイル I/O、外部 HTTP 呼び出し、長い文字列の正規表現、100 KB ペイロードの JSON パース — は OnMessage ではなくワーカースレッドに属します。このルールを絶対にしてください。ジュニア開発者は 3 ヶ月後にこれを違反します。「OnMessage でブロッキングなし」を含むコードレビューチェックリストは、出荷前に捕捉します。

次にどこへ

高トラフィックサーバーをチューニングするなら、次に sgcWebSockets パフォーマンスチューニング を読んでください。ライブラリは初めてですか?はじめにハブ から始めて、5 分で最初の WebSocket を接続してください。