Net-Base списание

01.06.2026

Delphi WebSocket клиент: стабилно поврзување, грациозно затворање, надежно дебагирање

Еден Delphi WebSocket клиент брзо е „на некој начин поврзан“ – но во експлоатација пресудни се повторното поврзување, heartbeat-пораките, чистото запирање и дебагирањето. Со практичен Wrapper базиран на System.Net.WebSockets (со Fallback) и извадок од изворен код за работа со нишки и...

01.06.2026

Од тема во магазинот до проектна пракса

Соодветни страници за услуги и технички информации поврзани со објавата

Зошто ein Delphi WebSocket Client во пракса е повеќе од „Connect“

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

Во овој Source-Schnipsel не станува збор за „Hello WebSocket“, туку за практичен Client-Wrapper со:

  • уредно Start/Stop (без зависнување при Shutdown),
  • Receive-Loop со Cancellation (сигнал за прекин) наместо „Thread kill“,
  • Reconnect со Backoff (контролирано повторно поврзување),
  • Heartbeat како образец за апликација (бидејќи Ping/Pong не е достапен насекаде),
  • Debug- и Trace-Hooks што навистина помагаат во случаите со поддршка.

Реализацијата се базира на System.Net.WebSockets (Delphi RTL; WebSocket-Client-API со TClientWebSocket). Каде што овој RTL-слој во постари верзии не е достапен или е преголемо ограничен, често е практично да се користи Fallback преку библиотека (н. пр. ICS) – за тоа подолу следува појаснување.

Architektur-Skizze: ein Wrapper statt verstreuter WebSocket-Aufrufe

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

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

Source-Schnipsel: Delphi WebSocket Client mit Stop, Reconnect und Message-Dispatch

Следниот код е намерно структуриран како „Betriebs-Bausteин“: класа која може да се користи во VCL/FMX или во еден Windows- и Windows- и Linux-Services (во зависност од Delphi-верзијата/платформата) на сличен начин. Јадрото е Worker-Thread што го одржува Receive-Loop и преку Events пријавува во апликацијата.

Delphi
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-таймаути зад проксите
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: Работникот не реагираше во рокот; можно блокирање во мрежниот стек');
    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
                // Во многу бизнис-протоколи 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;

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

Услови и стапки при оперативна работа

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

TClientWebSocket.Connect е синхрон. Во зависност од DNS-резолуцијата, TLS-ракувањето, проверката на сертификат или прокси-околината, тоа може да трае неколку секунди. Кодот намерно го става тоа во еден Worker. Ако ви требаат дополнителни строги таймаути, мора да проверите на ниво на API дали вашата Delphi-верзија нуди опции за timeout, или да го капсулирате Connect во посебен thread и да го прекинете преку логика на процесот. Важно: „прекин“ тука најчесто значи „означи ја врската како скршена и подигни нов Worker“, не „еднократно убивање на Socket-операцијата“.

2) Idle-timeouts: зошто Heartbeat често е задолжителен

Во корпоративните мрежи WebSocket често е терминален зад Reverse Proxy (nginx, IIS ARR) или Load Balancer. Многу од овие компоненти ги затвораат врските ако подолго време не тече податок. TCP-Keepalive не е секогаш конфигуриран доволно кратко (и под Windows често е во минути наместо секунди). Затоа Heartbeat на апликативно ниво е стабилен workaround. Внимавајте серверот и клиентот да имаат ист концепт (на пр. „ping“/„pong“ како текст или JSON).

3) Треадинг и UI: настаните мора да останат одделени

Ако обработката OnText е тешка (JSON-парсирање, пристапи до DB со BDE-замена со нативна интеграција, UI-ажурирања), не треба да блокира сè во Mainthread-от. Wrapper-от само ја доставува порака. Типичен образец е: OnText ја става payload-от во Queue (на пр. TThreadedQueue<string>), а посебен Worker обработува со Backpressure (односно ограничена должина на Queue). Тоа ја спречува ситуацијата при бурст-нагрузување да замрзне 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 е доволен за многу интеграциски случаи, особено кога станува збор за текст/JSON, умерено оптоварување и јасни стратегии за повторно поврзување. Ограничувањата се појавуваат во зависност од верзијата на Delphi и целната платформа:

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

За legacy‑ситуации (постари генерации на Delphi, многу специфични TLS/Proxy‑потреби) библиотеки како ICS во некои проекти се попрагматични. Повеќе не е клучно „која библиотека“, туку да ги третирајте shutdown, reconnect и observability (логови/метрики) како теми од прв ред.

Заклучок: еден Delphi WebSocket клиент е оперативен градежен блок – со јасни ограничувања

WebSocket е погоден за push‑настани, live‑статус, сигнали од машини или процеси и како повратен канал за портали и сервиси. Прикажаниот wrapper се фокусира на точките што во дигитални корпоративни решенија често прават разлика: контролирано повторно поврзување, heartbeat против idle‑timeout‑и, фрагмент‑безбедна обработка на текст и пат за стоп кој при deployment или update не се заглавува.

Ограничувањата остануваат: ако ви требаат цврсти гаранции за отказ на Connect/Receive во многу кратки временски прозорци или ако оперирате со екстремно високи стапки на податоци, ќе треба подлабоко да навлезете во timeouts, особеностите на платформата и евентуално алтернативни stack‑ови. За мнозинството интеграциски и модернизациски сценарија, сепак, чисто капсулиран, со добри логови клиент како горниот е солидна основа која се интегрира во постоечки Delphi‑системи.

Ако сакате таков градежен блок да го прилагодите во постоечка архитектура (на пр. Layer-3 архитектура со јасни service‑ и UI‑слоеви) или ако треба да дебагирате спорадични disconnect‑и во реални услови, можеме тоа целно да го разгледаме со вас: Контактирајте нe.

Во стручната околина heartbeat Ping/Pong исто така игра значајна улога кога интеграциите, тековите на податоци и понатамошниот развој треба да функционираат прецизно заедно.

Разговарајте за проект или модернизациски потфат со Net-Base.

Следен чекор

Кога темата ќе прерасне во реален проект, архитектурата, постоечката средина и експлоатацијата треба рано да се разгледаат заедно.

Wir unterstuetzen nicht nur bei Einzelfragen, sondern auch dann, wenn aus Source-Schnipseln, Legacy-Themen oder Portalideen ein belastbares Unternehmensprojekt werden soll.

  • Постоечката состојба, целната слика и техничките ризици се проценуваат заедно.
  • REST, пристапот до податоци, порталите и Rollout не се одложуваат како подоцнежни последици.
  • Уште рано идентификувате кој пат е економски и оперативно одржлив.

Сподели објава

Споделете го овој пост директно.

LinkedIn, X, XING, Facebook, WhatsApp и е-пошта се веднаш достапни. За Instagram директно подготвуваме линк и краток текст.

Е-пошта

Instagram се отвора во нов таб. Линкот и краткиот текст претходно се копираат во меѓуспремникот.