Net-Base Ajakiri

01.06.2026

Delphi WebSocket-klient: töökindel ühendus, korrektne sulgemine, usaldusväärne silumine

Üks Delphi WebSocket-kliendi saab kiiresti „mingil moel ühendatud“ – kuid tootmises loevad Reconnect, Heartbeats, korrektne peatamine ja debugitavus. Praktilise wrapperi põhjal, mis põhineb System.Net.WebSockets'il (koos fallbackiga), ja lähtekoodilõiguga threadingu ja...

01.06.2026

Ajakirjateemast projektipraktikasse

Sobivad teenuse- ja tehnilised lehed postituse jaoks

Miks praktilises kasutuses on ein Delphi WebSocket Client rohkem kui „Connect“

Ein Delphi WebSocket Client on mõne minutiga kokku pandav: URL, Connect, SendText, valmis. Individuaalses ettevõtte­tarkvaras ja protsessile lähedastes lahendustes ilmnevad probleemid aga tavaliselt alles jooksval toimimisel: reverse-proxy katkestab inaktiivsed ühendused, mobiili- või VPN-liinidel on lühikesed NAT-aegumised, sertifikaadid vahetuvad ning lõpetamisel jääb protsess kinni, sest Receive-Loop on endiselt blokeeritud. Lisaks on WebSocket püsiv, olekupõhine kanal — seega kehtivad teistsugused reeglid kui klassikalise HTTP/REST puhul (Request/Response, lühiajaline).

Selles source-koodis ei ole juttu „Hello WebSocket“ demonstratsioonist, vaid praktilise kliendi-wrapperi ehitusest, mis sisaldab:

  • korrektselt hallatavat Start/Stop-i (ilma hangeteta shutdowni ajal),
  • Receive-Loopi koos Cancellation-iga (tühistussignaal) asemel „Thread kill“-i,
  • Reconnecti koos Backoff-iga (kontrollitud taasühendamine),
  • Heartbeat’i kui rakendusmustrit (sest Ping/Pong pole kõikjal kasutatav),
  • debug- ja trace-hooke, mis tugijuhtumites reaalselt abistavad.

Teostus põhineb System.Net.WebSockets (Delphi RTL; WebSocket‑kliendi API TClientWebSocket-iga). Kui see RTL‑kiht vanemates versioonides pole saadaval või on liiga piiratud, on sageli mõistlik kasutada fallback’i mõne teegi kaudu (nt ICS) — selle kohta on allpool täpsem selgitus.

Architektur-Skizze: ein Wrapper statt verstreuter WebSocket-Aufrufe

Levinud viga kasvavates Delphi-rakendustes on see, et UI-vormid või teenusemoodulid „räägivad otse WebSocketiga“ ja tulemuseks on hajutatud taimerid, threadid ja erandikäitlejad. Tõhusam on selge komponendiületus koos hästi määratletud eventidega ja väikese olekumasinaga.

Terminite selgitus lühidalt: Backoff tähendab ootust, mis pärast vigu astmelt suureneb (nt 1s, 2s, 4s …), et mitte serverit ega võrku üle ujutada. CancellationToken on tühistussignaal .NET-maailmast; Delphi-sarnases keskkonnas pole identset mustrit, kuid seda saab modelleerida TEvent-i ja „StopRequested“-lipuga. TThread.Queue planeerib koodi täitmise peathreadis (UI), ilma workerit blokeerimata; Synchronize blokeerib ning on shutdown-radadel tihti deadlock‑ide põhjus.

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

Järgmine kood on teadlikult üles ehitatud kui „käituskomponent“: klass, mida saab kasutada VCL/FMX‑rakenduses või Windows- ja Windows- ja Linux-teenustes (olenevalt Delphi-versioonist/platvormist). Tuumikuks on worker-thread, mis hoiab Receive‑Loop’i ja teatab sündmuste kaudu rakendusele.

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; // Rakenduse heartbeat — kasulik proxy-de taga tekkinud idle-timeoutide vastu
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 ei reageerinud ajapiiri jooksul; võimalik blokeerimine võrgu-pinu tasemel');
    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
        // Märkus: TClientWebSocket.Connect on sünkroonne ja võib sõltuvalt DNS/TLS-ist blokeeruda.
        // Seetõttu jookseb see siin Worker'is.
        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 saadetakse rakenduse sõnumina, kuna Ping/Pong ei ole iga Delphi-versioonis puhtalt eksponeeritud.
          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;

          // Vastuvõtt: põhineb raamidel, seetõttu kasutatakse fragmentatsiooni jaoks TStringBuilderit.
          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
                // Paljudes äriprotokollides on tekst/JSON standard.
                // Binaarandmeid saab siin sarnaselt puhverdada või otse edasi anda.
                Log(llDebug, 'Binary frame received: ' + Received.BytesReceived.ToString + ' bytes');
              end;

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

          // Lühike paus, et väga kiire silmuse korral CPU-d säästa.
          // Mitte liiga pikk, muidu halveneb latentsus.
          TThread.Sleep(1);
        end;
      finally
        SB.Free;
      end;

      SafeClose;
      State('closed');

    finally
      WS.Free;
    end;

    if StopRequested then
      Break;

    // Ühenduse taastamine pärast korrektset sulgemist või vigade korral
    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.

Mida kood teadlikult „teisiti“ teeb võrreldes tüüpiliste näidetega

  • Stop ohne Gewalt: Statt Threads abzuschießen, setzt Stop ein Event. Der Worker beendet Loops an definierten Stellen. Das reduziert Hänger beim Beenden und vermeidet Ressourcenlecks im Socket-Stack.
  • Queue statt Synchronize: Logging und Events gehen per TThread.Queue in den Mainthread. Das ist wichtig, wenn Stop/Shutdown aus dem UI oder aus Service-Control-Handlern kommt. Synchronize kann blockieren, wenn der Mainthread gerade wartet.
  • Fragmentierung berücksichtigt: WebSocket-Text kann in Frames fragmentiert kommen. Deshalb der TStringBuilder und das Prüfen von EndOfMessage.
  • Heartbeat als App-Protokoll: Viele Setups sterben an Idle-Timeouts (Load Balancer, nginx, Cloud WAF). Ein leichtgewichtiger „ping“-Text ist als Betriebshebel oft effektiver als die Hoffnung auf „TCP keepalive“ oder ein nicht überall verfügbares Ping/Pong-API.

Käituspiirangud ja lõksud

1) DNS, TLS und Proxy: Connect kann blockieren

TClientWebSocket.Connect ist synchron. Je nach DNS-Auflösung, TLS-Handshake, Zertifikatsprüfung oder Proxy-Umgebung kann das mehrere Sekunden dauern. Der Code legt das bewusst in einen Worker. Wenn Sie zusätzlich harte Timeouts brauchen, müssen Sie auf API-Ebene prüfen, ob Ihre Delphi-Version Timeout-Optionen bereitstellt, oder Sie kapseln Connect in einen separaten Thread und brechen über Prozesslogik ab. Wichtig: Ein „abbrechen“ heißt hier meist „Verbindung als kaputt markieren und Worker neu aufsetzen“, nicht „Socket-Operation sofort killen“.

2) Idle-Timeouts: warum Heartbeat häufig Pflicht ist

In Unternehmensnetzwerken ist ein WebSocket oft hinter einem Reverse Proxy (nginx, IIS ARR) oder einem Load Balancer terminiert. Viele dieser Komponenten schließen Verbindungen, wenn über längere Zeit keine Daten fließen. TCP-Keepalive ist nicht immer kurz genug konfiguriert (und unter Windows oft eher Minuten als Sekunden). Ein Heartbeat auf Anwendungsebene ist deshalb ein stabiler Workaround. Achten Sie darauf, dass Server und Client das gleiche Konzept haben (z. B. „ping“/„pong“ als Text oder JSON).

3) Threading und UI: Ereignisse müssen entkoppelt bleiben

Wenn die OnText-Verarbeitung schwer ist (JSON-Parsing, DB-Zugriffe mit BDE-Ablosung mit nativer Anbindung, UI-Updates), sollte sie nicht im Mainthread alles blockieren. Der Wrapper liefert nur die Nachricht. Ein typisches Muster ist: OnText legt die Payload in eine Queue (z. B. TThreadedQueue<string>), ein separater Worker verarbeitet mit Backpressure (also begrenzter Queue-Länge). Das verhindert, dass bei Burst-Last die UI einfriert oder der Empfang aus dem Tritt kommt.

Debugging: was Sie loggen sollten, wenn es „manchmal“ abbricht

WebSockets sind berüchtigt für „läuft tagelang, dann nicht mehr“. Ohne Logging ist das kaum einzugrenzen. Sinnvolle Logpunkte:

  • Zeitstempel (UTC), URL, und Zustandswechsel (connecting/open/closed).
  • Close-Reason, sofern verfügbar (Server fordert Close vs. Netzwerkfehler).
  • Heartbeat-Sendefehler und Receive-Ausnahmen inkl. Exception-Typ.
  • Optional: Größen der empfangenen Nachrichten (nicht die Inhalte), um Datenexplosion zu erkennen.

Wenn Sie über TLS terminieren: Prüfen Sie zusätzlich, ob Zertifikatswechsel (Ablauf, neuer Issuer) zeitlich mit Fehlern korrelieren. In gehärteten Umgebungen sind auch Proxy- und DPI-Boxen (Deep Packet Inspection) Kandidaten.

Variantid: millal System.Net.WebSockets piisab – ja millal mitte

System.Net.WebSockets on paljude integratsioonijuhtumite jaoks piisav, eriti kui tegemist on tekst/JSON-i, mõõduka koormuse ja selgete taasühenduse strateegiatega. Piirid ilmnevad sõltuvalt Delphi-versioonist ja sihtplatvormist:

  • Puuduv/piiratud Ping/Pong-tugi: sellisel juhul jääb App-Heartbeat robustseks mustriks.
  • Puuduvad timeoutid/tühistamine Connect/Receive puhul: siis peate arhitektuuri üles ehitama nii, et umbejooksev worker jääb isoleerituks ja rakendus saab ikkagi korrektselt sulguda (nt protsessi watchdogi või eraldatud worker-instantside abil).
  • Suur koormus või binaarsed voogud: siis tasub tugevam framing-/buffering-kontseptsioon (nt ring buffer, eraldatud Binary-Event, Message-Assembler koos limiitidega).

Legacysituatsioonide (vanemad Delphi-põlvkonnad, väga spetsiifilised TLS/Proxy-nõuded) puhul on mõnedes projektides praktilisemad teegid nagu ICS. Olulisem pole niivõrd „milline Library“, vaid et käsitlete Shutdowni, Reconnecti ja Observability (logid/meetmed) esmaklassiliste teemadena.

Kokkuvõte: ein Delphi WebSocket Client ist ein Betriebsbaustein – mit klaren Grenzen

WebSocket sobib suurepäraselt push-sündmuste, reaalaja oleku, masinate või protsesside teavituste ning tagakanalina portaalide ja teenuste jaoks. Kuvatud wrapper keskendub punktidele, mis ettevõtte digilahendustes sageli vahet teevad: kontrollitud reconnect, heartbeat idle-timeoutide vastu, fragmentide-kindel tekstitöötlus ja peatamistee, mis deploymenti või uuenduse ajal ei takerdu.

Piirangud jäävad: kui vajate rangeid garantiisid Connect/Receive katkestuse kohta väga kitsastes ajaraamides või töötlete äärmiselt kõrgeid andmevooge, peate sügavamalt tegelema timeoutide, platvormi eripärade ja vajadusel alternatiivsete stackidega. Enamiku integratsiooni- ja moderniseerimisscenaaride jaoks on puhtalt kapseldatud, hästi logitud klient nagu eespool siiski kindel alus, mida saab integreerida olemasolevatesse Delphi-süsteemidesse.

Kui soovite sellist komponenti sobitada olemasolevasse arhitektuuri (nt Layer-3 arhitektuur selgete teenuse- ja UI-kihtidega) või peate reaaltingimustes sporadiliste disconnectide puhul debugima, saame selle koos teiega sihipäraselt hinnata: Võtke ühendust.

Ametialases kontekstis on Heartbeat Ping/Pong samuti oluline, kui integratsioonid, andmevood ja edasine arendus peavad korralikult kooskõlastatult toimima.

Arutage projekti või moderniseerimisettevõtmist koos Net-Base.

Järgmine samm

Kui teema muutub reaalseks projektiks, tuleks arhitektuuri, olemasolevat süsteemi ja käitust varakult ühiselt vaadelda.

Me ei toeta ainult üksikute küsimuste lahendamist, vaid ka siis, kui lähtekoodilõikudest, pärandsüsteemidest või portaalikontseptsioonidest peab saama usaldusväärne ettevõtteprojekt.

  • Olemasolev olukord, sihtpilt ja tehnilised riskid hinnatakse üheskoos.
  • REST, andmete juurdepääs, portaalid ja juurutamine ei lükata hilisemaks.
  • Te näete varakult, milline tee on majanduslikult ja operatiivselt jätkusuutlik.

Jaga postitust

Jaga seda postitust otse

LinkedIn, X, XING, Facebook, WhatsApp ja e-post on kohe saadaval. Instagrami jaoks valmistame kohe lingi ja lühiteksti ette.

e-post

Instagram avatakse uues vahekaardis. Link ja lühitekst kopeeritakse eelnevalt lõikepuhvrisse.