Kāpēc Multipart in Delphi bieži vien darbībā „sabrūk”
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);
// Izveido pilnu Body kā straumi. Ja ASpoolToFile ir tukšs,
// tiek izmantots TMemoryStream; pretējā gadījumā tiek izveidots fails.
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 jābūt pietiekami nejaušam. Svarīgi: bez atstarpēm.
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 galvenes ir ASCII. Vērtībām body (piem., UTF-8) mēs katram part norādām Content-Type.
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''..." ir daudz robustāks, ja faila nosaukumā ir ne-ASCII rakstzīmes, nekā tikai 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 nedrīkst būt nil');
if (FileStream is TCustomMemoryStream) and (FileStream.Size = 0) then
; // atļauts, bet bieži kļūda: tukšs fails
P := TPart.Create;
P.Kind := pkFile;
P.Name := FieldName;
P.FileName := FileName;
P.ContentType := ContentType;
P.FileStream := FileStream; // Owner bleibt beim Aufrufer
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
// Uzmanību: straumes pozīcija tiks patērēta.
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);
// Lauka Body kā UTF-8, ja charset=utf-8 ir iestatīts.
WriterUtf8 := TEncoding.UTF8.GetBytes(P.Value);
WriteBytes(WriterUtf8);
WriteBytes(CRLF);
end
else
begin
// Divi faila nosaukuma parametri: filename (vecākiem serveriem) un 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);
// Svarīgi: iestatīt pozīciju uz sākumu, citādi tiks augšupielādētas tikai atlikušie dati.
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.
Ko kods apzināti dara citādāk
- Bez „automātiskas Multipart”: Kontrole pār galvenēm, kodējumiem un boundary paliek pie jums. Pie stingrām REST-API tas bieži ir izšķiroši.
- RFC-5987 atbalsts izmantojot
filename*: Kad faila nosaukumā ir umlauti (piem., „Prüfbericht.pdf”), tas ir visbiežāk sastopamais interoperabilitātes kļūdas iemesls. Daži serveri ignorēfilename*, tad kā rezerves variants tiek izmantotsfilename. - Spool-to-File kā ekspluatācijas funkcija: lieliem augšupielādes apjomiem un atkārtotām pārsūtīšanas reizēm atkārtoti izmantojams Body-Stream ir ārkārtīgi vērtīgs.
- Content-Length ir pieejams, jo Body tiek izveidots iepriekš. Tas izvairās no Chunked-Encoding, ja mērķsistēma to nepieņem.
Pieprasījuma sūtīšana: Timeouti, Header un jēdzīga retry-stratēģija
Multipart pats par sevi neatrisina integrācijas problēmas: jums nepieciešami timeouti, kļūdu klasifikācija un pēc izvēles atkārtoti mēģinājumi. Svarīgi ir atšķirt idempotents un ne-idempotents: augšupielādes bieži nav idempotentas (var rasties dublikāti). Tādēļ atkārtotus mēģinājumus vajadzētu veikt tikai, ja serveris nodrošina idempotentu semantiku (piem., Upload-ID, dedizēts Idempotency-Key Header) vai jums servera pusē ir deduplikācija.
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
// Timeouti: iestatīt reālistiski atkarībā no faila un tīkla pieslēguma.
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);
// Daži serveri vai starpniekserveri pieprasa Content-Length obligāti.
Req.AddHeader('Content-Length', ContentLen.ToString);
if Token <> '' then
Req.AddHeader('Authorization', 'Bearer ' + Token);
// Pēc izvēles: ja serveris atgriež tīru JSON, Accept var palīdzēt.
Req.AddHeader('Accept', 'application/json');
Result := Client.Execute(Req, nil);
finally
Body.Free;
end;
finally
Client.Free;
end;
end;
Biežākās kļūdas praksē
- Stream-Position: Ja FileStream nav pozīcijā 0, tiks augšupielādēta tikai atlikušie dati. Tāpēc builderī tiek izsaukts
Seek(0). - Chunked vs. Content-Length: Dažas vārtejas (vai vecākas serveru platformas) nepieņem chunked. Tā ir bieža legacy situācija procesiem tuvos programmatūras risinājumos. Spool-to-File šeit ir pragmatiska pieeja.
- CRLF: Multipart sagaida CRLF (
#13#10), ne tikai LF. Daži serveri ir tolerantāki, citi nē. - Content-Type katram failam: Ja sūtāt vispārīgi
application/octet-stream, tas bieži ir pieņemami. Ja serveris pārbauda (piem., PDF), iestatiet pareizi. In Delphi varat risināt MIME-mapping ar savu tabulu vai OS funkcijām, bet nepaļaujieties akli uz faila paplašinājumiem.
Debugging: reproducējams Wire-Dump bez TLS-Aufbruch
Ar HTTPS jūs Proxy neredzat Body, ja nedrīkstat izmantot MitM (piem., Fiddler‑sertifikātu). Tas ir uzņēmuma vidē normāli. Builder palīdz, jo jums ir pilns Body kā straumes plūsma un (ja Spool‑fails) tas ir pieejams kā fails.
Pārbaudīta prakse:
- Ierakstiet Spool‑Body pagaidu failā.
- Reģistrējiet
Content-Typeieskaitot Boundary unContent-Length. - Izveidojiet atbalstam/DevOps pēc izvēles
curl-repro: šeit nav jāatveido Body 1:1, bet varat atspoguļot parametrus un failu(s).
Svarīgi: Nekad nereģistrējiet produktīvās Tokens vai personas datus. Daudzās biznesa programmatūras integrācijās tieši tas ir atbilstības (compliance) ziņā nozīmīgā daļa.
Varianti: vairāki faili, izvēles lauki, serveris ar „dīvainām“ gaidām
Vairāki faili ar to pašu lauka nosaukumu
Daudzas API sagaida files[] vai vairākkārt tādu pašu nosaukumu. Builder to atbalsta tieši: izsauciet AddFile vairākas reizes ar to pašu FieldName. Vai izmantojat files, files[] vai attachments, ir tikai servera konvencija.
Servers pieprasa tieši „application/json“ kā papildu part
Izplatīts modelis: JSON metadatu bloks plus fails. Tad sūtiet JSON kā lauka part, bet ar Content-Type: application/json; charset=utf-8. Tas nav „Form Field“ UI izpratnē, bet Multipart to var skaidri attēlot:
MP.AddField('metadata', '{"documentType":"report","source":"delphi"}', 'application/json; charset=utf-8');
Legacy: Server pieņem tikai filename, nevis filename*
Tad palīdz fallback, izmantojot filename. Ja servers tomēr ne-ASCII vērtības filename kļūdaini dekodē, bieži vien vienīgais robustais ceļš ir ignorēt faila nosaukumu servera pusē un tā vietā sūtīt papildu lauku originalName JSON.
Novietojums modernizācijai un ekspluatācijai
Gadījumos ar izveidojušām Delphi ainām Multipart bieži atrodas perifērijā: saskarne uz DMS, arhīvu, ticketing, Klientu portāls vai iekšējs REST-Server. Tieši tur rodas spiediens no jauniem drošības prasījumiem (TLS, Gateways, Proxies) un no lielākiem failu izmēriem.
Šī pieeja ir īpaši pamatota, ja:
- Jums jāspēj reproducējami debugot augšupielādes (operācijas/administrācija)
- Jūs vēlaties/jums jāizvairās no Chunked
- Failu nosaukumi/enkodējumi praksē patiešām parādās (diakritika, atstarpes, iekavas)
- Retry/Idempotency konceptuāli skaidri jāatrisina
Tas mazāk atmaksājas, ja jūs sūtāt tikai nelielus failus uz tolerantu serveri un jums nav nepieciešama darbības caurspīdība. Tad vienkārša augsta līmeņa risinājuma pietiek — līdz brīdim, kad no nodaļas ienāk pirmais „dīvainais“ fails.
Secinājums: stabils Multipart-Upload ir straumēšanas un ekspluatācijas problēma
Kārtīgs Multipart/Form-Data Upload in Delphi nav tik ļoti jautājums par “kuru komponenti”, cik par kontroli: Boundary, CRLF, faila nosaukums, Content-Type un, galvenokārt, deterministisks Body‑straumes plūsma. Kurš to agri izveido kārtīgi, vēlāk ietaupa laiku debugošanas cilpās ar API‑Gateways un Reverse‑Proxies.
Šīs pieejas pielietojuma robeža: Ja jums jāaugšupielādē ārkārtīgi lielas datnes (vairāki GB) bez spooling un bez Content-Length, kļūst aktuāls jautājums par Streaming ohne Vorabberechnung – tad mērķserveriem un infrastruktūrai jāatbalsta Chunked uzticami, un jums nepieciešama cita veida atkļūdošanas koncepcija. Daudzām integrācijām digitālajos uzņēmuma risinājumos tomēr šeit parādītais Builder ir tieši pragmatisks kompromiss starp robustumu, izsekojamību un kontrolējamu resursu patēriņu.
Ja jūsu attīstītā Delphi integrācija ir tāda, pie kuras augšupielādes sporādiski neizdodas vai notiek tikai “ar dažām datnēm”, tas parasti norāda uz tieši šādiem robežnosacījumiem. Lai saņemtu mērķtiecīgu atbalstu analīzei, modernizācijai vai ekspluatācijas noskaidrošanai, sasniedziet mūs šeit:
Tehniskajā kontekstā arī Delphi Thttpclient un REST API failu augšupielāde spēlē svarīgu lomu, ja integrācijām, datu plūsmām un turpmākai attīstībai jādarbojas saskaņoti.