Prečo sa Multipart v Delphi často až v prevádzke „pokazí“
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.
Architekturentscheidung: THTTPClient statt Indy – und wann das kippt
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);
// Zostaví celý body do streamu. Ak je ASpoolToFile prázdny, použije sa TMemoryStream; inak sa vytvorí súbor.
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 by mal byť dostatočne náhodný. Dôležité: žiadne medzery.
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 hlavičky sú ASCII. Hodnoty v tele (napr. UTF-8) majú pre každú časť nastavený 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''..." je pre názvy súborov s ne-ASCII znakmi výrazne robustnejší ako len 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 nesmie byť nil');
if (FileStream is TCustomMemoryStream) and (FileStream.Size = 0) then
; // povolené, no často chyba: prázdny súbor
P := TPart.Create;
P.Kind := pkFile;
P.Name := FieldName;
P.FileName := FileName;
P.ContentType := ContentType;
P.FileStream := FileStream; // Vlastník zostáva u volajúceho
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
// Pozor: čítanie zmení pozíciu streamu.
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);
// Telo poľa v UTF-8, ak je nastavené charset=utf-8.
WriterUtf8 := TEncoding.UTF8.GetBytes(P.Value);
WriteBytes(WriterUtf8);
WriteBytes(CRLF);
end
else
begin
// Dva parametre názvu súboru: filename (pre staré servery) a 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);
// Dôležité: nastaviť pozíciu na začiatok, inak sa odošlú len zostávajúce dáta.
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.
Čo kód zámerne robí inak
- Žiadne „automatické Multipart“: Kontrola nad Header, encodovaniami a boundary zostáva vo vašich rukách. To je pri prísnych REST-API často rozhodujúce.
- Podpora RFC-5987 cez
filename*: Ak názvy súborov obsahujú diakritiku (napr. „Prüfbericht.pdf“), ide to o najčastejšiu chybu pri interoperabilite. Niektoré servery ignorujúfilename*, vtedy ako fallback platífilename. - Spool-to-File ako prevádzková funkcia: Pre veľké uploady a opakované pokusy je znovupoužiteľný Body-Stream mimoriadne cenný.
- Content-Length je k dispozícii, pretože Body sa vytvorí vopred. To zabraňuje Chunked-Encoding, ak cieľový systém tento režim neprijíma.
Odoslanie požiadavky: časové limity, hlavičky a rozumná stratégia opakovaných pokusov
Multipart samo o sebe ešte nerieši integračné problémy: potrebujete časové limity, klasifikáciu chýb a voliteľne opakovania. Dôležité je rozlíšenie medzi idempotent a nicht idempotent: uploady často nie sú idempotentné (môžu vzniknúť duplikáty). Opakované pokusy by sa mali vykonávať len vtedy, ak server poskytuje idempotentnú sémantiku (napr. Upload-ID, dedikovaný Idempotency-Key header) alebo ak máte na serveri deduplikáciu.
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
// Časové limity: nastaviť realisticky podľa súboru a pripojenia.
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);
// Niektoré servery alebo proxy vyžadujú hlavičku Content-Length.
Req.AddHeader('Content-Length', ContentLen.ToString);
if Token <> '' then
Req.AddHeader('Authorization', 'Bearer ' + Token);
// Voliteľné: ak server korektne vracia JSON, hlavička Accept môže pomôcť.
Req.AddHeader('Accept', 'application/json');
Result := Client.Execute(Req, nil);
finally
Body.Free;
end;
finally
Client.Free;
end;
end;
Úskalia v praxi
- Pozícia streamu: Ak FileStream nie je na pozícii 0, nahrajete iba zvyšok. Preto v builderi sa vynucuje
Seek(0). - Chunked vs. Content-Length: Niektoré brány (alebo staršie serverové stacky) odmietajú Chunked. Ide o bežný legacy prípad v procesne orientovaných softvérových riešeniach. V takom prípade je Spool-to-File pragmatické riešenie.
- CRLF: Multipart očakáva CRLF (
#13#10), nie len LF. Niektoré servery sú tolerantné, iné nie. - Content-Type na súbor: Ak posielate všeobecne
application/octet-stream, je to často v poriadku. Ak server vykonáva kontrolu (napr. PDF), nastavte ho správne. V Delphi si môžete riešiť mapovanie MIME cez vlastnú tabuľku alebo OS-funkcie, ale nespoliehajte sa slepo na prípony súborov.
Debugging: reprodukovateľný wire-dump bez prerušovania TLS
Pri HTTPS nevidíte telo požiadavky v proxy, ak nesmiete použiť MitM (napr. Fiddler‑certifikát). To je v podnikových prostrediach bežné. Builder pomáha, pretože máte celý telo požiadavky streamovo k dispozícii a (pri Spool-Datei) aj ako súbor.
Osvedčený postup:
- Zapíšte telo spoolu do dočasného súboru.
- Logujte
Content-Typevrátane Boundary aContent-Length. - Vytvorte pre Support/DevOps voliteľne
curl-reprodukciu: nemusíte telo 1:1 reprodukovať, ale môžete zrkadliť parametre a súbor(y).
Dôležité: Nikdy nelogujte produkčné tokeny ani osobné údaje. V mnohých integračných scenároch podnikovej softvérovej integrácie je práve toto tá časť relevantná z hľadiska compliance.
Varianty: viacero súborov, voliteľné polia, servery s „neštandardnými“ očakávaniami
Viacero súborov pod rovnakým názvom poľa
Mnohé API očakávajú files[] alebo viackrát rovnaký názov. Builder to podporuje priamo: zavolajte AddFile opakovane s rovnakým FieldName. Či použijete files, files[] alebo attachments, je čisto serverová konvencia.
Server vyžaduje presne „application/json“ ako dodatočnú časť
Bežný vzor: JSON blok metadát plus súbor. Vtedy odošlete JSON ako Field‑Part, ale s Content-Type: application/json; charset=utf-8. To nie je „form field“ v zmysle UI, ale v multipart to možno korektne vyjadriť:
MP.AddField('metadata', '{"documentType":"report","source":"delphi"}', 'application/json; charset=utf-8');
Legacy: Server akceptuje len filename, nie filename*
V tom prípade pomôže fallback cez filename. Ak však server nesprávne dekóduje ne-ASCII v filename, zostáva často robustným riešením: názov súboru na serveri ignorovať a namiesto toho poslať v JSON ďalšie pole originalName.
Zaradenie pre modernizáciu a prevádzku
V už existujúcich Delphi prostrediach sa multipart často nachádza na okraji: rozhranie k DMS, archívu, ticketingu, klientske/klientské portály alebo interný REST-server. Práve tam vzniká tlak v dôsledku nových bezpečnostných požiadaviek (TLS, brány, proxy) a v dôsledku rastúcich veľkostí súborov.
Predložený prístup sa oplatí najmä, ak:
- musíte debugovať uploady reprodukovateľne (prevádzka/administrácia)
- chcete/musíte vyhnúť sa režimu chunked
- v praxi sa skutočne vyskytujú názvy súborov/kódovania (diakritika, medzery, zátvorky)
- retry/idempotencia má byť koncepčne čisto vyriešená
Menej sa to oplatí, ak posielate výhradne malé súbory tolerantnému serveru a nepotrebujete žiadnu prevádzkovú transparentnosť. Vtedy stačí jednoduché high-level riešenie – až kým nepríde prvý „neštandardný“ súbor z odboru.
Záver: Stabilný Multipart-Upload je otázkou streamovania a prevádzky
Čistý Multipart/Form-Data upload v Delphi nie je tak veľmi otázkou „ktorej komponenty“ ako otázkou kontroly: Boundary, CRLF, názov súboru, Content-Type a predovšetkým deterministický body‑stream. Kto to postaví správne už spočiatku, ušetrí neskôr čas v debugovacích cykloch s API‑gatewaymi a reverse‑proxy.
Hranica použitia prístupu: Ak potrebujete nahrávať extrémne veľké súbory (niekoľko GB) bez Spooling a bez Content-Length, stáva sa relevantnou téma Streaming bez predbežného výpočtu – v tom prípade musia cieľové servery a infraštruktúra spoľahlivo podporovať Chunked a potrebujete iný koncept ladenia. Pre mnohé integrácie v digitálnych podnikových riešeniach je však tu uvedený Builder práve pragmatickým kompromisom medzi robustnosťou, sledovateľnosťou a kontrolovateľnou spotrebou zdrojov.
Ak ste viazaní na existujúcu Delphi-integráciu, pri ktorej nahrávania sporadicky zlyhávajú alebo len „pri niektorých súboroch“, je to zvyčajne indikátor práve týchto okrajových podmienok. Pre cielenú podporu pri analýze, modernizácii alebo prevádzkovej konzultácii nás môžete kontaktovať tu:
V odbornom kontexte zohrávajú tiež Delphi Thttpclient a REST API nahrávanie súborov dôležitú rolu, keď musia integrácie, dátové toky a ďalší vývoj hladko spolupracovať.