Kodėl Multipart sistemoje Delphi dažnai „sugenda“ tik eksploatacijos metu
Vienas Multipart/Form-Data įkėlimas į Delphi greitai surenkamas – ir realiose integracijose žlunga dėl smulkmenų: neteisingas Content-Type kiekvienam part’ui, Boundary-String, kuris netyčia pasirodo Payload’e, netinkami eilutės lūžiai, ne-ASCII failų pavadinimai arba serveriai, kurie atmeta chunked transfer encoding (HTTP be Content-Length). Pridėkite įprastas praktikos problemas individualioje įmonės programinėje įrangoje: dideli failai (CAD, PDF, skanai), svyruojantys tinklai, reverse-proxy, griežti API gateway ir administravimo reikalavimai derinimui.
Delphi pateikia su System.Net.HttpClient tinkamą stack’ą, tačiau „Happy Path“ pavyzdžiai praleidžia svarbias ribines sąlygas. Žemiau esantis kodo fragmentas sąmoningai leidžiasi giliau: mes statome Multipart kaip srautą deterministiškai, korektiškai apskaičiuojame Content-Length, palaikome RFC-5987 failų vardus ir pateikiame derinimo parinktį, kuri leidžia atkurti užklausą be TLS pertraukimo.
Architektūros sprendimas: THTTPClient vietoje Indy – ir kada tai gali pavirsti
THTTPClient (System.Net) naudoja skirtingus backend’us priklausomai nuo platformos (po Windows paprastai WinHTTP/WinINet). Įmonių aplinkose tai dažnai privalumas: proxy ir TLS politikos labiau suderinamos su sistema. Indy tuo tarpu yra labai skaidrus ir pritaikomas, bet atneša savus TLS-binding’us ir eksploatacijoje kartais „reikalauja atskiro priežiūros“ (OpenSSL versijos, Cipher-Suiten).
Šis požiūris naudoja THTTPClient, nes jis modernizacijose dažnai jau yra naudojamas (REST-klientas, OAuth, atsisiuntimai). Tačiau jei jums reikalinga griežta kontrolė TLS rankų paspaudimų, kliento sertifikatų nestandartinėms formoms arba labai specifinių proxy grandinių valdymas, Indy (arba specializuotas HTTP stack’as) gali būti prasmingas. Tai mažai keičia Multipart konstravimą – bet keičia derinimą ir eksploatavimą.
Multipart/Form-Data įkėlimas į Delphi: srautas, ne magija
Pagrindinė idėja: Multipart galiausiai yra tik baitų srautas. Jei jį susikuriame patys, galime:
- Sąmoningai pasirinkti Boundary ir patikimai ištestuoti
- Kiekvienam part’ui teisingai nustatyti antraštes (įskaitant
Content-Disposition,Content-Type) - Patikimai apskaičiuoti
Content-Length(svarbu serveriams be chunked palaikymo) - Srautiniu būdu siųsti didelius failus, nelaikant visko RAM
Kodas: Multipart-Builder su srautu ir RFC-5987 failų pavadinimais
Žemiau esantis builder’is gali generuoti arba grynai atminties pagrindu sudarytą kūną (mažiems įkėlimams), arba spool failą diske (dideliems Payload’ams). Tai atrodo „oldschool“, bet eksploatacijoje itin praktiška, nes išvengiama Chunked ir palengvinamas derinimas. Spool’inimas reiškia: galite pakartotinai naudoti tą patį užklausos kūną, net jei reikia pakartotinio bandymo.
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
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);
// Sukuria visą užklausos turinį (body) kaip Stream. Jei ASpoolToFile yra tuščias,
// naudojamas TMemoryStream; priešingu atveju sukuriamas failas.
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
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 turi būti pakankamai atsitiktinis. Svarbu: jokių tarpų.
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 antraštės yra ASCII. Kūno reikšmėms (pvz. UTF-8) nustatome Content-Type kiekvienai daliai.
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“…“ yra gerokai patikimesnis ne ASCII failų vardams nei tik 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 negali būti nil‘);
if (FileStream is TCustomMemoryStream) and (FileStream.Size = 0) then
; // leidžiama, bet dažnai klaida: tuščias failas
P := TPart.Create;
P.Kind := pkFile;
P.Name := FieldName;
P.FileName := FileName;
P.ContentType := ContentType;
P.FileStream := FileStream; // Savininkas lieka kvietėjo atsakomybėje
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
// Dėmesio: Stream pozicija bus pakeista (sunaudota).
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);
// Field-Body in UTF-8, sofern charset=utf-8 gesetzt ist.
WriterUtf8 := TEncoding.UTF8.GetBytes(P.Value);
WriteBytes(WriterUtf8);
WriteBytes(CRLF);
end
else
begin
// Du parametrai failo vardui: filename (senesniems serveriams) ir 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);
// Svarbu: nustatyti poziciją į pradžią, priešingu atveju bus įkelta tik likusi dalis.
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.
Ką kodas sąmoningai daro kitaip
- Kein „automatisches Multipart“: Kontrolė dėl Header, Encodings ir Boundary lieka pas jus. Tai dažnai yra lemiama dirbant su griežtomis REST-API.
- RFC-5987-Unterstützung über
filename*: Kai failo pavadinime yra umlautai (pvz. „Prüfbericht.pdf“), tai yra dažniausia tarpvieniavimo klaida. Kai kurie serveriai ignoruojafilename*, tada kaip atsarginis sprendimas naudojamasfilename. - Spool-to-File als Betriebsfeature: Für große Uploads und Retries ist ein wiederverwendbarer Body-Stream Gold wert.
- Content-Length ist verfügbar, weil der Body vorab erzeugt wird. Das vermeidet Chunked-Encoding, falls das Zielsystem es nicht akzeptiert.
Request senden: Timeouts, Header und eine sinnvolle Retry-Strategie
Multipart selbst löst noch nicht die Integrationsprobleme: Sie brauchen Timeouts, Fehlerklassifikation und optional Retries. Wichtig ist die Unterscheidung zwischen idempotent und nicht idempotent: Uploads sind häufig nicht idempotent (Dubletten möglich). Retries sollten daher nur erfolgen, wenn der Server eine idempotente Semantik anbietet (z. B. Upload-ID, dedizierter Idempotency-Key Header) oder Sie serverseitig Deduplizierung haben.
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;
Praktikoje pasitaikantys spąstai
- Stream-Position: Wenn der FileStream nicht auf Position 0 steht, laden Sie nur den Rest hoch. Im Builder wird daher
Seek(0)erzwungen. - Chunked vs. Content-Length: Einige Gateways (oder ältere Server-Stacks) lehnen Chunked ab. Das ist ein häufiger Legacy-Fall in prozessnahen Softwarelösungen. Spool-to-File ist dann pragmatisch.
- CRLF: Multipart erwartet CRLF (
#13#10), nicht nur LF. Manche Server sind tolerant, andere nicht. - Content-Type pro Datei: Wenn Sie pauschal
application/octet-streamsenden, ist das oft ok. Wenn der Server prüft (z. B. PDF), setzen Sie korrekt. In Delphi können Sie MIME-Mapping über eigene Tabelle oder OS-Funktionen lösen, aber verlassen Sie sich nicht blind auf Dateiendungen.
Debugging: reproduzierbarer Wire-Dump ohne TLS-Aufbruch
Per HTTPS proksyje nematysite body, jei negalite taikyti MitM (pvz., Fiddler sertifikato). Tai įmonių aplinkose yra normalu. Builder padeda, nes turite visą body srautu ir (jeigu naudojate spool failą) jis yra kaip failas.
Patikrinta praktika:
- Įrašykite spool-body į laikiną failą.
- Registruokite
Content-Type, įskaitant Boundary, irContent-Length. - Sukurkite Support/DevOps pasirenkamą
curlreprodukciją: čia nebūtina body atkurti 1:1, tačiau galite atvaizduoti parametrus ir failą(-us).
Svarbu: niekada nerašykite į žurnalus gamybinių žetonų ar asmens duomenų. Daugelyje verslo programinės įrangos integracijų būtent tai yra compliance reikšmės turintis aspektas.
Variantai: kelios bylos, neprivalomi laukai, serveris su „keistais“ lūkesčiais
Kelios bylos po tuo pačiu laukelio pavadinimu
Daugelis API tikisi files[] arba kelis kartus to paties vardo. Builder tai palaiko tiesiogiai: iškvieskite AddFile kelis kartus su tuo pačiu FieldName. Ar naudojate files, files[] ar attachments — tai grynai serverio konvencija.
Serveris reikalauja tiksliai „application/json“ kaip papildomos dalies
Bendras modelis: JSON metaduomenų blokas + failas. Tada siunčiate JSON kaip lauko dalį, bet su Content-Type: application/json; charset=utf-8. Tai nėra „form field“ UI prasme, bet multipart tai tvarkingai atvaizduoja:
MP.AddField('metadata', '{"documentType":"report","source":"delphi"}', 'application/json; charset=utf-8');
Legacy: Serveris priima tik filename, ne filename*
Tada gali padėti fallback per filename. Jei serveris neteisingai dekoduoja ne-ASCII reikšmes filename, patikimiausias sprendimas dažnai yra: serverio pusėje ignoruoti bylos pavadinimą ir vietoje to siųsti papildomą lauką originalName JSON’e.
Kontekstas modernizacijai ir eksploatavimui
Seniai susiformavusiose Delphi aplinkose Multipart dažnai yra periferinė funkcija: sąsaja į DMS, archyvą, ticketingą, Klientų portalas arba vidinis REST-serveris. Būtent ten atsiranda spaudimas dėl naujų saugumo reikalavimų (TLS, Gateways, Proxies) ir dėl didesnių bylų dydžių.
Pateiktas požiūris ypač naudingas, jei:
- turite reprodukuojamai debuginti įkėlimus (eksploatavimas/administracija)
- turite/ norite vengti „chunked“ režimo
- bylų pavadinimai/enkodingai praktiškai pasitaiko (pvz., umlautai, tarpai, skliaustai)
- retry/idempotency konceptualiai turi būti aiškiai išspręsti
Mažiau apsimoka, jei siunčiate tik nedidelius failus į tolerantišką serverį ir neturite jokios operacinės skaidrumo reikšmės. Tokiu atveju paprastas aukšto lygio sprendimas yra pakankamas — kol neatsiranda pirmasis „keistas“ failas iš verslo padalinio.
Išvada: Patikimas Multipart-įkėlimas yra srauto ir eksploatacijos problema
Tvarkingas Multipart/Form-Data įkėlimas Delphi kontekste mažiau priklauso nuo to „kuri komponentė“, o daugiau nuo kontrolės: Boundary, CRLF, failo pavadinimas, Content-Type ir, svarbiausia, deterministinis body srautas. Kas tai nuo pradžių padaro tvarkingai, vėliau sutaupo laiko derinimo cikluose su API-Gateways ir Reverse-Proxies.
Požiūrio taikymo riba: Jei turite įkelti itin didelius failus (kelių GB) be spooling ir be Content-Length, aktualizuojasi tema Srautinis perdavimas be išankstinio skaičiavimo – tuomet tiksliniai serveriai ir infrastruktūra turi patikimai palaikyti Chunked, ir jums reikia kitos derinimo koncepcijos. Daugeliui integracijų skaitmeninėse įmonių sprendimuose čia parodytas Builder yra būtent pragmatiškas vidurys tarp robustumo, atsekamumo ir kontroliuojamo išteklių suvartojimo.
Jei naudojate susiklosčiusią Delphi-integraciją, kurioje įkėlimai periodiškai nepavyksta arba tik „kai kuriems failams“, tai dažniausiai rodo būtent šias ribines sąlygas. Dėl tikslios pagalbos analizėje, modernizacijoje ar eksploatacijos išaiškinime susisiekite su mumis čia:
Profesiniame kontekste taip pat svarbų vaidmenį atlieka Delphi Thttpclient ir REST API failų įkėlimas, kai integracijos, duomenų srautai ir tolesnė plėtra turi veikti darniai.