Net-Base Revija

27.05.2026

Multipart/Form-Data nalaganje v Delphi: robustni tokovi, nadzor mejne ločnice in odpravljanje napak brez ugibanja

Nalaganja Multipart/Form-Data se zdijo trivialna, a v Delphi hitro odpovejo pri tokih, imenih datotek, Content-Type, upravljanju mejnih oznak in časovnih omejitvah. Ta izsek izvorne kode prikazuje robustno, enostavno za razhroščevanje implementacijo z THTTPClient – vključno s pravilno izračunano Content-Length.

27.05.2026

Od teme v reviji do projektne prakse

Ustrezne strani storitev in tehnični opisi k prispevku

Zakaj Multipart v Delphi pogosto šele v obratovanju odpove

Ein Multipart/Form-Data Upload in Delphi je hitro sestavljen – in nato v realnih integracijah spodleti zaradi detajlov: napačen Content-Type za posamezen del, Boundary-niz, ki se pomotoma pojavi v vsebini (payload), neustrezni prelomi vrstic, datotečna imena, ki niso v ASCII, ali strežniki, ki zavračajo chunked transfer encoding (HTTP brez Content-Length). Poleg tega so tipične praktične težave v individualni poslovni programski opremi: velike datoteke (CAD, PDF, skenirani dokumenti), nihanja omrežja, Reverse-Proxies, strogi API-gatewayi in skrbniške zahteve glede razhroščevanja.

Delphi prinaša z System.Net.HttpClient uporaben sloj, vendar pa „Happy Path“ primeri puščajo pomembne robne pogoje neobdelane. Naslednji izsek iz izvorne kode gre namensko globlje: Multipart zgradimo kot tok deterministično, Content-Length izračunamo pravilno, podpiramo RFC-5987 za imena datotek in ponudimo debug-opcijo, ki zahtevek reproducira, brez da bi bilo treba razbijati TLS.

Arhitekturna odločitev: THTTPClient namesto Indy – in kdaj ta izbira zataji

THTTPClient (System.Net) uporablja glede na platformo različna ozadja (na Windows običajno WinHTTP/WinINet). To je v podjetniških okoljih pogosto koristno: politike proxyjev in TLS so bolj kompatibilne s sistemom. Indy je za to zelo pregleden in prilagodljiv, vendar prinaša svoja TLS-vezja in ga je v obratovanju včasih treba „ločeno vzdrževati“ (OpenSSL-verzije, nabore šifer).

Pristop tukaj uporablja THTTPClient, ker je pri modernizacijah pogosto že v uporabi (REST-Client, OAuth, prenosi). Če pa potrebujete strogo kontrolo TLS-handshakov, odjemalske certifikate v posebnih oblikah ali zelo specifične proxy-verige, je lahko smiselno uporabiti Indy (ali namenski HTTP-sloj). To pri sami izgradnji Multipart-a spremeni malo – vpliva pa na razhroščevanje in obratovanje.

Multipart/Form-Data prenos v Delphi: tok podatkov, nič magije

Glavna zamisel: Multipart je na koncu le bajtni tok. Če ga zgradimo sami, lahko:

  • Boundary zavestno izberemo in ga zanesljivo testiramo
  • nastavimo header za vsak del pravilno (vključno z Content-Disposition, Content-Type)
  • Content-Length zanesljivo izračunamo (pomembno za strežnike brez podpore za chunked)
  • pretakamo velike datoteke, brez da bi vse držali v RAM-u

Der Code: Multipart-Builder mit Streaming und RFC-5987-Dateinamen

Graditelj spodaj po izbiri ustvari bodisi popolnoma v pomnilniku temelječ body (za majhne prenose) ali pa Spool-Datei na disku (za velike payload-e). To deluje „starošolsko“, je pa v obratovanju izjemno praktično, ker se izogne chunked in olajša razhroščevanje. Spoolanje pomeni: lahko ponovno uporabite isto telo zahtevka, tudi če je potreben ponovni poskus.

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

    // Sestavi kompleten body v tok. Če je ASpoolToFile prazen,
    // se uporabi TMemoryStream; sicer se ustvari 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 naj bo dovolj naključen. Pomembno: brez presledkov.
  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-Header so ASCII. Za vrednosti v telesu (npr. UTF-8) nastavimo Content-Type na posameznem delu.
  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 za ne-ASCII imena datotek bistveno bolj robusten kot 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 sme biti nil');

  if (FileStream is TCustomMemoryStream) and (FileStream.Size = 0) then
    ; // dovoljeno, vendar pogosto napaka: prazna datoteka

  P := TPart.Create;
  P.Kind := pkFile;
  P.Name := FieldName;
  P.FileName := FileName;
  P.ContentType := ContentType;
  P.FileStream := FileStream; // Owner ostane pri klicatelju
  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
    // Pozor: položaj toka bo premaknjen.
    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);
        // Telo polja v UTF-8, če je charset=utf-8 nastavljen.
        WriterUtf8 := TEncoding.UTF8.GetBytes(P.Value);
        WriteBytes(WriterUtf8);
        WriteBytes(CRLF);
      end
      else
      begin
        // Dva parametra z imenom datoteke: filename (za stare strežnike) in 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);

        // Pomembno: položaj nastaviti na začetek, sicer se naložijo le preostanki.
        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.

Kaj koda zavestno naredi drugače

  • Brez „samodejnega Multipart“: Nadzor nad headerji, kodiranji in boundary ostane pri vas. To je pri striktnih REST-API-jih pogosto odločilno.
  • Podpora RFC-5987 preko filename*: Ko imena datotek vsebujejo Umlaute (npr. „Prüfbericht.pdf“), je to najpogostejši interoperabilnostni bug. Nekateri strežniki ignorirajo filename*, potem velja filename kot fallback.
  • Spool-to-File kot obratovalna funkcija: Za velike prenose in retries je ponovno uporabni body-stream zlata vredna.
  • Content-Length je na voljo, ker je telo ustvarjeno vnaprej. To prepreči Chunked-Encoding, če ciljni sistem tega ne sprejema.

Pošiljanje zahtevka: časovne omejitve, headerji in smiselna strategija ponovnih poizkusov

Sam multipart še ne reši integracijskih težav: potrebujete časovne omejitve, klasifikacijo napak in po potrebi retries. Pomembna je razlika med idempotentnim in ne-idempotentnim: nalaganja (Uploads) pogosto niso idempotentna (možne podvojitve). Ponovne poizkuse zato izvajajte le, če strežnik ponuja idempotentno semantiko (npr. Upload-ID, namenski Idempotency-Key header) ali če na strežni strani izvajate deduplikacijo.

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: nastaviti realistične vrednosti glede na datoteko in povezavo.
    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);
      // Nekateri strežniki ali proxy-ji pričakujejo Content-Length obvezno.
      Req.AddHeader('Content-Length', ContentLen.ToString);

      if Token <> '' then
        Req.AddHeader('Authorization', 'Bearer ' + Token);

      // Opcijsko: če strežnik vrača veljaven JSON, lahko header Accept pomaga.
      Req.AddHeader('Accept', 'application/json');

      Result := Client.Execute(Req, nil);
    finally
      Body.Free;
    end;
  finally
    Client.Free;
  end;
end;

Pasti v praksi

  • Pozicija toka: Če FileStream ni na poziciji 0, se naloži le preostanek. V Builderju zato prisilimo Seek(0).
  • Chunked vs. Content-Length: Nekateri gatewayi (ali starejši serverski stacki) zavračajo Chunked. To je pogost legacy primer v procesno bližnjih programskih rešitvah. Spool-to-File je v takih primerih pragmatična rešitev.
  • CRLF: Multipart pričakuje CRLF (#13#10), ne le LF. Nekateri strežniki so tolerantni, drugi ne.
  • Content-Type na datoteko: Če pošljete povsem generično application/octet-stream, je to pogosto v redu. Če strežnik preverja (npr. PDF), nastavite pravilno. V Delphi lahko MIME-mapiranje rešite z lastno tabelo ali funkcijami OS, vendar se ne zanašajte slepo na končnice datotek.

Odpravljanje napak: reproducibilen Wire-Dump brez razbijanja TLS

Pri HTTPS v proxyju ne vidite telesa, če ne smete uporabiti MitM (npr. Fiddler-Zertifikat). To je v podjetniških okoljih običajno. Builder pomaga, ker imate celotno telo kot stream in (pri Spool-Datei) tudi kot datoteko.

Preizkušen postopek:

  1. Zapišite spool-telo v začasno datoteko.
  2. Beležite Content-Type z vključenim Boundary in Content-Length.
  3. Po potrebi ustvarite za Support/DevOps curl-reprodukcijo: tukaj ni treba telo 1:1 reproducirati, lahko pa zrcalite parametre in datoteko(e).

Pomembno: nikoli ne beležite produkcijskih tokenov ali osebnih podatkov. V mnogih integracijah poslovne programske opreme je prav to del, pomemben za skladnost.

Variante: več datotek, izbirna polja, strežnik z „čudnimi“ pričakovanji

Več datotek pod istim imenom polja

Veliko API-jev pričakuje files[] ali večkrat isto ime. Builder to podpira neposredno: pokličite AddFile večkrat z istim FieldName. Ali uporabljate files, files[] ali attachments, je vprašanje strežnične konvencije.

Strežnik zahteva natanko „application/json“ kot dodaten del

Pogost vzorec: JSON-metapodatkovni blok plus datoteka. V tem primeru pošljete JSON kot field-part, vendar z Content-Type: application/json; charset=utf-8. To ni „form field“ v smislu UI, a se v multipartu čisto prikaže:

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

Legacy: strežnik sprejema samo filename, ne filename*

Takrat pomaga fallback preko filename. Če pa strežnik nicht-ASCII v filename napačno dekodira, kot robustna rešitev pogosto ostane le: na strežniški strani ignorirati ime datoteke in namesto tega poslati dodatno polje originalName v JSON-u.

Pomen za modernizacijo in obratovanje

V razvitih Delphi okoljih se Multipart pogosto pojavi na robu: vmesnik do DMS, arhiva, ticketinga, portal strank ali notranji REST-strežnik. Prav tam nastaja pritisk zaradi novih varnostnih zahtev (TLS, gateways, proxies) in zaradi večjih velikosti datotek.

Predlagan pristop se posebej izplača, če:

  • morate reproducibilno debugirati nalaganja (obratovanje/administracija)
  • se želite/morate izogniti Chunked
  • se v praksi pojavijo imena datotek/enkodiranja (umlauti, presledki, oklepaji)
  • naj bo retry/idempotency konceptualno čisto rešen

Manj se izplača, če pošiljate le majhne datoteke na toleranten strežnik in ne potrebujete nobene operativne preglednosti. Potem je enostavna High-Level-Lösung zadostna — dokler iz poslovne enote ne pride prva „čudna“ datoteka.

Zaključek: stabilen Multipart-Upload je vprašanje pretakanja in obratovanja

Čist Multipart/Form-Data upload v Delphi ni toliko vprašanje „katere komponente“ kot nadzora: Boundary, CRLF, ime datoteke, Content-Type in predvsem determinističen body-stream. Kdor to zgodaj naredi pravilno, prihrani kasneje čas v debug- zankah z API-Gateways in Reverse-Proxies.

Mejna uporabe pristopa: Če morate nalagati izjemno velike datoteke (več GB) brez Spooling in brez Content-Length, postane pomembno vprašanje pretakanja brez predhodnega izračuna – takrat morata ciljni strežnik in infrastruktura zanesljivo podpirati Chunked, in potrebovali boste drugačen koncept razhroščevanja. Za številne integracije v digitalnih poslovnih rešitvah pa je prikazani Builder prav pragmatična sredina med robustnostjo, sledljivostjo in obvladljivo porabo virov.

Če ste vezani na obstoječo Delphi-integracijo, pri kateri nalaganja sporadično odpovedujejo ali le „pri nekaterih datotekah“, je to ponavadi indikator teh robnih pogojev. Za ciljno podporo pri analizi, modernizaciji ali pojasnitvi obratovanja nas dosežete tukaj:

V strokovnem okolju igrajo tudi Delphi Thttpclient in REST API za nalaganje datotek pomembno vlogo, kadar morajo integracije, podatkovni tokovi in nadaljnji razvoj tesno sodelovati.

Prediskutirajte projekt ali modernizacijsko pobudo z Net-Base.

naslednji korak

Ko iz teme nastane resničen projekt, je treba arhitekturo, obstoječe sisteme in obratovanje zgodaj obravnavati skupaj.

Ne podpiramo le pri posameznih vprašanjih, ampak tudi takrat, ko iz izrezkov izvorne kode, legacy-tem ali idej za portale nastane zanesljiv podjetniški projekt.

  • Obstoječe stanje, ciljno stanje in tehnična tveganja se ocenjujejo skupaj.
  • REST, dostop do podatkov, portali in Rollout ne bodo prestavljeni v kasnejše faze.
  • Že zgodaj vidite, katera pot je ekonomsko in operativno vzdržna.

Deli objavo

Deli ta prispevek neposredno

LinkedIn, X, XING, Facebook, WhatsApp in e-pošta so takoj na voljo. Za Instagram pripravljamo povezavo in kratek tekst.

E-pošta

Instagram se odpre v novem zavihku. Povezava in kratek opis se pred tem kopirata v odložišče.