Zašto Multipart u Delphi često tek u produkciji „pokvari“
Ein Multipart/Form-Data Upload in Delphi ist schnell zusammengeklickt – und scheitert dann in realen Integrationen an Details: falscher Content-Type pro Part, ein Boundary-String, der versehentlich im Payload vorkommt, unpassende Zeilenumbrüche, nicht-ASCII-Dateinamen oder Server, die chunked transfer encoding (HTTP ohne Content-Length) ablehnen. Dazu kommen typische Praxisprobleme in individueller Unternehmenssoftware: große Dateien (CAD, PDFs, Scans), schwankende Netze, Reverse-Proxies, strikte API-Gateways und Admin-Anforderungen an Debugging.
Delphi bringt mit System.Net.HttpClient einen brauchbaren Stack mit, aber die „Happy Path“-Beispiele lassen wichtige Randbedingungen offen. Der folgende Source-Schnipsel geht bewusst tiefer: Wir bauen Multipart als Stream deterministisch auf, berechnen Content-Length korrekt, unterstützen RFC-5987 für Dateinamen und liefern eine Debug-Option, die den Request reproduzierbar macht, ohne dass Sie TLS aufbrechen müssen.
Arhitektonska odluka: THTTPClient umjesto Indy – i kada to postane problem
THTTPClient (System.Net) nutzt je nach Plattform unterschiedliche Backends (unter Windows typischerweise WinHTTP/WinINet). Das ist für Unternehmensumgebungen oft vorteilhaft: Proxy- und TLS-Policies sind eher kompatibel mit dem System. Indy ist dafür sehr transparent und anpassbar, bringt aber eigene TLS-Bindings und ist im Betrieb manchmal „separat zu pflegen“ (OpenSSL-Versionen, Cipher-Suiten).
Der Ansatz hier nutzt THTTPClient, weil er in Modernisierungen häufig schon im Einsatz ist (REST-Client, OAuth, Downloads). Wenn Sie jedoch harte Kontrolle über TLS-Handshakes, Client-Zertifikate in Sonderformen oder sehr spezielle Proxy-Ketten benötigen, kann Indy (oder ein dedizierter HTTP-Stack) sinnvoll sein. Das ändert am Multipart-Aufbau wenig – aber an Debugging und Betrieb.
Multipart/Form-Data Upload in Delphi: ein Stream, keine Magie
Die Kernidee: Multipart ist am Ende nur ein Byte-Stream. Wenn wir ihn selbst aufbauen, können wir:
- Boundary bewusst wählen und stabil testen
- Header pro Part korrekt setzen (inkl.
Content-Disposition,Content-Type) Content-Lengthzuverlässig berechnen (wichtig für Server ohne Chunked-Support)- große Dateien streamen, ohne alles im RAM zu halten
Der Code: Multipart-Builder mit Streaming und RFC-5987-Dateinamen
Der Builder unten erzeugt wahlweise einen rein speicherbasierten Body (für kleine Uploads) oder eine Spool-Datei auf Disk (für große Payloads). Das wirkt „oldschool“, ist aber im Betrieb extrem praktisch, weil es Chunked vermeidet und Debugging erleichtert. Spoolen heißt: Sie können denselben Request-Body wiederverwenden, auch wenn ein Retry nötig ist.
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);
// Sastavlja kompletan body u TStream. Ako je ASpoolToFile prazan,
// koristi se TMemoryStream; u suprotnom se kreira datoteka.
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 treba biti dovoljno nasumičan. Važno: bez razmaka.
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 zaglavlja su ASCII. Za vrijednosti u tijelu (npr. UTF-8) postavljamo Content-Type za svaki dio.
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''..." je znatno robusniji za ne-ASCII imena datoteka nego samo 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 ne smije biti nil');
if (FileStream is TCustomMemoryStream) and (FileStream.Size = 0) then
; // dozvoljeno, ali često greška: prazna datoteka
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
// Pažnja: pozicija Streama će biti pomjerena.
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);
// Sadržaj polja u UTF-8, ako je charset=utf-8 postavljen.
WriterUtf8 := TEncoding.UTF8.GetBytes(P.Value);
WriteBytes(WriterUtf8);
WriteBytes(CRLF);
end
else
begin
// Dva parametra imena datoteke: filename (za stare servere) i 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);
// Važno: postaviti poziciju na početak, inače će biti poslani samo preostali podaci.
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.
Šta kod namjerno drugačije radi
- Nema „automatskog Multipart“: Kontrola nad zaglavljima, kodiranjima i boundary ostaje vama. To je često presudno kod striktnih REST-API-ja.
- Podrška za RFC-5987 preko
filename*: Kada nazivi fajlova sadrže dijakritičke znakove (npr. „Prüfbericht.pdf“), to je najčešći interop-bug. Neki serveri ignorišufilename*, tada se kao fallback koristifilename. - Spool-to-File kao operativna funkcija: Za velike upload-e i ponovne pokušaje je ponovno upotrebljiv stream tijela (body-stream) izuzetno vrijedan.
- Content-Length je dostupan, jer se body generiše unaprijed. To izbjegava Chunked-Encoding ako ciljni sistem to ne prihvata.
Slanje zahtjeva: Timeout-i, zaglavlja i smislena strategija ponovnih pokušaja
Samo multipart ne rješava integracijske probleme: potrebni su vam timeout-i, klasifikacija grešaka i opcionalni ponovni pokušaji. Važno je razlikovati između idempotentno i neidempotentno: upload-i često nisu idempotentni (mogu nastati duplikati). Ponovni pokušaji trebaju se izvoditi samo ako server pruža idempotentnu semantiku (npr. Upload-ID, posvećeno Idempotency-Key zaglavlje) ili ako imate deduplikaciju na strani servera.
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;
Zamke u praksi
- Pozicija streama: Ako FileStream nije na poziciji 0, učitat ćete samo preostali dio. Zato Builder prisilno poziva
Seek(0). - Chunked vs. Content-Length: Neki gateway-evi (ili stariji server-stackovi) odbacuju Chunked. To je čest legacy slučaj u softverskim rješenjima bliskim procesu. Spool-to-File je tada pragmatično rješenje.
- CRLF: Multipart očekuje CRLF (
#13#10), ne samo LF. Neki serveri su tolerantni, drugi nisu. - Content-Type po fajlu: Ako po defaultu šaljete
application/octet-stream, to je često u redu. Ako server provjerava (npr. PDF), postavite ispravan tip. U Delphi možete riješiti mapiranje MIME tipova preko vlastite tabele ili OS-funkcija, ali se nemojte slijepo oslanjati na ekstenzije fajlova.
Debugging: reproducibilan wire-dump bez prekidanja TLS-a
Pri HTTPS-u ne vidite body u proxyju ako ne smijete koristiti MitM (npr. Fiddler-Zertifikat). To je u korporativnim okruženjima normalno. Der Builder pomaže, jer posjedujete kompletan body stream-based i (kod Spool-Datei) imate ga kao datoteku.
Preporučeni postupak:
- Zapišite Spool-Body u privremenu datoteku.
- Logirajte
Content-Typeuključujući Boundary iContent-Length. - Izradite za Support/DevOps opcionalno jedno
curl-Repro: ovdje ne morate vratiti body 1:1, ali možete preslikati parametre i datoteku(e).
Važno: Nikada ne logirajte proizvodne tokene ili lične podatke. U mnogim poslovnim softverskim integracijama upravo je to dio relevantan za usklađenost.
Varijante: više datoteka, opcionalna polja, server sa „čudnim“ očekivanjima
Više datoteka pod istim imenom polja
Mnogi API-ji očekuju files[] ili više puta isto ime. Der Builder to direktno podržava: pozovite AddFile više puta sa istim FieldName. Da li ćete koristiti files, files[] ili attachments čisto je konvencija servera.
Server zahtijeva tačno „application/json“ kao dodatni part
Uobičajeni obrazac: JSON-blok metapodataka plus datoteka. Tada pošaljete JSON kao field-part, ali sa Content-Type: application/json; charset=utf-8. To nije „Form Field“ u smislu UI-ja, ali se u Multipartu uredno može prikazati:
MP.AddField('metadata', '{"documentType":"report","source":"delphi"}', 'application/json; charset=utf-8');
Legacy: Server prihvata samo filename, ne filename*
Tada pomaže fallback preko filename. Ako server međutim ne-ASCII u filename pogrešno dekodira, kao robustan put često ostaje: ignorirati ime datoteke na serverskoj strani i umjesto toga poslati dodatno polje originalName u JSON-u.
Kontekst za modernizaciju i operativu
U etabliranim Delphi-okruženjima Multipart često stoji na rubu: sučelje prema DMS-u, arhivu, ticketingu, Kundenportal ili interni REST-Server. Upravo tamo nastaje pritisak zbog novih sigurnosnih zahtjeva (TLS, gatewayi, proxyji) i zbog većih veličina datoteka.
Predloženi pristup se posebno isplati kada:
- Morate uploadove reproducibilno debugirati (operacije/administracija)
- Želite/trebate izbjegavati Chunked
- Imena datoteka/encodiranja se u praksi zaista pojavljuju (Umlaute, razmaci, zagrade)
- Retry/Idempotency treba konceptualno čisto biti riješen
Manje se isplati ako šaljete isključivo male datoteke na tolerantni server i ne trebate nikakvu operativnu transparentnost. Tada je jednostavno High-Level rješenje dovoljno – dok ne stigne prva „čudna“ datoteka iz poslovne jedinice.
Zaključak: Stabilan Multipart-Upload je problem streaminga i operacija
Ispravan Multipart/Form-Data upload u Delphi manje je pitanje „koje komponente“ nego kontrole: Boundary, CRLF, naziv datoteke, Content-Type i prije svega deterministički body-stream. Ko to rano pravilno implementira, uštediće kasnije vrijeme u debugging-petljama sa API-Gateways i Reverse-Proxies.
Granica primjene pristupa: Ako morate otpremati izuzetno velike datoteke (nekoliko GB) bez spooling i bez Content-Length, postaje relevantna tema streaming bez prethodnog izračuna – tada ciljni serveri i infrastruktura moraju pouzdano podržavati chunked, i potreban vam je drugačiji koncept za otklanjanje grešaka. Za mnoge integracije u digitalnim poslovnim rješenjima, međutim, ovdje prikazani Builder predstavlja upravo pragmatičnu sredinu između robustnosti, mogućnosti praćenja i kontroliranog korištenja resursa.
Ako se oslanjate na postojeću Delphi-integraciju pri kojoj otpremanja povremeno ne uspijevaju ili samo „kod nekih datoteka“, to je obično indikator upravo ovih graničnih uvjeta. Za ciljanu podršku pri analizi, modernizaciji ili razjašnjenju operativnih pitanja možete nas kontaktirati ovdje:
U stručnom kontekstu također važnu ulogu igraju Delphi Thttpclient i REST API prijenos datoteka, kada integracije, tokovi podataka i dalji razvoj moraju besprijekorno surađivati.