Net-Base Časopis

27.05.2026

Multipart/Form-Data otpremanje u Delphi: robustni streamovi, kontrola boundary-a i otklanjanje grešaka bez nagađanja

Multipart/Form-Data prijenosi djeluju trivijalno, ali u Delphi brzo zakažu pri radu sa streamovima, imenima datoteka, Content-Type, Boundary-Handling i timeoutima. Ovaj isječak izvornog koda prikazuje robusnu, lako otklonjivu implementaciju s THTTPClient – uključujući ispravno izračunat Content-Length...

27.05.2026

Zašto Multipart u Delphi često tek u produkciji „pokvari“

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.

Arhitektonska odluka: THTTPClient umjesto Indy – i kada to postane problem

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-Length zuverlä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.

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);

    // Sastavlja kompletan body u TStream. Ako je ASpoolToFile prazan,
    // koristi se TMemoryStream; u suprotnom se kreira datoteka.
    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 treba biti dovoljno nasumičan. Važno: bez razmaka.
  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 zaglavlja su ASCII. Za vrijednosti u tijelu (npr. UTF-8) postavljamo Content-Type za svaki dio.
  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''..." je znatno robusniji za ne-ASCII imena datoteka nego samo 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 ne smije biti nil');

  if (FileStream is TCustomMemoryStream) and (FileStream.Size = 0) then
    ; // dozvoljeno, ali često greška: prazna datoteka

  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
    // Pažnja: pozicija Streama će biti pomjerena.
    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);
        // Sadržaj polja u UTF-8, ako je charset=utf-8 postavljen.
        WriterUtf8 := TEncoding.UTF8.GetBytes(P.Value);
        WriteBytes(WriterUtf8);
        WriteBytes(CRLF);
      end
      else
      begin
        // Dva parametra imena datoteke: filename (za stare servere) i 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);

        // Važno: postaviti poziciju na početak, inače će biti poslani samo preostali podaci.
        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 namjerno drugačije radi

  • Nema „automatskog Multipart“: Kontrola nad zaglavljima, kodiranjima i boundary ostaje vama. To je često presudno kod striktnih REST-API-ja.
  • Podrška za RFC-5987 preko filename*: Kada nazivi fajlova sadrže dijakritičke znakove (npr. „Prüfbericht.pdf“), to je najčešći interop-bug. Neki serveri ignorišu filename*, tada se kao fallback koristi filename.
  • Spool-to-File kao operativna funkcija: Za velike upload-e i ponovne pokušaje je ponovno upotrebljiv stream tijela (body-stream) izuzetno vrijedan.
  • Content-Length je dostupan, jer se body generiše unaprijed. To izbjegava Chunked-Encoding ako ciljni sistem to ne prihvata.

Slanje zahtjeva: Timeout-i, zaglavlja i smislena strategija ponovnih pokušaja

Samo multipart ne rješava integracijske probleme: potrebni su vam timeout-i, klasifikacija grešaka i opcionalni ponovni pokušaji. Važno je razlikovati između idempotentno i neidempotentno: upload-i često nisu idempotentni (mogu nastati duplikati). Ponovni pokušaji trebaju se izvoditi samo ako server pruža idempotentnu semantiku (npr. Upload-ID, posvećeno Idempotency-Key zaglavlje) 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 na poziciji 0, učitat ćete samo preostali dio. Zato Builder prisilno poziva Seek(0).
  • Chunked vs. Content-Length: Neki gateway-evi (ili stariji server-stackovi) odbacuju Chunked. To je čest legacy slučaj u softverskim rješenjima bliskim procesu. Spool-to-File je tada pragmatično rješenje.
  • CRLF: Multipart očekuje CRLF (#13#10), ne samo LF. Neki serveri su tolerantni, drugi nisu.
  • Content-Type po fajlu: Ako po defaultu šaljete application/octet-stream, to je često u redu. Ako server provjerava (npr. PDF), postavite ispravan tip. U Delphi možete riješiti mapiranje MIME tipova preko vlastite tabele ili OS-funkcija, ali se nemojte slijepo oslanjati na ekstenzije fajlova.

Debugging: reproducibilan wire-dump bez prekidanja TLS-a

Pri HTTPS-u ne vidite body u proxyju ako ne smijete koristiti MitM (npr. Fiddler-Zertifikat). To je u korporativnim okruženjima normalno. Der Builder pomaže, jer posjedujete kompletan body stream-based i (kod Spool-Datei) imate ga kao datoteku.

Preporučeni postupak:

  1. Zapišite Spool-Body u privremenu datoteku.
  2. Logirajte Content-Type uključujući Boundary i Content-Length.
  3. Izradite za Support/DevOps opcionalno jedno curl-Repro: ovdje ne morate vratiti body 1:1, ali možete preslikati parametre i datoteku(e).

Važno: Nikada ne logirajte proizvodne tokene ili lične podatke. U mnogim poslovnim softverskim integracijama upravo je to dio relevantan za usklađenost.

Varijante: više datoteka, opcionalna polja, server sa „čudnim“ očekivanjima

Više datoteka pod istim imenom polja

Mnogi API-ji očekuju files[] ili više puta isto ime. Der Builder to direktno podržava: pozovite AddFile više puta sa istim FieldName. Da li ćete koristiti files, files[] ili attachments čisto je konvencija servera.

Server zahtijeva tačno „application/json“ kao dodatni part

Uobičajeni obrazac: JSON-blok metapodataka plus datoteka. Tada pošaljete JSON kao field-part, ali sa Content-Type: application/json; charset=utf-8. To nije „Form Field“ u smislu UI-ja, ali se u Multipartu uredno može prikazati:

Delphi
MP.AddField('metadata', '{"documentType":"report","source":"delphi"}', 'application/json; charset=utf-8');

Legacy: Server prihvata samo filename, ne filename*

Tada pomaže fallback preko filename. Ako server međutim ne-ASCII u filename pogrešno dekodira, kao robustan put često ostaje: ignorirati ime datoteke na serverskoj strani i umjesto toga poslati dodatno polje originalName u JSON-u.

Kontekst za modernizaciju i operativu

U etabliranim Delphi-okruženjima Multipart često stoji na rubu: sučelje prema DMS-u, arhivu, ticketingu, Kundenportal ili interni REST-Server. Upravo tamo nastaje pritisak zbog novih sigurnosnih zahtjeva (TLS, gatewayi, proxyji) i zbog većih veličina datoteka.

Predloženi pristup se posebno isplati kada:

  • Morate uploadove reproducibilno debugirati (operacije/administracija)
  • Želite/trebate izbjegavati Chunked
  • Imena datoteka/encodiranja se u praksi zaista pojavljuju (Umlaute, razmaci, zagrade)
  • Retry/Idempotency treba konceptualno čisto biti riješen

Manje se isplati ako šaljete isključivo male datoteke na tolerantni server i ne trebate nikakvu operativnu transparentnost. Tada je jednostavno High-Level rješenje dovoljno – dok ne stigne prva „čudna“ datoteka iz poslovne jedinice.

Zaključak: Stabilan Multipart-Upload je problem streaminga i operacija

Ispravan Multipart/Form-Data upload u Delphi manje je pitanje „koje komponente“ nego kontrole: Boundary, CRLF, naziv datoteke, Content-Type i prije svega deterministički body-stream. Ko to rano pravilno implementira, uštediće kasnije vrijeme u debugging-petljama sa API-Gateways i Reverse-Proxies.

Granica primjene pristupa: Ako morate otpremati izuzetno velike datoteke (nekoliko GB) bez spooling i bez Content-Length, postaje relevantna tema streaming bez prethodnog izračuna – tada ciljni serveri i infrastruktura moraju pouzdano podržavati chunked, i potreban vam je drugačiji koncept za otklanjanje grešaka. Za mnoge integracije u digitalnim poslovnim rješenjima, međutim, ovdje prikazani Builder predstavlja upravo pragmatičnu sredinu između robustnosti, mogućnosti praćenja i kontroliranog korištenja resursa.

Ako se oslanjate na postojeću Delphi-integraciju pri kojoj otpremanja povremeno ne uspijevaju ili samo „kod nekih datoteka“, to je obično indikator upravo ovih graničnih uvjeta. Za ciljanu podršku pri analizi, modernizaciji ili razjašnjenju operativnih pitanja možete nas kontaktirati ovdje:

U stručnom kontekstu također važnu ulogu igraju Delphi Thttpclient i REST API prijenos datoteka, kada integracije, tokovi podataka i dalji razvoj moraju besprijekorno surađivati.

Raspravite projekt ili plan modernizacije s Net-Base.

Podijeli objavu

Ovu objavu direktno proslijediti

LinkedIn, X, XING, Facebook, WhatsApp i e-pošta su odmah dostupni. Za Instagram pripremamo link i kratak tekst.

E-pošta

Instagram se otvara u novom tabu. Link i kratak tekst se prethodno kopiraju u međuspremnik.