雑誌のテーマからプロジェクト実践へ
該当記事に関連するサービス・技術ページ
なぜ ein Delphi WebSocket Client は実務で単なる „Connect“ 以上になるのか
Ein Delphi WebSocket Client は数分で組み立てられます:URL、Connect、SendText、完了。しかし、個別の企業向けソフトウェアやプロセスに近いソリューションでは、問題は多くの場合運用段階で顕在化します。リバースプロキシがアイドル接続を切る、モバイルやVPN経路は短いNATタイムアウトを持つ、証明書が更新される、終了時に Receive-Loop がまだブロックしていてプロセスがハングする、などです。加えて、WebSocketは長寿命で状態を持つチャネルであり、従来のHTTP/REST(Request/Response、短命)とは異なるルールが適用されます。
このソーススニペットは「Hello WebSocket」ではなく、実運用に耐えるクライアントラッパーを示すものです。特徴は:
- シャットダウン時にハングしない、きれいな Start/Stop、
- 「Thread kill」ではなく Cancellation(中断シグナル)による Receive-Loop、
- Backoff を伴う Reconnect(制御された再接続)、
- Ping/Pong が常に使えない環境を考慮したアプリケーションパターンとしての Heartbeat、
- サポート時に実際に役立つ Debug-/Trace-Hooks。
実装は System.Net.WebSockets (Delphi RTL;WebSocketクライアントAPI、TClientWebSocket を使用) を前提としています。古いバージョンでこの RTL 層が利用不可、または機能が制限される場合は、ライブラリ(例:ICS)へのフォールバックが有効なことが多く、その評価については下方で解説します。
アーキテクチャ概略:散在する WebSocket 呼び出しの代わりにラッパー
成長した Delphi アプリケーションでよくある誤りは、UIフォームやサービスモジュールが「直接 WebSocket とやり取り」し、その結果タイマーやスレッド、例外処理が至る所に散らばることです。明確なイベントと小さな状態マシンを持つ単一のコンポーネントにまとめる方が優れています。
用語の簡単な整理:Backoff は、エラー後に待機時間を段階的に増やす手法(例:1s、2s、4s …)で、サーバやネットワークへの負荷集中を避けます。CancellationToken は .NET 世界の中断シグナルです;Delphi には同一のパターンはありませんが、TEvent と「StopRequested」フラグで再現できます。TThread.Queue はワーカーをブロックせずにメインスレッド(UI)でコードを実行するために使用されます;一方で Synchronize はブロックし、シャットダウン処理でデッドロックの原因になることが多いです。
Source-Schnipsel: Delphi WebSocket Client mit Stop, Reconnect und Message-Dispatch
以下のコードは意図的に「運用コンポーネント」として設計されています:VCL/FMX で使うクラス、または Windows および Windows- und Linux-Services(Delphi のバージョンやプラットフォームに応じて)でも同様に利用できる設計です。コアは Receive-Loop を維持し、イベントでアプリケーションに通知するワーカースレッドです。
unit Net.WebSocketClientEx;
interface
uses
System.SysUtils,
System.Classes,
System.SyncObjs,
System.Generics.Collections,
System.Net.URLClient,
System.Net.WebSockets;
type
TWsLogLevel = (llDebug, llInfo, llWarn, llError);
TWsLogEvent = reference to procedure(Level: TWsLogLevel; const Msg: string);
TWsTextEvent = reference to procedure(const Text: string);
TWsStateEvent = reference to procedure(const State: string);
TDelphiWebSocketClient = class
private
FUrl: string;
FOnLog: TWsLogEvent;
FOnText: TWsTextEvent;
FOnState: TWsStateEvent;
FStopEvent: TEvent;
FWorker: TThread;
FMinBackoffMs: Integer;
FMaxBackoffMs: Integer;
FHeartbeatSec: Integer;
procedure Log(Level: TWsLogLevel; const Msg: string);
procedure State(const S: string);
procedure Run;
function NextBackoffMs(const Prev: Integer): Integer;
function NowUtcStr: string;
public
constructor Create(const AUrl: string);
destructor Destroy; override;
procedure Start;
procedure Stop(TimeoutMs: Cardinal = 5000);
property OnLog: TWsLogEvent read FOnLog write FOnLog;
property OnText: TWsTextEvent read FOnText write FOnText;
property OnState: TWsStateEvent read FOnState write FOnState;
property MinBackoffMs: Integer read FMinBackoffMs write FMinBackoffMs;
property MaxBackoffMs: Integer read FMaxBackoffMs write FMaxBackoffMs;
property HeartbeatSec: Integer read FHeartbeatSec write FHeartbeatSec;
end;
implementation
type
TBytesBuffer = record
Data: TBytes;
Len: Integer;
end;
{ TDelphiWebSocketClient }
constructor TDelphiWebSocketClient.Create(const AUrl: string);
begin
inherited Create;
FUrl := AUrl;
FStopEvent := TEvent.Create(nil, True, False, “);
FMinBackoffMs := 500;
FMaxBackoffMs := 15000;
FHeartbeatSec := 20; // アプリケーションハートビート: プロキシ越しのアイドルタイムアウト対策に有効
end;
destructor TDelphiWebSocketClient.Destroy;
begin
Stop;
FStopEvent.Free;
inherited;
end;
procedure TDelphiWebSocketClient.Start;
begin
if Assigned(FWorker) then
Exit;
FStopEvent.ResetEvent;
FWorker := TThread.CreateAnonymousThread(
procedure
begin
Run;
end);
FWorker.FreeOnTerminate := False;
FWorker.Start;
end;
procedure TDelphiWebSocketClient.Stop(TimeoutMs: Cardinal);
var
W: TThread;
begin
FStopEvent.SetEvent;
W := FWorker;
if Assigned(W) then
begin
if W.WaitFor(TimeoutMs) = wrTimeout then
Log(llWarn, ‚停止: ワーカーが指定したタイムアウト内に応答しませんでした。ネットワークスタックでブロックされている可能性があります‘);
FreeAndNil(FWorker);
end;
end;
procedure TDelphiWebSocketClient.Log(Level: TWsLogLevel; const Msg: string);
begin
if Assigned(FOnLog) then
TThread.Queue(nil,
procedure
begin
FOnLog(Level, NowUtcStr + ‚ ‚ + Msg);
end);
end;
procedure TDelphiWebSocketClient.State(const S: string);
begin
if Assigned(FOnState) then
TThread.Queue(nil,
procedure
begin
FOnState(S);
end);
end;
function TDelphiWebSocketClient.NowUtcStr: string;
begin
Result := FormatDateTime(‚yyyy-mm-dd“T“hh:nn:ss.zzz“Z“‚, TTimeZone.Local.ToUniversalTime(Now));
end;
function TDelphiWebSocketClient.NextBackoffMs(const Prev: Integer): Integer;
var
N: Integer;
begin
if Prev <= 0 then
Exit(FMinBackoffMs);
N := Prev * 2;
if N < FMinBackoffMs then N := FMinBackoffMs;
if N > FMaxBackoffMs then N := FMaxBackoffMs;
Result := N;
end;
procedure TDelphiWebSocketClient.Run;
var
WS: TClientWebSocket;
Backoff: Integer;
LastHeartbeat: UInt64;
Msg: string;
Buf: TBytes;
Received: TWebSocketReceiveResult;
SB: TStringBuilder;
WaitRes: TWaitResult;
function StopRequested: Boolean;
begin
Result := (FStopEvent.WaitFor(0) = wrSignaled);
end;
procedure SafeClose;
begin
try
if WS.State = TWebSocketState.Open then
WS.Close(TWebSocketCloseStatus.NormalClosure, ‚client shutdown‘);
except
on E: Exception do
Log(llDebug, ‚Close: ‚ + E.ClassName + ‚: ‚ + E.Message);
end;
end;
begin
Backoff := 0;
LastHeartbeat := 0;
while not StopRequested do
begin
WS := TClientWebSocket.Create;
try
State(‚connecting‘);
Log(llInfo, ‚Connect to ‚ + FUrl);
try
// 注: TClientWebSocket.Connect は同期処理で、DNS/TLS に依存してブロックする可能性があります。
// そのため、ここはワーカースレッド内で実行します。
WS.Connect(FUrl);
except
on E: Exception do
begin
State(‚connect_failed‘);
Log(llWarn, ‚Connect failed: ‚ + E.ClassName + ‚: ‚ + E.Message);
Backoff := NextBackoffMs(Backoff);
WaitRes := FStopEvent.WaitFor(Backoff);
if WaitRes = wrSignaled then Break;
Continue;
end;
end;
State(‚open‘);
Backoff := 0;
SetLength(Buf, 16 * 1024);
SB := TStringBuilder.Create;
try
while (WS.State = TWebSocketState.Open) and (not StopRequested) do
begin
// Ping/Pong がすべての Delphi バージョンで明確に公開されているわけではないため、アプリケーションメッセージとしてハートビートを送信します。
if (FHeartbeatSec > 0) then
begin
if (LastHeartbeat = 0) or (TThread.GetTickCount64 – LastHeartbeat >= UInt64(FHeartbeatSec) * 1000) then
begin
try
WS.Send(‚ping‘);
LastHeartbeat := TThread.GetTickCount64;
Log(llDebug, ‚Heartbeat ping sent‘);
except
on E: Exception do
begin
Log(llWarn, ‚Heartbeat send failed: ‚ + E.Message);
Break;
end;
end;
end;
end;
// 受信: フレームベースのため、フラグメント処理に TStringBuilder を使用します。
try
Received := WS.Receive(Buf);
except
on E: Exception do
begin
Log(llWarn, ‚Receive failed: ‚ + E.ClassName + ‚: ‚ + E.Message);
Break;
end;
end;
case Received.Kind of
TWebSocketMessageKind.Text:
begin
SB.Append(TEncoding.UTF8.GetString(Buf, 0, Received.BytesReceived));
if Received.EndOfMessage then
begin
Msg := SB.ToString;
SB.Clear;
if Assigned(FOnText) then
TThread.Queue(nil,
procedure
begin
FOnText(Msg);
end);
end;
end;
TWebSocketMessageKind.Binary:
begin
// 多くのビジネスプロトコルでは Text/JSON が標準です。
// Binary は同様にバッファリングするか、直接渡すことができます。
Log(llDebug, ‚Binary frame received: ‚ + Received.BytesReceived.ToString + ‚ bytes‘);
end;
TWebSocketMessageKind.Close:
begin
Log(llInfo, ‚Server requested close‘);
Break;
end;
end;
// 非常に高速なループの場合に CPU 負荷を抑えるための短いスリープです。
// 大きすぎるとレイテンシが悪化します。
TThread.Sleep(1);
end;
finally
SB.Free;
end;
SafeClose;
State(‚closed‘);
finally
WS.Free;
end;
if StopRequested then
Break;
// Reconnect nach sauberem Close oder nach Fehlern
Backoff := NextBackoffMs(Backoff);
Log(llInfo, ‚Reconnect in ‚ + Backoff.ToString + ‚ ms‘);
WaitRes := FStopEvent.WaitFor(Backoff);
if WaitRes = wrSignaled then
Break;
end;
State(’stopped‘);
Log(llInfo, ‚Worker stopped‘);
end;
end.
このコードが典型的な例と意図的に「異なる」点
- 強制終了を行わない停止: スレッドを強制終了する代わりに、Stop はイベントを設定します。Workerは定義された箇所でループを終了します。これにより終了時のハングを減らし、ソケットスタックにおけるリソースリークを回避します。
- SynchronizeではなくQueue: ログ記録やイベントはTThread.Queueでメインスレッドに渡します。これはStop/ShutdownがUIやService-Controlハンドラから発生する場合に重要です。Synchronizeはメインスレッドが待機中だとブロックする可能性があります。
- フラグメンテーションを考慮: WebSocketのテキストはフレームで分割されて到着することがあります。だからこそTStringBuilderを使い、EndOfMessageを確認します。
- アプリケーションプロトコルとしてのハートビート: 多くの構成はアイドルタイムアウトで切断されます(ロードバランサ、nginx、クラウドWAF)。軽量な「ping」テキストは、TCP keepaliveや必ずしも利用可能でないPing/Pong APIに期待するよりも運用上有効な手段になることが多いです。
運用上の前提条件と落とし穴
1) DNS、TLS、プロキシ:Connectがブロックされる可能性
TClientWebSocket.Connectは同期処理です。DNS解決、TLSハンドシェイク、証明書検証、あるいはプロキシ環境によっては数秒かかることがあります。コードは意図的にこれをWorkerに入れています。さらに厳格なタイムアウトが必要な場合は、APIレベルでお使いのDelphiのバージョンがタイムアウトオプションを提供しているか確認するか、Connectを別スレッドにカプセル化してプロセスロジックで中断する必要があります。重要:ここでの「中断」は通常「接続を壊れた状態としてマークしてWorkerを再構築する」ことであり、「ソケット操作を即座に強制終了する」ことではありません。
2) アイドルタイムアウト:なぜハートビートがしばしば必須なのか
企業ネットワークでは、WebSocketがリバースプロキシ(nginx、IIS ARR)やロードバランサの背後で終端されていることが多いです。これらの多くは長時間データが流れないと接続を切断します。TCPキープアライブは常に短く設定されているわけではなく(Windows環境では分単位の設定になっていることが多い)、そのためアプリケーション層でのハートビートは安定した回避策になります。サーバとクライアントで同じ方式を使うこと(例えばテキストとしての「ping」/「pong」やJSON)を確認してください。
3) スレッディングとUI:イベントは疎結合に保つべき
OnTextの処理が重い場合(JSONパース、BDEによるネイティブ接続を伴うDBアクセス、UI更新など)、それらがメインスレッドを完全にブロックしてはいけません。ラッパーはメッセージを供給するだけに留めるべきです。典型的なパターンは、OnTextがペイロードをキュー(例:TThreadedQueue<string>)に入れ、別スレッドのWorkerがバックプレッシャー(キュー長の制限)を伴って処理することです。これによりバースト負荷時にUIがフリーズしたり受信が追いつかなくなることを防げます。
デバッグ:「時々」切断される場合に何をログすべきか
WebSocketは「数日動作してから切れる」といった現象で知られています。ログがなければ原因の絞り込みは困難です。記録すべき代表的なポイント:
- タイムスタンプ(UTC)、URL、および状態遷移(connecting/open/closed)。
- 可能ならClose-Reason(サーバによるClose要求かネットワーク障害か)。
- ハートビート送信エラーと受信時の例外(例外タイプを含む)。
- 任意:受信メッセージのサイズ(内容ではなく)を記録し、データ爆発を検出する。
TLSで終端している場合は、証明書の切替(有効期限、発行者の変更)がエラーと時間的に相関していないかも確認してください。ハードニングされた環境ではプロキシやDPIボックス(Deep Packet Inspection)も候補になります。
バリアント:System.Net.WebSocketsで足りる場合 — そうでない場合
System.Net.WebSocketsは多くの統合ケースで十分です。特にテキスト/JSON、適度な負荷、明確な再接続戦略を想定する場合に有効です。制約はDelphiのバージョンや対象プラットフォームによって現れます:
- Ping/Pongサポートの欠如・制限:その場合はApp-Heartbeatが堅牢なパターンとなります。
- Connect/Receiveでのタイムアウト/キャンセル不在:この場合、ハングしたワーカーを隔離しつつアプリケーションを正常に終了させるようアーキテクチャを設計する必要があります(例:プロセスウォッチドッグや分離したワーカーインスタンス)。
- 高負荷やバイナリストリーム:より強力なフレーミング/バッファリング設計が有益です(例:ring buffer、分離された Binary-Event、上限付きの Message-Assembler)。
レガシー環境(古いDelphi世代、非常に特異なTLS/Proxy要件)では、ICSのようなライブラリが実務的に有利となる場合があります。重要なのは「どのライブラリを使うか」よりも、Shutdown、Reconnect、Observability(ログ/メトリクス)を第一級の課題として扱うことです。
結論:Delphi WebSocketクライアントは運用のビルディングブロック — 明確な境界あり
WebSocketはプッシュイベント、ライブステータス、機械やプロセスの通知、ポータルやサービスの返送チャネルとして有効です。本稿のラッパーは、デジタル企業向けソリューションで差が出やすい点に注力しています:制御された再接続、Idle-Timeoutに対するHeartbeat、フラグメントに対して安全なテキスト処理、デプロイやアップデート時にハングしない停止経路。
適用限界は残ります。非常に短い時間枠でのConnect/Receive中断に対する厳格な保証が必要な場合や、極端に高いデータレートを扱う場合は、タイムアウト、プラットフォーム固有の挙動、場合によっては代替スタックまで深く検討する必要があります。しかし、統合やモダナイゼーションの多くのシナリオにおいては、上記のような適切にカプセル化され、良くログ出力されたクライアントが、成長したDelphiシステムに組み込むための堅実な基盤となります。
このようなコンポーネントを既存のアーキテクチャ(例:Layer-3 Architekturのような、サービス層とUI層が明確に分かれた構成)に組み込む場合や、実運用下での断続的な切断をデバッグする必要がある場合は、我々とともに優先度付けして整理できます:Kontakt aufnehmen.
専門的な文脈では、統合、データフロー、継続的な拡張を整合させる際に、HeartbeatやPing/Pongが重要な役割を果たします。
Projekt oder Modernisierungsvorhaben mit Net-Base besprechen.
次のステップ
テーマが実際のプロジェクトになる場合、アーキテクチャ、既存資産、運用は早い段階でまとめて検討するべきです。
私たちは単なる個別の問い合わせへの対応にとどまらず、ソースの断片やレガシー課題、ポータルの構想が堅牢な企業向けプロジェクトへと成長する段階まで支援します。
- 既存環境、目標像、技術的リスクを一体として評価します。
- REST、データアクセス、ポータル、ロールアウトは後工程として先送りされることはありません。
- 早期に、どのアプローチが経済的かつ運用面で実行可能かを判断できます。