Зашто се Multipart у Delphi често тек у раду „поквари“
Један Multipart/Form-Data upload у Delphi се брзо направи кликом – али у реалним интеграцијама зачепи због детаља: погрешан Content-Type по делу, Boundary-String који се случајно јави у payload-у, неприкладни прекиди реда, имена фајлова која нису ASCII или сервери који одбијају chunked transfer encoding (HTTP без Content-Length). Поред тога долазе типични практични проблеми у индивидуалном корпоративном софтверу: велики фајлови (CAD, PDF-ови, скенирани документи), нестабилне мреже, reverse-proxy-ји, строги API-gateway-ји и захтеви администратора за отклањање грешака.
Delphi доноси са собом System.Net.HttpClient користан стек, али примери „Happy Path“ остављају важне граничне случајеве неразрађеним. Следећи исечак из извора намерно иде дубље: градимо Multipart као stream детерминистички, израчунавајемо Content-Length правилно, подржавамо RFC-5987 за имена фајлова и обезбеђујемо опцију за дебаг која чини захтев репродуцибилним без потребе да рушите TLS.
Архитектонска одлука: THTTPClient уместо Indy – и када то закаже
THTTPClient (System.Net) користи у зависности од платформе различите бекенде (под Windows типично WinHTTP/WinINet). То је у корпоративним окружењима често повољно: прокси и TLS политике су углавном компатибилније са системом. Indy је за то веома транспарентан и прилагодљив, али доноси сопствене TLS биндинге и у раду се понекад мора „одвојено одржавати“ (OpenSSL-верзије, Cipher-сuite-ови).
Приступ овде користи THTTPClient, јер се он у модернизацијама често већ користи (REST-Client, OAuth, Downloads). Ако вам међутим треба строга контрола над TLS-handshake-овима, клијентским сертификатима у посебним формама или веома специфичним ланцима проксија, Indy (или посвећени HTTP-стек) може бити погодан. То мало мења у изградњи Multipart-а – али мења дебаговање и рад у оперативи.
Multipart/Form-Data upload у Delphi: један stream, нема магије
Кључна идеја: Multipart је на крају само бајт-ток. Ако га сами изграђујемо, можемо:
- Свесно изабрати Boundary и темељно га тестирати
- Подесити заглавља по делу правилно (укључујући
Content-Disposition,Content-Type) - Поуздано израчунати
Content-Length(важно за сервере без подршке за chunked) - Стримовати велике фајлове без држања свега у RAM-у
Код: Multipart-Builder са стримовањем и RFC-5987 именима фајлова
Билдер испод генерише по избору или потпуно меморијски базирано тело (за мале уплоаде) или spool-фајл на диску (за велике payload-е). То делује „oldschool“, али је у раду изузетно практично јер избегава chunked и олакшава отклањање грешака. Spooling значи: можете поново користити исто тело захтева чак и ако је потребан retry.
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);
// Саставља цео body у TStream. Ако је ASpoolToFile празан,
// користи се TMemoryStream; у супротном се креира датотека.
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 треба да буде довољно случајан. Важно: без размака.
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 заглавља су ASCII. За вредности у телу (нпр. UTF-8) постављамо 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''..." је знатно робуснији за имена датотека која нису ASCII у односу на само 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 не сме бити nil');
if (FileStream is TCustomMemoryStream) and (FileStream.Size = 0) then
; // дозвољено, али често грешка: празна датотека
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
// Пажња: позиција стрима ће бити потрошена.
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);
// Тело поља у UTF-8 ако је charset=utf-8 постављен.
WriterUtf8 := TEncoding.UTF8.GetBytes(P.Value);
WriteBytes(WriterUtf8);
WriteBytes(CRLF);
end
else
begin
// Два параметра имена датотеке: filename (за старије сервере) и 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);
// Важно: позицију поставити на почетак, иначе ће се послати само преостали подаци.
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.
Šta kod namerno radi drugačije
- Bez „automatskog multipart“-a: Kontrola nad Header-ima, enkodinzima i Boundary ostaje kod vas. To je često presudno kod striktnih REST-API-ja.
- Podrška za RFC-5987 über
filename*: Kad nazivi fajlova sadrže dijakritičke znakove (npr. „Prüfbericht.pdf“), to je najčešća greška interoperabilnosti. Neki serveri ignorišufilename*, u tom slučaju koristi sefilenamekao fallback. - Spool-to-File kao operativna funkcionalnost: Za velike upload-e i ponovne pokušaje, ponovo upotrebljiv Body-Stream je izuzetno vredan.
- Content-Length je dostupan, jer se Body unapred generiše. To izbegava Chunked-Encoding ukoliko ciljni sistem to ne prihvata.
Slanje zahteva: Timeouts, Header i smislena strategija ponovnog pokušaja
Multipart sam po sebi ne rešava integracione probleme: potrebni su vam timeouts, klasifikacija grešaka i opcionalno ponovni pokušaji. Važna je razlika između idempotentnog i ne-idempotentnog: upload-i često nisu idempotentni (mogu se pojaviti duplikati). Pokušaji ponovnog slanja treba da se izvršavaju samo ako server nudi idempotentnu semantiku (npr. Upload-ID, dedikovani Idempotency-Key Header) ili ako 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 postavljen na poziciju 0, uploadujete samo ostatak. Zato Builder forsira
Seek(0). - Chunked vs. Content-Length: Neki gateway-i (ili stariji serverski stack-ovi) odbijaju Chunked. To je čest legacy slučaj u softverskim rešenjima bliskim procesu. Spool-to-File je tada pragmatično rešenje.
- CRLF: Multipart očekuje CRLF (
#13#10), ne samo LF. Neki serveri su tolerantni, drugi nisu. - Content-Type po fajlu: Ako po defaultu pošaljete
application/octet-stream, to često prođe. Ako server vrši proveru (npr. za PDF), postavite ispravan tip. U Delphi možete rešiti MIME-mapping preko sopstvene tabele ili OS-funkcija, ali se nemojte slepo oslanjati na ekstenzije datoteka.
Debugovanje: reproduktivni Wire-Dump bez prekidanja TLS-a
При HTTPS не видите body у проксију ако не можете да користите MitM (нпр. Fiddler-Zertifikat). То је у корпоративним окружењима нормално. Der Builder помаже јер имате цео body стримски доступан и (у случају Spool-Datei) као фајл.
Препоручени поступак:
- Запишите Spool-Body у привремени фајл.
- Забележите
Content-Typeукључујући Boundary иContent-Length. - Направите по потреби за Support/DevOps
curl-репро: овде не морате да вратите body 1:1, али можете да репликујете параметре и фајл(ове).
Важно: никада не логовајте продуктивне токене или персоналне податке. У многим интеграцијама бизнис софтвера управо је то део који је релевантан за усаглашеност.
Варијанте: више фајлова, опциони фелдови, сервер са „чудним“ очекивањима
Више фајлова под истим именом поља
Многи API-ји очекују files[] или више пута исти назив. Der Builder то директно подржава: позовите AddFile више пута са истим FieldName. Да ли користите files, files[] или attachments је чисто серверска конвенција.
Сервер захтева тачно „application/json“ као додатни Part
Чест образац: JSON-блок метаподатака плус фајл. У том случају пошаљете JSON као Field-Part, али са Content-Type: application/json; charset=utf-8. То није „Form Field“ у смислу UI, али се у Multipart-у чисто приказује:
MP.AddField('metadata', '{"documentType":"report","source":"delphi"}', 'application/json; charset=utf-8');
Legacy: Сервер прихвата само filename, не filename*
У том случају помаже fallback преко filename. Ако сервер међутим погрешно декодује nicht-ASCII у filename, као робусно решење често остаје једино: игнорисати називе фајлова на серверској страни и уместо тога послати додатно поље originalName у JSON-у.
Разматрање у контексту модернизације и операција
У развијеним Delphi-ландшафтима Multipart често стоји на ивици: интерфејс ка DMS, архиви, тикетингу, Korisnički portal или унутрашњи REST-сервер. Управо тамо настаје притисак услед нових безбедносних захтева (TLS, Gateways, Proxies) и због већих величина фајлова.
Предложени приступ се посебно исплати када:
- морате репродуковати upload-ове за отклањање грешака (операције/администрација)
- желите/морате избегавати Chunked
- у пракси се заиста појављују називи фајлова/енкодирања (умлаути, размаци, заграде)
- retry/idempotency треба концептуално чисто решење
Мање се исплати ако пошаљете искључиво мале фајлове на толерантан сервер и уопште вам није потребна оперативна транспарентност. Тада је једноставно High-Level решење довољно – све док не стигне први „чудни“ фајл из стручног одељења.
Закључак: стабилан Multipart-Upload је питање стриминга и операција
Чист Multipart/Form-Data upload у Delphi мање је питање „која компонента“ а више контроле: Boundary, CRLF, назив фајла, Content-Type и пре свега детерминистички body-стрим. Ко то рано правилно изгради, уштедеће касније време у петљама отклањања грешака са API-Gateways и Reverse-Proxies.
Граница применљивости приступа: Ако морате да отпремате екстремно велике датотеке (неколико GB) без Spooling и без Content-Length, постаје релевантно питање стриминг без претходног израчунавања — тада циљни сервери и инфраструктура морају поуздано да подржавају Chunked, и потребан вам је другачији концепт отклањања грешака. За многе интеграције у дигиталним пословним решењима, међутим, овде приказани Builder представља управо прагматичну средину између робусности, проверљивости и контролисане потрошње ресурса.
Ако сте везани за развијену Delphi-интеграцију која има повремене неуспехе при отпремању или пада само „код неких датотека“, то је обично индикатор управо тих граничних услова. За циљану подршку у анализи, модернизацији или разјашњавању операција можете нас контактирати овде:
У стручној области значајну улогу играју и Delphi Thttpclient и REST API за upload датотека, када интеграције, токови података и даљи развој морају да функционишу усклађено.
Разговарајте о пројекту или модернизационом подухвату са Net-Base.