Proč se Multipart v Delphi často až v provozu „selže“
Jedno Multipart/Form-Data nahrání v Delphi se rychle poskládá – a v reálných integracích pak naráží na detaily: špatný Content-Type pro část, Boundary-řetězec, který se omylem vyskytne v payloadu, nevhodné konce řádků, názvy souborů s ne-ASCII znaky nebo servery, které odmítají chunked transfer encoding (HTTP bez Content-Length). K tomu typické provozní problémy v individuálním podnikových aplikacích: velké soubory (CAD, PDF, skeny), proměnlivé sítě, reverse proxy, přísné API brány a požadavky administrace na debugování.
Delphi přináší s System.Net.HttpClient použitelný stack, ale ukázky „Happy Path“ nechávají důležité okrajové podmínky stranou. Následující ukázka zdrojového kódu jde záměrně hlouběji: Multipart sestavíme jako proud (stream) deterministicky, počítáme Content-Length korektně, podporujeme RFC-5987 pro názvy souborů a dodáváme debugovací volbu, která požadavek reprodukuje bez toho, abyste museli prolomit TLS.
Rozhodnutí v architektuře: THTTPClient místo Indy – a kdy to přestává platit
THTTPClient (System.Net) používá v závislosti na platformě různá backendy (pod Windows typicky WinHTTP/WinINet). To je v podnikových prostředích často výhodné: proxy a TLS policy jsou obvykle více kompatibilní se systémem. Indy je naopak velmi transparentní a přizpůsobitelný, ale přináší vlastní TLS bindingy a je v provozu někdy „separátně k udržování“ (verze OpenSSL, sady šifer).
Přístup zde používá THTTPClient, protože je při modernizacích často již nasazen (REST-client, OAuth, stahování). Pokud ale potřebujete tvrdou kontrolu nad TLS handshake, klientské certifikáty ve speciálních formátech nebo velmi specifické proxy řetězce, může být Indy (nebo dedikovaný HTTP-stack) vhodnější. To na sestavení Multipartu příliš nemění – ovlivní to však debugování a provoz.
Multipart/Form-Data upload v Delphi: stream, žádná magie
Jádrem myšlenky je, že Multipart je nakonec jen byte-stream. Když ho sestavíme sami, můžeme:
- Boundary vědomě zvolit a stabilně testovat
- hlavičky pro jednotlivé části správně nastavit (včetně
Content-Disposition,Content-Type) - spolehlivě spočítat
Content-Length(důležité pro servery bez podpory chunked) - streamovat velké soubory, aniž bychom drželi vše v RAM
Kód: Multipart-Builder se streamováním a názvy souborů podle RFC-5987
Builder níže vytváří buď paměťově založené tělo (pro malé uploady), nebo spool-soubor na disku (pro velké payloady). Působí to „oldschool“, ale v provozu je to extrémně praktické, protože to eliminuje chunked a usnadňuje debugování. Spoolování znamená: můžete znovu použít stejný request-body i při nutnosti opakování (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);
// Sestaví celý body do streamu. Pokud je ASpoolToFile prázdné,
// použije se TMemoryStream; jinak se vytvoří soubor.
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;
// Pole
Value: string;
// Soubor
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 mělo být dostatečně náhodné. Důležité: žádné mezery.
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 jsou v ASCII. Pro hodnoty v těle (např. UTF-8) nastavujeme pro každý part 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 pro názvy souborů s ne-ASCII znaky výrazně robustnější než pouhé 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 nesmí být nil');
if (FileStream is TCustomMemoryStream) and (FileStream.Size = 0) then
; // povoleno, ale často chyba: prázdný soubor
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
// Pozor: pozice ve streamu bude posunuta.
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
// Dva parametry názvu souboru: filename (pro 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é: nastavit pozici na začátek, jinak se nahrají pouze zbytky.
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.
Co kód záměrně dělá jinak
- Žádné „automatické Multipart“: Kontrola nad hlavičkami, kódováním a boundary zůstává u vás. To je u přísných REST-API často rozhodující.
- Podpora RFC-5987 přes
filename*: Jakmile názvy souborů obsahují diakritiku (např. „Prüfbericht.pdf“), jde o nejčastější chybu při interoperabilitě. Některé servery ignorujífilename*, v takovém případě se použijefilenamejako fallback. - Spool-to-File jako provozní funkce: Pro velké nahrávání a opakované pokusy o odeslání je znovupoužitelný stream těla mimořádně cenný.
- Content-Length je dostupný, protože tělo se vygeneruje předem. To zabrání Chunked-Encoding, pokud cílový systém tento režim nepřijímá.
Odeslání požadavku: timeouty, hlavičky a smysluplná strategie opakování
Multipart samo o sobě neřeší integrační problémy: potřebujete timeouty, klasifikaci chyb a volitelné opakování pokusů. Důležité je rozlišit mezi idempotentní a ne-idempotentní: nahrávání souborů často není idempotentní (mohou vzniknout duplicity). Opakování pokusů by proto mělo probíhat pouze, pokud server poskytuje idempotentní sémantiku (např. Upload-ID, dedikovaný Idempotency-Key header) nebo pokud máte na serveru deduplikaci.
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;
Úskalí v praxi
- Pozice streamu: Pokud FileStream není na pozici 0, nahrajete pouze zbytek. V builderu se proto vynucuje
Seek(0). - Chunked vs. Content-Length: Některé brány (nebo starší serverové stacky) Chunked odmítají. To je častý legacy případ v procesně orientovaných softwarových řešeních. Spool-to-File je v takovém případě pragmatické řešení.
- CRLF: Multipart očekává CRLF (
#13#10), ne pouze LF. Některé servery jsou tolerantní, jiné ne. - Content-Type pro soubor: Pokud posíláte paušálně
application/octet-stream, je to často v pořádku. Pokud server provádí kontrolu (např. PDF), nastavte ho správně. V Delphi můžete řešit mapování MIME pomocí vlastní tabulky nebo funkcí OS, ale nespoléhejte se slepě na přípony souborů.
Debugging: reprodukovatelný Wire-Dump bez narušení TLS
Při HTTPS nevidíte tělo požadavku v proxy, pokud nesmíte používat MitM (např. Fiddler‑certifikát). To je v podnikových prostředích běžné. Der Builder pomáhá, protože máte celý body streamově k dispozici a (u spoolového souboru) také jako soubor.
Osvědčený postup:
- Zapište spool‑tělo do dočasného souboru.
- Zalogujte
Content-Typevčetně boundary aContent-Length. - Vytvořte pro support/DevOps volitelně
curl-repro: Není nutné tělo 1:1 reprodukovat, ale můžete zrcadlit parametry a soubor(y).
Důležité: Nikdy nezalogujte produkční tokeny ani osobní údaje. V mnoha integračních scénářích podnikového softwaru jde právě o ten compliance‑relevantní aspekt.
Varianty: více souborů, volitelná pole, servery s „podivnými“ očekáváními
Více souborů pod stejným názvem pole
Mnoho API očekává files[] nebo opakovaně stejný název pole. Der Builder to podporuje přímo: volejte AddFile opakovaně se stejným FieldName. Zda použijete files, files[] nebo attachments, je čistě serverová konvence.
Server vyžaduje přesně „application/json“ jako dodatečnou část
Běžný scénář: JSON blok metadat plus soubor. JSON tedy pošlete jako field‑part, ale s Content-Type: application/json; charset=utf-8. To není „form field“ ve smyslu UI, ale v multipartu se to dá čistě vyjádřit:
MP.AddField('metadata', '{"documentType":"report","source":"delphi"}', 'application/json; charset=utf-8');
Legacy: Server akceptuje pouze filename, ne filename*
Pak pomůže fallback přes filename. Pokud však server ne‑ASCII v filename špatně dekóduje, jako robustní řešení často zůstává: ignorovat název souboru na serveru a místo toho posílat další pole originalName v JSONu.
Zařazení pro modernizaci a provoz
V rostoucích Delphi‑landskapích leží multipart často na okraji: rozhraní k DMS, archivu, ticketingu, zákaznické portály nebo interní REST-Server. Právě tam roste tlak kvůli novým bezpečnostním požadavkům (TLS, gatewaye, proxy) a kvůli větším velikostem souborů.
Představený přístup se vyplatí zejména, pokud:
- musíte nahrávání reprodukovatelně ladit (provoz/administrace)
- chcete/musíte vyhnout se chunked režimu
- se v praxi opravdu objevují problémy s názvy souborů/kódováním (umlauty, mezery, závorky)
- má být retry/idempotency konceptuálně čistě řešeno
Nevyplatí se tolik, pokud posíláte výhradně malé soubory na tolerantní server a nepotřebujete žádnou provozní transparentnost. Pak stačí jednoduché high‑level řešení – dokud nepřijde první „podivný“ soubor z obchodního oddělení.
Závěr: Stabilní Multipart‑Upload je streamovací a provozní problém
Čistý Multipart/Form‑Data upload v Delphi je méně otázkou „které komponenty“ a více otázkou kontroly: boundary, CRLF, název souboru, Content‑Type a především deterministický body‑stream. Kdo to postaví správně hned, ušetří později čas v debugovacích smyčkách s API‑gatewayi a reverse‑proxy.
Meze použitelnosti přístupu: Pokud musíte nahrávat extrémně velké soubory (několik GB) bez spooling a bez Content-Length, stává se relevantním téma streamingu bez předběžného výpočtu – v takovém případě musí cílové servery a infrastruktura spolehlivě podporovat chunked a potřebujete jiné koncepce ladění. Pro mnoho integrací v digitálních podnikových řešeních je však zde ukázaný Builder právě pragmatickým středem mezi robustností, sledovatelností a kontrolovatelnou spotřebou zdrojů.
Pokud máte existující Delphi-integraci, u které nahrávání příležitostně selhává nebo pouze „u některých souborů“, je to obvykle indikátor právě těchto okrajových podmínek. Pro cílenou podporu při analýze, modernizaci nebo objasnění provozu nás můžete kontaktovat zde:
V odborném kontextu hrají také Delphi Thttpclient a REST API nahrávání souborů důležitou roli, pokud integrace, tok dat a další vývoj musí bezproblémově spolupracovat.