Net-Base Lehti

27.05.2026

Multipart/Form-Data-lähetys Delphi: luotettavat streamit, boundary-valvonta ja virheenkorjaus ilman arvailua

Multipart/Form-Data-lähetykset vaikuttavat triviaalilta, mutta Delphi ne ajautuvat nopeasti ongelmiin streamien, tiedostonimien, Content-Type-otsakkeen, rajankäsittelyn (boundary handling) ja aikakatkaisujen kanssa. Tämä lähdekoodikatkelma esittelee robustin, debugattavan toteutuksen THTTPClientilla – mukaan lukien oikein laskettu Content-Length...

27.05.2026

Miksi Multipart in Delphi usein vasta tuotannossa pettää

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.

Architekturentscheidung: THTTPClient statt Indy – und wann das kippt

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

    // Rakentaa koko bodyn streamiin. Jos ASpoolToFile on tyhjä,
    // käytetään TMemoryStreamia; muuten luodaan tiedosto.
    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:n tulee olla riittävän satunnainen. Tärkeää: ei välilyöntejä.
  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-otsikot ovat ASCII-merkistöä. Bodyn arvot (esim. UTF-8) osoitetaan Part-kohtaisella Content-Typella.
  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''..." on paljon luotettavampi ei-ASCII-tiedostonimille kuin pelkkä 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 ei saa olla nil');

  if (FileStream is TCustomMemoryStream) and (FileStream.Size = 0) then
    ; // sallittu, mutta usein virhe: tyhjä tiedosto

  P := TPart.Create;
  P.Kind := pkFile;
  P.Name := FieldName;
  P.FileName := FileName;
  P.ContentType := ContentType;
  P.FileStream := FileStream; // Omistajuus jää kutsujalle
  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
    // Varoitus: Streamin asema muuttuu lukemisen aikana.
    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);
        // Kentän sisältö UTF-8-muodossa, mikäli charset=utf-8 on asetettu.
        WriterUtf8 := TEncoding.UTF8.GetBytes(P.Value);
        WriteBytes(WriterUtf8);
        WriteBytes(CRLF);
      end
      else
      begin
        // Kaksi tiedostonimi-parametria: filename (vanhoille palvelimille) ja 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);

        // Tärkeää: aseta asema alkuun, muuten lähetetään vain jäljellä oleva osa.
        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.

Mitä koodi tietoisesti tekee toisin

  • Ei ”automaattista Multipartia”: Hallinta Headereista, enkoodauksista ja boundarystä pysyy teillä. Tämä on usein ratkaisevaa tiukoissa REST-APIissa.
  • RFC-5987-tuki filename*-kentän kautta: Kun tiedostonimissä on Umlauteja (esim. „Prüfbericht.pdf“), tämä on yleisin yhteensopivuusbugi. Jotkin palvelimet sivuuttavat filename*-kentän; silloin filename toimii fallbackina.
  • Spool-to-File osana tuotantoa: Suurille uploadille ja uusintayrityksille uudelleenkäytettävä body-stream on arvokas.
  • Content-Length on saatavilla, koska body luodaan etukäteen. Tämä välttää chunked-enkoodauksen, jos kohdejärjestelmä ei sitä hyväksy.

Request senden: Timeouts, Header und eine sinnvolle Retry-Strategie

Multipart itsessään ei vielä ratkaise integraatio-ongelmia: tarvitsette aikakatkaisut, virheiden luokittelun ja valinnaisesti uudelleenyrittämiset. Tärkeää on erotella idempotent ja nicht idempotent: lataukset eivät usein ole idempotentteja (kaksoiskappaleet mahdollisia). Uudelleenyrittämisiä tulisi tehdä vain, jos palvelin tarjoaa idempotenttisen semantiikan (esim. Upload-ID, dedikoitu Idempotency-Key Header) tai jos teillä on palvelinpuolen deduplikointi.

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;

Käytännön sudenkuopat

  • Stream-Position: Jos FileStream ei ole positiossa 0, lähetätte vain jäljellä olevan osan. Builderissa siksi Seek(0) pakotetaan.
  • Chunked vs. Content-Length: Jotkin gatewayt (tai vanhemmat palvelinpinot) hylkäävät Chunked-enkoodauksen. Tämä on yleinen legacy-tapaus prosessiläheisissä ohjelmistoratkaisuissa. Spool-to-File on silloin pragmaattinen.
  • CRLF: Multipart edellyttää CRLF:ää (#13#10), ei pelkkää LF:ää. Jotkin palvelimet ovat tolerantteja, toiset eivät.
  • Content-Type per tiedosto: Jos lähetätte yleisesti application/octet-stream, se on usein riittävä. Jos palvelin tarkistaa (esim. PDF), asettakaa oikea tyyppi. In Delphi voitte toteuttaa MIME-mappingin omalla taulukolla tai OS-funktioilla, mutta älkää luottako sokeasti tiedostopäätteisiin.

Debugging: reproduzierbarer Wire-Dump ohne TLS-Aufbruch

HTTPS-yhteydessä et näe Bodyä proxyn kautta, jos et saa käyttää MitM:tä (esim. Fiddler-sertifikaatti). Tämä on yritysympäristöissä normaalia. Builder auttaa, koska hallitset koko Bodyn streamina ja (spool-tiedoston tapauksessa) tiedostona.

Hyväksi todettu käytäntö:

  1. Kirjoita Spool-Body väliaikaiseen tiedostoon.
  2. Kirjaa lokiin Content-Type mukaan lukien Boundary ja Content-Length.
  3. Luo tukia/DevOpsia varten valinnainen curl-repro: tässä sinun ei tarvitse palauttaa Bodyä 1:1, mutta voit peilata parametrien ja tiedosto(a) rakenne.

Tärkeää: Älä koskaan kirjaa tuotantotokeneja tai henkilötietoja lokiin. Monissa yritysohjelmisto-integraatioissa juuri tämä on säädöstenmukaisuuden kannalta merkittävä osa.

Vaihtoehdot: useita tiedostoja, valinnaiset kentät, palvelin, jolla „erikoiset“ odotukset

Useita tiedostoja samalla kentänimellä

Monet API:t odottavat files[]:ää tai samaa nimeä useaan kertaan. Builder tukee tätä suoraan: kutsu AddFile useaan kertaan samalla FieldName-arvolla. Käytätkö files, files[] vai attachments on puhtaasti palvelinpuolen konventio.

Palvelin vaatii nimenomaan „application/json“ lisäparttia

Yleinen malli: JSON-metatietolohko plus tiedosto. Silloin lähetät JSONin kenttäparttina, mutta Content-Type: application/json; charset=utf-8. Tämä ei ole „lomakekenttä“ käyttöliittymämerkityksessä, mutta se on Multipartissa siististi esitettävissä:

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

Legacy: Palvelin hyväksyy vain filename, ei filename*

Silloin fallback filename auttaa. Jos palvelin kuitenkin dekoodaa non-ASCII-merkit väärin filename-kentässä, usein ainoa robusti tapa on jättää tiedostonimi palvelinpuolella huomiotta ja sen sijaan lähettää lisäkenttä originalName JSONissa.

Merkitys modernisoinnissa ja käytössä

Kasvaneissa Delphi-ympäristöissä Multipart on usein reunalla: rajapinta DMS:ään, arkistoon, tikettijärjestelmään, Asiakasportaali tai sisäinen REST-palvelin. Juuri siellä syntyy painetta uusista turvallisuusvaatimuksista (TLS, Gateways, Proxies) ja suuremmista tiedostokokoista.

Esitetty lähestymistapa kannattaa erityisesti, kun:

  • Sinun pitää debugata latauksia toistettavasti (käyttö/ylläpito)
  • Haluat/joudut välttämään chunked-siirtoa
  • Tiedostonimet/koodaukset esiintyvät käytännössä (umlautit, välilyönnit, sulkeet)
  • Retry/Idempotency halutaan ratkaista konseptuaalisesti puhtaasti

Se kannattaa vähemmän, jos lähetät yksinomaan pieniä tiedostoja toleroivalle palvelimelle etkä tarvitse lainkaan käyttöön liittyvää läpinäkyvyyttä. Silloin yksinkertainen korkean tason ratkaisu riittää – kunnes ensimmäinen „erikoinen“ tiedosto tulee liiketoimintayksiköstä.

Yhteenveto: Vakaa Multipart-lataus on striimaus- ja käyttöongelma

Siisti Multipart/Form-Data-lähetys Delphi-ympäristössä on vähemmän kysymys „mistä komponentista“ ja enemmän kontrollista: Boundary, CRLF, tiedostonimi, Content-Type ja ennen kaikkea deterministinen Body-stream. Ne, jotka rakentavat tämän varhain oikein, säästävät myöhemmin aikaa debuggauslenkeissä API-gatewayiden ja reverse-proxien kanssa.

Lähestymistavan käyttöraja: Jos sinun täytyy ladata erittäin suuria tiedostoja (useita GB) ilman spoolingia ja ilman Content-Length-otsaketta, tulee aiheelliseksi suoratoisto ilman ennakkolaskentaa – silloin kohdepalvelimen ja infrastruktuurin on tuettava Chunked-lähettämistä luotettavasti, ja tarvitset toisenlaisen virheenkorjauskonseptin. Monissa digitaalisten yritysratkaisujen integraatioissa tässä esitelty Builder on kuitenkin juuri pragmaattinen keskikohta robustisuuden, jäljitettävyyden ja hallittavissa olevan resurssikulutuksen välillä.

Jos olette sidoksissa kehittyneeseen Delphi-integraatioon, jossa lataukset epäonnistuvat satunnaisesti tai vain „joidenkin tiedostojen“ kohdalla, on se yleensä merkki juuri näistä reunaehdoista. Kohdennettua tukea analyysissä, modernisoinnissa tai käyttöselvityksessä saat meihin yhteyden täältä:

Ammattimaisessa kontekstissa myös Delphi Thttpclient ja REST API-tiedostojen lataus näyttelevät tärkeää roolia, kun integraatioiden, datavirtojen ja jatkokehityksen on toimittava saumattomasti yhteen.

Keskustele projektista tai modernisointihankkeesta Net-Base kanssa.

Jaa artikkeli

Jaa tämä viesti suoraan

LinkedIn, X, XING, Facebook, WhatsApp ja sähköposti ovat heti käytettävissä. Instagramia varten valmistelemme linkin ja lyhyen tekstin.

Sähköposti

Instagram avautuu uuteen välilehteen. Linkki ja lyhyt teksti kopioidaan ensin leikepöydälle.