Zakaj Multipart v Delphi pogosto šele v obratovanju odpove
Ein Multipart/Form-Data Upload in Delphi je hitro sestavljen – in nato v realnih integracijah spodleti zaradi detajlov: napačen Content-Type za posamezen del, Boundary-niz, ki se pomotoma pojavi v vsebini (payload), neustrezni prelomi vrstic, datotečna imena, ki niso v ASCII, ali strežniki, ki zavračajo chunked transfer encoding (HTTP brez Content-Length). Poleg tega so tipične praktične težave v individualni poslovni programski opremi: velike datoteke (CAD, PDF, skenirani dokumenti), nihanja omrežja, Reverse-Proxies, strogi API-gatewayi in skrbniške zahteve glede razhroščevanja.
Delphi prinaša z System.Net.HttpClient uporaben sloj, vendar pa „Happy Path“ primeri puščajo pomembne robne pogoje neobdelane. Naslednji izsek iz izvorne kode gre namensko globlje: Multipart zgradimo kot tok deterministično, Content-Length izračunamo pravilno, podpiramo RFC-5987 za imena datotek in ponudimo debug-opcijo, ki zahtevek reproducira, brez da bi bilo treba razbijati TLS.
Arhitekturna odločitev: THTTPClient namesto Indy – in kdaj ta izbira zataji
THTTPClient (System.Net) uporablja glede na platformo različna ozadja (na Windows običajno WinHTTP/WinINet). To je v podjetniških okoljih pogosto koristno: politike proxyjev in TLS so bolj kompatibilne s sistemom. Indy je za to zelo pregleden in prilagodljiv, vendar prinaša svoja TLS-vezja in ga je v obratovanju včasih treba „ločeno vzdrževati“ (OpenSSL-verzije, nabore šifer).
Pristop tukaj uporablja THTTPClient, ker je pri modernizacijah pogosto že v uporabi (REST-Client, OAuth, prenosi). Če pa potrebujete strogo kontrolo TLS-handshakov, odjemalske certifikate v posebnih oblikah ali zelo specifične proxy-verige, je lahko smiselno uporabiti Indy (ali namenski HTTP-sloj). To pri sami izgradnji Multipart-a spremeni malo – vpliva pa na razhroščevanje in obratovanje.
Multipart/Form-Data prenos v Delphi: tok podatkov, nič magije
Glavna zamisel: Multipart je na koncu le bajtni tok. Če ga zgradimo sami, lahko:
- Boundary zavestno izberemo in ga zanesljivo testiramo
- nastavimo header za vsak del pravilno (vključno z
Content-Disposition,Content-Type) Content-Lengthzanesljivo izračunamo (pomembno za strežnike brez podpore za chunked)- pretakamo velike datoteke, brez da bi vse držali v RAM-u
Der Code: Multipart-Builder mit Streaming und RFC-5987-Dateinamen
Graditelj spodaj po izbiri ustvari bodisi popolnoma v pomnilniku temelječ body (za majhne prenose) ali pa Spool-Datei na disku (za velike payload-e). To deluje „starošolsko“, je pa v obratovanju izjemno praktično, ker se izogne chunked in olajša razhroščevanje. Spoolanje pomeni: lahko ponovno uporabite isto telo zahtevka, tudi če je potreben ponovni poskus.
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);
// Sestavi kompleten body v tok. Če je ASpoolToFile prazen,
// se uporabi TMemoryStream; sicer se ustvari 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 naj bo dovolj naključen. Pomembno: brez presledkov.
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 so ASCII. Za vrednosti v telesu (npr. UTF-8) nastavimo Content-Type na posameznem delu.
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 za ne-ASCII imena datotek bistveno bolj robusten kot samo 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 sme biti nil');
if (FileStream is TCustomMemoryStream) and (FileStream.Size = 0) then
; // dovoljeno, vendar pogosto napaka: prazna datoteka
P := TPart.Create;
P.Kind := pkFile;
P.Name := FieldName;
P.FileName := FileName;
P.ContentType := ContentType;
P.FileStream := FileStream; // Owner ostane pri klicatelju
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
// Pozor: položaj toka bo premaknjen.
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);
// Telo polja v UTF-8, če je charset=utf-8 nastavljen.
WriterUtf8 := TEncoding.UTF8.GetBytes(P.Value);
WriteBytes(WriterUtf8);
WriteBytes(CRLF);
end
else
begin
// Dva parametra z imenom datoteke: filename (za stare strežnike) in 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);
// Pomembno: položaj nastaviti na začetek, sicer se naložijo le preostanki.
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.
Kaj koda zavestno naredi drugače
- Brez „samodejnega Multipart“: Nadzor nad headerji, kodiranji in boundary ostane pri vas. To je pri striktnih REST-API-jih pogosto odločilno.
- Podpora RFC-5987 preko
filename*: Ko imena datotek vsebujejo Umlaute (npr. „Prüfbericht.pdf“), je to najpogostejši interoperabilnostni bug. Nekateri strežniki ignorirajofilename*, potem veljafilenamekot fallback. - Spool-to-File kot obratovalna funkcija: Za velike prenose in retries je ponovno uporabni body-stream zlata vredna.
- Content-Length je na voljo, ker je telo ustvarjeno vnaprej. To prepreči Chunked-Encoding, če ciljni sistem tega ne sprejema.
Pošiljanje zahtevka: časovne omejitve, headerji in smiselna strategija ponovnih poizkusov
Sam multipart še ne reši integracijskih težav: potrebujete časovne omejitve, klasifikacijo napak in po potrebi retries. Pomembna je razlika med idempotentnim in ne-idempotentnim: nalaganja (Uploads) pogosto niso idempotentna (možne podvojitve). Ponovne poizkuse zato izvajajte le, če strežnik ponuja idempotentno semantiko (npr. Upload-ID, namenski Idempotency-Key header) ali če na strežni strani izvajate deduplikacijo.
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: nastaviti realistične vrednosti glede na datoteko in povezavo.
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);
// Nekateri strežniki ali proxy-ji pričakujejo Content-Length obvezno.
Req.AddHeader('Content-Length', ContentLen.ToString);
if Token <> '' then
Req.AddHeader('Authorization', 'Bearer ' + Token);
// Opcijsko: če strežnik vrača veljaven JSON, lahko header Accept pomaga.
Req.AddHeader('Accept', 'application/json');
Result := Client.Execute(Req, nil);
finally
Body.Free;
end;
finally
Client.Free;
end;
end;
Pasti v praksi
- Pozicija toka: Če FileStream ni na poziciji 0, se naloži le preostanek. V Builderju zato prisilimo
Seek(0). - Chunked vs. Content-Length: Nekateri gatewayi (ali starejši serverski stacki) zavračajo Chunked. To je pogost legacy primer v procesno bližnjih programskih rešitvah. Spool-to-File je v takih primerih pragmatična rešitev.
- CRLF: Multipart pričakuje CRLF (
#13#10), ne le LF. Nekateri strežniki so tolerantni, drugi ne. - Content-Type na datoteko: Če pošljete povsem generično
application/octet-stream, je to pogosto v redu. Če strežnik preverja (npr. PDF), nastavite pravilno. V Delphi lahko MIME-mapiranje rešite z lastno tabelo ali funkcijami OS, vendar se ne zanašajte slepo na končnice datotek.
Odpravljanje napak: reproducibilen Wire-Dump brez razbijanja TLS
Pri HTTPS v proxyju ne vidite telesa, če ne smete uporabiti MitM (npr. Fiddler-Zertifikat). To je v podjetniških okoljih običajno. Builder pomaga, ker imate celotno telo kot stream in (pri Spool-Datei) tudi kot datoteko.
Preizkušen postopek:
- Zapišite spool-telo v začasno datoteko.
- Beležite
Content-Typez vključenim Boundary inContent-Length. - Po potrebi ustvarite za Support/DevOps
curl-reprodukcijo: tukaj ni treba telo 1:1 reproducirati, lahko pa zrcalite parametre in datoteko(e).
Pomembno: nikoli ne beležite produkcijskih tokenov ali osebnih podatkov. V mnogih integracijah poslovne programske opreme je prav to del, pomemben za skladnost.
Variante: več datotek, izbirna polja, strežnik z „čudnimi“ pričakovanji
Več datotek pod istim imenom polja
Veliko API-jev pričakuje files[] ali večkrat isto ime. Builder to podpira neposredno: pokličite AddFile večkrat z istim FieldName. Ali uporabljate files, files[] ali attachments, je vprašanje strežnične konvencije.
Strežnik zahteva natanko „application/json“ kot dodaten del
Pogost vzorec: JSON-metapodatkovni blok plus datoteka. V tem primeru pošljete JSON kot field-part, vendar z Content-Type: application/json; charset=utf-8. To ni „form field“ v smislu UI, a se v multipartu čisto prikaže:
MP.AddField('metadata', '{"documentType":"report","source":"delphi"}', 'application/json; charset=utf-8');
Legacy: strežnik sprejema samo filename, ne filename*
Takrat pomaga fallback preko filename. Če pa strežnik nicht-ASCII v filename napačno dekodira, kot robustna rešitev pogosto ostane le: na strežniški strani ignorirati ime datoteke in namesto tega poslati dodatno polje originalName v JSON-u.
Pomen za modernizacijo in obratovanje
V razvitih Delphi okoljih se Multipart pogosto pojavi na robu: vmesnik do DMS, arhiva, ticketinga, portal strank ali notranji REST-strežnik. Prav tam nastaja pritisk zaradi novih varnostnih zahtev (TLS, gateways, proxies) in zaradi večjih velikosti datotek.
Predlagan pristop se posebej izplača, če:
- morate reproducibilno debugirati nalaganja (obratovanje/administracija)
- se želite/morate izogniti Chunked
- se v praksi pojavijo imena datotek/enkodiranja (umlauti, presledki, oklepaji)
- naj bo retry/idempotency konceptualno čisto rešen
Manj se izplača, če pošiljate le majhne datoteke na toleranten strežnik in ne potrebujete nobene operativne preglednosti. Potem je enostavna High-Level-Lösung zadostna — dokler iz poslovne enote ne pride prva „čudna“ datoteka.
Zaključek: stabilen Multipart-Upload je vprašanje pretakanja in obratovanja
Čist Multipart/Form-Data upload v Delphi ni toliko vprašanje „katere komponente“ kot nadzora: Boundary, CRLF, ime datoteke, Content-Type in predvsem determinističen body-stream. Kdor to zgodaj naredi pravilno, prihrani kasneje čas v debug- zankah z API-Gateways in Reverse-Proxies.
Mejna uporabe pristopa: Če morate nalagati izjemno velike datoteke (več GB) brez Spooling in brez Content-Length, postane pomembno vprašanje pretakanja brez predhodnega izračuna – takrat morata ciljni strežnik in infrastruktura zanesljivo podpirati Chunked, in potrebovali boste drugačen koncept razhroščevanja. Za številne integracije v digitalnih poslovnih rešitvah pa je prikazani Builder prav pragmatična sredina med robustnostjo, sledljivostjo in obvladljivo porabo virov.
Če ste vezani na obstoječo Delphi-integracijo, pri kateri nalaganja sporadično odpovedujejo ali le „pri nekaterih datotekah“, je to ponavadi indikator teh robnih pogojev. Za ciljno podporo pri analizi, modernizaciji ali pojasnitvi obratovanja nas dosežete tukaj:
V strokovnem okolju igrajo tudi Delphi Thttpclient in REST API za nalaganje datotek pomembno vlogo, kadar morajo integracije, podatkovni tokovi in nadaljnji razvoj tesno sodelovati.
Prediskutirajte projekt ali modernizacijsko pobudo z Net-Base.