Net-Base Магазин

27.05.2026

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

Multipart/Form-Data отпремања делују тривијално, али у Delphi у пракси брзо затајеју када су у питању стримови, имена фајлова, Content-Type, Boundary-Handling и timeout-и. Овај исечак извора показује робусну, лако дебагујиву имплементацију са THTTPClient — укључујући исправно израчунату Content-Length...

27.05.2026

Зашто се 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.

Delphi
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šu filename*, u tom slučaju koristi se filename kao 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.

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: 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) као фајл.

Препоручени поступак:

  1. Запишите Spool-Body у привремени фајл.
  2. Забележите Content-Type укључујући Boundary и Content-Length.
  3. Направите по потреби за 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-у чисто приказује:

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

Подели објаву

Поделите ову објаву директно

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

Е-пошта

Инстаграм се отвара у новој картици. Линк и кратак текст се претходно копирају у међуспремник.