Net-Base Списание

01.06.2026

Delphi WebSocket клиент: надеждно свързване, коректно прекратяване, надеждно отстраняване на грешки

Един Delphi WebSocket клиент бързо изглежда „някак си свързан“ – но в експлоатация решаващи са Reconnect, Heartbeats, коректно спиране и дебъгваемост. С практичен Wrapper, базиран на System.Net.WebSockets (с Fallback) и фрагмент от сорс-кода за Threading и...

01.06.2026

От темата в списанието към проектната практика

Подходящи страници за услуги и технологии към публикацията

Защо един Delphi WebSocket клиент в практиката е повече от „Connect“

Един Delphi WebSocket Client се сглобява за минути: URL, Connect, SendText, готово. В индивидуалния корпоративен софтуер и в решения, близки до процесите, обаче проблемите обикновено възникват в продукция: Reverse Proxy прекъсва неактивни връзки, мобилни или VPN канали имат кратки NAT-Timeouts, сертификатите се сменят, а при спиране процесът се закача, защото един Receive-Loop все още е блокиран. Освен това: WebSocket е дълготраен, състояниево зависим канал – при него важат други правила в сравнение с класическия HTTP/REST (Request/Response, краткотраен).

В този изходен фрагмент не става въпрос за „Hello WebSocket“, а за практически приложим клиент-Wrapper с:

  • чист старт/стоп (без зацикляне при shutdown),
  • Receive-Loop с Cancellation (сигнал за прекъсване) вместо „Thread kill“,
  • Reconnect с Backoff (контролирано повторно свързване),
  • Heartbeat като модел в приложението (защото Ping/Pong не е наличен навсякъде),
  • Debug- и Trace-Hooks, които реално помагат при случаи на поддръжка.

Реализацията се базира на System.Net.WebSockets (Delphi RTL; WebSocket-клиентска API с TClientWebSocket). Там, където този RTL слой в по-стари версии не е наличен или е твърде ограничен, често е разумно да се използва fallback чрез библиотека (напр. ICS) – по-долу има оценка.

Архитектурна скица: един Wrapper вместо разпръснати WebSocket повиквания

Често срещана грешка в развивали се Delphi приложения: UI форми или сервизни модули „говорят директно със WebSocket“ и в резултат имат навсякъде разпръснати таймери, нишки и обработка на изключения. По-добре е да има ясен компонент с добре дефинирани Events и малка машина на състоянията.

Кратка класификация на термините: Backoff означава време за изчакване, което след грешки нараства стъпаловидно (напр. 1s, 2s, 4s …), за да не „залейме“ сървъра и мрежата. CancellationToken е сигнал за прекъсване от .NET света; в Delphi няма идентичен патърн, но можем да го имитираме с TEvent и флаг „StopRequested“. TThread.Queue планира код за изпълнение в основния нишков поток (UI), без да блокира worker-а; Synchronize блокира и в shutdown-пътища често е причина за deadlocks.

Изходен фрагмент: Delphi WebSocket клиент със Stop, Reconnect и Message-Dispatch

Следният код е нарочно структуриран като „компонент за експлоатация“: клас, който може да се използва подобно във VCL/FMX или в един Windows- и Windows- und Linux-Services (в зависимост от версията/платформата на Delphi). Сърцевината е worker-нишка, която поддържа Receive-Loop и съобщава в приложението чрез Events.

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: полезен срещу idle timeout-и зад проксита
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.
// Затова това се изпълнява в Worker-а.
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;

// Receive: базирано на фреймове, затова се използва 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;

// Reconnect след коректно затваряне или при грешки
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 без грубо прекъсване: Вместо да убива нишки, Stop задава събитие. Worker-ът прекратява цикли на дефинирани места. Това намалява зависвания при спиране и предотвратява изтичане на ресурси в стека на сокетите.
  • Опашка вместо Synchronize: Логове и събития се предават към главния поток чрез TThread.Queue. Това е важно, когато Stop/Shutdown се инициира от UI или от обработвачи на Service Control. Synchronize може да блокира, ако главният поток в момента е в изчакване.
  • Учет на фрагментацията: WebSocket-текстът може да пристигне фрагментиран във фреймове. Затова се използва TStringBuilder и се проверява EndOfMessage.
  • Heartbeat като приложение-протокол: Много конфигурации загиват поради idle-timeouts (Load Balancer, nginx, Cloud WAF). Лекият текст „ping“ често е по-ефективен оперативен лост от надеждата на „TCP keepalive“ или от непоследователно налично Ping/Pong-API.

Ограничения и капани при експлоатация

1) DNS, TLS и прокси: Connect може да блокира

TClientWebSocket.Connect е синхронен. В зависимост от DNS-разрешаването, TLS-рукуването (Handshake), проверката на сертификата или прокси средата това може да отнеме няколко секунди. Кодът умишлено поставя тази стъпка в Worker. Ако имате допълнителна нужда от твърди таймаути, трябва на ниво API да проверите дали вашата Delphi-версия предоставя опции за таймаут, или да инкапсулирате Connect в отделна нишка и да прекъснете чрез логика на процеса. Важно: „прекъсване“ тук обикновено означава „маркиране на връзката като невалидна и стартиране на нов Worker“, а не „незабавно убиване на socket-операция“.

2) Idle-Timeouts: защо Heartbeat често е задължителен

В корпоративни мрежи WebSocket често е терминализиран зад reverse proxy (nginx, IIS ARR) или load balancer. Много от тези компоненти затварят връзки, ако за продължителен период не преминават данни. TCP-Keepalive не винаги е конфигуриран с достатъчно кратък интервал (и под Windows често е по-скоро минути, отколкото секунди). Затова Heartbeat на ниво приложение е стабилен workaround. Уверете се, че сървърът и клиентът следват една и съща концепция (например „ping“/„pong“ като текст или JSON).

3) Threading и UI: събитията трябва да останат разделени

Ако обработката в OnText е тежка (JSON-Parsing, достъп до БД с BDE-Ablosung mit nativer Anbindung, обновявания на UI), тя не трябва да блокира всичко в главния поток. Wrapper-ът доставя само съобщението. Типичен модел е: OnText поставя payload-а в опашка (например TThreadedQueue<string>), а отделен Worker обработва с backpressure (т.е. ограничена дължина на опашката). Това предотвратява при burst-натоварване UI-то да замръзне или получаването да излезе от ритъм.

Отстраняване на грешки: какво да логвате, когато „понякога“ прекъсва

WebSockets са печално известни с „работи дни наред, после спира“. Без логване трудно се локализира причината. Смислени точки за логване:

  • Времеви щамп (UTC), URL и промени на състоянието (connecting/open/closed).
  • Причина за затваряне, ако е налична (Server инициира Close срещу мрежова грешка).
  • Грешки при изпращане на Heartbeat и изключения при получаване, включително тип на Exception.
  • По желание: размери на получените съобщения (не съдържанието), за да се открие експлозия на данни.

Ако терминализирате чрез TLS: проверете допълнително дали смяна на сертификати (изтичане, нов издател) корелира по време с грешките. В втвърдени среди и прокси/DPI-устройства (Deep Packet Inspection) също са потенциални виновници.

Варианти: кога System.Net.WebSockets е достатъчен – и кога не

System.Net.WebSockets е достатъчен за много интеграционни случаи, особено когато става дума за текст/JSON, умерено натоварване и ясни стратегии за повторно свързване. Ограниченията се проявяват в зависимост от версията на Delphi и целевата платформа:

  • Липса/ограничена поддръжка на Ping/Pong: В този случай App-Heartbeat остава сигурният модел.
  • Липса на таймаути/отмяна при Connect/Receive: Трябва да конструирате архитектурата така, че заседнал worker да остане изолиран и приложението да може все пак да се затвори чисто (например чрез процесен watchdog или отделни инстанции на worker).
  • Високо натоварване или двоични потоци: Тогава има смисъл от по-строго фреймиране/буфериране (напр. ring buffer, отделно Binary-Event, Message-Assembler с лимити).

За legacy ситуации (по-стари поколения на Delphi, много специфични TLS/Proxy изисквания) библиотеки като ICS в някои проекти са по-прагматични. По-важно е не „коя библиотека“, а да третирате Shutdown, Reconnect und Observability (Logs/Metriken) като първостепенни теми.

Fazit: ein Delphi WebSocket Client ist ein Betriebsbaustein – mit klaren Grenzen

WebSocket е подходящ за push-събития, live-статус, машинни или процесни съобщения и като обратен канал за портали и услуги. Показаният Wrapper се фокусира върху точките, които в дигиталните корпоративни решения често правят разликата: контролирано Reconnect, Heartbeat срещу Idle-Timeouts, фрагмент-сигурна обработка на текст и път за Stop, който не заседява при Deployment или Update.

Ограничения остават: ако ви трябват строги гаранции за прекъсване на Connect/Receive в много тесни времеви прозорци или оперирате с екстремно високи скорости на данни, ще трябва да навлезете по-дълбоко в таймаути, особеностите на платформата и евентуално алтернативни стекове. За мнозинството интеграционни и модернизационни сценарии обаче чисто капсулиран, с адекватно логиране клиент като описания по-горе представлява солидна основа, която може да се интегрира в съществуващи Delphi-системи.

Ако трябва да вмъкнете такъв компонент в съществуваща архитектура (напр. Layer-3 архитектура с ясни service- и UI-слоеве) или да дебъгнете спорадични disconnects в реални условия, можем да ви помогнем да го оценим целенасочено: Свържете се с нас.

В техническия контекст Heartbeat Ping/Pong също играят важна роля, когато интеграциите, потокът от данни и последващото развитие трябва да работят синхронизирано.

Обсъдете проект или инициатива за модернизация с Net-Base.

Следваща стъпка

Когато темата прерасне в реален проект, архитектурата, съществуващото състояние и експлоатацията трябва да бъдат разгледани съвместно още в ранна фаза.

Подпомагаме не само при отделни въпроси, но и когато от фрагменти от изходен код, проблеми с наследени системи или идеи за портал трябва да бъде реализиран надежден корпоративен проект.

  • Сегашното състояние, целевото състояние и техническите рискове се оценяват съвместно.
  • REST, достъпът до данни, порталите и разгръщането не се отлагат като по-късни последици.
  • Виждате рано кой път е икономически и експлоатационно жизнеспособен.

Сподели публикацията

Споделете тази публикация директно

LinkedIn, X, XING, Facebook, WhatsApp и имейл са незабавно достъпни. За Instagram ще подготвим връзка и кратък текст.

Електронна поща

Instagram се отваря в нов раздел. Връзката и краткият текст се копират предварително в клипборда.