למה 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 גם אם צריך ניסיון חוזר.
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) או כאשר יש לכם דה-דופליקציה בצד השרת.
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 הוא קיים גם כקובץ.
נוהל מומלץ:
- כתבו את ה-Spool-Body לקובץ זמני.
- לוגו את
Content-Typeכולל Boundary ואתContent-Length. - הפיקו עבור 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 זה ניתן לייצוג נכון:
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 העלאת קבצים ממלאים תפקיד חשוב, כאשר אינטגרציות, זרמי נתונים ופיתוח מתמשך צריכים להשתלב באופן מסודר.