Net-Base מגזין

01.06.2026

Delphi WebSocket Client: חיבור יציב, עצירה נקייה, דיבוג מהימן

לקוח WebSocket של Delphi יכול במהירות להיראות "מחובר איכשהו" — אך בתפעול מה שחשוב הם חיבור מחדש, heartbeats, עצירה מסודרת ויכולת ניפוי שגיאות. בעזרת עטיפה פרקטית המבוססת על System.Net.WebSockets (עם fallback) וקטע קוד מקור לטיפול ב-Threading ו...

01.06.2026

מהנושא במגזין ליישום בפרויקט

דפי שירות וטכניים רלוונטיים למאמר

מדוע לקוח WebSocket של Delphi במציאות הוא יותר מ„Connect“

לקוח Delphi WebSocket Client מורכב בדקות: URL, Connect, SendText — מוכן. בתוכנה ארגונית מותאמת ובפתרונות קרובים לתהליכים הבעיות מתגלות בדרך‑כלל רק בזמן runtime: ה‑Reverse Proxy סוגר חיבורים אי‑פעילים, קווים סלולריים או VPN קצרים מבחינת timeout של NAT, תעודות (certificates) מתחלפות, ובסגירה התהליך יכול להיתקע כי Receive-Loop עדיין חסום. בנוסף: WebSocket הוא ערוץ ארוך־חיים ובעל מצב (stateful) — לכן חלים כללים שונים מאלו של HTTP/REST (Request/Response, קצר‑זמני).

בקטע קוד זה הכוונה אינה ל“Hello WebSocket“, אלא ל‑Client‑Wrapper מתאים לשימוש פרקטי הכולל:

  • הפעלה/כיבוי נקיים (ללא תלישות בתהליך בעת Shutdown),
  • Receive-Loop עם Cancellation (אות ביטול) במקום „Thread kill“,
  • Reconnect עם Backoff (חיבור מחדש מבוקר),
  • Heartbeat כדפוס יישומי (כי Ping/Pong לא תמיד זמין),
  • Debug‑ ו‑Trace‑Hooks שמסייעים באמת במקרי תמיכה.

היישום מבוסס על System.Net.WebSockets (Delphi RTL; ממשק WebSocket‑Client עם TClientWebSocket). במקומות שבהם שכבת ה‑RTL הזו חסרה בגרסאות ישנות או מוגבלת מדי, פיינבק דרך ספרייה חיצונית (למשל ICS) הוא ברוב המקרים הגיוני — להלן מיקום הדבר בהקשר.

סקיצה ארכיטקטונית: Wrapper במקום קריאות WebSocket מפוזרות

טעויות נפוצות במערכות Delphi שהתפתחו עם הזמן: טפסי UI או מודולי שירות „מדברים ישירות עם WebSocket“ ואז יש בכל מקום טיימרים, ת’רים וטיפולי חריגות מפוזרים. עדיף לבנות רכיב ברור עם Events מוגדרים היטב ומכונה ממדית (state machine) קטנה.

מונחים בקצרה: Backoff פירושו זמני המתנה שגדלים בשלבים לאחר שגיאות (למשל 1s, 2s, 4s …), כדי לא להציף את השרת או הרשת. CancellationToken הוא אות ביטול מעולם .NET; ב‑Delphi אין דפוס זהה, אך ניתן לדמותו עם TEvent ודגל „StopRequested“. TThread.Queue מתזמן קוד לביצוע ב‑main thread (UI) מבלי לחסום את ה‑worker; Synchronize חוסם ועל נתיבי Shutdown הוא לעתים קרובות הגורם ל‑deadlocks.

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

הקוד שלהלן בנוי במודע כרכיב תפעולי: מחלקה שניתן להשתמש בה ב‑VCL/FMX או בשירותים של Windows ו‑Windows‑ und Linux‑Services (תלות בגרסת/פלטפורמת Delphi). הליבה היא Worker‑Thread שמחזיק את Receive‑Loop ומדווח לאפליקציה דרך Events.

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 של היישום: מועיל נגד ניתוקי Idle שנוצרים מאחורי פרוקסים
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, 'עצירה: ה-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.
        // לכן זה רץ כאן בתוך ה-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 כהודעת אפליקציה, מכיוון ש-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 הוא ברירת המחדל.
                // נתונים בינאריים ניתן כאן לאחסן בבופר באופן דומה או להעביר ישירות.
                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;

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

מה שהקוד עושה בכוונה «שונה» בהשוואה לדוגמאות טיפוסיות

  • Stop ללא אלימות: במקום להרוג Threads, Stop מרים Event. ה-Worker מסיים לולאות בנקודות מוגדרות. זה מקטין תלויות בעת סגירה ומונע דליפות משאבים ב‑socket‑stack.
  • Queue במקום Synchronize: Logging ואירועים נשלחים דרך TThread.Queue ל‑Mainthread. זה חשוב אם Stop/Shutdown מגיע מ‑UI או מ‑Service‑Control‑Handlern. Synchronize עלול לחסום אם ה‑Mainthread נמצא במצב המתנה.
  • מתחשב בפראגמנטציה: טקסט WebSocket יכול להגיע מפוצל ב‑frames. לכן משתמשים ב‑TStringBuilder ובבדיקה של EndOfMessage.
  • Heartbeat כפרוטוקול אפליקטיבי: מערכות רבות „מתות“ בגלל Idle‑Timeouts (Load Balancer, nginx, Cloud WAF). טקסט „ping“ קל משקל כתמרור פעולה לעתים יעיל יותר מאשר ההסתמכות על „TCP keepalive“ או על ממשק Ping/Pong שלא קיים בכל מקום.

תנאים ופתקי טיפוס בתפעול

1) DNS, TLS und Proxy: Connect עלול להיחסם

TClientWebSocket.Connect הוא סינכרוני. בהתאם ליישוב DNS, TLS‑Handshake, בדיקת תעודה או סביבת Proxy זה יכול לארוך מספר שניות. הקוד מניח זאת במכוון ומריץ את הבקשה בתוך Worker. אם אתם צריכים Timeouts קשיחים נוספים, יש לבדוק ברמת ה‑API האם גרסת Delphi שלכם מספקת אפשרויות Timeout, או לעטוף את Connect ב‑Thread נפרד ולנתק באמצעות לוגיקת תהליך. חשוב: „לבטל“ כאן בדרך כלל אומר „לסמן את החיבור כפגום ולהרים Worker חדש“, לא „להרוג מיד פעולה על Socket“.

2) Idle‑Timeouts: למה Heartbeat לעתים קריטי

ברשתות ארגוניות WebSocket לעתים מסתיים מאחורי Reverse Proxy (nginx, IIS ARR) או Load Balancer. רכיבים רבים סוגרים חיבורים אם לא זורמים נתונים לתקופה ארוכה. TCP‑Keepalive לא תמיד מוגדר לפרקי זמן קצרים מספיק (ובתנאי Windows לעתים זה דקות ולא שניות). לכן Heartbeat ברמת היישום מהווה פתרון יציב. שימו לב שלשרת וללקוח צריך להיות אותו מושג (למשל „ping“/“pong“ כטקסט או JSON).

3) Threading und UI: אירועים חייבים להישאר מופרדים

אם עיבוד OnText כבד (JSON‑Parsing, גישות DB עם BDE-Ablosung mit nativer Anbindung, עדכוני UI), אין להריץ אותו כך שיחסום את ה‑Mainthread. ה‑Wrapper מעביר רק את ההודעה. תבנית טיפוסית היא: OnText שמניח את ה‑Payload ל‑Queue (למשל TThreadedQueue<string>), Worker נפרד מעבד עם Backpressure (כלומר אורך Queue מוגבל). זה מונע שה‑UI יקפא או שהקבלה תתערער בזמן עומס פתאומי.

Debugging: מה כדאי לוג כשזה „לפעמים“ נופל

WebSockets ידועים ב‑“רצים ימים ואז מפסיקים“. בלי Logging קשה מאוד לאתר. נקודות לוג משמעותיות:

  • חותמות זמן (UTC), URL, ושינויים במצב (connecting/open/closed).
  • Close‑Reason, אם זמין (Server מבקש Close לעומת תקלה ברשת).
  • שגיאות שליחת Heartbeat וחריגות קבלה כולל סוג ה‑Exception.
  • אופציונלי: גדלים של הודעות שהתקבלו (לא התוכן), כדי לזהות התפוצצות נתונים.

אם אתם מבצעים Terminierung על TLS: בדקו גם האם שינויים בתעודה (פגות, Issuer חדש) מתזמנים עם שגיאות. בסביבות מחוזקות גם פרוקסי ו‑DPI‑Boxen (Deep Packet Inspection) הם מועמדים לבעיה.

מגוון אפשרויות: מתי System.Net.WebSockets מספיק – ומתי לא

System.Net.WebSockets מספק ברוב מקרים של אינטגרציה, במיוחד כשמדובר בטקסט/JSON, בעומס בינוני ובאסטרטגיות חיבור מחדש ברורות. גבולותיו ניכרים בהתאם לגרסת Delphi וליעד הפלטפורמה:

  • חוסר/תמיכה מוגבלת ב‑Ping/Pong: במקרה כזה, דפוס יציב נשאר בדיקת חיים של האפליקציה.
  • חוסר Timeouts/ביטול בתהליכי Connect/Receive: במקרה כזה עליכם לבנות את הארכיטקטורה כך שעובד תקוע יישאר מבודד והיישום ייסגר בצורה נקייה (למשל בעזרת watchdog לתהליך או מופעי Worker נפרדים).
  • עומס גבוה או זרמי בינארי: במקרה כזה כדאי לממש קונספט חזק יותר של framing/buffering (למשל ring buffer, אירוע בינארי נפרד, Message-Assembler עם מגבלות).

למצבים ישנים (דורות Delphi ישנים יותר, דרישות TLS/Proxy מאוד ספציפיות) ספריות כמו ICS פרגמטיות בפרויקטים מסוימים. החשוב פחות איזו ספריה, ויותר שתטפלו ב‑Shutdown, Reconnect ו‑Observability (לוגים/מדדים) כנושאים ברמת חשיבות ראשונה.

מסקנה: לקוח WebSocket של Delphi הוא רכיב תפעולי – עם גבולות ברורים

WebSocket מתאים היטב לאירועי push, סטטוס בזמן אמת, דיווחי מכונות או תהליכים ולערוץ חזרה לפורטלים ושירותים. ה‑wrapper המוצג מתמקד בנקודות שלעיתים מהוות את ההבדל בפתרונות ארגוניים דיגיטליים: חיבור מחדש מבוקר, בדיקת חיים כנגד Idle‑Timeouts, עיבוד טקסט עמיד לפירגמנטים ונתיב עצירה שאינו נתקע בעת פריסה או עדכון.

מגבילים בשימוש נשארים: אם אתם זקוקים לערבויות קשיחות לניתוק Connect/Receive במסגרת חלונות זמן קצרים מאוד או מפעילים קצבי נתונים גבוהים במיוחד, תצטרכו להעמיק ב‑Timeouts, בפרטיות הפלטפורמה ובמקרים מסוימים בסטקים חלופיים. עבור רוב תרחישי אינטגרציה ומודרניזציה, לקוח מבודר היטב ומלווה בלוגים מהווה בסיס איתן שניתן לשלב במערכות Delphi קיימות.

אם אתם רוצים להתאים רכיב כזה לארכיטקטורה קיימת (למשל ארכיטקטורת Layer-3 עם שכבות שירות ו‑UI ברורות) או לדבג ניתוקים ספורים בתנאי אמת, נוכל למקד את המיון יחד אתכם: פנו אלינו.

בהקשר מקצועי גם Heartbeat ו‑Ping/Pong ממלאים תפקיד משמעותי כאשר אינטגרציות, זרמי נתונים ופיתוח המשכי צריכים לפעול בהרמוניה.

לדון בפרויקט או במהלך מודרניזציה יחד עם Net-Base.

השלב הבא

כאשר הנושא הופך לפרויקט ממשי, יש לבחון כבר בשלב מוקדם את הארכיטקטורה, המצב הקיים והתפעול יחד.

אנו תומכים לא רק בשאלות נקודתיות, אלא גם כשמקטעי קוד מקור, נושאי Legacy או רעיונות פורטל אמורים להפוך לפרויקט ארגוני מהימן ועמיד.

  • המצב הקיים, תמונת היעד והסיכונים הטכניים מוערכים יחד.
  • REST, גישה לנתונים, פורטלים ו-Rollout לא יידחו כתוצאות מאוחרות.
  • אתם מזהים מוקדם איזה נתיב בר-קיימא מבחינה כלכלית ותפעולית.

שתף פוסט

לשתף את הפוסט הזה ישירות

LinkedIn, X, XING, Facebook, WhatsApp ודוא"ל זמינים מיידית. עבור Instagram אנו מכינים קישור וטקסט קצר באופן מיידי.

דוא״ל

אינסטגרם נפתח בכרטיסייה חדשה. הקישור וטקסט קצר מועתקים מראש ללוח.