Del tema de la revista a la práctica del proyecto
Páginas de servicios y técnicas relacionadas
Por qué un cliente WebSocket Delphi en la práctica es más que „Connect“
Un Delphi WebSocket Client se monta en minutos: URL, Connect, SendText, listo. En software empresarial a medida y en soluciones cercanas al proceso, el problema suele aparecer en funcionamiento: el Reverse Proxy corta conexiones inactivas, los enlaces móviles o VPN tienen timeouts NAT cortos, los certificados cambian, y al finalizar el proceso se queda colgado porque un bucle de recepción aún está bloqueado. Además: un WebSocket es un canal de larga duración con estado; por ello aplican reglas distintas a las del HTTP clásico/REST (Request/Response, de corta duración).
En este fragmento de código no se trata de «Hello WebSocket», sino de un wrapper de cliente apto para la práctica con:
- inicio/parada limpios (sin bloqueos en el shutdown),
- bucle de recepción con Cancellation (señal de interrupción) en lugar de „Thread kill“,
- reconexión con Backoff (reconexión controlada),
- heartbeat como patrón de aplicación (porque Ping/Pong no está disponible en todas partes),
- hooks de depuración y trace que resultan útiles en casos de soporte.
La implementación se basa en System.Net.WebSockets (Delphi RTL; API de cliente WebSocket con TClientWebSocket). Donde esta capa RTL no está disponible en versiones antiguas o es demasiado limitada, un fallback mediante una biblioteca (p. ej. ICS) suele ser razonable; más abajo se ofrece una valoración al respecto.
Esquema de arquitectura: un wrapper en lugar de llamadas WebSocket dispersas
Un error frecuente en aplicaciones Delphi maduras: los formularios de UI o los módulos de servicio „hablan WebSocket“ directamente y terminan con timers, threads y manejos de excepciones repartidos por todas partes. Es mejor un componente claro con eventos bien definidos y una pequeña máquina de estados.
Breve definición de términos: Backoff se refiere a un tiempo de espera que aumenta de forma escalonada tras errores (p. ej. 1s, 2s, 4s …), para no saturar el servidor ni la red. CancellationToken es una señal de cancelación del mundo .NET; en Delphi no existe un patrón idéntico, pero podemos emularlo con TEvent y una bandera ‚StopRequested‘. TThread.Queue programa código para su ejecución en el hilo principal (UI) sin bloquear al worker; Synchronize bloquea y con frecuencia es la causa de deadlocks en rutas de shutdown.
Fragmento de código: cliente WebSocket Delphi con parada, reconexión y despacho de mensajes
El siguiente código está deliberadamente concebido como un „componente de operación“: una clase que se puede usar de forma similar en VCL/FMX o en un Windows- y Windows- und Linux-Services (según la versión/plataforma de Delphi). El núcleo es un hilo de trabajo que mantiene el bucle de recepción y notificará a la aplicación mediante eventos.
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 de la aplicación: útil para evitar timeouts por inactividad detrás de proxies
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 no responde dentro del tiempo de espera; posible bloqueo en la pila de red‘);
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
// Nota: TClientWebSocket.Connect es sincrónico y puede bloquear dependiendo de DNS/TLS.
// Por eso se ejecuta aquí en el 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 como mensaje de la aplicación, porque Ping/Pong no está expuesto correctamente en todas las versiones de 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;
// Recepción: basado en frames, por eso se usa TStringBuilder para la fragmentación.
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
// En muchos protocolos empresariales, Text/JSON es el estándar.
// Binary se puede poner en búfer de forma similar o reenviar directamente.
Log(llDebug, ‚Binary frame received: ‚ + Received.BytesReceived.ToString + ‚ bytes‘);
end;
TWebSocketMessageKind.Close:
begin
Log(llInfo, ‚Server requested close‘);
Break;
end;
end;
// Mini-sleep para reducir uso de CPU en bucles muy rápidos.
// No demasiado grande; de lo contrario empeora la latencia.
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.
Lo que el código hace deliberadamente „diferente“ respecto a ejemplos típicos
- Stop sin agresividad: En lugar de matar hilos, Stop establece un event. El Worker finaliza los bucles en puntos definidos. Esto reduce bloqueos al terminar y evita fugas de recursos en la pila de sockets.
- Cola en lugar de Synchronize: Logging y eventos se envían mediante TThread.Queue al hilo principal. Esto es importante cuando Stop/Shutdown proviene de la UI o de handlers del Service Control. Synchronize puede bloquear si el hilo principal está esperando.
- Se tiene en cuenta la fragmentación: El texto de WebSocket puede llegar fragmentado en frames. Por eso se utiliza TStringBuilder y se comprueba EndOfMessage.
- Heartbeat como protocolo de aplicación: Muchos despliegues fallan por timeouts por inactividad (balanceadores de carga, nginx, Cloud WAF). Un texto ligero de „ping“ suele ser, como palanca operativa, más efectivo que confiar en „TCP keepalive“ o en una API Ping/Pong que no está disponible en todas partes.
Condiciones y puntos críticos en operación
1) DNS, TLS y proxy: Connect puede bloquear
TClientWebSocket.Connect es síncrono. Según la resolución DNS, el TLS handshake, la verificación de certificados o el entorno de proxy, eso puede tardar varios segundos. El código lo coloca deliberadamente en un Worker. Si además necesita timeouts estrictos, debe comprobar a nivel de API si su versión Delphi proporciona opciones de timeout, o encapsular Connect en un hilo separado y abortar mediante lógica de proceso. Importante: „abortar“ aquí suele significar „marcar la conexión como rota y volver a inicializar el Worker“, no „matar inmediatamente la operación de socket“.
2) Timeouts por inactividad: por qué el Heartbeat suele ser obligatorio
En redes empresariales un WebSocket suele terminar detrás de un reverse proxy (nginx, IIS ARR) o de un balanceador de carga. Muchos de estos componentes cierran conexiones si durante largo tiempo no circulan datos. TCP-Keepalive no siempre está configurado con valores suficientemente cortos (y bajo Windows suele medirse en minutos más que en segundos). Un Heartbeat a nivel de aplicación es por tanto un workaround estable. Asegúrese de que servidor y cliente comparten el mismo concepto (p. ej. „ping“/“pong“ como texto o JSON).
3) Hilos y UI: los eventos deben permanecer desacoplados
Si el procesamiento OnText es pesado (parseo JSON, accesos a la BD con BDE-Ablosung con enlace nativo, actualizaciones de la UI), no debería bloquearlo todo en el hilo principal. El wrapper solo entrega el mensaje. Un patrón típico es: OnText coloca la payload en una cola (p. ej. TThreadedQueue<string>), y un Worker separado procesa con backpressure (es decir, longitud de cola limitada). Eso evita que ante ráfagas de carga la interfaz se congele o que la recepción se descontrole.
Depuración: qué debe registrar cuando se interrumpe „a veces“
Los WebSockets son famosos por „funcionar durante días y luego dejar de hacerlo“. Sin logging es difícil acotar el problema. Puntos de log recomendables:
- Marca temporal (UTC), URL y cambios de estado (connecting/open/closed).
- Close-Reason, si está disponible (cierre solicitado por el servidor vs. fallo de red).
- Errores al enviar el Heartbeat y excepciones al recibir, incl. tipo de excepción.
- Opcional: tamaños de los mensajes recibidos (no los contenidos), para detectar explosiones de datos.
Si termina TLS: compruebe además si cambios de certificado (caducidad, nuevo issuer) se correlacionan temporalmente con errores. En entornos endurecidos también son candidatas las cajas de proxy y DPI (Deep Packet Inspection).
Variantes: cuándo System.Net.WebSockets es suficiente — y cuándo no
System.Net.WebSockets es suficiente para muchos casos de integración, sobre todo cuando se trata de texto/JSON, cargas moderadas y estrategias de reconexión claras. Los límites aparecen según la versión de Delphi y la plataforma objetivo:
- Soporte de Ping/Pong ausente o limitado: En ese caso, App-Heartbeat sigue siendo el patrón robusto.
- Falta de timeouts/cancelación en Connect/Receive: Entonces debe diseñar la arquitectura de modo que un worker bloqueado permanezca aislado y la aplicación pueda cerrarse limpiamente (p. ej. mediante un watchdog de proceso o instancias de worker separadas).
- Alta carga o flujos binarios: En ese caso conviene un concepto de framing/buffering más robusto (p. ej. ring buffer, Binary-Event separado, ensamblador de mensajes con límites).
Para situaciones legacy (generaciones más antiguas de Delphi, requisitos TLS/Proxy muy específicos) bibliotecas como ICS son, en algunos proyectos, más pragmáticas. Lo importante no es tanto «qué librería», sino que trate Shutdown, Reconnect y observabilidad (logs/métricas) como temas de primera clase.
Conclusión: un cliente WebSocket Delphi es un componente operativo — con límites claros
Un WebSocket es excelente para eventos push, estado en vivo, notificaciones de máquinas o procesos y como canal de retorno para portales y servicios. El wrapper mostrado se centra en los puntos que en las soluciones empresariales digitales suelen marcar la diferencia: reconexión controlada, Heartbeat frente a Idle-Timeouts, procesamiento de texto seguro frente a fragmentos y una ruta de parada que no se queda bloqueada durante el despliegue o la actualización.
Permanecen límites de uso: si necesita garantías estrictas de interrupción de Connect/Receive en ventanas de tiempo muy reducidas o maneja tasas de datos extremadamente altas, deberá profundizar en timeouts, particularidades de la plataforma y, si procede, en stacks alternativos. Para la mayoría de los escenarios de integración y modernización, un cliente bien encapsulado y con buen logging como el mostrado es, no obstante, una base sólida que puede integrarse en sistemas Delphi existentes.
Si necesita encajar un componente así en una arquitectura existente (p. ej. Layer-3 arquitectura con capas claras de servicio y UI) o depurar desconexiones esporádicas en condiciones reales, podemos ayudarle a evaluarlo de forma específica: póngase en contacto.
En el entorno técnico, Heartbeat Ping/Pong también son importantes cuando integraciones, flujos de datos y evolución deben funcionar de forma coherente.
Discutir un proyecto o iniciativa de modernización con Net-Base.
Siguiente paso
Cuando el tema se convierte en un proyecto real, la arquitectura, los sistemas existentes y la operación deben considerarse desde el principio.
No solo apoyamos en consultas puntuales, sino también cuando, a partir de fragmentos de código fuente, temas heredados o ideas de portales, debe consolidarse un proyecto empresarial robusto.
- La situación actual, el estado objetivo y los riesgos técnicos se evalúan conjuntamente.
- REST, el acceso a datos, los portales y el rollout no se posponen como consecuencias tardías.
- Detecta con antelación qué enfoque es viable desde el punto de vista económico y operativo.