Net-Base Журнал

01.06.2026

Delphi WebSocket-клиент: надежное подключение, корректная остановка, надежная отладка

Delphi WebSocket-клиент быстро оказывается «как‑то подключён» — но в эксплуатации важны повторное подключение (reconnect), контрольные сигналы (heartbeats), корректная остановка и отладимость. С практичной обёрткой на базе System.Net.WebSockets (с fallback) и фрагментом исходного кода для работы с потоками и...

01.06.2026

От темы в журнале к проектной практике

Соответствующие страницы услуг и технологий к статье

Почему Delphi WebSocket Client в реальной эксплуатации больше, чем «Connect»

Delphi WebSocket Client собирается за считанные минуты: URL, Connect, SendText, готово. В индивидуальном корпоративном ПО и процессно-близких решениях проблемы обычно проявляются в эксплуатации: обратный прокси разрывает простаивающие соединения, мобильные или VPN-каналы имеют короткие NAT-timeout’ы, сертификаты меняются, а при завершении процесс зависает, потому что Receive-Loop всё ещё блокирован. Кроме того: WebSocket — это долговременный, состоянийный канал, поэтому действуют другие правила, чем при классическом HTTP/REST (Request/Response, краткосрочный).

В этом исходном фрагменте речь не о «Hello WebSocket», а о практичном обёрточном клиенте с:

  • чётким стартом/стопом (без зависаний при завершении),
  • Receive-Loop с Cancellation (сигнал отмены) вместо «Thread kill»,
  • Reconnect с Backoff (контролируемое переподключение),
  • Heartbeat как шаблон приложения (потому что Ping/Pong не везде доступен),
  • Debug- и Trace-хуки, которые действительно помогают в случаях поддержки.

Реализация базируется на System.Net.WebSockets (Delphi RTL; WebSocket-Client-API с TClientWebSocket). Там, где этот уровень RTL в старых версиях недоступен или слишком ограничен, часто имеет смысл использовать fallback через библиотеку (например, ICS) — ниже дана соответствующая оценка.

Архитектурная схема: обёртка вместо разбросанных вызовов WebSocket

Распространённая ошибка в эволюционировавших Delphi-приложениях: UI-формы или сервисные модули «общаются напрямую с WebSocket» и в результате в коде повсюду оказываются таймеры, потоки и обработчики исключений. Лучше выделить ясный модуль с чётко определёнными событиями и небольшой машиной состояний.

Краткие пояснения терминов: Backoff означает время ожидания, которое после ошибок растёт по шагам (например, 1 с, 2 с, 4 с …), чтобы не перегружать сервер и сеть. CancellationToken — это сигнал отмены из мира .NET; в Delphi нет идентичного паттерна, но его можно моделировать с помощью TEvent и флага «StopRequested». TThread.Queue планирует выполнение кода в главном потоке (UI), не блокируя рабочий поток; Synchronize блокирует и часто является причиной дедлоков в путях завершения.

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

Следующий код намеренно построен как «эксплуатационный модуль»: класс, который можно использовать в VCL/FMX или в Windows- и Windows- и Linux-сервисах (в зависимости от версии/платформы Delphi). Ядро — worker-поток, который удерживает Receive-Loop и через события сообщает в приложение.

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: полезен против таймаутов простоя за прокси
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.
        // Поэтому это выполняется в рабочем потоке.
        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;

          // Приём: на основе фреймов, поэтому используется 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;

    // Повторное подключение после корректного закрытия или после ошибок
    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 ohne Gewalt: Вместо принудительного завершения потоков Stop выставляет событие. Worker завершает циклы в определённых местах. Это уменьшает зависания при завершении и предотвращает утечки ресурсов в стеке сокетов.
  • Queue statt Synchronize: Логирование и события отправляются в главный поток через TThread.Queue. Это важно, когда Stop/Shutdown инициируется из UI или из обработчиков Service‑Control. Synchronize может блокировать, если главный поток в данный момент ожидает.
  • Fragmentierung berücksichtigt: Текст WebSocket может приходить фрагментированными фреймами. Поэтому используется TStringBuilder и проверка EndOfMessage.
  • Heartbeat als App-Protokoll: Многие конфигурации прекращают работу из‑за idle‑тайм‑аутов (Load Balancer, nginx, Cloud WAF). Лёгкий текстовый «ping» как эксплуатационный рычаг часто эффективнее, чем надежда на «TCP keepalive» или на не везде доступное API Ping/Pong.

Ограничения и подводные камни при эксплуатации

1) DNS, TLS и прокси: Connect kann blockieren

TClientWebSocket.Connect синхронен. В зависимости от разрешения DNS, TLS‑рукопожатия, проверки сертификата или прокси‑окружения это может занять несколько секунд. Код сознательно помещает это в Worker. Если вам нужны жёсткие тайм‑ауты, необходимо на уровне API проверить, предоставляет ли ваша Delphi‑версия опции таймаута, или инкапсулировать Connect в отдельный поток и прерывать через логику процесса. Важно: «отмена» здесь обычно означает «пометить соединение как повреждённое и поднять Worker заново», а не «немедленно убить операцию сокета».

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

В корпоративных сетях WebSocket часто завершается за Reverse Proxy (nginx, IIS ARR) или Load Balancer. Многие из этих компонентов закрывают соединения, если в течение длительного времени не передаются данные. TCP‑keepalive не всегда настроен с достаточной частотой (и под Windows чаще измеряется минутами, а не секундами). Поэтому Heartbeat на уровне приложения — надёжный обходной путь. Убедитесь, что сервер и клиент используют одну и ту же схему (например, «ping»/«pong» в виде текста или JSON).

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

Если обработка OnText тяжёлая (парсинг JSON, доступы к БД с BDE‑Ablosung с нативной привязкой, обновления UI), она не должна блокировать главный поток. Обёртка лишь доставляет сообщение. Типичный паттерн: OnText помещает payload в очередь (например, TThreadedQueue<string>), отдельный Worker обрабатывает с механизмом backpressure (т. е. с ограниченной длиной очереди). Это предотвращает зависание 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 достаточен для многих сценариев интеграции, особенно когда речь идёт о Text/JSON, умеренной нагрузке и понятных стратегиях переподключения. Ограничения зависят от версии Delphi и целевой платформы:

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

В legacy-сценариях (старые поколения Delphi, очень специфичные требования к TLS/Proxy) библиотеки вроде ICS в некоторых проектах оказываются прагматичнее. Важнее не «какая библиотека», а то, что вы рассматриваете Shutdown, Reconnect и Observability (логи/метрики) как первостепенные темы.

Вывод: ein Delphi WebSocket Client ist ein Betriebsbaustein – mit klaren Grenzen

WebSocket хорошо подходит для push-событий, live-статусов, сообщений от машин или процессов и как обратный канал для порталов и сервисов. Показанный wrapper фокусируется на аспектах, которые в цифровых корпоративных решениях часто имеют значение: контролируемый Reconnect, Heartbeat против таймаутов простоя, обработка текста, устойчивая к фрагментации, и путь остановки, который при деплое или обновлении не зависает.

Ограничения остаются: если вам нужны жёсткие гарантии прерывания Connect/Receive в очень узких временных окнах или вы оперируете экстремально высокими скоростями передачи, придётся глубже работать с таймаутами, особенностями платформы и, при необходимости, альтернативными стеками. Для большинства сценариев интеграции и модернизации аккуратно инкапсулированный, хорошо залогированный клиент, как показано выше, всё же является надёжной базой, которую можно интегрировать в эволюционирующие Delphi-системы.

Если вы хотите вписать такой компонент в существующую архитектуру (например, Layer-3 Architektur с чёткими сервисными и UI-слоями) или отлаживать спорадические разрывы в реальных условиях, мы можем помочь вам оценить это целенаправленно: Свяжитесь с нами.

В профессиональном контексте также важную роль играют 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, доступ к данным, порталы и развертывание не переносятся на более поздние этапы.
  • Вы заранее видите, какой путь экономически и эксплуатационно жизнеспособен.

Поделиться записью

Поделиться этой записью напрямую

LinkedIn, X, XING, Facebook, WhatsApp и E-Mail доступны сразу. Для Instagram мы сразу подготовим ссылку и краткий текст.

Электронная почта

Instagram открывается в новой вкладке. Ссылка и короткий текст предварительно копируются в буфер обмена.