Miksi Multipart in Delphi usein vasta tuotannossa pettää
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);
// Rakentaa koko bodyn streamiin. Jos ASpoolToFile on tyhjä,
// käytetään TMemoryStreamia; muuten luodaan tiedosto.
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:n tulee olla riittävän satunnainen. Tärkeää: ei välilyöntejä.
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-otsikot ovat ASCII-merkistöä. Bodyn arvot (esim. UTF-8) osoitetaan Part-kohtaisella Content-Typella.
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''..." on paljon luotettavampi ei-ASCII-tiedostonimille kuin pelkkä 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 ei saa olla nil');
if (FileStream is TCustomMemoryStream) and (FileStream.Size = 0) then
; // sallittu, mutta usein virhe: tyhjä tiedosto
P := TPart.Create;
P.Kind := pkFile;
P.Name := FieldName;
P.FileName := FileName;
P.ContentType := ContentType;
P.FileStream := FileStream; // Omistajuus jää kutsujalle
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
// Varoitus: Streamin asema muuttuu lukemisen aikana.
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);
// Kentän sisältö UTF-8-muodossa, mikäli charset=utf-8 on asetettu.
WriterUtf8 := TEncoding.UTF8.GetBytes(P.Value);
WriteBytes(WriterUtf8);
WriteBytes(CRLF);
end
else
begin
// Kaksi tiedostonimi-parametria: filename (vanhoille palvelimille) ja 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);
// Tärkeää: aseta asema alkuun, muuten lähetetään vain jäljellä oleva osa.
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.
Mitä koodi tietoisesti tekee toisin
- Ei ”automaattista Multipartia”: Hallinta Headereista, enkoodauksista ja boundarystä pysyy teillä. Tämä on usein ratkaisevaa tiukoissa REST-APIissa.
- RFC-5987-tuki
filename*-kentän kautta: Kun tiedostonimissä on Umlauteja (esim. „Prüfbericht.pdf“), tämä on yleisin yhteensopivuusbugi. Jotkin palvelimet sivuuttavatfilename*-kentän; silloinfilenametoimii fallbackina. - Spool-to-File osana tuotantoa: Suurille uploadille ja uusintayrityksille uudelleenkäytettävä body-stream on arvokas.
- Content-Length on saatavilla, koska body luodaan etukäteen. Tämä välttää chunked-enkoodauksen, jos kohdejärjestelmä ei sitä hyväksy.
Request senden: Timeouts, Header und eine sinnvolle Retry-Strategie
Multipart itsessään ei vielä ratkaise integraatio-ongelmia: tarvitsette aikakatkaisut, virheiden luokittelun ja valinnaisesti uudelleenyrittämiset. Tärkeää on erotella idempotent ja nicht idempotent: lataukset eivät usein ole idempotentteja (kaksoiskappaleet mahdollisia). Uudelleenyrittämisiä tulisi tehdä vain, jos palvelin tarjoaa idempotenttisen semantiikan (esim. Upload-ID, dedikoitu Idempotency-Key Header) tai jos teillä on palvelinpuolen deduplikointi.
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;
Käytännön sudenkuopat
- Stream-Position: Jos FileStream ei ole positiossa 0, lähetätte vain jäljellä olevan osan. Builderissa siksi
Seek(0)pakotetaan. - Chunked vs. Content-Length: Jotkin gatewayt (tai vanhemmat palvelinpinot) hylkäävät Chunked-enkoodauksen. Tämä on yleinen legacy-tapaus prosessiläheisissä ohjelmistoratkaisuissa. Spool-to-File on silloin pragmaattinen.
- CRLF: Multipart edellyttää CRLF:ää (
#13#10), ei pelkkää LF:ää. Jotkin palvelimet ovat tolerantteja, toiset eivät. - Content-Type per tiedosto: Jos lähetätte yleisesti
application/octet-stream, se on usein riittävä. Jos palvelin tarkistaa (esim. PDF), asettakaa oikea tyyppi. In Delphi voitte toteuttaa MIME-mappingin omalla taulukolla tai OS-funktioilla, mutta älkää luottako sokeasti tiedostopäätteisiin.
Debugging: reproduzierbarer Wire-Dump ohne TLS-Aufbruch
HTTPS-yhteydessä et näe Bodyä proxyn kautta, jos et saa käyttää MitM:tä (esim. Fiddler-sertifikaatti). Tämä on yritysympäristöissä normaalia. Builder auttaa, koska hallitset koko Bodyn streamina ja (spool-tiedoston tapauksessa) tiedostona.
Hyväksi todettu käytäntö:
- Kirjoita Spool-Body väliaikaiseen tiedostoon.
- Kirjaa lokiin
Content-Typemukaan lukien Boundary jaContent-Length. - Luo tukia/DevOpsia varten valinnainen
curl-repro: tässä sinun ei tarvitse palauttaa Bodyä 1:1, mutta voit peilata parametrien ja tiedosto(a) rakenne.
Tärkeää: Älä koskaan kirjaa tuotantotokeneja tai henkilötietoja lokiin. Monissa yritysohjelmisto-integraatioissa juuri tämä on säädöstenmukaisuuden kannalta merkittävä osa.
Vaihtoehdot: useita tiedostoja, valinnaiset kentät, palvelin, jolla „erikoiset“ odotukset
Useita tiedostoja samalla kentänimellä
Monet API:t odottavat files[]:ää tai samaa nimeä useaan kertaan. Builder tukee tätä suoraan: kutsu AddFile useaan kertaan samalla FieldName-arvolla. Käytätkö files, files[] vai attachments on puhtaasti palvelinpuolen konventio.
Palvelin vaatii nimenomaan „application/json“ lisäparttia
Yleinen malli: JSON-metatietolohko plus tiedosto. Silloin lähetät JSONin kenttäparttina, mutta Content-Type: application/json; charset=utf-8. Tämä ei ole „lomakekenttä“ käyttöliittymämerkityksessä, mutta se on Multipartissa siististi esitettävissä:
MP.AddField('metadata', '{"documentType":"report","source":"delphi"}', 'application/json; charset=utf-8');
Legacy: Palvelin hyväksyy vain filename, ei filename*
Silloin fallback filename auttaa. Jos palvelin kuitenkin dekoodaa non-ASCII-merkit väärin filename-kentässä, usein ainoa robusti tapa on jättää tiedostonimi palvelinpuolella huomiotta ja sen sijaan lähettää lisäkenttä originalName JSONissa.
Merkitys modernisoinnissa ja käytössä
Kasvaneissa Delphi-ympäristöissä Multipart on usein reunalla: rajapinta DMS:ään, arkistoon, tikettijärjestelmään, Asiakasportaali tai sisäinen REST-palvelin. Juuri siellä syntyy painetta uusista turvallisuusvaatimuksista (TLS, Gateways, Proxies) ja suuremmista tiedostokokoista.
Esitetty lähestymistapa kannattaa erityisesti, kun:
- Sinun pitää debugata latauksia toistettavasti (käyttö/ylläpito)
- Haluat/joudut välttämään chunked-siirtoa
- Tiedostonimet/koodaukset esiintyvät käytännössä (umlautit, välilyönnit, sulkeet)
- Retry/Idempotency halutaan ratkaista konseptuaalisesti puhtaasti
Se kannattaa vähemmän, jos lähetät yksinomaan pieniä tiedostoja toleroivalle palvelimelle etkä tarvitse lainkaan käyttöön liittyvää läpinäkyvyyttä. Silloin yksinkertainen korkean tason ratkaisu riittää – kunnes ensimmäinen „erikoinen“ tiedosto tulee liiketoimintayksiköstä.
Yhteenveto: Vakaa Multipart-lataus on striimaus- ja käyttöongelma
Siisti Multipart/Form-Data-lähetys Delphi-ympäristössä on vähemmän kysymys „mistä komponentista“ ja enemmän kontrollista: Boundary, CRLF, tiedostonimi, Content-Type ja ennen kaikkea deterministinen Body-stream. Ne, jotka rakentavat tämän varhain oikein, säästävät myöhemmin aikaa debuggauslenkeissä API-gatewayiden ja reverse-proxien kanssa.
Lähestymistavan käyttöraja: Jos sinun täytyy ladata erittäin suuria tiedostoja (useita GB) ilman spoolingia ja ilman Content-Length-otsaketta, tulee aiheelliseksi suoratoisto ilman ennakkolaskentaa – silloin kohdepalvelimen ja infrastruktuurin on tuettava Chunked-lähettämistä luotettavasti, ja tarvitset toisenlaisen virheenkorjauskonseptin. Monissa digitaalisten yritysratkaisujen integraatioissa tässä esitelty Builder on kuitenkin juuri pragmaattinen keskikohta robustisuuden, jäljitettävyyden ja hallittavissa olevan resurssikulutuksen välillä.
Jos olette sidoksissa kehittyneeseen Delphi-integraatioon, jossa lataukset epäonnistuvat satunnaisesti tai vain „joidenkin tiedostojen“ kohdalla, on se yleensä merkki juuri näistä reunaehdoista. Kohdennettua tukea analyysissä, modernisoinnissa tai käyttöselvityksessä saat meihin yhteyden täältä:
Ammattimaisessa kontekstissa myös Delphi Thttpclient ja REST API-tiedostojen lataus näyttelevät tärkeää roolia, kun integraatioiden, datavirtojen ja jatkokehityksen on toimittava saumattomasti yhteen.
Keskustele projektista tai modernisointihankkeesta Net-Base kanssa.