Net-Base Lehti

01.06.2026

Delphi WebSocket-asiakas: vakaa yhdistäminen, siisti pysäytys, luotettava virheenkorjaus

Delphi WebSocket-asiakas on nopeasti 'jollain tavalla yhteydessä' – mutta tuotannossa ratkaisevia ovat uudelleenyhdistäminen, heartbeatit, siisti pysäytys ja debugattavuus. Käytännöllinen wrapper, joka perustuu System.Net.WebSocketsiin (vararatkaisulla), sekä lähdekoodikatkelma säikeistykseen ja...

01.06.2026

Lehden aiheesta projektikäytäntöön

Artikkeliin liittyvät palvelu- ja tekniikkasivut

Miksi ein Delphi WebSocket-asiakas käytännössä on enemmän kuin „Connect“

Ein Delphi WebSocket Client ist in Minuten zusammengesteckt: URL, Connect, SendText, fertig. In individueller Unternehmenssoftware und prozessnahen Softwarelösungen kippt das Thema aber meist erst im Betrieb: Der Reverse Proxy trennt Idle-Verbindungen, Mobil- oder VPN-Strecken haben kurze NAT-Timeouts, Zertifikate wechseln, und beim Beenden hängt der Prozess, weil ein Receive-Loop noch blockiert. Dazu kommt: Ein WebSocket ist ein langlebiger, zustandsbehafteter Kanal – damit gelten andere Regeln als bei klassischem HTTP/REST (Request/Response, kurzlebig).

Tässä lähdekoodikatkelmassa ei ole kyse „Hello WebSocket“ -esimerkistä, vaan käytännössä toimivasta asiakas-wrapperista, jolla on:

  • siisti käynnistys/sammutus (ei jumiutumista suljettaessa),
  • Receive-Loop, jossa käytetään Cancellation-mekanismia (peruutussignaali) eikä „Thread kill“ -toimintoa,
  • uudelleenyhdistäminen Backoff-mekanismilla (ohjattu uudelleenliittäminen),
  • heartbeat sovellusmallina (koska Ping/Pong ei ole aina käytettävissä),
  • debug- ja trace-hookit, jotka käytännössä auttavat tukitapauksissa.

Toteutus perustuu System.Net.WebSockets (Delphi RTL; WebSocket-Client-API mit TClientWebSocket). Wo diese RTL-Schicht in älteren Versionen nicht verfügbar oder zu eingeschränkt ist, ist ein Fallback über eine Bibliothek (z. B. ICS) oft sinnvoll – dazu weiter unten eine Einordnung.

Architektur-Skizze: ein Wrapper statt verstreuter WebSocket-Aufrufe

Ein häufiger Fehler in gewachsenen Delphi-Anwendungen: UI-Formulare oder Service-Module „sprechen direkt WebSocket“ und haben dann überall Timer, Threads und Ausnahmebehandlungen verteilt. Besser ist ein klarer Baustein mit wohldefinierten Events und einer kleinen Zustandsmaschine.

Begriffe kurz eingeordnet: Backoff meint eine Wartezeit, die nach Fehlern schrittweise wächst (z. B. 1s, 2s, 4s …), um Server und Netzwerk nicht zu fluten. CancellationToken ist ein Abbruchsignal aus .NET-Welt; in Delphi gibt es kein identisches Pattern, aber wir können es mit TEvent und einem „StopRequested“-Flag nachbilden. TThread.Queue plant Code zur Ausführung im Hauptthread (UI), ohne den Worker zu blockieren; Synchronize blockiert und ist in Shutdown-Pfaden oft der Grund für Deadlocks.

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

Der folgende Code ist bewusst als „Betriebs-Baustein“ aufgebaut: eine Klasse, die man in VCL/FMX oder in einem Windows- und Windows- und Linux-Services (je nach Delphi-Version/Plattform) ähnlich nutzen kann. Der Kern ist ein Worker-Thread, der den Receive-Loop hält und über Events in die Anwendung meldet.

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: hyödyllinen idle-aikakatkaisujen estämiseen välityspalvelinten takana
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 vastannut aikakatkaisun sisällä; mahdollinen lukkiutuminen verkkopinossa');
    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
        // Huom: TClientWebSocket.Connect on synkroninen ja voi DNS-/TLS-riippuvuudesta johtuen estää suorituksen.
        // Siksi tämä ajetaan Workerissa.
        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 sovellusviestinä, koska Ping/Pong ei ole kaikissa Delphi-versioissa luotettavasti saatavilla.
          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;

          // Vastaanotto: kehyspohjainen, siksi StringBuilder fragmentoitumisen käsittelyyn.
          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
                // Monissa yritysprotokollissa teksti/JSON on standardi.
                // Binary voidaan täällä puskuroida vastaavasti tai välittää suoraan.
                Log(llDebug, 'Binary frame received: ' + Received.BytesReceived.ToString + ' bytes');
              end;

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

          // Pieni tauko, jotta erittäin nopeasti toistuvassa silmukassa CPU:ta säästetään.
          // Ei liian pitkä, muuten latenssi heikkenee.
          TThread.Sleep(1);
        end;
      finally
        SB.Free;
      end;

      SafeClose;
      State('closed');

    finally
      WS.Free;
    end;

    if StopRequested then
      Break;

    // Uudelleenyhdistä siistin sulkemisen tai virheiden jälkeen
    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.

Was der Code absichtlich „anders“ macht als typische Beispiele

  • 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.

Randbedingungen und Stolperfallen im Betrieb

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.

Vaihtoehdot: milloin System.Net.WebSockets riittää – ja milloin ei

System.Net.WebSockets riittää moniin integraatiotapauksiin, erityisesti kun kyse on tekstistä/JSONista, kohtalaisesta kuormituksesta ja selkeistä uudelleenyhdistämisstrategioista. Rajat tulevat esiin riippuen Delphi-versiosta ja kohdealustasta:

  • Puuttuva/rajoitettu Ping/Pong-tuki: Tällöin App-Heartbeat on luotettava malli.
  • Puuttuvat aikakatkaisut/peruutukset yhdistämisessä/vastaanotossa: Tällöin sinun täytyy rakentaa arkkitehtuuri niin, että jumiutuva worker pysyy eristettynä ja sovellus voidaan silti sulkea siististi (esim. prosessivalvojan tai erillisten worker-instanssien avulla).
  • Korkea kuorma tai binäärivirrat: Tällöin kannattaa vahvempi kehystys-/puskurointikonsepti (esim. ring buffer, erillinen Binary-Event, Message-Assembler rajoituksin).

Legacy-tilanteissa (vanhemmat Delphi-sukupolvet, hyvin spesifiset TLS/Proxy-vaatimukset) kirjastot kuten ICS ovat joissain projekteissa pragmaattisempia. Tärkeämpää ei ole „mikä kirjasto“, vaan että käsittelette sulkemisen, uudelleenyhdistämisen ja havaittavuuden (lokit/metriikat) ensiluokkaisina aiheina.

Yhteenveto: Delphi WebSocket-asiakas on käyttökomponentti – selkeillä rajoilla

WebSocket sopii erinomaisesti push-tapahtumiin, reaaliaikaiseen tilaan, kone- tai prosessihälytyksiin ja takaisinkanaliksi portaleille ja palveluille. Esitelty wrapper keskittyy niihin kohtiin, jotka digitaalisissa yritysratkaisuissa usein ratkaisevat: kontrolloitu uudelleenyhdistäminen, Heartbeat idle-timeoutteja vastaan, fragmenttiturvallinen tekstinkäsittely ja pysäytyspolku, joka ei juutu käyttöönoton tai päivityksen yhteydessä.

Käyttörajoitukset säilyvät: jos tarvitsette tiukat takuut yhteyden/vastaanoton katkaisulle hyvin ahtaissa aikaväleissä tai käsittelette äärimmäisen suuria datamääriä, teidän täytyy syventyä aikakatkaisuihin, alustan erityispiirteisiin ja tarvittaessa vaihtoehtoisiin stakkeihin. Suurimmassa osassa integraatio- ja modernisointiskenaarioita puhtaasti kapseloitu, hyvin lokitettu asiakas kuten yllä on kuitenkin vankka perusta, joka voidaan integroida kasvaneisiin Delphi-järjestelmiin.

Jos haluatte sovittaa tällaista komponenttia olemassa olevaan arkkitehtuuriin (esim. Layer-3 arkkitehtuuri selkeillä palvelu- ja käyttöliittymäkerroksilla) tai debugata satunnaisia yhteyskatkoksia todellisissa olosuhteissa, voimme käsitellä tilanteen kanssanne kohdennetusti: ota yhteyttä.

Ammattimaisessa ympäristössä Heartbeat ja Ping/Pong ovat myös tärkeitä, kun integraatiot, tietovirrat ja jatkokehitys pitää toimia sujuvasti yhdessä.

Keskustele projektista tai modernisointihankkeesta Net-Base kanssa.

Seuraava vaihe

Kun aiheesta tulee todellinen projekti, arkkitehtuuri, nykyinen järjestelmäkanta ja käyttö tulisi varhaisessa vaiheessa tarkastella yhdessä.

Emme tue pelkästään yksittäiskysymyksissä, vaan myös silloin, kun lähdekoodipalasista, legacy-aiheista tai portaali-ideoista halutaan muodostaa luotettava yrityshanke.

  • Nykytila, tavoitetila ja tekniset riskit arvioidaan yhdessä.
  • REST, datan käyttö, portaalit ja käyttöönotto eivät jätetä myöhempien seurausten varaan.
  • Näette ajoissa, mikä ratkaisu on taloudellisesti ja toiminnallisesti kestävä.

Jaa artikkeli

Jaa tämä viesti suoraan

LinkedIn, X, XING, Facebook, WhatsApp ja sähköposti ovat heti käytettävissä. Instagramia varten valmistelemme linkin ja lyhyen tekstin.

Sähköposti

Instagram avautuu uuteen välilehteen. Linkki ja lyhyt teksti kopioidaan ensin leikepöydälle.