Warum Multipart in Delphi oft erst im Betrieb „kaputtgeht“
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);
// Bygger den komplette body i en stream. Hvis ASpoolToFile er tom,
// anvendes en TMemoryStream; ellers oprettes en fil.
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 bør være tilstrækkelig tilfældig. Vigtigt: ingen mellemrum.
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-headers er ASCII. For værdier i body'en (f.eks. UTF-8) sætter vi Content-Type pr. 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''..." er betydeligt mere robust for ikke-ASCII-filnavne end kun 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 må ikke være nil');
if (FileStream is TCustomMemoryStream) and (FileStream.Size = 0) then
; // tilladt, men ofte en fejl: tom fil
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
// Advarsel: streamens position forbruges.
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
// To filnavneparametre: filename (til ældre servere) og 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);
// Vigtigt: sæt positionen til starten, ellers uploades kun RESTer.
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.
Hvad koden bevidst gør anderledes
- Ingen „automatisches Multipart“: Kontrollen over headers, kodninger og boundary forbliver hos dig. Det er ofte afgørende ved strenge REST-APIs.
- RFC-5987-understøttelse via
filename*: Når filnavne indeholder umlauter (f.eks. „Prüfbericht.pdf“), er det den hyppigste interoperabilitetsfejl. Nogle servere ignorererfilename*, såfilenamefungerer som fallback. - Spool-to-File som driftsfeature: For store uploads og retries er en genanvendelig body-stream meget værdifuld.
- Content-Length er tilgængelig, fordi body genereres på forhånd. Det undgår chunked-encoding, hvis målsystemet ikke accepterer det.
Afsendelse af request: Timeouts, Header og en fornuftig Retry-strategi
Multipart i sig selv løser ikke integrationsproblemerne: I har brug for timeouts, fejlkategorisering og eventuelt retries. Vigtigt er adskillelsen mellem idempotent og ikke idempotent: Uploads er ofte ikke idempotente (dubletter kan forekomme). Retries bør derfor kun foretages, hvis serveren tilbyder idempotent semantik (f.eks. Upload-ID, dedikeret Idempotency-Key-header) eller hvis I har deduplikation på serversiden.
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;
Faldgruber i praksis
- Stream-position: Hvis FileStream ikke står i position 0, uploader I kun resten. Derfor tvinges
Seek(0)i builderen. - Chunked vs. Content-Length: Nogle gateways (eller ældre server-stacks) afviser chunked. Det er et hyppigt legacy-tilfælde i processnære softwareløsninger. Spool-to-File er da en pragmatisk løsning.
- CRLF: Multipart forventer CRLF (
#13#10), ikke kun LF. Nogle servere er tolerante, andre ikke. - Content-Type pr. fil: Hvis I generelt sender
application/octet-stream, er det ofte OK. Hvis serveren validerer (f.eks. PDF), angiv korrekt. I Delphi kan I løse MIME-mapping via egen tabel eller OS-funktioner, men stol ikke blindt på filendelser.
Debugging: reproducerbar Wire-Dump uden TLS-opbrud
Ved HTTPS kan du ikke se body’en i proxien, hvis du ikke må anvende en MitM (f.eks. et Fiddler-certifikat). Det er normalt i virksomhedsmiljøer. Builderen hjælper, fordi du har hele body’en som en stream og (ved spool-fil) som en fil.
Anbefalet fremgangsmåde:
- Skriv spool-body’en til en midlertidig fil.
- Log
Content-Typeinkl. Boundary ogContent-Length. - Opret eventuelt en
curl-reproducerbar til Support/DevOps: Her behøver du ikke gengive body’en 1:1, men du kan spejle parametre og fil(er).
Vigtigt: Log aldrig produktive tokens eller personoplysninger. I mange integrationer med forretningssoftware er netop det den compliance-relevante del.
Varianter: flere filer, valgfrie felter, servere med „specielle“ forventninger
Flere filer under samme feltnavn
Mange API’er forventer files[] eller gentagne samme navn. Builderen understøtter dette direkte: Kald AddFile flere gange med samme FieldName. Om du bruger files, files[] eller attachments er udelukkende serverkonvention.
Server kræver præcis application/json som ekstra part
Et udbredt mønster: En JSON-metadatalblok plus en fil. Så sender du JSON’en som et field-part, men med Content-Type: application/json; charset=utf-8. Det er ikke et „form field“ i UI-forstand, men det kan repræsenteres korrekt i multipart:
MP.AddField('metadata', '{"documentType":"report","source":"delphi"}', 'application/json; charset=utf-8');
Legacy: Server accepterer kun filename, ikke filename*
Så hjælper fallback via filename. Hvis serveren dog fejldekoder ikke-ASCII i filename, er en robust løsning ofte kun at ignorere filnavnet på serversiden og i stedet vedlægge et ekstra felt originalName i JSON’en.
Kontext for modernisering og drift
I voksede Delphi-landskaber hænger multipart ofte i periferien: et interface til DMS, arkiv, ticketing, Kundeportal eller en intern REST-server. Her opstår præcis det pres fra nye sikkerhedskrav (TLS, Gateways, Proxies) og fra større filstørrelser.
Den beskrevne tilgang er særligt relevant, når:
- du skal kunne debugge uploads reproducerbart (drift/administration)
- du vil/er nødt til at undgå chunked
- filnavne/encodings rent faktisk forekommer (Umlaute, mellemrum, parenteser)
- Retry/Idempotency bør være løst konceptuelt og entydigt
Den er mindre relevant, hvis du udelukkende sender små filer til en tolerant server og ikke har brug for driftstransparens. Så er en enkel high-level-løsning tilstrækkelig – indtil den første „specielle“ fil fra fagafdelingen dukker op.
Konklusion: Stabil multipart-upload er et streaming- og driftsproblem
Et korrekt multipart/form-data-upload i Delphi er mindre et spørgsmål om „hvilken komponent“ end om kontrol: Boundary, CRLF, filnavn, Content-Type og frem for alt en deterministisk body-stream. Den, der bygger det rigtigt fra starten, sparer senere tid i debug-sløjfer med API-Gateways og Reverse-Proxies.
Anvendelsesgrænse for tilgangen: Hvis der skal uploades ekstremt store filer (flere GB) uden spooling og uden Content-Length, bliver emnet Streaming uden forudberegning relevant – så skal målservere og infrastruktur pålideligt understøtte Chunked, og der er behov for et andet debugging-koncept. For mange integrationer i digitale virksomhedsløsninger er den her viste Builder dog netop den pragmatiske midte mellem robusthed, efterprøvelighed og kontrolleret ressourceforbrug.
Hvis I er bundet til en etableret Delphi-integration, hvor uploads sporadisk fejler eller kun „for nogle filer“, er det som regel en indikator for netop disse randbetingelser. For målrettet støtte ved analyse, modernisering eller afklaring af drift kan I kontakte os her:
I det faglige miljø spiller også Delphi Thttpclient og REST API filupload en vigtig rolle, når integrationer, dataflows og videreudvikling skal fungere pålideligt sammen.