Net-Base Magazyn

01.06.2026

Delphi Klient WebSocket: stabilne łączenie, czyste zamykanie, niezawodne debugowanie

Klient WebSocket Delphi jest szybko „jakoś połączony” — ale w eksploatacji liczą się ponowne łączenia, mechanizmy heartbeat, czyste zatrzymywanie oraz możliwość debugowania. Z praktycznym wrapperem opartym na System.Net.WebSockets (z fallbackiem) oraz fragmentem kodu źródłowego do zarządzania wątkami i...

01.06.2026

Od tematu magazynowego do praktyki projektowej

Pasujące strony usługowe i techniczne do artykułu

Dlaczego klient WebSocket Delphi w praktyce to więcej niż „Connect”

Klient Delphi WebSocket można złożyć w kilka minut: URL, Connect, SendText, gotowe. Jednak w oprogramowaniu korporacyjnym i systemach bliskich procesom problemy zwykle pojawiają się dopiero w eksploatacji: Reverse Proxy rozłącza połączenia bezczynne, łącza mobilne lub VPN mają krótkie NAT-timeouty, certyfikaty się zmieniają, a przy zamykaniu proces zawiesza się, ponieważ Receive-Loop nadal blokuje. Dodatkowo: WebSocket to długotrwały, stanowy kanał – obowiązują więc inne zasady niż przy klasycznym HTTP/REST (Request/Response, krótkotrwały).

Ten fragment kodu nie dotyczy „Hello WebSocket”, lecz praktycznego wrappera klienta z:

  • czystym Start/Stop (bez zawieszeń podczas zamykania),
  • Receive-Loop z Cancellation (sygnał przerwania) zamiast „Thread kill”,
  • Reconnect z Backoff (kontrolowane ponowne łączenie),
  • Heartbeat jako wzorzec aplikacyjny (ponieważ Ping/Pong nie jest dostępny wszędzie),
  • debug- i trace-hookami, które w sytuacjach wsparcia rzeczywiście pomagają.

Realizacja opiera się na System.Net.WebSockets (Delphi RTL; WebSocket-Client-API z TClientWebSocket). Tam, gdzie ta warstwa RTL w starszych wersjach nie jest dostępna lub jest zbyt ograniczona, sensowny jest fallback przez bibliotekę (np. ICS) – poniżej krótka klasyfikacja.

Szkic architektury: wrapper zamiast rozproszonych wywołań WebSocket

Częstym błędem w rozwiniętych aplikacjach Delphi jest to, że formularze UI lub moduły serwisowe „komunikują się bezpośrednio z WebSocket” i w efekcie mają rozproszone timery, wątki i obsługi wyjątków. Lepiej jest mieć jasny komponent z dobrze zdefiniowanymi Events i niewielką maszyną stanów.

Pojęcia krótko wyjaśnione: Backoff oznacza czas oczekiwania, który po błędach rośnie stopniowo (np. 1s, 2s, 4s …), aby nie zalać serwera i sieci. CancellationToken to sygnał przerwania ze świata .NET; w Delphi nie ma identycznego wzorca, ale możemy go odwzorować za pomocą TEvent i flagi „StopRequested”. TThread.Queue planuje kod do wykonania w wątku głównym (UI), nie blokując workera; Synchronize blokuje i często bywa przyczyną deadlocków podczas ścieżek zamykania.

Fragment kodu: Delphi WebSocket Client z Stop, Reconnect i Message-Dispatch

Poniższy kod jest celowo skonstruowany jako „komponent operacyjny”: klasa, której można użyć w VCL/FMX lub podobnie w Windows- i Windows- i Linux-Services (w zależności od wersji/platformy Delphi). Rdzeniem jest worker-thread, który utrzymuje Receive-Loop i zgłasza zdarzenia do aplikacji.

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; // Heartbeat aplikacji — przydatny przeciwko timeoutom bezczynności za proxy
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 nie odpowiada w wyznaczonym czasie; możliwe zablokowanie w stosie sieciowym');
    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
        // Uwaga: TClientWebSocket.Connect działa synchronicznie i może blokować w zależności od DNS/TLS.
        // Dlatego uruchamiane jest to w wątku roboczym.
        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 jako wiadomość aplikacyjna, ponieważ Ping/Pong nie jest we wszystkich wersjach Delphi poprawnie udostępniany.
          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;

          // Odbiór: oparty na ramkach, dlatego StringBuilder służy do obsługi fragmentacji.
          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
                // W wielu protokołach biznesowych standardem jest tekst/JSON.
                // Dane binarne można tutaj podobnie buforować lub przekazać dalej bezpośrednio.
                Log(llDebug, 'Binary frame received: ' + Received.BytesReceived.ToString + ' bytes');
              end;

            TWebSocketMessageKind.Close:
              begin
                Log(llInfo, 'Server requested close');
                Break;
              end;
          end;

          // Krótki sen (sleep), aby chronić CPU przy bardzo szybkim pętlu.
          // Nie za długi, w przeciwnym razie pogorszy się latencja.
          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.

Co kod celowo robi „inaczej” niż typowe przykłady

  • Stop bez brutalnego przerywania: Zamiast „zabijać” wątki, Stop ustawia zdarzenie. Worker kończy pętle w zdefiniowanych punktach. To zmniejsza zacięcia przy zamykaniu i zapobiega wyciekom zasobów w stosie socketów.
  • Kolejka zamiast Synchronize: Logowanie i zdarzenia trafiają do Mainthread przez TThread.Queue. To ma znaczenie, gdy Stop/Shutdown pochodzi z UI lub z handlerów Service-Control. Synchronize może blokować, jeśli Mainthread akurat oczekuje.
  • Uwaga na fragmentację: Tekst WebSocket może przychodzić w podzielonych ramkach. Dlatego TStringBuilder i sprawdzanie EndOfMessage.
  • Heartbeat jako protokół aplikacji: Wiele konfiguracji umiera z powodu idle-timeoutów (Load Balancer, nginx, Cloud WAF). Lekki tekst „ping” jako mechanizm operacyjny jest często skuteczniejszy niż poleganie na „TCP keepalive” lub na API Ping/Pong, które nie jest dostępne wszędzie.

Warunki brzegowe i pułapki w eksploatacji

1) DNS, TLS i proxy: Connect może blokować

TClientWebSocket.Connect jest synchroniczny. W zależności od rozwiązywania DNS, TLS-handshake, weryfikacji certyfikatu lub środowiska proxy może to trwać kilka sekund. Kod umieszcza to świadomie w Workerze. Jeśli potrzebujecie dodatkowych twardych timeoutów, sprawdźcie na poziomie API, czy Wasza Delphi-wersja udostępnia opcje timeoutu, albo opakujcie Connect w osobny wątek i przerwijcie go logiką procesu. Ważne: „przerwanie” zwykle oznacza tutaj „oznaczyć połączenie jako uszkodzone i zainicjować nowy Worker”, a nie „natychmiast zabić operację socketu”.

2) Idle-Timeouts: dlaczego Heartbeat często jest obowiązkowy

W sieciach korporacyjnych WebSocket często jest terminowany za reverse-proxy (nginx, IIS ARR) lub przez Load Balancer. Wiele z tych komponentów zamyka połączenia, jeśli przez dłuższy czas nie przepływają dane. TCP-Keepalive nie zawsze jest skonfigurowany na krótkie interwały (i pod Windows częściej jest to minuty niż sekundy). Heartbeat na poziomie aplikacji to stabilne obejście. Upewnijcie się, że serwer i klient stosują ten sam model (np. „ping”/„pong” jako tekst lub JSON).

3) Wątkowanie i UI: zdarzenia muszą pozostać odseparowane

Jeśli przetwarzanie OnText jest ciężkie (parsowanie JSON, dostęp do DB przy użyciu BDE-zastąpienie z natywną integracją, aktualizacje UI), nie powinno ono blokować wszystkiego w Mainthread. Wrapper dostarcza jedynie wiadomość. Typowy wzorzec to: OnText umieszcza payload w kolejce (np. TThreadedQueue<string>), a oddzielny Worker przetwarza z backpressure (czyli ograniczoną długością kolejki). To zapobiega zamrożeniom UI lub zaburzeniom odbioru przy nagłych skokach obciążenia.

Debugowanie: co należy logować, gdy to „czasami” przerywa

WebSockety słyną z sytuacji „działa przez dni, potem przestaje”. Bez logów trudno to zlokalizować. Przydatne punkty logowania:

  • Znacznik czasu (UTC), URL oraz zmiany stanu (connecting/open/closed).
  • Close-Reason, jeśli dostępne (serwer inicjuje Close vs. błąd sieciowy).
  • Błędy wysyłania Heartbeat oraz wyjątki przy odbiorze, łącznie z typem Exception.
  • Opcjonalnie: rozmiary otrzymanych wiadomości (nie ich treść), aby wykryć eksplozję danych.

Jeśli terminujecie przez TLS: sprawdźcie dodatkowo, czy zmiany certyfikatów (wygaśnięcie, nowy issuer) korelują czasowo z błędami. W utwardzonych środowiskach podejrzanymi elementami są też urządzenia proxy i DPI (Deep Packet Inspection).

Varianten: wann System.Net.WebSockets reicht – und wann nicht

System.Net.WebSockets ist für viele Integrationsfälle ausreichend, vor allem wenn es um Text/JSON, moderate Last und klare Reconnect-Strategien geht. Grenzen zeigen sich je nach Delphi-Version und Plattformziel:

  • Brak/ograniczone wsparcie Ping/Pong: W takim przypadku pozostaje wzorzec App-Heartbeat jako bardziej niezawodne rozwiązanie.
  • Brak timeoutów/anulowania w Connect/Receive: Należy zaprojektować architekturę tak, żeby wiszący worker pozostawał izolowany, a aplikacja mimo to mogła się poprawnie zamknąć (np. przez watchdog procesu lub oddzielne instancje workerów).
  • Duże obciążenie lub strumienie binarne: Warto zastosować silniejsze podejście do framingu/bufferowania (np. ring buffer, oddzielne zdarzenia binarne, Message-Assembler z limitami).

W sytuacjach legacy (starsze Delphi-generacje, bardzo specyficzne wymagania TLS/Proxy) biblioteki takie jak ICS bywają w niektórych projektach bardziej pragmatyczne. Ważniejsze jest mniej „która biblioteka”, a bardziej to, by traktować Shutdown, Reconnect i Observability (Logs/Metriken) jako tematy priorytetowe.

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

WebSocket nadaje się doskonale do Push-Events, Live-Statusów, komunikatów maszynowych lub procesowych oraz jako kanał zwrotny dla portali i usług. Pokazany wrapper koncentruje się na kwestiach, które w rozwiązaniach korporacyjnych często robią różnicę: kontrolowany Reconnect, Heartbeat przeciw Idle-Timeouts, przetwarzanie tekstu odporne na fragmentację oraz ścieżka zatrzymania, która nie blokuje się podczas deploymentu lub aktualizacji.

Ograniczenia pozostają: jeśli potrzebujecie twardych gwarancji przerwania Connect/Receive w bardzo krótkich oknach czasowych lub osiągacie ekstremalnie wysokie przepływy danych, musicie zagłębić się w timeouty, specyfikę platform i ewentualnie alternatywne stacki. Dla większości scenariuszy integracji i modernizacji jednak czysto zamknięty, dobrze logowany klient jak powyżej stanowi solidną podstawę, którą można zintegrować z istniejącymi systemami Delphi.

Jeśli chcesz dopasować taki element do istniejącej architektury (np. Layer-3 architektura z wyraźnymi warstwami usług i UI) lub debugować sporadyczne rozłączenia w warunkach produkcyjnych, możemy to z Tobą konkretnie omówić: Skontaktuj się z nami.

W kontekście merytorycznym mechanizmy Heartbeat Ping/Pong również odgrywają istotną rolę, gdy integracje, przepływy danych i rozwój muszą ze sobą współgrać.

Omów projekt lub prace modernizacyjne z Net-Base.

Następny krok

Gdy temat stanie się rzeczywistym projektem, architekturę, stan istniejący i eksploatację należy wcześnie rozpatrywać wspólnie.

Wspieramy nie tylko w pojedynczych zagadnieniach, lecz także wtedy, gdy z fragmentów kodu źródłowego, kwestii związanych z systemami legacy lub koncepcji portalu ma powstać solidny projekt dla przedsiębiorstwa.

  • Stan istniejący, obraz docelowy i ryzyka techniczne są oceniane łącznie.
  • REST, dostęp do danych, portale i Rollout nie są odkładane na później.
  • Wcześnie widzą Państwo, która droga jest ekonomicznie opłacalna i operacyjnie wykonalna.

Udostępnij wpis

Udostępnij ten wpis bezpośrednio

LinkedIn, X, XING, Facebook, WhatsApp i e‑mail są natychmiast dostępne. Dla Instagrama przygotowujemy od razu link i krótki tekst.

E-mail

Instagram otwiera się w nowej karcie. Link i krótki tekst są wcześniej kopiowane do schowka.