Miért hibásodik meg a Multipart a Delphi-ben gyakran csak üzemeltetés közben
Egy Multipart/Form-Data feltöltés a Delphi gyorsan összekattintható – a valós integrációkban azonban részleteken bukik meg: rossz Content-Type partonként, egy Boundary-string, amely véletlenül megjelenik a payloadban, nem megfelelő sortörések, nem-ASCII fájlnevek vagy olyan szerverek, amelyek elutasítják a chunked transfer encoding (HTTP Content-Length nélkül). Emellett tipikus üzemeltetési problémák egyedi vállalati szoftvereknél: nagy fájlok (CAD, PDF-ek, szkennelt dokumentumok), ingadozó hálózatok, reverse proxy-k, szigorú API-gatewayek és adminisztrátori követelmények a hibakeresésre.
Delphi a System.Net.HttpClient-tel egy használható stacket biztosít, de a „Happy Path”-példák fontos peremfeltételeket nyitva hagynak. Az alábbi forrásszösszenet szándékosan mélyebbre megy: Multipartet determinisztikusan streamként építünk fel, a Content-Length-et helyesen számoljuk, támogatjuk az RFC-5987 szerinti fájlneveket, és adunk egy hibakeresési opciót, amely reprodukálhatóvá teszi a kérést anélkül, hogy TLS-t kellene feltörni.
Architekturális döntés: THTTPClient az Indy helyett – és mikor válik ez problémássá
THTTPClient (System.Net) platformtól függően különböző back-endeket használ (például Windows alatt jellemzően WinHTTP/WinINet). Ez vállalati környezetben gyakran előnyös: a proxy- és TLS-szabályzatok inkább kompatibilisek a rendszerrel. Indy viszont nagyon átlátható és testreszabható, de saját TLS-kötéseket hoz magával, és üzemeltetésben néha „külön karban tartandó” (OpenSSL-verziók, cipher-suite-ok).
Ez a megközelítés a THTTPClient-et használja, mert modernizációk során gyakran már be van vetve (REST-kliens, OAuth, letöltések). Ha azonban nagyfokú kontrollra van szüksége a TLS-handshake-ek, speciális formátumú klienstanúsítványok vagy nagyon speciális proxy-láncok felett, az Indy (vagy egy dedikált HTTP-stack) ésszerű választás lehet. Ez a Multipart felépítését kevéssé érinti — de a hibakeresést és az üzemeltetést jelentősen befolyásolhatja.
Multipart/Form-Data feltöltés a Delphi-ben: egy stream, semmi varázslat
A lényeg: a Multipart végső soron csak egy bájtfolyam. Ha mi magunk építjük fel, képesek vagyunk:
- Boundary-t tudatosan választani és stabilan tesztelni
- Minden parthoz helyes header-eket beállítani (beleértve a
Content-Disposition,Content-Type) - A
Content-Lengthmegbízható kiszámítása (fontos azokhoz a szerverekhez, amelyek nem támogatják a chunked-et) - Nagy fájlok streamelése anélkül, hogy mindent a RAM-ban tartanánk
A kód: Multipart-builder streaminggel és RFC-5987 szerinti fájlnevekkel
Az alábbi builder vagy teljesen memóriában tartott body-t hoz létre (kis feltöltésekhez), vagy egy Spool-Datei a lemezen (nagy payloadokhoz). Ez „oldschool”-nak tűnik, de üzemeltetésben rendkívül hasznos, mert elkerüli a chunked-et és megkönnyíti a hibakeresést. A spoololás azt jelenti, hogy ugyanazt a request-body-t újra felhasználhatja, még ha retry-re is szükség van.
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);
// Összeállítja a teljes body-t egy streambe. Ha az ASpoolToFile üres,
// TMemoryStream kerül használatra; különben fájlt hoz létre.
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
// A boundary-nek elég véletlenszerűnek kell lennie. Fontos: ne tartalmazzon szóközt.
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
// A multipart-fejlécek ASCII karakterek. A body értékeihez (pl. UTF-8) partonként állítjuk be a Content-Type-ot.
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''..." jóval robusztusabb nem ASCII fájlnevek esetén, mint az egyszerű 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('A FileStream nem lehet nil');
if (FileStream is TCustomMemoryStream) and (FileStream.Size = 0) then
; // megengedett, de gyakran hiba: üres fájl
P := TPart.Create;
P.Kind := pkFile;
P.Name := FieldName;
P.FileName := FileName;
P.ContentType := ContentType;
P.FileStream := FileStream; // A tulajdonos a hívónál marad
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
// Figyelem: a stream pozíciója előrehalad, a stream olvasása megváltoztatja a pozíciót.
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);
// A mező tartalma UTF-8-ban, ha charset=utf-8 van beállítva.
WriterUtf8 := TEncoding.UTF8.GetBytes(P.Value);
WriteBytes(WriterUtf8);
WriteBytes(CRLF);
end
else
begin
// Két fájlnév-paraméter: filename (régebbi szerverekhez) és 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);
// Fontos: állítsuk a pozíciót az elejére, különben csak a maradék kerül feltöltésre.
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 tesz a kód tudatosan másként
- Nincs „automatikus Multipart”: A fejlécek, kódolások és boundary feletti kontroll Önnél marad. Ez szigorú REST-API-k esetén gyakran döntő jelentőségű.
- RFC-5987-támogatás a
filename*révén: Amint a fájlnevek ékezeteket tartalmaznak (pl. „Prüfbericht.pdf”), ez a leggyakoribb interoperabilitási hiba. Néhány szerver figyelmen kívül hagyja afilename*-et; ilyenkor afilenamelép be visszalépésként. - Spool-to-File üzemeltetési funkcióként: nagy feltöltések és újrapróbálkozások esetén egy újrahasználható body-stream kifejezetten hasznos.
- Content-Length elérhető, mert a body előre létrehozásra kerül. Ez elkerüli a Chunked-Encodinget, ha a célrendszer azt nem fogadja el.
Kérés küldése: időkorlátok, fejlécek és ésszerű újrapróbálkozási stratégia
Multipart önmagában még nem oldja meg az integrációs problémákat: szükség van időkorlátokra, hibaosztályozásra és opcionális újrapróbálkozásokra. Fontos a megkülönböztetés az idempotent és az nicht idempotent között: a feltöltések gyakran nem idempotensek (duplikátumok lehetségesek). Újrapróbálkozás ezért csak akkor javasolt, ha a szerver idempotens szemantikát kínál (pl. Upload-ID, dedikált Idempotency-Key fejléc) vagy ha szerveroldali deduplikációt alkalmaznak.
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;
Gyakorlati buktatók
- Stream-pozíció: Ha a FileStream nincs 0 pozícióban, csak a maradékot tölti fel. A Builderben ezért a
Seek(0)végrehajtása kötelező. - Chunked vs. Content-Length: Néhány gateway (vagy régebbi szerver-stack) elutasítja a chunked-et. Ez gyakori legacy eset folyamatközeli szoftvermegoldásoknál. Ilyenkor a Spool-to-File pragmatikus megoldás.
- CRLF: A multipart CRLF-t (
#13#10) vár, nem csak LF-et. Néhány szerver toleráns, mások nem. - Content-Type fájlonként: Ha általánosan a
application/octet-stream-et küldi, az gyakran elfogadható. Ha a szerver ellenőrzi (pl. PDF), állítsa be helyesen. A Delphi-ben MIME-térképezést megoldhat saját táblával vagy OS-funkciókkal, de ne bízzon vakon a fájlkiterjesztésekben.
Debugging: reprodukálható wire-dump TLS-bontás nélkül
HTTPS esetén a proxyn nem látja a Body-t, ha nem használhat MitM-et (z. B. Fiddler-tanúsítvány). Ez vállalati környezetben normális. A Builder segít, mert a teljes Body-t streamként birtokolja, és (spool-fájl esetén) fájlként is rendelkezésre áll.
Bevált eljárás:
- Írja a spool-body-t egy ideiglenes fájlba.
- Naplózza a
Content-Type-ot, a Boundary-t és aContent-Length-et. - Készítsen Support/DevOps számára opcionálisan egy
curl-reprodukciót: itt nem kell a Body-t 1:1 visszaadni, de tükrözheti a paramétereket és a fájlt(okat).
Fontos: Soha ne naplózzon éles tokeneket vagy személyes adatokat. Sok üzleti szoftver-integrációnál éppen ez az, ami megfelelőség (compliance) szempontjából kritikus.
Változatok: több fájl, opcionális mezők, szerver „furcsa” elvárásaival
Több fájl ugyanazzal a mezőnévvel
Sok API várja a files[]-t vagy többször ugyanazt a nevet. A Builder ezt közvetlenül támogatja: hívja meg többször az AddFile-t ugyanazzal a FieldName-mel. Hogy files, files[] vagy attachments-t használ, az kizárólag szerverkonvenció.
Szerver pontosan „application/json” típusú kiegészítő partot vár
Gyakori minta: egy JSON metaadat-blokk plusz fájl. Ilyenkor a JSON-t Field-Partként küldi el, de a Content-Type: application/json; charset=utf-8-tel. Ez nem „Form Field” a UI értelemben, de a multipartban tisztán ábrázolható:
MP.AddField('metadata', '{"documentType":"report","source":"delphi"}', 'application/json; charset=utf-8');
Legacy: a szerver csak a filename-et fogadja el, nem a filename*
Ebben segít a visszaesés filename-re. Ha azonban a szerver a nem-ASCII-t a filename-ben rosszul dekódolja, gyakran a legrobosztusabb megoldás az, hogy a fájlneveket szerveroldalon figyelmen kívül hagyják, és helyette egy kiegészítő originalName mezőt küldenek a JSON-ben.
Modernizáció és üzemeltetés kontextusában
Öröklött Delphi-környezetekben a multipart gyakran periférián helyezkedik el: egy interfész DMS-hez, archiváláshoz, ticketinghez, Ügyfélportál vagy egy belső REST-szerver. Pont ott jelentkezik nyomás az új biztonsági követelmények (TLS, gateway-k, proxy-k) és a nagyobb fájlméretek miatt.
A bemutatott megközelítés különösen megéri, ha:
- Az uploadok reprodukálható módon történő hibakeresésére van szükség (üzemeltetés/adminisztráció)
- Ha a Chunked-et el akarja/ki kell kerülnie
- Ha fájlnevek/enkódolások a gyakorlatban ténylegesen előfordulnak (ékezetes karakterek, szóközök, zárójelek)
- Ha a Retry/Idempotency koncepcionálisan tisztán megoldandó
Kevésbé éri meg, ha kizárólag kis fájlokat küld egy toleráns szervernek és semmilyen üzemeltetési átláthatóságra nincs szüksége. Ilyenkor egy egyszerű High-Level-megoldás elegendő – amíg az első „furcsa” fájl a szakterületről meg nem érkezik.
Következtetés: A stabil multipart-feltöltés streaming és üzemeltetési probléma
Egy tisztán megvalósított Multipart/Form-Data feltöltés Delphi-ben kevésbé kérdés, hogy „melyik komponens”, mint inkább a kontroll: Boundary, CRLF, fájlnév, Content-Type és elsősorban egy determinisztikus Body-Stream. Aki ezt korán tisztán megépíti, később időt takarít meg a hibakeresési körökben az API-Gateways és Reverse-Proxies-szal.
A megközelítés alkalmazási határa: Ha extrém nagy fájlokat (több GB) kell feltöltenie spooling és Content-Length nélkül, fontossá válik a Előzetes számítás nélküli streamelés kérdése – ekkor a cél szervernek és az infrastruktúrának megbízhatóan támogatnia kell a chunked átvitelét, és más hibakeresési koncepcióra van szükség. Sok digitális vállalati integrációnál azonban az itt bemutatott Builder éppen a pragmatikus közép a robusztusság, a nyomon követhetőség és az ellenőrizhető erőforráshasználat között.
Ha egy évek során kialakult Delphi-integrációhoz kötődik, ahol a feltöltések időnként meghiúsulnak vagy csak „egyes fájloknál”, az általában éppen ezekre a peremfeltételekre utal. Célzott támogatásért az elemzésben, a modernizálásban vagy az üzemeltetési tisztázásban az alábbi címen ér el minket:
A szakmai környezetben a Delphi Thttpclient és a REST API fájlfeltöltés szintén fontos szerepet játszik, amikor az integrációknak, az adatfolyamoknak és a továbbfejlesztésnek zavartalanul kell együttműködniük.