От темы в журнале к проектной практике
Соответствующие страницы услуг и технологий к статье
Почему Delphi WebSocket Client в реальной эксплуатации больше, чем «Connect»
Delphi WebSocket Client собирается за считанные минуты: URL, Connect, SendText, готово. В индивидуальном корпоративном ПО и процессно-близких решениях проблемы обычно проявляются в эксплуатации: обратный прокси разрывает простаивающие соединения, мобильные или VPN-каналы имеют короткие NAT-timeout’ы, сертификаты меняются, а при завершении процесс зависает, потому что Receive-Loop всё ещё блокирован. Кроме того: WebSocket — это долговременный, состоянийный канал, поэтому действуют другие правила, чем при классическом HTTP/REST (Request/Response, краткосрочный).
В этом исходном фрагменте речь не о «Hello WebSocket», а о практичном обёрточном клиенте с:
- чётким стартом/стопом (без зависаний при завершении),
- Receive-Loop с Cancellation (сигнал отмены) вместо «Thread kill»,
- Reconnect с Backoff (контролируемое переподключение),
- Heartbeat как шаблон приложения (потому что Ping/Pong не везде доступен),
- Debug- и Trace-хуки, которые действительно помогают в случаях поддержки.
Реализация базируется на System.Net.WebSockets (Delphi RTL; WebSocket-Client-API с TClientWebSocket). Там, где этот уровень RTL в старых версиях недоступен или слишком ограничен, часто имеет смысл использовать fallback через библиотеку (например, ICS) — ниже дана соответствующая оценка.
Архитектурная схема: обёртка вместо разбросанных вызовов WebSocket
Распространённая ошибка в эволюционировавших Delphi-приложениях: UI-формы или сервисные модули «общаются напрямую с WebSocket» и в результате в коде повсюду оказываются таймеры, потоки и обработчики исключений. Лучше выделить ясный модуль с чётко определёнными событиями и небольшой машиной состояний.
Краткие пояснения терминов: Backoff означает время ожидания, которое после ошибок растёт по шагам (например, 1 с, 2 с, 4 с …), чтобы не перегружать сервер и сеть. CancellationToken — это сигнал отмены из мира .NET; в Delphi нет идентичного паттерна, но его можно моделировать с помощью TEvent и флага «StopRequested». TThread.Queue планирует выполнение кода в главном потоке (UI), не блокируя рабочий поток; Synchronize блокирует и часто является причиной дедлоков в путях завершения.
Source-Schnipsel: Delphi WebSocket Client mit Stop, Reconnect und Message-Dispatch
Следующий код намеренно построен как «эксплуатационный модуль»: класс, который можно использовать в VCL/FMX или в Windows- и Windows- и Linux-сервисах (в зависимости от версии/платформы Delphi). Ядро — worker-поток, который удерживает 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; // App-Heartbeat: полезен против таймаутов простоя за прокси
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, 'Stop: Worker не отвечает в течение таймаута; возможна блокировка в сетевом стеке');
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
// Heartbeat как сообщение приложения, поскольку 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;
// Приём: на основе фреймов, поэтому используется StringBuilder для сборки фрагментов.
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
// В многих бизнес-протоколах стандарт — текст/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;
// Повторное подключение после корректного закрытия или после ошибок
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 ohne Gewalt: Вместо принудительного завершения потоков Stop выставляет событие. Worker завершает циклы в определённых местах. Это уменьшает зависания при завершении и предотвращает утечки ресурсов в стеке сокетов.
- Queue statt Synchronize: Логирование и события отправляются в главный поток через TThread.Queue. Это важно, когда Stop/Shutdown инициируется из UI или из обработчиков Service‑Control. Synchronize может блокировать, если главный поток в данный момент ожидает.
- Fragmentierung berücksichtigt: Текст WebSocket может приходить фрагментированными фреймами. Поэтому используется TStringBuilder и проверка EndOfMessage.
- Heartbeat als App-Protokoll: Многие конфигурации прекращают работу из‑за idle‑тайм‑аутов (Load Balancer, nginx, Cloud WAF). Лёгкий текстовый «ping» как эксплуатационный рычаг часто эффективнее, чем надежда на «TCP keepalive» или на не везде доступное API Ping/Pong.
Ограничения и подводные камни при эксплуатации
1) DNS, TLS и прокси: Connect kann blockieren
TClientWebSocket.Connect синхронен. В зависимости от разрешения DNS, TLS‑рукопожатия, проверки сертификата или прокси‑окружения это может занять несколько секунд. Код сознательно помещает это в Worker. Если вам нужны жёсткие тайм‑ауты, необходимо на уровне API проверить, предоставляет ли ваша Delphi‑версия опции таймаута, или инкапсулировать Connect в отдельный поток и прерывать через логику процесса. Важно: «отмена» здесь обычно означает «пометить соединение как повреждённое и поднять Worker заново», а не «немедленно убить операцию сокета».
2) Idle-Timeouts: warum Heartbeat häufig Pflicht ist
В корпоративных сетях WebSocket часто завершается за Reverse Proxy (nginx, IIS ARR) или Load Balancer. Многие из этих компонентов закрывают соединения, если в течение длительного времени не передаются данные. TCP‑keepalive не всегда настроен с достаточной частотой (и под Windows чаще измеряется минутами, а не секундами). Поэтому Heartbeat на уровне приложения — надёжный обходной путь. Убедитесь, что сервер и клиент используют одну и ту же схему (например, «ping»/«pong» в виде текста или JSON).
3) Threading und UI: Ereignisse müssen entkoppelt bleiben
Если обработка OnText тяжёлая (парсинг JSON, доступы к БД с BDE‑Ablosung с нативной привязкой, обновления UI), она не должна блокировать главный поток. Обёртка лишь доставляет сообщение. Типичный паттерн: OnText помещает payload в очередь (например, TThreadedQueue<string>), отдельный Worker обрабатывает с механизмом backpressure (т. е. с ограниченной длиной очереди). Это предотвращает зависание UI или потерю приёма при всплесках нагрузки.
Отладка: что логировать, если соединение «иногда» разрывается
WebSockets печально известны тем, что «работают днями, а потом перестают». Без логирования это почти невозможно локализовать. Полезные точки логирования:
- Метка времени (UTC), URL и переходы состояний (connecting/open/closed).
- Причина закрытия (Close‑Reason), если доступна (инициированное сервером Close vs сетевая ошибка).
- Ошибки отправки heartbeat и исключения при приёме, включая тип Exception.
- Опционально: размеры полученных сообщений (не содержимое), чтобы обнаруживать взрывы объёма данных.
Если вы выполняете TLS‑терминацию: дополнительно проверьте, коррелируют ли по времени смены сертификатов (истечение срока, новый Issuer) с появлением ошибок. В жёстко защищённых средах также кандидаты на причину — прокси и DPI‑устройства (Deep Packet Inspection).
Варианты: когда System.Net.WebSockets хватает — и когда нет
System.Net.WebSockets достаточен для многих сценариев интеграции, особенно когда речь идёт о Text/JSON, умеренной нагрузке и понятных стратегиях переподключения. Ограничения зависят от версии Delphi и целевой платформы:
- Отсутствие/ограниченная поддержка Ping/Pong: Тогда паттерн App-Heartbeat остаётся надёжным решением.
- Отсутствие таймаутов/возможности отмены в Connect/Receive: В этом случае архитектуру нужно строить так, чтобы зависший Worker оставался изолированным, а приложение тем не менее корректно завершалось (например, через процессный watchdog или отдельные Worker-Instanzen).
- Высокая нагрузка или бинарные потоки: Тогда оправдана более серьёзная концепция фрейминга/буферизации (например, ring buffer, отдельное Binary-Event, Message-Assembler с лимитами).
В legacy-сценариях (старые поколения Delphi, очень специфичные требования к TLS/Proxy) библиотеки вроде ICS в некоторых проектах оказываются прагматичнее. Важнее не «какая библиотека», а то, что вы рассматриваете Shutdown, Reconnect и Observability (логи/метрики) как первостепенные темы.
Вывод: ein Delphi WebSocket Client ist ein Betriebsbaustein – mit klaren Grenzen
WebSocket хорошо подходит для push-событий, live-статусов, сообщений от машин или процессов и как обратный канал для порталов и сервисов. Показанный wrapper фокусируется на аспектах, которые в цифровых корпоративных решениях часто имеют значение: контролируемый Reconnect, Heartbeat против таймаутов простоя, обработка текста, устойчивая к фрагментации, и путь остановки, который при деплое или обновлении не зависает.
Ограничения остаются: если вам нужны жёсткие гарантии прерывания Connect/Receive в очень узких временных окнах или вы оперируете экстремально высокими скоростями передачи, придётся глубже работать с таймаутами, особенностями платформы и, при необходимости, альтернативными стеками. Для большинства сценариев интеграции и модернизации аккуратно инкапсулированный, хорошо залогированный клиент, как показано выше, всё же является надёжной базой, которую можно интегрировать в эволюционирующие Delphi-системы.
Если вы хотите вписать такой компонент в существующую архитектуру (например, Layer-3 Architektur с чёткими сервисными и UI-слоями) или отлаживать спорадические разрывы в реальных условиях, мы можем помочь вам оценить это целенаправленно: Свяжитесь с нами.
В профессиональном контексте также важную роль играют Heartbeat Ping/Pong, когда интеграции, потоки данных и дальнейшее развитие должны работать согласованно.
Следующий шаг
Если из темы вырастет реальный проект, архитектуру, текущую систему и эксплуатацию следует рассматривать совместно на ранних этапах.
Wir unterstuetzen nicht nur bei Einzelfragen, sondern auch dann, wenn aus Source-Schnipseln, Legacy-Themen oder Portalideen ein belastbares Unternehmensprojekt werden soll.
- Текущее состояние, целевое состояние и технические риски оцениваются совместно.
- REST, доступ к данным, порталы и развертывание не переносятся на более поздние этапы.
- Вы заранее видите, какой путь экономически и эксплуатационно жизнеспособен.