Zašto Multipart u Delphi često tek u pogonu postane neispravan
Ein Multipart/Form-Data Upload in Delphi je brzo sastavljen klikom – i potom u stvarnim integracijama zakaže zbog detalja: pogrešan Content-Type po dijelu, Boundary-string koji se slučajno pojavljuje u payloadu, neodgovarajući prijelomi redaka, nazivi datoteka koji nisu ASCII ili serveri koji odbijaju chunked transfer encoding (HTTP bez Content-Length). Uz to dolaze tipični praktični problemi u individualnom enterprise softveru: velike datoteke (CAD, PDFs, skenovi), nestabilne mreže, reverse-proxyji, strogi API-gatewayi i administracijski zahtjevi za debugiranje.
Delphi donosi sa sobom System.Net.HttpClient solidan stack, ali „Happy Path“ primjeri ostavljaju važne rubne uvjete neobrađenima. Sljedeći isječak izvornog koda namjerno ide dublje: gradimo multipart kao stream deterministički, izračunavamo Content-Length ispravno, podržavamo RFC-5987 za nazive datoteka i isporučujemo opciju za debug koja čini request reproducibilnim bez potrebe za razbijanjem TLS-a.
Arhitektonska odluka: THTTPClient umjesto Indy – i kada to zakaže
THTTPClient (System.Net) koristi, ovisno o platformi, različite backende (na Windows tipično WinHTTP/WinINet). To je u poslovnim okruženjima često prednost: proxy i TLS politike su u pravilu kompatibilnije sa sistemskim komponentama. Indy je zauzvrat vrlo transparentan i prilagodljiv, no donosi vlastite TLS-bindings i ponekad u pogonu zahtijeva „posebno održavanje“ (verzije OpenSSL-a, Cipher-suiti).
Pristup prikazan ovdje koristi THTTPClient, jer je često već prisutan pri modernizacijama (REST-klijent, OAuth, preuzimanja). Ako vam pak treba stroga kontrola nad TLS-handshakeovima, klijentskim certifikatima u specifičnim formatima ili vrlo posebne proxy-lance, Indy (ili dedikirani HTTP-stack) može biti prikladniji. To malo mijenja sam multipart-nacin izgradnje — ali značajno utječe na debugiranje i pogon.
Multipart/Form-Data Upload u Delphi: tok bajtova, ne magija
Osnovna ideja: multipart je na kraju samo tok bajtova. Ako ga sami gradimo, možemo:
- Svjesno odabrati boundary i stabilno ga testirati
- Ispravno postaviti header za svaki dio (inkl.
Content-Disposition,Content-Type) - Pouzdano izračunati
Content-Length(važno za servere bez podrške za chunked) - Streamati velike datoteke bez držanja svega u RAM-u
Der Code: Multipart-Builder mit Streaming und RFC-5987-Dateinamen
Builder ispod po izboru generira ili potpuno memorijski bazirano tijelo (za male uploadove) ili spool-datoteku na disku (za velike payload-e). To djeluje „oldschool“, ali je u pogonu izuzetno praktično, jer izbjegava chunked i olakšava debugiranje. Spooling znači: možete ponovno koristiti isti request-body, čak i ako je potrebna retry operacija.
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);
// Sastavlja kompletno tijelo u stream. Ako je ASpoolToFile prazan,
// koristi se TMemoryStream; inače se stvara datoteka.
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 treba biti dovoljno nasumičan. Važno: bez razmaka.
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 zaglavlja su ASCII. Za vrijednosti u tijelu (npr. UTF-8) postavljamo Content-Type po dijelu.
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 znatno robusniji za nazive datoteka koji nisu ASCII od samog 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 ne smije biti nil');
if (FileStream is TCustomMemoryStream) and (FileStream.Size = 0) then
; // dozvoljeno, ali često greška: prazna datoteka
P := TPart.Create;
P.Kind := pkFile;
P.Name := FieldName;
P.FileName := FileName;
P.ContentType := ContentType;
P.FileStream := FileStream; // Vlasništvo ostaje kod pozivatelja
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
// Pažnja: pozicija streama se pomiče.
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);
// Tijelo polja u UTF-8, pod uvjetom da je charset=utf-8 postavljen.
WriterUtf8 := TEncoding.UTF8.GetBytes(P.Value);
WriteBytes(WriterUtf8);
WriteBytes(CRLF);
end
else
begin
// Dva parametra naziva datoteke: filename (za stare servere) i 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);
// Važno: poziciju postaviti na početak; inače će se poslati samo preostali podaci.
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.
Što kod svjesno radi drugačije
- Bez „automatskog multiparta“: Kontrola nad Headerima, enkodiranjima i boundaryjem ostaje vama. To je kod strogih REST-API-ja često presudno.
- RFC-5987-podrška preko
filename*: Kad nazivi datoteka sadrže dijakritičke znakove (npr. „Prüfbericht.pdf“), to je najčešći interoperabilni bug. Neki serveri ignorirajufilename*, tada se kao fallback koristifilename. - Spool-to-File kao operativna značajka: Za velike uploadove i ponovne pokušaje višekratno upotrebljiv Body-Stream je zlata vrijedan.
- Content-Length je dostupan, jer se Body unaprijed generira. To izbjegava Chunked-Encoding ako ciljni sustav to ne prihvaća.
Slanje zahtjeva: Timeouti, zaglavlja i smislena strategija ponovnog pokušaja
Sam multipart još ne rješava integracijske probleme: trebate timeout-e, klasifikaciju pogrešaka i opcionalne ponovne pokušaje. Važno je razlikovati između idempotentnog i ne idempotentnog: uploadi često nisu idempotentni (moguće duplikacije). Ponovne pokušaje treba izvoditi samo ako server nudi idempotentnu semantiku (npr. Upload-ID, posvećeno Idempotency-Key zaglavlje) ili imate deduplikaciju na strani servera.
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;
Zamke u praksi
- Pozicija streama: Ako FileStream nije na poziciji 0, uploadate samo ostatak. U builderu se stoga poziva
Seek(0). - Chunked vs. Content-Length: Neki gatewayi (ili stariji server-stackovi) odbijaju Chunked. To je čest naslijeđeni slučaj u procesno-bliskim softverskim rješenjima. Spool-to-File je tada pragmatično rješenje.
- CRLF: Multipart očekuje CRLF (
#13#10), ne samo LF. Neki serveri su tolerantni, drugi nisu. - Content-Type po datoteci: Ako općenito šaljete
application/octet-stream, to je često u redu. Ako server provjerava (npr. PDF), postavite ispravan tip. U Delphi možete riješiti MIME-mapping preko vlastite tablice ili OS-funkcija, ali se ne oslanjajte slijepo na ekstenzije datoteka.
Debugging: ponovljiv wire-dump bez prekida TLS-a
Pri HTTPS-u ne vidite tijelo zahtjeva u proxyju ako ne smijete koristiti MitM (npr. Fiddler-Zertifikat). To je u poslovnim okruženjima normalno. Der Builder pomaže, jer imate kompletno tijelo zahtjeva stream-based i (u slučaju spool-datoteke) kao datoteku.
Provjereni postupak:
- Zapišite spool-tijelo u privremenu datoteku.
- Zabilježite
Content-Typeuključujući Boundary iContent-Length. - Opcionalno izradite za Support/DevOps
curl-repro: ovdje ne morate tijelo reproducirati 1:1, ali možete preslikati parametre i datoteku(e).
Važno: Nikada ne zapisujte u log produkcijske tokene ili osobne podatke. U mnogim poslovnim softverskim integracijama upravo je to dio relevantan za usklađenost.
Varijante: mehrere Dateien, optionale Felder, Server mit „neobičnim“ Erwartungen
Više datoteka pod istim nazivom polja
Mnogo API-ja očekuje files[] ili više puta isti naziv. Der Builder to podržava izravno: pozovite AddFile više puta s istim FieldName. Koristite li files, files[] ili attachments, čista je konvencija poslužitelja.
Poslužitelj zahtijeva točno „application/json“ kao dodatni dio
Čest obrazac: JSON-blok metapodataka plus datoteka. Tada pošaljete JSON kao field-part, ali s Content-Type: application/json; charset=utf-8. To nije „Form Field“ u smislu UI-a, ali u multipartu se to uredno može prikazati:
MP.AddField('metadata', '{"documentType":"report","source":"delphi"}', 'application/json; charset=utf-8');
Legacy: Poslužitelj prihvaća samo filename, ne filename*
Tada pomaže fallback preko filename. Ako poslužitelj međutim pogrešno dekodira ne-ASCII u filename, često kao robustno rješenje ostaje samo: zanemariti nazive datoteka na strani poslužitelja i umjesto toga poslati dodatno polje originalName u JSON-u.
Kontekst za modernizaciju i operativu
U uvećanim Delphi-landskapima multipart često leži na rubu: sučelje prema DMS-u, arhivu, ticketingu, portal za klijente ili interni REST-poslužitelj. Upravo tamo nastaje pritisak zbog novih sigurnosnih zahtjeva (TLS, Gateways, Proxies) i zbog većih veličina datoteka.
Predloženi pristup posebno se isplati kada:
- morate reproducibilno debugirati uploadove (operacije/administracija)
- želite/trebate izbjeći Chunked
- nazivi datoteka/enkodingi u praksi zaista nastupaju (Umlaute, razmaci, zagrade)
- retry/Idempotency treba biti konceptualno čisto riješeno
Manje je isplativ ako šaljete isključivo male datoteke poslužitelju koji je tolerant i ne trebate operativnu transparentnost. Tada je jednostavno High-Level-rješenje dovoljno – dok ne stigne prva „neobična“ datoteka iz poslovne jedinice.
Zaključak: Stabilan Multipart-Upload je problem streaminga i operacija
Čist Multipart/Form-Data upload u Delphi manje je pitanje „koje komponente“, a više pitanje kontrole: Boundary, CRLF, naziv datoteke, Content-Type i prije svega deterministički body-stream. Tko to rano čisto izgradi, uštedit će kasnije vrijeme u debug-petljama s API-Gateways i Reverse-Proxies.
Granica primjene pristupa: Ako morate učitavati izuzetno velike datoteke (nekoliko GB) bez Spoolinga i bez Content-Length, postaje relevantna tema streaminga bez prethodnog izračuna – tada ciljni server i infrastruktura moraju pouzdano podržavati Chunked, i potreban vam je drugi koncept debugiranja. Za mnoge integracije u digitalnim poslovnim rješenjima prikazani Builder ipak predstavlja pragmatičnu sredinu između robusnosti, sljedivosti i kontrolirane potrošnje resursa.
Ako ste vezani za razvijenu Delphi-integraciju kod koje uploadi povremeno ne uspijevaju ili samo „kod nekih datoteka“, to je obično indikator upravo tih rubnih uvjeta. Za ciljanu podršku pri analizi, modernizaciji ili pojašnjenju rada možete nas kontaktirati ovdje:
U stručnom okruženju važnu ulogu također imaju Delphi Thttpclient i REST API prijenos datoteka kada integracije, tokovi podataka i daljnji razvoj moraju besprijekorno surađivati.