Hvorfor Multipart i Delphi ofte først «går i stykker» i drift
En Multipart/Form-Data-opplasting i Delphi er rask å sette opp – men feiler i reelle integrasjoner på detaljer: feil Content-Type per part, en Boundary-streng som ved et uhell forekommer i payload, upassende linjeskift, ikke-ASCII filnavn eller servere som avviser chunked transfer encoding (HTTP uten Content-Length). I tillegg kommer typiske praksisproblemer i individuell bedriftsprogramvare: store filer (CAD, PDF-er, skannede dokumenter), varierende nett, reverse-proxyer, strenge API-gateways og administrative krav til debugging.
Delphi leverer med System.Net.HttpClient en brukbar stack, men «Happy Path»-eksemplene utelater viktige randbetingelser. Følgende kildekodesnutt går bevisst dypere: Vi bygger Multipart som en stream deterministisk, beregner Content-Length korrekt, støtter RFC-5987 for filnavn og tilbyr en debug-valg som gjør requesten reproduserbar uten at du må bryte opp TLS.
Arkitekturvalg: THTTPClient i stedet for Indy – og når det svikter
THTTPClient (System.Net) bruker avhengig av plattform ulike backends (under Windows typisk WinHTTP/WinINet). Dette er ofte fordelaktig i bedriftsmiljøer: proxy- og TLS-policyer er mer kompatible med systemet. Indy er til gjengjeld svært transparent og tilpassbart, men bringer egne TLS-bindings og må i drift noen ganger «vedlikeholdes separat» (OpenSSL-versjoner, cipher-suiter).
Tilnærmingen her bruker THTTPClient, fordi det i moderniseringsprosjekter ofte allerede er i bruk (REST-client, OAuth, nedlastinger). Hvis du derimot trenger svært streng kontroll over TLS-handshakes, klientsertifikater i spesielle former eller svært spesielle proxy-kjeder, kan Indy (eller en dedikert HTTP-stack) være hensiktsmessig. Det endrer lite ved måten Multipart bygges på – men det påvirker debugging og drift.
Multipart/Form-Data-opplasting i Delphi: en stream, ingen magi
Kjerneideen: Multipart er til syvende og sist bare en byte-stream. Hvis vi bygger den selv, kan vi:
- velge Boundary bevisst og teste den stabilt
- sette header per part korrekt (inkl.
Content-Disposition,Content-Type) - beregne
Content-Lengthpålitelig (viktig for servere uten chunked-støtte) - streame store filer uten å holde alt i RAM
Der Code: Multipart-Builder mit Streaming und RFC-5987-Dateinamen
Builderen nedenfor kan enten produsere en ren minnebasert body (for små opplastinger) eller en spool-fil på disk (for store payloads). Dette virker «gammeldags», men er i praksis svært nyttig i produksjon fordi det unngår chunked og forenkler debugging. Spooling betyr: Du kan gjenbruke samme request-body selv om et retry er nødvendig.
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 hele bodyen inn i en stream. Hvis ASpoolToFile er tom,
// brukes en TMemoryStream; ellers opprettes 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 tilstrekkelig tilfeldig. Viktig: ingen mellomrom.
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-header er ASCII. For verdier i bodyen (f.eks. UTF-8) angir vi Content-Type per del.
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 betydelig mer robust for ikke-ASCII-filnavn enn bare 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
; // tillatt, men ofte en feil: tom fil
P := TPart.Create;
P.Kind := pkFile;
P.Name := FieldName;
P.FileName := FileName;
P.ContentType := ContentType;
P.FileStream := FileStream; // Eier forblir hos anroperen
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
// Merk: streamens posisjon blir konsumert.
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);
// Felt-body i UTF-8, hvis charset=utf-8 er satt.
WriterUtf8 := TEncoding.UTF8.GetBytes(P.Value);
WriteBytes(WriterUtf8);
WriteBytes(CRLF);
end
else
begin
// To filnavneparametre: filename (for eldre 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);
// Viktig: sett posisjonen til starten, ellers vil bare RESTer lastes opp.
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.
Hva koden bevisst gjør annerledes
- Ingen „automatisk Multipart“: Kontroll over Header, Encodings og Boundary forblir hos deg. Det er ofte avgjørende for strenge REST-APIer.
- RFC-5987-støtte via
filename*: Når filnavn inneholder diakritiske tegn (f.eks. „Prüfbericht.pdf“), er dette den vanligste interoperabilitetsfeilen. Noen servere ignorererfilename*, da fallerfilenametilbake som fallback. - Spool-to-File som driftsfunksjon: For store opplastinger og gjenforsøk er en gjenbrukbar body-stream svært verdifull.
- Content-Length er tilgjengelig, fordi body genereres på forhånd. Det unngår Chunked-Encoding hvis målsystemet ikke aksepterer det.
Request senden: Timeouts, Header und eine sinnvolle Retry-Strategie
Multipart i seg selv løser ikke integrasjonsproblemene: Du trenger timeouts, feilkategorisering og eventuelt gjenforsøk. Viktig er skillet mellom idempotent og nicht idempotent: Opplastinger er ofte ikke idempotente (duplikater kan oppstå). Derfor bør gjenforsøk kun skje hvis serveren tilbyr idempotent semantikk (f.eks. Upload-ID, en dedikert Idempotency-Key Header) eller du har deduplisering 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;
Fallgruver i praksis
- Stream-posisjon: Hvis FileStream ikke står på posisjon 0, laster du bare opp resten. I Builderen blir derfor
Seek(0)tvunget. - Chunked vs. Content-Length: Noen gateways (eller eldre server-stacks) avviser Chunked. Dette er en vanlig legacy-tilfelle i prosessnær programvare. Spool-to-File er da pragmatisk.
- CRLF: Multipart forventer CRLF (
#13#10), ikke bare LF. Noen servere er tolerante, andre ikke. - Content-Type per fil: Hvis du sender generelt
application/octet-stream, er det ofte ok. Hvis serveren sjekker (f.eks. PDF), sett riktig verdi. I Delphi kan du løse MIME-mapping via egen tabell eller OS-funksjoner, men ikke stol blindt på filendelser.
Feilsøking: reproduserbar wire-dump uten å bryte TLS
Ved HTTPS ser du ikke Body-en i proxyen hvis du ikke får bruke en MitM (f.eks. Fiddler-sertifikat). Dette er normalt i bedriftsmiljøer. Builderen hjelper fordi du har hele Body-en som en strøm og (ved spool-fil) som en fil.
Anbefalt fremgangsmåte:
- Skriv spool-bodyen til en midlertidig fil.
- Logg
Content-Typeinkludert boundary ogContent-Length. - Lag for Support/DevOps valgfritt en
curl-reproduksjon: Her trenger du ikke gjengi Body-en 1:1, men du kan speile parametrene og filene.
Viktig: Logg aldri produksjonstoken eller personopplysninger. I mange forretningsprogramvare-integrasjoner er nettopp dette den compliance-relevante delen.
Varianter: flere filer, valgfrie felt, servere med „merkelige“ forventninger
Flere filer under samme feltnavn
Mange API-er forventer files[] eller gjentatte ganger samme navn. Builderen støtter dette direkte: kall AddFile flere ganger med samme FieldName. Om du bruker files, files[] eller attachments er ren serverkonvensjon.
Server krever nøyaktig „application/json“ som en ekstra del
Et vanlig mønster: en JSON-metadatablokk pluss fil. Da sender du JSON som et felt-part, men med Content-Type: application/json; charset=utf-8. Dette er ikke et „form field“ i UI-forstand, men kan representeres presist i multipart:
MP.AddField('metadata', '{"documentType":"report","source":"delphi"}', 'application/json; charset=utf-8');
Legacy: Server aksepterer bare filename, ikke filename*
Da hjelper fallback via filename. Hvis serveren derimot dekoderer ikke-ASCII i filename feil, er ofte den mest robuste løsningen å ignorere filnavnet på serversiden og i stedet sende et ekstra felt originalName i JSON-en.
Vurdering for modernisering og drift
I etablerte Delphi-landskap sitter multipart ofte i utkanten: et grensesnitt mot DMS, arkiv, ticketing, kundeportal eller en intern REST-server. Det er nettopp der presset oppstår fra nye sikkerhetskrav (TLS, gateways, proxyer) og fra større filstørrelser.
Den beskrevne tilnærmingen er særlig nyttig når:
- Du må feilsøke opplastinger reproduserbart (drift/administrasjon)
- Du vil/må unngå Chunked
- Filnavn/encodings forekommer i praksis (umlauter, mellomrom, parenteser)
- Retry/Idempotency skal være konseptuelt ryddig løst
Den lønner seg mindre hvis du utelukkende sender små filer til en tolerant server og ikke trenger driftstransparens. Da er en enkel high-level-løsning tilstrekkelig – inntil den første „merkelige“ filen fra fagavdelingen dukker opp.
Konklusjon: Stabil multipart-opplasting er et streaming- og driftsproblem
En ryddig Multipart/Form-Data-opplasting i Delphi er mindre et spørsmål om «hvilken komponent» enn om kontroll: boundary, CRLF, filnavn, Content-Type og fremfor alt en deterministisk Body-strøm. Den som bygger dette skikkelig tidlig, sparer senere tid i feilsøkingssløyfer med API-gateways og reverse-proxyer.
Begrensning for tilnærmingen: Hvis du må laste opp svært store filer (flere GB) uten spooling og uten Content-Length, blir temaet streaming uten forhåndsberegning relevant – da må målserver og infrastruktur støtte Chunked pålitelig, og du trenger et annet feilsøkingskonsept. For mange integrasjoner i digitale virksomhetsløsninger er byggverktøyet som er vist her likevel nettopp det pragmatiske kompromisset mellom robusthet, etterprøvbarhet og kontrollerbart ressursforbruk.
Hvis du er bundet til en etablert Delphi-integrasjon der opplastinger sporadisk feiler eller bare „for enkelte filer“, er det som regel en indikator på nettopp disse randbetingelsene. For målrettet støtte ved analyse, modernisering eller driftsavklaring kan du kontakte oss her:
I det faglige miljøet spiller også Delphi Thttpclient og REST API filopplasting en viktig rolle når integrasjoner, dataflyter og videreutvikling må fungere sammen på en ryddig måte.