De ce Multipart în Delphi deseori eşuează abia în exploatare
Un upload Multipart/Form-Data în Delphi se configurează rapid cu click-uri — şi eşuează în integrări reale din cauza detaliilor: Content-Type greşit per part, un string Boundary care apare accidental în payload, caractere de sfârşit de linie neconforme, nume de fişiere non-ASCII sau servere care resping chunked transfer encoding (HTTP fără Content-Length). La acestea se adaugă probleme tipice din software-ul enterprise individual: fişiere mari (CAD, PDF, scanări), reţele instabile, reverse-proxy-uri, API-Gateway-uri stricte şi cerinţe administrative pentru depanare.
Delphi oferă cu System.Net.HttpClient un stack utilizabil, dar exemplele „Happy Path“ lasă nesaţializate condiţii limită importante. Fragmentul de cod de mai jos intră intenţionat mai adânc: construim Multipart ca flux (stream) în mod determinist, calculăm corect Content-Length, suportăm RFC-5987 pentru numele fişierelor şi furnizăm o opţiune de depanare care face request-ul reproductibil fără a intercepta TLS.
Decizie arhitecturală: THTTPClient în loc de Indy — şi când aceasta nu mai e potrivită
THTTPClient (System.Net) foloseşte, în funcţie de platformă, backend-uri diferite (pe Windows de obicei WinHTTP/WinINet). Acest comportament este adesea avantajos în medii enterprise: politicile de proxy şi TLS sunt mai compatibile cu cele ale sistemului. Indy, în schimb, este foarte transparent şi adaptabil, dar vine cu propriile binding-uri TLS şi în exploatare necesită uneori întreţinere separată (versiuni OpenSSL, suite de cifrare).
Abordarea de faţă foloseşte THTTPClient pentru că apare frecvent deja în modernizări (client REST, OAuth, descărcări). Dacă însă aveţi nevoie de control strict asupra handshake-urilor TLS, asupra certificatelor client în forme speciale sau asupra unor lanţuri de proxy foarte particulare, Indy (sau un HTTP-stack dedicat) poate fi adecvat. Asta schimbă puţin la construcţia multipart — dar mult la depanare şi operare.
Multipart/Form-Data Upload in Delphi: un flux, nu magie
Ideea centrală: Multipart este la bază doar un flux de octeţi. Dacă îl construim noi înşine, putem:
- Alege boundary în mod deliberat şi testaţi-l stabil
- Seta corect header-ele pentru fiecare part (incl.
Content-Disposition,Content-Type) - Calcula fiabil
Content-Length(important pentru servere fără suport chunked) - Transmite fişiere mari în flux fără a le păstra integral în RAM
Codul: Multipart-Builder cu streaming şi nume de fişiere conform RFC-5987
Builderul de mai jos generează opţional un body pur bazat pe memorie (pentru upload-uri mici) sau un fişier de spool pe disc (pentru payload-uri mari). Pare „oldschool“, dar este extrem de practic în exploatare, deoarece evită chunked şi uşurează depanarea. Spooling înseamnă: puteţi reutiliza acelaşi request-body chiar şi dacă este necesară o reîncercare.
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
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);
// Construiește corpul complet într-un Stream. Dacă ASpoolToFile este gol,
// se folosește un TMemoryStream; altfel se creează un fișier.
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
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 ar trebui să fie suficient de aleator. Important: fără spații.
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
// Headerele multipart sunt ASCII. Pentru valorile din corp (de ex. UTF-8) setăm Content-Type pe fiecare 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“…“ este mult mai robust pentru nume de fișiere non-ASCII decât numai 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 nu poate fi nil‘);
if (FileStream is TCustomMemoryStream) and (FileStream.Size = 0) then
; // permis, dar deseori o eroare: fișier gol
P := TPart.Create;
P.Kind := pkFile;
P.Name := FieldName;
P.FileName := FileName;
P.ContentType := ContentType;
P.FileStream := FileStream; // Proprietarul rămâne la apelant
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
// Atenție: poziția stream-ului va fi consumată.
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);
// Important: poziția trebuie setată la început, altfel se vor încărca doar RESTurile.
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.
Ce face codul intenționat diferit
- Niciun „Multipart automat”: Controlul asupra headerelor, codificărilor și boundary rămâne la dvs. Acest lucru este adesea esențial pentru API-urile stricte REST.
- Suport RFC-5987 prin
filename*: De îndată ce numele fișierelor conțin diacritice (de ex. „Prüfbericht.pdf”), acesta este cel mai frecvent bug de interoperabilitate. Unele servere ignorăfilename*, caz în carefilenameservește ca fallback. - Spool-to-File ca funcționalitate operațională: Pentru încărcări mari și reîncercări, un stream de body reutilizabil este deosebit de util.
- Content-Length este disponibil, deoarece body-ul este generat în avans. Aceasta evită Chunked-Encoding, dacă sistemul țintă nu îl acceptă.
Trimiterea cererii: Timeout-uri, headere și o strategie de reîncercare rezonabilă
Multipart în sine nu rezolvă problemele de integrare: aveți nevoie de timeout-uri, clasificare a erorilor și, opțional, reîncercări. Este importantă distincția între idempotent și non-idempotent: încărcările sunt adesea non-idempotente (pot apărea duplicate). Prin urmare, reîncercările ar trebui efectuate doar dacă serverul oferă o semantică idempotentă (de ex. Upload-ID, header dedicat Idempotency-Key) sau dacă aveți deduplicare pe partea de server.
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;
Capcane în practică
- Poziția stream-ului: Dacă FileStream nu este la poziția 0, veți încărca doar restul. Din acest motiv, în builder se forțează
Seek(0). - Chunked vs. Content-Length: Unele gateway-uri (sau stack-uri de server mai vechi) resping Chunked. Acesta este un caz frecvent de legacy în soluțiile software apropiate de proces. Spool-to-File este atunci pragmatic.
- CRLF: Multipart așteaptă CRLF (
#13#10), nu doar LF. Unele servere sunt tolerante, altele nu. - Content-Type per fișier: Dacă trimiteți în mod general
application/octet-stream, este de multe ori ok. Dacă serverul verifică (de ex. PDF), setați corect. În Delphi puteți rezolva maparea MIME printr-un tabel propriu sau funcții ale OS, dar nu vă bazați orb pe extensiile de fișier.
Debugging: Wire-Dump reproducibil fără a sparge TLS
În HTTPS nu vedeți body-ul în proxy dacă nu aveți voie să folosiți un MitM (de ex. certificatul Fiddler). Acest lucru este normal în mediile enterprise. Builder ajută pentru că aveți întregul body bazat pe stream și (în cazul fișierului Spool) și ca fișier.
Procedură recomandată:
- Scrieți Spool-Body într-un fișier temporar.
- Înregistrați în jurnal
Content-Typeinclusiv Boundary șiContent-Length. - Generați opțional pentru Support/DevOps un reproducere cu
curl: aici nu trebuie să redați body-ul 1:1, dar puteți oglindi parametrii și fișier(e).
Important: Nu înregistrați niciodată token-uri de producție sau conținut cu caracter personal. În multe integrări de software business tocmai aceasta este partea relevantă pentru conformitate.
Variante: mai multe fișiere, câmpuri opționale, servere cu „neobișnuite“ așteptări
Mai multe fișiere sub același nume de câmp
Multe API-uri așteaptă files[] sau același nume repetat. Builder suportă asta direct: apelați AddFile de mai multe ori cu același FieldName. Dacă folosiți files, files[] sau attachments este o convenție a serverului.
Serverul cere exact „application/json“ ca parte adițională
Un tipar frecvent: un bloc de metadate JSON plus fișier. Atunci trimiteți JSON-ul ca part de field, dar cu Content-Type: application/json; charset=utf-8. Nu este un „form field“ în sensul UI, dar este reprezentabil curat în Multipart:
MP.AddField('metadata', '{"documentType":"report","source":"delphi"}', 'application/json; charset=utf-8');
Legacy: Serverul acceptă doar filename, nu filename*
Atunci ajută fallback-ul prin filename. Dacă serverul decodifică însă greșit non-ASCII în filename, ca soluție robustă rămâne adesea doar: ignorați numele de fișier la nivel de server și trimiteți în schimb un câmp adițional originalName în JSON.
Context pentru modernizare și operare
În peisaje Delphi consolidate, Multipart stă adesea la margine: o interfață către DMS, arhivă, ticketing, portalul clienților sau un REST-server intern. Tocmai acolo apare presiunea din cauza noilor cerințe de securitate (TLS, Gateways, Proxies) și din cauza creșterii dimensiunilor de fișiere.
Abordarea prezentată merită în mod special dacă:
- Trebuie să depanați upload-urile în mod reproducibil (operare/administrare)
- Doriți/trebuie să evitați Chunked
- Numele fișierelor/encodările apar în practică (ex. diacritice precum ä/ö/ü, spații, paranteze)
- Să fie rezolvate conceptual în mod curat problemele de retry/idempotency
Este mai puțin utilă dacă trimiteți exclusiv fișiere mici către un server tolerant și nu aveți nevoie de transparență operațională. Atunci o soluție high-level simplă este suficientă – până apare primul fișier „neobișnuit“ din departamentul de business.
Concluzie: Un upload Multipart stabil este o problemă de streaming și de operare
Un upload Multipart/Form-Data curat în Delphi este mai puțin o chestiune de „ce componentă“ și mai mult de control: Boundary, CRLF, numele fișierului, Content-Type și, mai presus de toate, un flux de body determinist. Cine construiește asta corect de la început economisește timp mai târziu în buclele de depanare cu API-Gateways și Reverse-Proxies.
Limita de aplicabilitate a abordării: Dacă trebuie să încărcați fișiere extrem de mari (mai mulți GB) fără spooling și fără Content-Length, devine relevant subiectul Streaming fără calcul prealabil – în acest caz serverele țintă și infrastructura trebuie să suporte fiabil Chunked, iar dumneavoastră aveți nevoie de un concept de depanare diferit. Pentru multe integrări în soluții digitale pentru întreprinderi, Builder-ul prezentat aici este însă tocmai acel echilibru pragmatic între robustețe, trasabilitate și consum de resurse controlabil.
Dacă aveți o integrare Delphi dezvoltată de-a lungul timpului, în care încărcările eșuează sporadic sau doar „la anumite fișiere”, acesta este, de regulă, un indicator pentru exact aceste condiții limită. Pentru asistență specifică la analiză, modernizare sau clarificare operațională ne puteți contacta aici:
În contextul profesional, Delphi Thttpclient și REST API de încărcare a fișierelor joacă, de asemenea, un rol important, atunci când integrările, fluxurile de date și dezvoltarea ulterioară trebuie să funcționeze coerent.
Discutați un proiect sau un demers de modernizare cu Net-Base.