Net-Base Magazyn

27.05.2026

Przesyłanie Multipart/Form-Data w Delphi: odporne strumienie, kontrola boundary i debugowanie bez zgadywania

Przesyłanie multipart/form-data wydaje się trywialne, jednak w Delphi szybko zawodzą kwestie związane ze strumieniami, nazwami plików, Content-Type, obsługą boundary i limitami czasu. Ten fragment źródłowy pokazuje solidną, łatwą do debugowania implementację z wykorzystaniem THTTPClient — włącznie z poprawnie obliczonym nagłówkiem Content-Length.

27.05.2026

Dlaczego Multipart w Delphi często dopiero w eksploatacji „psuje się”

Przesyłanie Multipart/Form-Data w Delphi można szybko skonfigurować – a w realnych integracjach zawodzi ono na szczegółach: błędny Content-Type dla części, łańcuch boundary, który przypadkowo występuje w payloadzie, nieodpowiednie znaki końca linii, nazwy plików nie-ASCII lub serwery, które odrzucają chunked transfer encoding (HTTP bez Content-Length). Do tego dochodzą typowe problemy praktyczne w dedykowanym oprogramowaniu korporacyjnym: duże pliki (CAD, PDFs, skany), niestabilne sieci, reverse-proxy, rygorystyczne API-Gateways i wymagania administratorów dotyczące debugowania.

Delphi dostarcza wraz z System.Net.HttpClient użyteczny stos, ale przykłady „Happy Path” pomijają ważne warunki brzegowe. Poniższy fragment źródłowy idzie celowo głębiej: budujemy Multipart jako strumień deterministycznie, obliczamy poprawnie Content-Length, wspieramy RFC-5987 dla nazw plików i dostarczamy opcję debugowania, która pozwala odtworzyć żądanie bez konieczności przełamywania TLS.

Architekturentscheidung: THTTPClient statt Indy – und wann das kippt

THTTPClient (System.Net) korzysta w zależności od platformy z różnych backendów (pod Windows typowo WinHTTP/WinINet). To często korzystne w środowiskach korporacyjnych: polityki proxy i TLS są bardziej zgodne z systemowymi ustawieniami. Indy jest za to bardzo transparentny i konfigurowalny, ale wnosi własne powiązania TLS i bywa w eksploatacji czasem „do osobnej konserwacji” (wersje OpenSSL, zestawy szyfrów).

Podejście zaprezentowane tutaj wykorzystuje THTTPClient, ponieważ jest on przy modernizacjach często już stosowany (REST-Client, OAuth, pobierania). Jeśli jednak potrzebują Państwo ścisłej kontroli nad przebiegiem negocjacji TLS, certyfikatami klienta w niestandardowych formach lub bardzo specyficznymi łańcuchami proxy, Indy (lub dedykowany stos HTTP) może mieć sens. To niewiele zmienia w konstrukcji Multipart — ale wpływa na debugowanie i eksploatację.

Multipart/Form-Data Upload in Delphi: ein Stream, keine Magie

Zasadnicza idea: Multipart to w istocie strumień bajtów. Jeśli zbudujemy go sami, możemy:

  • świadomie dobrać boundary i solidnie go przetestować
  • ustawić nagłówki dla każdej części poprawnie (włącznie z Content-Disposition, Content-Type)
  • rzetelnie obliczyć Content-Length (ważne dla serwerów bez wsparcia dla chunked)
  • streamować duże pliki, nie trzymając wszystkiego w pamięci RAM

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

Builder poniżej generuje opcjonalnie ciało wyłącznie w pamięci (dla małych uploadów) lub plik spool na dysku (dla dużych payloadów). To może wyglądać na „oldschool”, ale w eksploatacji jest ekstremalnie praktyczne, ponieważ unika chunked i upraszcza debugowanie. Spoolowanie oznacza: mogą Państwo ponownie użyć tego samego Request-Body, nawet jeśli konieczny jest retry.

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

// Buduje kompletny body do strumienia. Jeśli ASpoolToFile jest pusty,
// używany jest TMemoryStream; w przeciwnym razie tworzony jest plik.
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.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 powinien być wystarczająco losowy. Ważne: bez spacji.
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
// Nagłówki multipart są w ASCII. Dla wartości w body (np. UTF-8) ustawiamy Content-Type dla każdej części.
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“…“ jest znacznie bardziej odporne dla nazw plików nie-ASCII niż 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 nie może być nil‘);

if (FileStream is TCustomMemoryStream) and (FileStream.Size = 0) then
; // dozwolone, ale często błąd: pusty plik

P := TPart.Create;
P.Kind := pkFile;
P.Name := FieldName;
P.FileName := FileName;
P.ContentType := ContentType;
P.FileStream := FileStream; // Właściciel pozostaje po stronie wywołującego
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
// Uwaga: pozycja strumienia będzie konsumowana.
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);
// Treść pola w UTF-8, jeśli charset=utf-8 jest ustawiony.
WriterUtf8 := TEncoding.UTF8.GetBytes(P.Value);
WriteBytes(WriterUtf8);
WriteBytes(CRLF);
end
else
begin
// Dwa parametry nazwy pliku: filename (dla starych serwerów) 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);

// Ważne: ustawić pozycję na początek, w przeciwnym razie przesłane zostaną tylko pozostałości.
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.

Co kod świadomie robi inaczej

  • Brak „automatycznego Multipart”: Kontrola nad nagłówkami, kodowaniami i boundary pozostaje po Państwa stronie. To bywa decydujące przy restrykcyjnych REST-API.
  • Obsługa RFC-5987 przez filename*: Gdy nazwy plików zawierają znaki diakrytyczne (np. „Prüfbericht.pdf”), to najczęstszy błąd interoperacyjności. Niektóre serwery ignorują filename*, wtedy jako fallback używany jest filename.
  • Spool-to-File jako funkcja operacyjna: Przy dużych uploadach i ponowieniach prób możliwość wielokrotnego użycia strumienia body jest istotnym udogodnieniem.
  • Dostępny Content-Length, ponieważ body jest wygenerowany z góry. To zapobiega użyciu Chunked-Encoding, jeśli system docelowy go nie akceptuje.

Wysyłanie żądania: Timeouts, nagłówki i sensowna strategia ponawiania

Samo multipart nie rozwiązuje jeszcze problemów integracyjnych: potrzebne są timeouts, klasyfikacja błędów i opcjonalnie ponawianie prób. Ważne jest rozróżnienie między idempotentny i nieidempotentny: uploady często nie są idempotentne (możliwe duplikaty). Ponawianie prób powinno więc występować tylko wtedy, gdy serwer oferuje semantykę idempotentną (np. Upload-ID, dedykowany nagłówek Idempotency-Key) lub gdy po stronie serwera istnieje deduplikacja.

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;

Pułapki w praktyce

  • Pozycja strumienia: Jeśli FileStream nie jest ustawiony na pozycji 0, wyślesz tylko pozostałą część. W builderze dlatego wymuszane jest Seek(0).
  • Chunked vs. Content-Length: Niektóre bramy (lub starsze stosy serwerowe) odrzucają Chunked. To częsty przypadek legacy w rozwiązaniach bliskich procesowi. Wtedy Spool-to-File jest rozwiązaniem pragmatycznym.
  • CRLF: Multipart oczekuje CRLF (#13#10), nie tylko LF. Niektóre serwery są tolerancyjne, inne nie.
  • Content-Type dla każdego pliku: Jeśli wysyłasz domyślnie application/octet-stream, często to wystarcza. Jeśli serwer dokonuje kontroli (np. PDF), ustaw poprawny typ. W Delphi możesz rozwiązać mapowanie MIME poprzez własną tabelę lub funkcje systemu operacyjnego, ale nie polegaj ślepo na rozszerzeniach plików.

Debugowanie: odtwarzalny wire-dump bez przerywania TLS

Przy HTTPS nie zobaczysz Body w proxy, jeśli nie możesz użyć MitM (np. certyfikatu Fiddler). To jest normalne w środowiskach korporacyjnych. Builder pomaga, ponieważ masz cały Body strumieniowo i (w przypadku pliku spool) dostępny jako plik.

Sprawdzone podejście:

  1. Zapisz den Spool-Body do pliku tymczasowego.
  2. Zaloguj Content-Type wraz z Boundary i Content-Length.
  3. Opcjonalnie przygotuj dla wsparcia/DevOps repro w curl: nie musisz odtwarzać Body 1:1, ale możesz odzwierciedlić parametry i plik(i).

Ważne: Nigdy nie loguj tokenów produkcyjnych ani danych osobowych. W wielu integracjach oprogramowania biznesowego jest to dokładnie ten element istotny dla zgodności (compliance).

Warianty: wiele plików, pola opcjonalne, serwer z „dziwnymi“ oczekiwaniami

Wiele plików pod tą samą nazwą pola

Wiele API oczekuje files[] lub wielokrotnego użycia tej samej nazwy. Builder obsługuje to bezpośrednio: wywołaj AddFile wielokrotnie z tą samą wartością FieldName. Czy użyjesz files, files[] czy attachments, to kwestia konwencji po stronie serwera.

Serwer wymaga dokładnie „application/json“ jako dodatkowej części

Powszechny wzorzec: blok metadanych JSON plus plik. Wówczas wysyłasz JSON jako część pola, ale z Content-Type: application/json; charset=utf-8. To nie jest „pole formularza“ w sensie UI, ale w Multipart da się to odwzorować poprawnie:

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

Legacy: serwer akceptuje tylko filename, nie filename*

Wtedy pomaga fallback przez filename. Jeśli jednak serwer błędnie dekoduje nie-ASCII w filename, często jedynym odpornym rozwiązaniem jest zignorowanie nazwy pliku po stronie serwera i przesłanie dodatkowego pola originalName w JSON.

Kontekst dla modernizacji i eksploatacji

W rozwiniętych Delphi-landskapach Multipart często występuje na obrzeżach: interfejs do DMS, archiwum, systemu zgłoszeń, portal klienta lub wewnętrzny REST-serwer. To właśnie tam pojawia się presja wynikająca z nowych wymagań bezpieczeństwa (TLS, bramy, proxy) oraz z powodu rosnących rozmiarów plików.

Przedstawione podejście jest szczególnie opłacalne, gdy:

  • musisz debugować uploady w sposób powtarzalny (eksploatacja/administracja)
  • chcesz/musisz unikać Chunked
  • w praktyce występują problemy z nazwami plików/kodowaniami (znaki diakrytyczne, spacje, nawiasy)
  • powtórzenia/retry oraz idempotency mają być koncepcyjnie poprawnie rozwiązane

Jest to mniej opłacalne, jeśli wysyłasz wyłącznie małe pliki do tolerancyjnego serwera i nie potrzebujesz żadnej przejrzystości operacyjnej. Wtedy wystarcza proste rozwiązanie wysokiego poziomu – aż pojawi się pierwsza „dziwna“ plik z wydziału merytorycznego.

Wniosek: stabilny Multipart-Upload to problem strumieniowania i eksploatacji

Poprawnie zaimplementowany Multipart/Form-Data Upload w Delphi to mniej kwestia „której komponenty“, a bardziej kontroli: Boundary, CRLF, nazwa pliku, Content-Type i przede wszystkim deterministyczny strumień Body. Kto zbuduje to poprawnie od początku, zaoszczędzi później czas w pętlach debugowania z API-Gateways i reverse-proxy.

Granica zastosowania podejścia: Jeśli muszą Państwo przesyłać ekstremalnie duże pliki (kilka GB) bez spoolingu i bez Content-Length, staje się istotne strumieniowanie bez wstępnego obliczania – wtedy serwery docelowe i infrastruktura muszą niezawodnie obsługiwać Chunked, a potrzebne jest inne podejście do debugowania. Dla wielu integracji w cyfrowych rozwiązaniach dla przedsiębiorstw pokazany tutaj Builder jest jednak dokładnie pragmatycznym środkiem między odpornością, przejrzystością a kontrolowanym zużyciem zasobów.

Jeśli korzystają Państwo z rozwiniętej Delphi-integracji, w której przesyłanie plików sporadycznie zawodzi lub tylko „przy niektórych plikach”, jest to zwykle wskaźnik właśnie tych warunków brzegowych. Aby uzyskać ukierunkowane wsparcie przy analizie, modernizacji lub wyjaśnieniu kwestii eksploatacyjnych, prosimy o kontakt tutaj:

W środowisku merytorycznym również Delphi Thttpclient i REST API przesyłania plików odgrywają ważną rolę, gdy integracje, przepływy danych i dalszy rozwój muszą ściśle współgrać.

Omówić projekt lub przedsięwzięcie modernizacyjne z Net-Base.

Udostępnij wpis

Udostępnij ten wpis bezpośrednio

LinkedIn, X, XING, Facebook, WhatsApp i e‑mail są natychmiast dostępne. Dla Instagrama przygotowujemy od razu link i krótki tekst.

E-mail

Instagram otwiera się w nowej karcie. Link i krótki tekst są wcześniej kopiowane do schowka.