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
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 jestfilename. - 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.
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:
- Zapisz den Spool-Body do pliku tymczasowego.
- Zaloguj
Content-Typewraz z Boundary iContent-Length. - 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:
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.