Net-Base מגזין

27.05.2026

העלאת Multipart/Form-Data ב-Delphi: זרמים עמידים, בקרת גבולות ודיבאגינג ללא ניחושים

העלאות Multipart/Form-Data נראות טריוויאליות, אך ב־Delphi הן מתמוטטות במהירות כשיש בעיות עם Streams, שמות קבצים, Content-Type, Boundary-Handling ו־Timeouts. קטע קוד זה מציג מימוש יציב וברי-דיבוג עם THTTPClient — כולל חישוב נכון של Content-Length...

27.05.2026

למה Multipart ב-Delphi לעיתים נכשל רק בזמן התפעול

העלאת Multipart/Form-Data ב-Delphi נבנית במהירות בלחיצות — אך באינטגרציות בשטח היא נכשלת בגלל פרטים: Content-Type שגוי לכל חלק, מחרוזת Boundary שמופיעה בטעות ב־payload, שברי שורה לא מתאימים, שמות קבצים שאינם ASCII או שרתים שמסרבים ל־chunked transfer encoding (HTTP ללא Content-Length). בנוסף קיימות בעיות פרקטיות אופייניות בתוכנות ארגוניות מותאמות: קבצים גדולים (CAD, PDFs, סריקות), רשתות בלתי יציבות, Reverse-Proxies, שערי API נוקשים ודרישות מנהליות לניפוי שגיאות.

Delphi מספק עם System.Net.HttpClient סט חזק, אבל דוגמאות ה“Happy Path“ משאירות תנאי קצה חשובים ללא מענה. קטע המקור הבא מעמיק במכוון: אנו בונים Multipart כזרם דטרמיניסטי, מחשבים את Content-Length נכון, תומכים ב־RFC-5987 לשמות קבצים ומספקים אפשרות דיבוג שהופכת את ה־Request לשחזרי מבלי שתצטרכו לשבור את TLS.

החלטת ארכיטקטורה: THTTPClient במקום Indy — ומתי זה עלול להיכשל

THTTPClient (System.Net) משתמש בהתאם לפלטפורמה ב־backends שונים (תחת Windows בדרך כלל WinHTTP/WinINet). זה ברוב המקרים מועיל בסביבות ארגוניות: מדיניות Proxy ו־TLS תואמות יותר למערכת ההפעלה. Indy לעומת זאת שקוף וניתן להתאמה, אך מביא איתו bindings של TLS משלו ולעתים בתפעול דורש „תחזוקה נפרדת“ (גרסאות OpenSSL, Cipher-Suites).

הגישה כאן משתמשת ב־THTTPClient, כיוון שהוא כבר נמצא בשימוש בתהליכי מודרניזציה רבים (לקוח REST, OAuth, הורדות). אם אתם זקוקים לשליטה הדוקה על TLS‑handshakes, תעודות לקוח בצורות מיוחדות או שרשראות פרוקסי מאוד ספציפיות, Indy (או סט HTTP ייעודי) יכול להיות הגיוני. הדבר משנה מעט בהקמת ה‑Multipart — אך משפיע על הדיבוג והתפעול.

העלאת Multipart/Form-Data ב-Delphi: זרם, לא קסם

הרעיון המרכזי: Multipart הוא בסופו של דבר זרם בתים. אם אנחנו בונים אותו בעצמנו, נוכל:

  • לבחור Boundary באופן מודע ולבחון את יציבותו
  • להגדיר כותרות לכל חלק נכון (כולל Content-Disposition, Content-Type)
  • לחישוב אמין של Content-Length (חשוב לשרתים שאין להם תמיכה ב־chunked)
  • לסטרים קבצים גדולים מבלי להחזיק הכול בזיכרון RAM

הקוד: Multipart-Builder עם זרימה ושמות קבצים לפי RFC-5987

ה־Builder שלמטה יוצר על־פי בחירה גוף מבוסס־זיכרון בלבד (ל־uploads קטנים) או קובץ Spool על הדיסק (ל־payloads גדולים). זה מרגיש „oldschool“, אבל בתפעול פרקטי במיוחד כי זה מונע chunked ומקל על דיבוג. Spooling משמעותו: תוכלו להשתמש שוב באותו Request‑Body גם אם צריך ניסיון חוזר.

Delphi
unit Net-Base.Multipart;

interface

uses
  System.SysUtils, System.Classes, System.Net.HttpClient, System.Net.URLClient,
  System.NetEncoding, System.Hash;

type
  TMultipartFormData = class
  private
    FBoundary: string;
    FParts: TObjectList<TObject>;
    function CRLF: TBytes;
    function BytesOfAscii(const S: string): TBytes;
    function Quote(const S: string): string;
    function Rfc5987FileNameStar(const FileName: string): string;
    function NewBoundary: string;
  public
    constructor Create(const ABoundary: string = '');
    destructor Destroy; override;

    function ContentType: string;
    function Boundary: string;

    procedure AddField(const Name, Value: string; const ContentType: string = 'text/plain; charset=utf-8');
    procedure AddFile(const FieldName, FileName, ContentType: string; const FileStream: TStream);

    // בונה את גוף הבקשה המלא ל-Stream. אם ASpoolToFile ריק,
    // ישמש TMemoryStream; אחרת תיווצר קובץ.
    function BuildBodyStream(out AContentLength: Int64; const ASpoolToFile: string = ''): TStream;
  end;

implementation

uses
  System.Generics.Collections;

type
  TPartKind = (pkField, pkFile);

  TPart = class
  public
    Kind: TPartKind;
    Name: string;
    ContentType: string;
    // Field
    Value: string;
    // File
    FileName: string;
    FileStream: TStream;
    constructor Create; 
  end;

constructor TPart.Create;
begin
  inherited Create;
end;

constructor TMultipartFormData.Create(const ABoundary: string);
begin
  inherited Create;
  FParts := TObjectList<TObject>.Create(True);
  if ABoundary <> '' then
    FBoundary := ABoundary
  else
    FBoundary := NewBoundary;
end;

destructor TMultipartFormData.Destroy;
begin
  FParts.Free;
  inherited;
end;

function TMultipartFormData.NewBoundary: string;
var
  R: TBytes;
begin
  // ה-Boundary צריך להיות אקראי מספיק. חשוב: אין רווחים.
  SetLength(R, 16);
  TNetEncoding.Base64.Decode(TNetEncoding.Base64.EncodeBytesToBytes(THashSHA2.GetHashBytes(GuidToString(TGuid.NewGuid))), R);
  Result := '----DelphiBoundary' + THashSHA2.GetHashString(GuidToString(TGuid.NewGuid));
  Result := Result.Replace('{','').Replace('}','').Replace('-','');
end;

function TMultipartFormData.Boundary: string;
begin
  Result := FBoundary;
end;

function TMultipartFormData.ContentType: string;
begin
  Result := 'multipart/form-data; boundary=' + FBoundary;
end;

function TMultipartFormData.CRLF: TBytes;
begin
  Result := TBytes.Create($0D, $0A);
end;

function TMultipartFormData.BytesOfAscii(const S: string): TBytes;
begin
  // כותרות multipart הן ASCII. עבור ערכים בגוף (למשל UTF-8) נגדיר Content-Type לכל חלק.
  Result := TEncoding.ASCII.GetBytes(S);
end;

function TMultipartFormData.Quote(const S: string): string;
begin
  Result := '"' + S.Replace('"', '"') + '"';
end;

function TMultipartFormData.Rfc5987FileNameStar(const FileName: string): string;
var
  Utf8: TBytes;
  Enc: string;
begin
  // filename*="UTF-8''..." יציב משמעותית עבור שמות קבצים שאינם ASCII מאשר רק filename="...".
  Utf8 := TEncoding.UTF8.GetBytes(FileName);
  Enc := TNetEncoding.URL.EncodeBytesToString(Utf8);
  Result := 'filename*=' + 'UTF-8'''''+ Enc;
end;

procedure TMultipartFormData.AddField(const Name, Value: string; const ContentType: string);
var
  P: TPart;
begin
  P := TPart.Create;
  P.Kind := pkField;
  P.Name := Name;
  P.Value := Value;
  P.ContentType := ContentType;
  FParts.Add(P);
end;

procedure TMultipartFormData.AddFile(const FieldName, FileName, ContentType: string; const FileStream: TStream);
var
  P: TPart;
begin
  if FileStream = nil then
    raise EArgumentNilException.Create('FileStream לא יכול להיות nil');

  if (FileStream is TCustomMemoryStream) and (FileStream.Size = 0) then
    ; // מותר, אך לעיתים זה טעות: קובץ ריק

  P := TPart.Create;
  P.Kind := pkFile;
  P.Name := FieldName;
  P.FileName := FileName;
  P.ContentType := ContentType;
  P.FileStream := FileStream; // Owner bleibt beim Aufrufer
  FParts.Add(P);
end;

function TMultipartFormData.BuildBodyStream(out AContentLength: Int64; const ASpoolToFile: string): TStream;
var
  OutStream: TStream;
  WriterUtf8: TBytes;
  PartObj: TObject;
  P: TPart;
  Header: string;
  Sep, EndSep: string;
  B: TBytes;

  procedure WriteAscii(const S: string);
  begin
    B := BytesOfAscii(S);
    OutStream.WriteBuffer(B, Length(B));
  end;

  procedure WriteBytes(const Bytes: TBytes);
  begin
    if Length(Bytes) > 0 then
      OutStream.WriteBuffer(Bytes, Length(Bytes));
  end;

  procedure CopyStreamFully(Src: TStream);
  var
    Buf: array[0..64*1024-1] of Byte;
    ReadN: Integer;
  begin
    // שים לב: מיקום ה-Stream נצרך.
    while True do
    begin
      ReadN := Src.Read(Buf, SizeOf(Buf));
      if ReadN <= 0 then
        Break;
      OutStream.WriteBuffer(Buf, ReadN);
    end;
  end;

begin
  if ASpoolToFile <> '' then
    OutStream := TFileStream.Create(ASpoolToFile, fmCreate or fmShareDenyWrite)
  else
    OutStream := TMemoryStream.Create;

  try
    Sep := '--' + FBoundary + #13#10;
    EndSep := '--' + FBoundary + '--' + #13#10;

    for PartObj in FParts do
    begin
      P := TPart(PartObj);

      WriteAscii(Sep);

      if P.Kind = pkField then
      begin
        Header := 'Content-Disposition: form-data; name=' + Quote(P.Name) + #13#10 +
                  'Content-Type: ' + P.ContentType + #13#10 +
                  #13#10;
        WriteAscii(Header);
        // תוכן השדה ב-UTF-8, אם charset=utf-8 מוגדר.
        WriterUtf8 := TEncoding.UTF8.GetBytes(P.Value);
        WriteBytes(WriterUtf8);
        WriteBytes(CRLF);
      end
      else
      begin
        // שני פרמטרי שם קובץ: filename (עבור שרתים ישנים) ו-filename* (RFC 5987)
        Header := 'Content-Disposition: form-data; name=' + Quote(P.Name) + '; ' +
                  'filename=' + Quote(ExtractFileName(P.FileName)) + '; ' +
                  Rfc5987FileNameStar(ExtractFileName(P.FileName)) + #13#10 +
                  'Content-Type: ' + P.ContentType + #13#10 +
                  'Content-Transfer-Encoding: binary' + #13#10 +
                  #13#10;
        WriteAscii(Header);

        // חשוב: לאפס למיקום ההתחלתי, אחרת יועלו רק שארית הנתונים.
        if P.FileStream.Seek(0, soBeginning) <> 0 then
          ;
        CopyStreamFully(P.FileStream);
        WriteBytes(CRLF);
      end;
    end;

    WriteAscii(EndSep);

    AContentLength := OutStream.Size;
    OutStream.Position := 0;
    Result := OutStream;
  except
    OutStream.Free;
    raise;
  end;
end;

end.

מה שהקוד עושה שונה במכוון

  • אין „Multipart אוטומטי”: השליטה על Header, Encodings ו-Boundary נשארת אצלכם. הדבר חשוב לעיתים קרובות ב-APIs קשוחים של REST.
  • תמיכה ב-RFC-5987 באמצעות filename*: ברגע ששמות קבצים מכילים תווים עם Umlaut (למשל „Prüfbericht.pdf“), זה הבאג השכיח ביותר באינטרופר. חלק מהשרתים מתעלמים מ-filename*, ואז filename משמש כ-Fallback.
  • Spool-to-File כתכונת תפעול: עבור העלאות גדולות וניסיונות חוזרים, סטרים גוף שניתן לשימוש חוזר שווה זהב.
  • Content-Length זמין, כי הגוף נבנה מראש. זה מונע Chunked-Encoding, אם מערכת היעד אינה מקבלת זאת.

שליחת Request: Timeouts, Header ואסטרטגיית Retry הגיונית

Multipart בפני עצמו אינו פותר את בעיות האינטגרציה: אתם צריכים Timeouts, סיווג שגיאות וניסיונות חוזרים אופציונליים. חשוב להבחין בין idempotent ו־לא idempotent: העלאות לרוב אינן idempotent (יתכנו כפילויות). לכן יש לבצע Retries רק כאשר השרת מספק סמנטיקה idempotent (למשל Upload-ID, כותרת ייעודית Idempotency-Key) או כאשר יש לכם דה-דופליקציה בצד השרת.

Delphi
uses
  System.SysUtils, System.Classes, System.Net.HttpClient, System.Net.URLClient,
  Net-Base.Multipart;

function PostMultipart(const Url: string; const Token: string; const MP: TMultipartFormData;
  const SpoolFile: string = ''): IHTTPResponse;
var
  Client: THTTPClient;
  Body: TStream;
  ContentLen: Int64;
  Req: IHTTPRequest;
begin
  Client := THTTPClient.Create;
  try
    // Timeouts: je nach Datei und Leitung realistisch setzen.
    Client.ConnectionTimeout := 15000; // ms
    Client.ResponseTimeout := 600000;  // ms (10 min)

    Body := MP.BuildBodyStream(ContentLen, SpoolFile);
    try
      Req := Client.GetRequest('POST', Url);
      Req.SourceStream := Body;

      Req.AddHeader('Content-Type', MP.ContentType);
      // Manche Server oder Proxies erwarten Content-Length zwingend.
      Req.AddHeader('Content-Length', ContentLen.ToString);

      if Token <> '' then
        Req.AddHeader('Authorization', 'Bearer ' + Token);

      // Optional: wenn der Server sauber JSON liefert, kann Accept helfen.
      Req.AddHeader('Accept', 'application/json');

      Result := Client.Execute(Req, nil);
    finally
      Body.Free;
    end;
  finally
    Client.Free;
  end;
end;

מכשולים במעשית

  • מיקום ה-Stream: אם ה-FileStream אינו בעמדה 0, תעלו רק את השאר. לכן ב-Builder כופים Seek(0).
  • Chunked מול Content-Length: חלק מה-Gateways (או סטאקים ישנים של שרתים) דוחים Chunked. זה מקרה Legacy נפוץ בפתרונות תוכנה קרובים לתהליך. Spool-to-File הוא אז פתרון פרגמטי.
  • CRLF: Multipart מצפה ל-CRLF (#13#10), לא רק LF. חלק מהשרתים סלחניים, אחרים לא.
  • Content-Type לכל קובץ: אם תשלחו באופן גורף application/octet-stream, זה לרוב מקובל. אם השרת מבצע בדיקה (למשל PDF), הגדירו נכון. ב-Delphi תוכלו לבצע מיפוי MIME באמצעות טבלה משלכם או פונקציות מערכת ההפעלה, אך אל תסתמכו באופן עיוור על סיומות קבצים.

ניפוי שגיאות: Wire-Dump שניתן לשחזור מבלי לשבור TLS

ב-HTTPS אינכם רואים את ה-Body בפרוקסי אם אינכם מורשים לבצע MitM (למשל сертификат Fiddler). זאת התנהלות רגילה בסביבות ארגוניות. ה-Builder עוזר כי יש לכם את כל ה-Body כזרם (stream) ובמקרה של Spool-Datei הוא קיים גם כקובץ.

נוהל מומלץ:

  1. כתבו את ה-Spool-Body לקובץ זמני.
  2. לוגו את Content-Type כולל Boundary ואת Content-Length.
  3. הפיקו עבור Support/DevOps באופן אופציונלי מתכון לשחזור ב-curl: כאן אין צורך לשחזר את ה-Body 1:1, אבל אפשר לשקף את הפרמטרים ואת הקובץ(ים).

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

גרסאות: מספר קבצים, שדות אופציונליים, שרתים עם ציפיות „מוזרות“

כמה קבצים תחת אותו שם שדה

רבות מה-APIs מצפות ל-files[] או לשם זהה שמופיע פעמים רבות. ה-Builder תומך בזה ישירות: קראו ל-AddFile מספר פעמים עם אותו FieldName. האם תשתמשו ב-files, files[] או attachments זו קונבנציה של השרת בלבד.

השרת דורש במדויק „application/json“ כ-Part נוסף

תבנית נפוצה: בלוק מטא-נתונים ב-JSON ועוד קובץ. במצב כזה תשלחו את ה-JSON כ-Field-Part, אך עם Content-Type: application/json; charset=utf-8. זה לא „Form Field“ במובן UI, אבל במultipart זה ניתן לייצוג נכון:

Delphi
MP.AddField('metadata', '{"documentType":"report","source":"delphi"}', 'application/json; charset=utf-8');

Legacy: השרת מקבל רק filename, לא filename*

אז פיתרון הגיבוי הוא באמצעות filename. אם השרת מפענח תוכן לא-ASCII ב-filename באופן שגוי, הדרך העמידה ביותר לרוב היא להתעלם משם הקובץ בצד השרת ולשלוח במקום זאת שדה נוסף originalName בתוך ה-JSON.

מיקום מבחינת מודרניזציה ותפעול

בנופי Delphi שצמחו לאורך זמן, Multipart נמצא לעיתים בקצה: ממשק ל-DMS, ארכיון, ticketing, פורטל לקוחות או שרת פנימי REST-שרת. שם נוצר לחץ עקב דרישות אבטחה חדשות (TLS, Gateways, Proxies) וגם עקב גדלים גדולים יותר של קבצים.

הגישה המתוארת משתלמת במיוחד כאשר:

  • אתם צריכים לדבג העלאות באופן ניתן לשחזור (תפעול/מנהל מערכת)
  • אתם רוצים/נדרשים להימנע מ-Chunked
  • שמות קבצים/קידודים מופיעים במציאות (Umlaute, רווחים, סוגריים)
  • רוצים לפתור קונספטואלית נקודתיות של Retry/Idempotency בצורה מסודרת

זה פחות משתלם אם אתם שולחים אך ורק קבצים קטנים לשרת סלחני ואין לכם צורך בשקיפות תפעולית. אז פתרון High-Level פשוט מספיק — עד שהקובץ ה“מוזר“ הראשון מגיע מהמחלקה המקצועית.

מסקנה: העלאת Multipart יציבה היא בעיית סטרימינג ותפעול

העלאת Multipart/Form-Data מסודרת ב-Delphi היא פחות שאלה של „איזו קומפוננטה“ ויותר שאלה של בקרה: Boundary, CRLF, שם קובץ, Content-Type ובפרט זרם Body דטרמיניסטי. מי שבונה את זה נכון כבר בתחילה יחסוך זמן מאוחר יותר בלולאות דיבוג מול API-Gateways ו-Reverse-Proxies.

מגבלת השימוש בגישה: אם אתם צריכים להעלות קבצים גדולים במיוחד (כמה GB) ללא spooling וללא Content-Length, הנושא Streaming ללא חישוב מקדים הופך לרלוונטי — אז על שרת היעד והתשתית לתמוך ב‑Chunked באופן אמין, ותזדקקו לקונספט דיבאגינג שונה. עבור רבות מהאינטגרציות בפתרונות ארגוניים דיגיטליים, ה‑Builder המוצג כאן הוא עם זאת האמצע הפרגמטי בין עמידות, מעקב וצריכת משאבים שניתנת לשליטה.

אם אתם תלויים באינטגרציה קיימת Delphi, שבה ההעלאות נכשלים לעתים או רק „עם חלק מהקבצים“, זה לרוב אינדיקציה לאותם תנאי קצה. לתמיכה ממוקדת בניתוח, מודרניזציה או הבהרת תפעול ניתן ליצור קשר כאן:

בהקשר המקצועי גם Delphi Thttpclient וREST API העלאת קבצים ממלאים תפקיד חשוב, כאשר אינטגרציות, זרמי נתונים ופיתוח מתמשך צריכים להשתלב באופן מסודר.

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

שתף פוסט

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

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

דוא״ל

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