Зошто Multipart во Delphi често прво во работа „откажува“
Еден Multipart/Form-Data Upload во Delphi лесно се составува со неколку клика – и потоа во реални интеграции пропаѓа поради детали: погрешен Content-Type по Part, Boundary-стринг што случајно се појавува во payload-от, неподходни прекини на редови, имиња на датотеки кои не се ASCII или сервери кои одбиваат chunked transfer encoding (HTTP без Content-Length). Понатаму, типични практични проблеми во индивидуален корпоративен софтвер: големи датотеки (CAD, PDFs, скени), нестабилни мрежи, Reverse-Proxies, строги API-Gateways и барања од администраторите за дебагирање.
Delphi доаѓа со употреблив стек преку System.Net.HttpClient, но „Happy Path“-примерите ја оставаат важните рабни услови неизкажани. Следниот извештај од код оди намерно подлабоко: го градиме Multipart како Stream детерминистички, пресметуваме Content-Length коректно, поддржуваме RFC-5987 за имиња на датотеки и даваме опција за дебаг која го прави request-от репродуцирабилен без да морате да го «разбиете» TLS.
Архитектонска одлука: THTTPClient наместо Indy – и кога тоа станува проблем
THTTPClient (System.Net) користи различни бекенди во зависност од платформата (под Windows типично WinHTTP/WinINet). Тоа во корпоративни средини често е предност: Proxy- и TLS-политиките се поокомпатибилни со системот. Indy е многу транспарентен и прилагодлив, но носи свои TLS-bindings и во експлоатација понекогаш треба да се оддржува „одделно“ (OpenSSL-верзии, набори на шифри).
Пристапот овде ја користи THTTPClient, затоа што често е веќе во употреба при модернизации (REST-Client, OAuth, Downloads). Ако сепак ви треба строга контрола врз TLS-handshakes, клиентски сертификати во посебни форми или многу специфични proxy-ланци, Indy (или посветен HTTP-стек) може да биде поцелисходен. Тоа малку го менува начинот на градба на Multipart – но многу влијае на дебагирањето и оперативата.
Multipart/Form-Data Upload во Delphi: еден Stream, нема магија
Основната идеја: Multipart на крајот е само бајт-стрим. Ако го конструираме сами, можеме:
- свесно да избереме Boundary и да го тестираме стабилно
- заглавјата по Part правилно да ги поставиме (вкл.
Content-Disposition,Content-Type) - да ја пресметаме
Content-Lengthнадежно (важно за сервери без поддршка за chunked) - да стримираме големи датотеки, без да ги држиме сите во RAM
Кодот: Multipart-Builder со стриминг и RFC-5987-имиња на датотеки
Builder-от подолу по избор создава целосно меморијски базиран body (за мали Upload-ови) или Spool-Datei на диск (за големи payloads). Тоа делува „oldschool“, но во експлоатација е исклучително практично, бидејќи избегнува chunked и го олеснува дебагирањето. Spool-увањето значи: можете да го повторно користите истиот Request-Body дури и ако е потребен 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 во поток. Ако 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. За вредности во body-то (на пр. UTF-8) поставуваме Content-Type по Part.
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; // Сопственикот останува кај повикувачот
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.
Што кодот намерно прави поинаку
- Нема „автоматско Multipart“: Контролата над заглавија, кодирања и Boundary останува кај вас. Ова е често пресудно кај строги REST-APIs.
- RFC-5987-поддршка преку
filename*: Откако имињата на фајл ќе содржат умлаути (на пр. „Prüfbericht.pdf“), тоа е најчестиот Interop-bug. Некои сервери го игнорираатfilename*, тогашfilenameсе користи како Fallback. - Spool-to-File како оперативна карактеристика: За големи прикачувања и повторни обиди е повторно употреблив Body-Stream многу вреден.
- Content-Length е достапен, бидејќи Body се генерира однапред. Тоа ја избегнува Chunked-Encoding ако целниот систем не го прифаќа.
Слање на Request: Timeouts, заглавија и смислена стратегија за повторни обиди
Самото Multipart не ги решава интеграциските проблеми: ви требаат Timeouts, класификација на грешки и опционално повторни обиди. Важно е да се прави разлика помеѓу idempotent и не idempotent: прикачувањата често не се idempotent (могуќи се дупликати). Повторни обиди треба да се прават само ако серверот нуди idempotent семантика (на пр. Upload-ID, посебен Idempotency-Key header) или ако на серверската страна имате дедупликација.
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: поставете реалистични вредности во зависност од фајлот и линијата.
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);
// Некои сервери или проксита бараат Content-Length како задолжителен.
Req.AddHeader('Content-Length', ContentLen.ToString);
if Token <> '' then
Req.AddHeader('Authorization', 'Bearer ' + Token);
// Опционално: ако серверот правилно служи JSON, Accept може да помогне.
Req.AddHeader('Accept', 'application/json');
Result := Client.Execute(Req, nil);
finally
Body.Free;
end;
finally
Client.Free;
end;
end;
Проблеми во пракса
- Позиција на Stream: Ако FileStream не е на позиција 0, ќе прикачите само остатокот. Затоа во Builder се принудува
Seek(0). - Chunked vs. Content-Length: Некои gateway-и (или постари сервер-стекови) го одбиваат Chunked. Ова е чест legacy-случај во процесно-блиски софтверски решенија. Spool-to-File тогаш е прагматично решение.
- CRLF: Multipart очекува CRLF (
#13#10), не само LF. Некои сервери се толерантни, други не. - Content-Type по датотека: Ако генерално праќате
application/octet-stream, тоа често е ок. Ако серверот врши проверки (на пр. PDF), поставете го правилниот Content-Type. Во Delphi можете да го решите MIME-мепирањето преку сопствена табела или OS-функции, но не се потпирајте слепо на екстензии.
Дебагирање: репродуцирачки Wire-Dump без разбијање на TLS
При HTTPS не го гледате Body-то во прокси-то ако не смеете да користите MitM (напр. Fiddler-сертификат). Тоа е нормално во корпоративни опкружувања. Builder-от помага, затоа што ја имате целата содржина stream-базирано и (во случај на Spool-Datei) ја имате и како датотека.
Проверен пристап:
- Запишете го Spool-Body-то во привремена датотека.
- Логирајте ги
Content-Typeвкл. Boundary иContent-Length. - За Support/DevOps по избор креирајте едно
curl-Repro: тука не мора да го репродуцирате Body-то 1:1, но можете да ги огледате параметрите и датотеките.
Важно: никогаш не логирајте продуктивни токени или персонални податоци. Во многу бизнис-софтверски интеграции токму тоа е делот релевантен за compliance.
Варијанти: повеќе датотеки, изборни полиња, сервер со „чудни“ очекувања
Повеќе датотеки под истото име на поле
Многу API-ја очекуваат files[] или повеќекратно истото име. 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*
Тогаш помага фалбекот преку filename. Ако серверот, сепак, погрешно декодира nicht-ASCII во filename, како поотпорен пат често останува само: да се игнорираат имињата на датотеки од серверската страна и наместо тоа да се пратe дополнително поле originalName во JSON.
Поставување во контекстот на модернизација и оперативност
Во зрели Delphi-ландшафти Multipart често седи на работ: интерфејс кон DMS, архив, тикетинг, портал за клиенти или внатрешен REST-Server. Токму таму се создава притисок поради новите безбедносни барања (TLS, Gateways, Proxies) и поради поголеми големини на датотеки.
Прикажаниот пристап е особено корисен кога:
- Вие мора да ги debug-увате upload-ите репродуцибилно (експлоатација/администрација)
- Вие сакате/морате да избегнете Chunked
- Имиња на датотеки/Encodings се појавуваат во пракса (умлаути, празни места, загради)
- Retry/Idempotency треба концептуално чисто да се реши
Тоа е помалку исплатливо ако испраќате исклучиво мали датотеки до толерантен сервер и не ви треба оперативна транспарентност. Тогаш едноставно High-Level решение е доволно – додека не дојде првата „чудна“ датотека од стручната единица.
Заклучок: Стабилен Multipart-Upload е проблем на стриминг и експлоатација
Чист Multipart/Form-Data Upload во Delphi е помалку прашање „која компонента“ и повеќе прашање на контрола: Boundary, CRLF, Dateiname, Content-Type и пред сè детерминистички Body-Stream. Кој тоа рано и чисто го изгради, ќе заштеди време подоцна во debug-цикли со API-Gateways и Reverse-Proxies.
Граница на применливоста на пристапот: Ако треба да прикачувате екстремно големи датотеки (повеќе GB) без spooling и без Content-Length, станува релевантна темата стриминг без предходна пресметка – тогаш целните сервери и инфраструктурата мораат сигурно да ја поддржуваат Chunked, и ви треба поинаков концепт за дебагирање. За многу интеграции во дигитални корпоративни решенија, сепак, овде прикажаниот Builder е точната прагматична средина помеѓу робусност, следливост и контролирана потрошувачка на ресурси.
Ако сте врзани за постоечка Delphi-интеграција при која прикачувањата повремено не успеваат или само „со некои датотеки“, тоа најчесто е индикатор за точно овие гранични услови. За целисходна поддршка при анализа, модернизација или разјаснување на оперативни прашања, контактирајте не тука:
Во техничкиот контекст, исто така, Delphi Thttpclient и REST API за прикачување на датотеки играат важна улога кога интеграциите, тековите на податоци и понатамошниот развој треба да соработуваат без нарушувања.
Разговарајте за проект или модернизациски потфат со Net-Base.