Net-Base списание

27.05.2026

Multipart/Form-Data Upload во Delphi: робустни стримови, контрола на граници и отстранување на грешки без погодувања

Multipart/Form-Data прикачувањата изгледаат тривијални, но во Delphi брзо се распаѓаат кога станува збор за стримови, имиња на датотеки, Content-Type, ракување со boundary и timeouts. Овој исечок од изворен код покажува робусна, лесно дебагирачка имплементација со THTTPClient – вкл. коректно пресметан Content-Length...

27.05.2026

Зошто 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) или ако на серверската страна имате дедупликација.

Delphi
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) ја имате и како датотека.

Проверен пристап:

  1. Запишете го Spool-Body-то во привремена датотека.
  2. Логирајте ги Content-Type вкл. Boundary и Content-Length.
  3. За 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 може чисто да се моделира:

Delphi
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.

Сподели објава

Споделете го овој пост директно.

LinkedIn, X, XING, Facebook, WhatsApp и е-пошта се веднаш достапни. За Instagram директно подготвуваме линк и краток текст.

Е-пошта

Instagram се отвора во нов таб. Линкот и краткиот текст претходно се копираат во меѓуспремникот.