Γιατί το Multipart σε Delphi συχνά αποτυγχάνει μόνο κατά τη λειτουργία
Ένας Multipart/Form-Data Upload σε Delphi στήνεται γρήγορα με μερικά κλικ — και στη συνέχεια αποτυγχάνει σε πραγματικές ενσωματώσεις λόγω λεπτομερειών: λανθασμένος Content-Type ανά μέρος, ένας Boundary-String που εμφανίζεται κατά λάθος στο payload, ακατάλληλοι χαρακτήρες νέας γραμμής, ονόματα αρχείων μη ASCII ή servers που απορρίπτουν chunked transfer encoding (HTTP χωρίς Content-Length). Σε αυτά προστίθενται τυπικά πρακτικά προβλήματα σε εξατομικευμένο εταιρικό λογισμικό: μεγάλα αρχεία (CAD, PDFs, Scans), μεταβαλλόμενα δίκτυα, Reverse-Proxies, αυστηρά API-Gateways και απαιτήσεις διαχειριστών για debugging.
Delphi παρέχει με το System.Net.HttpClient ένα λειτουργικό stack, αλλά τα παραδείγματα του «Happy Path» αφήνουν εκτός σημαντικές γωνιακές περιπτώσεις. Το παρακάτω απόσπασμα κώδικα μπαίνει σκόπιμα πιο βαθιά: κατασκευάζουμε το Multipart ως stream ντετερμινιστικά, υπολογίζουμε σωστά το Content-Length, υποστηρίζουμε RFC-5987 για ονόματα αρχείων και προσφέρουμε μια επιλογή debug που κάνει το request αναπαραγώγιμο χωρίς να χρειάζεται να σπάσετε το TLS.
Αρχιτεκτονική επιλογή: THTTPClient αντί για Indy — και πότε αυτό παρουσιάζει προβλήματα
THTTPClient (System.Net) χρησιμοποιεί, ανάλογα με την πλατφόρμα, διαφορετικά backends (υπό Windows τυπικά WinHTTP/WinINet). Αυτό είναι συχνά πλεονεκτικό σε επιχειρησιακά περιβάλλοντα: οι πολιτικές proxy και TLS είναι γενικά πιο συμβατές με το σύστημα. Το Indy από την άλλη είναι πολύ διαφανές και προσαρμόσιμο, αλλά φέρει δικούς του TLS-bindings και στο πεδίο μπορεί να χρειάζεται «ξεχωριστή συντήρηση» (εκδόσεις OpenSSL, Cipher-Suiten).
Η προσέγγιση εδώ χρησιμοποιεί THTTPClient, επειδή σε έργα εκσυγχρονισμού συχνά ήδη υπάρχει (REST-Client, OAuth,Downloads). Αν όμως χρειάζεστε αυστηρό έλεγχο επί των TLS-handshakes, client certificates σε ειδικές μορφές ή πολύ ειδικές αλυσίδες proxy, τότε το Indy (ή ένας εξειδικευμένος HTTP-stack) μπορεί να είναι προτιμητέο. Αυτό αλλάζει ελάχιστα την κατασκευή του Multipart — επηρεάζει όμως το debugging και τον λειτουργικό χειρισμό.
Multipart/Form-Data Upload σε Delphi: ένα Stream, καμία μαγεία
Η βασική ιδέα: το Multipart στο τέλος είναι απλώς ένα byte-stream. Αν το κατασκευάσουμε εμείς, μπορούμε να:
- επιλέξουμε το Boundary με πρόθεση και να το δοκιμάσουμε σταθερά
- ορίσουμε σωστά τους header ανά Part (συμπεριλαμβανομένων
Content-Disposition,Content-Type) - υπολογίσουμε αξιόπιστα το
Content-Length(σημαντικό για servers χωρίς υποστήριξη chunked) - stream-άρουμε μεγάλα αρχεία χωρίς να τα φορτώνουμε όλα στη RAM
Ο κώδικας: Multipart-Builder με streaming και RFC-5987 ονόματα αρχείων
Ο builder παρακάτω παράγει είτε ένα αποκλειστικά μνήμη-βασισμένο body (για μικρά uploads) είτε μια Spool-Datei στο δίσκο (για μεγάλα payloads). Αυτό φαίνεται «oldschool», αλλά στην πράξη είναι εξαιρετικά πρακτικό, επειδή αποφεύγει το chunked και διευκολύνει το debugging. Το spoolen σημαίνει: μπορείτε να επαναχρησιμοποιήσετε το ίδιο request-body, ακόμη και αν χρειαστεί retry.
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);
// Baut den kompletten Body in einen Stream. Wenn ASpoolToFile leer ist,
// wird ein TMemoryStream verwendet; sonst eine Datei erzeugt.
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 sollte hinreichend zufällig sein. Wichtig: keine Leerzeichen.
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-Header sind ASCII. Für Werte im Body (z. B. UTF-8) setzen wir Content-Type pro Part.
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''..." ist für Nicht-ASCII-Dateinamen deutlich robuster als nur 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 darf nicht nil sein');
if (FileStream is TCustomMemoryStream) and (FileStream.Size = 0) then
; // erlaubt, aber oft ein Fehler: leere Datei
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
// Achtung: Streamposition wird konsumiert.
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);
// Field-Body in UTF-8, sofern charset=utf-8 gesetzt ist.
WriterUtf8 := TEncoding.UTF8.GetBytes(P.Value);
WriteBytes(WriterUtf8);
WriteBytes(CRLF);
end
else
begin
// Zwei Dateiname-Parameter: filename (für alte Server) und 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);
// Wichtig: Position auf Anfang setzen, sonst werden nur RESTe hochgeladen.
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»: Ο έλεγχος πάνω στις επικεφαλίδες, τις κωδικοποιήσεις και το boundary παραμένει σε εσάς. Αυτό είναι συχνά κρίσιμο σε αυστηρές REST-APIs.
- Υποστήριξη RFC-5987 μέσω
filename*: Μόλις τα ονόματα αρχείων περιέχουν Umlaute (π.χ. „Prüfbericht.pdf“), αυτό είναι το πιο συχνό σφάλμα interoperability. Κάποιοι servers αγνοούνfilename*, οπότε τοfilenameλειτουργεί ως fallback. - Spool-to-File ως λειτουργικό χαρακτηριστικό: Για μεγάλες μεταφορτώσεις και επανπροσπάθειες, ένας επαναχρησιμοποιήσιμος body-stream έχει μεγάλη αξία.
- Content-Length είναι διαθέσιμο, επειδή το body παράγεται εκ των προτέρων. Αυτό αποφεύγει το Chunked-Encoding όταν το σύστημα-προορισμός δεν το αποδέχεται.
Αποστολή αιτήματος: Timeouts, επικεφαλίδες και μια ορθή στρατηγική επανπροσπάθειας
Το Multipart από μόνο του δεν λύει τα προβλήματα ολοκλήρωσης: χρειάζεστε timeouts, ταξινόμηση σφαλμάτων και προαιρετικές επανπροσπάθειες. Σημαντική είναι η διάκριση μεταξύ idempotent και μη idempotent: τα uploads συχνά δεν είναι idempotent (μπορεί να προκύψουν διπλότυπα). Επανπροσπάθειες πρέπει να γίνονται μόνο όταν ο server προσφέρει idempotente σημασιολογία (π.χ. Upload-ID, αφιερωμένος header Idempotency-Key) ή όταν υπάρχει server-side deduplizierung.
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 vs. Content-Length: Ορισμένα Gateways (ή παλαιότερα Server-Stacks) απορρίπτουν το Chunked. Αυτό είναι συχνή περίπτωση legacy σε προσεγγίσεις κοντά στη διαδικασία. Το Spool-to-File είναι τότε μια πρακτική λύση.
- CRLF: Το Multipart αναμένει CRLF (
#13#10), όχι μόνο LF. Κάποιοι servers είναι ανεκτικοί, άλλοι όχι. - Content-Type ανά αρχείο: Εάν στέλνετε γενικά
application/octet-stream, συχνά είναι αποδεκτό. Αν ο server ελέγχει (π.χ. PDF), ορίστε σωστά. Στο Delphi μπορείτε να υλοποιήσετε MIME-mapping μέσω δικής σας πίνακας ή λειτουργιών του OS, αλλά μην βασίζεστε τυφλά στις επεκτάσεις αρχείων.
Debugging: αναπαραγόμενος Wire-Dump χωρίς αποκρυπτογράφηση TLS
Σε HTTPS δεν βλέπετε το Body στον proxy, εάν δεν επιτρέπεται η χρήση MitM (π.χ. Fiddler-πιστοποιητικού). Αυτό είναι συνηθισμένο σε εταιρικά περιβάλλοντα. Ο Builder βοηθά, επειδή έχετε το πλήρες Body ως stream και (σε περίπτωση Spool-Datei) διαθέσιμο και ως αρχείο.
Συνιστώμενη Vorgehensweise:
- Γράψτε το Spool-Body σε ένα προσωρινό αρχείο.
- Καταγράψτε τα
Content-Typeσυμπεριλαμβανομένου του Boundary και τοContent-Length. - Δημιουργήστε προαιρετικά για Support/DevOps ένα
curl-Repro: Εδώ δεν χρειάζεται να αναπαράγετε το Body 1:1, αλλά μπορείτε να αντικατοπτρίσετε τις παραμέτρους και το/τα αρχείο/αρχείa.
Σημαντικό: Μην καταγράφετε ποτέ παραγωγικά διακριτικά (Tokens) ή προσωπικά δεδομένα. Σε πολλές ενσωματώσεις επιχειρηματικού λογισμικού αυτό είναι ακριβώς το κομμάτι που αφορά τη συμμόρφωση.
Παραλλαγές: πολλαπλά αρχεία, προαιρετικά πεδία, διακομιστής με «ασυνήθιστες» απαιτήσεις
Πολλαπλά αρχεία με το ίδιο όνομα πεδίου
Πολλές APIs αναμένουν files[] ή επανειλημμένα το ίδιο όνομα. Ο Builder το υποστηρίζει άμεσα: καλέστε AddFile επανειλημμένα με το ίδιο FieldName. Το αν θα χρησιμοποιήσετε files, files[] ή attachments είναι καθαρά σύμβαση του server.
Ο διακομιστής απαιτεί ακριβώς „application/json“ ως επιπλέον τμήμα
Ένα διαδεδομένο μοτίβο: ένα μπλοκ μεταδεδομένων JSON συν αρχείο. Στέλνετε τότε το JSON ως Field-Part, αλλά με Content-Type: application/json; charset=utf-8. Αυτό δεν είναι «Form Field» με την έννοια του UI, αλλά σε Multipart αναπαρίσταται καθαρά:
MP.AddField('metadata', '{"documentType":"report","source":"delphi"}', 'application/json; charset=utf-8');
Legacy: Ο διακομιστής αποδέχεται μόνο filename, όχι filename*
Τότε βοηθά το fallback μέσω filename. Αν όμως ο server αποκωδικοποιεί λανθασμένα μη-ASCII στο filename, ως πιο ανθεκτικός τρόπος συχνά μένει μόνο: να αγνοηθεί το όνομα αρχείου server-side και να σταλεί επιπλέον πεδίο originalName μέσα στο JSON.
Συμφραζόμενα για εκσυγχρονισμό και λειτουργία
Σε ανεπτυγμένα Delphi-περιβάλλοντα το Multipart συχνά βρίσκεται στην περίμετρο: μια διεπαφή προς DMS, αρχείο, ticketing, Πύλη πελατών ή ένας εσωτερικός REST-διακομιστής. Εκεί ακριβώς δημιουργείται πίεση από νέες απαιτήσεις ασφάλειας (TLS, Gateways, Proxies) και από αυξανόμενα μεγέθη αρχείων.
Η περιγραφόμενη προσέγγιση αποδίδει ιδιαίτερα όταν:
- Πρέπει να κάνετε αναπαραγώγιμη αποσφαλμάτωση των uploads (λειτουργία/διαχείριση)
- Θέλετε/πρέπει να αποφύγετε το Chunked
- Στην πράξη εμφανίζονται ονόματα αρχείων/κωδικοποιήσεις (π.χ. Umlaute, κενά, παρενθέσεις)
- Το retry/η idempotency πρέπει να λυθεί εννοιολογικά καθαρά
Αποδίδει λιγότερο όταν στέλνετε αποκλειστικά μικρά αρχεία σε έναν ανεκτικό server και δεν χρειάζεστε καμία διαφάνεια λειτουργίας. Τότε μια απλή λύση υψηλού επιπέδου είναι επαρκής — μέχρι να εμφανιστεί το πρώτο «περίεργο» αρχείο από την Fachabteilung.
Συμπέρασμα: Ένας σταθερός Multipart-Upload είναι ζήτημα streaming και λειτουργίας
Ένας καθαρός Multipart/Form-Data Upload σε Delphi είναι λιγότερο ζήτημα «ποιας συνιστώσας» και περισσότερο θέμα ελέγχου: Boundary, CRLF, όνομα αρχείου, Content-Type και—πρωτίστως—ένας ντετερμινιστικός Body-Stream. Όποιος το υλοποιήσει σωστά από νωρίς εξοικονομεί χρόνο αργότερα σε κύκλους αποσφαλμάτωσης με API-Gateways και Reverse-Proxies.
Όρια εφαρμογής της προσέγγισης: Εάν πρέπει να ανεβάσετε εξαιρετικά μεγάλα αρχεία (αρκετά GB) χωρίς spooling και χωρίς Content-Length, γίνεται σχετικό το ζήτημα του Streaming χωρίς προκαταρκτικό υπολογισμό – τότε οι διακομιστές προορισμού και η υποδομή πρέπει να υποστηρίζουν αξιόπιστα chunked, και χρειάζεστε ένα διαφορετικό σχέδιο αποσφαλμάτωσης. Για πολλές ενσωματώσεις σε ψηφιακές επιχειρησιακές λύσεις είναι ο εδώ παρουσιαζόμενος Builder όμως ακριβώς η πρακτική μέση λύση ανάμεσα σε ανθεκτικότητα, ιχνηλασιμότητα και ελεγχόμενη κατανάλωση πόρων.
Εάν βασίζεστε σε μια ανεπτυγμένη Delphi-Integration, στην οποία τα Uploads σποραδικά αποτυγχάνουν ή μόνο «σε ορισμένα αρχεία», αυτό συνήθως αποτελεί ένδειξη για αυτές ακριβώς τις οριακές συνθήκες. Για στοχευμένη υποστήριξη στην ανάλυση, τον εκσυγχρονισμό ή τη διευκρίνιση λειτουργίας μπορείτε να επικοινωνήσετε μαζί μας εδώ:
Στο τεχνικό περιβάλλον παίζουν επίσης σημαντικό ρόλο ο Delphi Thttpclient και το REST API μεταφόρτωσης αρχείων, όταν ενσωματώσεις, ροές δεδομένων και περαιτέρω ανάπτυξη πρέπει να συνεργάζονται με σαφήνεια.