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

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.

Deli objavo

Deli ta prispevek neposredno

LinkedIn, X, XING, Facebook, WhatsApp in e-pošta so takoj na voljo. Za Instagram bomo neposredno pripravili povezavo in kratek opis.

E-pošta

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