Чому Multipart в Delphi часто лише в експлуатації «ламається»
Ein Multipart/Form-Data Upload in Delphi швидко збирається «в кілька кліків» — але в реальних інтеграціях зазнає поразки через деталі: неправильний Content-Type для кожного Part, рядок Boundary, який випадково зустрічається в payload, непридатні переноси рядків, імена файлів із не-ASCII символами або сервери, що відкидають chunked transfer encoding (HTTP без Content-Length). До цього додаються типові практичні проблеми в індивідуальному корпоративному ПЗ: великі файли (CAD, PDFs, скани), нестабільні мережі, Reverse-Proxies, суворі API-Gateways та адміністративні вимоги до налагодження.
Delphi поставляє з собою System.Net.HttpClient як придатний стек, але приклади «Happy Path» залишають важливі граничні умови без уваги. Наведений нижче фрагмент коду цілком свідомо йде глибше: ми будуємо Multipart як потік детерміновано, коректно обчислюємо Content-Length, підтримуємо RFC-5987 для імен файлів і надаємо опцію налагодження, яка робить запит відтворюваним без необхідності ламати TLS.
Архітектурне рішення: THTTPClient замість Indy — і коли це дає збій
THTTPClient (System.Net) використовує залежно від платформи різні бекенди (під Windows зазвичай WinHTTP/WinINet). Це в корпоративних середовищах часто переважно: політики проксі та TLS більш сумісні із системним стеком. Indy натомість дуже прозорий і налаштовуваний, але додає власні TLS-біндинги і в експлуатації інколи потребує «окремого супроводу» (версії OpenSSL, набори шифрів).
Підхід тут використовує THTTPClient, оскільки він у процесах модернізації часто вже застосовується (REST-Client, OAuth, завантаження). Якщо вам проте потрібен жорсткий контроль над TLS-handshakes, клієнтськими сертифікатами в нестандартних формах або дуже специфічними ланцюжками проксі, Indy (або спеціалізований HTTP-стек) може бути виправданий. Це мало змінює побудову Multipart — але впливає на налагодження та експлуатацію.
Multipart/Form-Data Upload in Delphi: лише потік байтів, без магії
Основна ідея: Multipart зрештою — це лише байтовий потік. Якщо ми будуємо його самі, ми можемо:
- усвідомлено вибирати Boundary і стабільно тестувати
- коректно встановлювати заголовки для кожного Part (включно з
Content-Disposition,Content-Type) - надійно обчислювати
Content-Length(важливо для серверів без підтримки chunked) - стримити великі файли, не тримаючи їх повністю в оперативній пам’яті
Код: Multipart-Builder із стрімінгом та іменами файлів за RFC-5987
Builder нижче опціонально генерує або повністю пам’яті-орієнтований Body (для малих завантажень), або Spool-Datei на диску (для великих payload). Це здається старомодним, але в експлуатації надзвичайно практично, оскільки уникaє chunked і полегшує налагодження. Спулінг означає: ви можете повторно використовувати той самий Request-Body, навіть якщо потрібна повторна спроба.
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);
// Формує весь тіло в потік. Якщо ASpoolToFile порожній,
// використовується TMemoryStream; інакше створюється файл.
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 має бути достатньо випадковим. Важливо: жодних пробілів.
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 ASCII. Для значень у тілі (наприклад UTF-8) ми вказуємо Content-Type для кожної частини.
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“…“ є значно стійкішим для імен файлів, що містять не-ASCII, ніж тільки 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 не може бути nil‘);
if (FileStream is TCustomMemoryStream) and (FileStream.Size = 0) then
; // дозволено, але часто помилка: порожній файл
P := TPart.Create;
P.Kind := pkFile;
P.Name := FieldName;
P.FileName := FileName;
P.ContentType := ContentType;
P.FileStream := FileStream; // Owner bleibt beim Aufrufer
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
// Увага: позиція потоку буде просунута.
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);
// Field-Body in UTF-8, sofern charset=utf-8 gesetzt ist.
WriterUtf8 := TEncoding.UTF8.GetBytes(P.Value);
WriteBytes(WriterUtf8);
WriteBytes(CRLF);
end
else
begin
// Два параметри імені файлу: filename (для старих серверів) та 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);
// Важливо: встановити позицію на початок, інакше буде завантажено лише залишок.
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.
Що код свідомо робить інакше
- Немає „автоматичного Multipart“: Контроль над заголовками, кодуваннями та boundary залишається за вами. Це часто критично для суворих REST-API.
- Підтримка RFC-5987 через
filename*: Як тільки імена файлів містять умлаути (наприклад „Prüfbericht.pdf“), це найпоширеніший баг сумісності. Деякі сервери ігноруютьfilename*, тоді використовуєтьсяfilenameяк fallback. - Spool-to-File як операційна функція: Для великих завантажень і повторних спроб багаторазовий поток тіла запиту є надзвичайно цінним.
- Content-Length доступний, бо тіло генерується заздалегідь. Це дозволяє уникнути Chunked-Encoding, якщо цільова система його не підтримує.
Відправлення запиту: таймаути, заголовки та розумна стратегія повторних спроб
Сам по собі multipart не вирішує проблем інтеграції: потрібні таймаути, класифікація помилок та опційно повторні спроби. Важливо розрізняти ідемпотентні та неідемпотентні операції: завантаження файлів часто не є ідемпотентними (можливі дублікати). Повторні спроби слід виконувати тільки якщо сервер забезпечує ідемпотентну семантику (наприклад Upload-ID, виділений заголовок Idempotency-Key) або якщо у вас є серверна дедуплікація.
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;
Підводні камені на практиці
- Позиція потоку: Якщо FileStream не знаходиться на позиції 0, ви завантажите лише залишок. Тому в Builder примусово виконується
Seek(0). - Chunked vs. Content-Length: Деякі шлюзи (або застарілі серверні стекі) відкидають Chunked. Це частий випадок legacy у процесно орієнтованих програмних рішеннях. У такому разі Spool-to-File є прагматичним рішенням.
- CRLF: Multipart очікує CRLF (
#13#10), а не лише LF. Деякі сервери толерантні, інші — ні. - Content-Type для кожного файлу: Якщо ви загалом відправляєте
application/octet-stream, це часто підходить. Якщо сервер перевіряє (наприклад PDF), вкажіть правильно. У Delphi ви можете реалізувати відображення MIME через власну таблицю або функції ОС, але не покладайтеся сліпо на розширення файлів.
Налагодження: відтворюваний wire-dump без розшифровки TLS
При HTTPS ви не бачите Body у проксі, якщо не дозволено використовувати MitM (наприклад сертифікат Fiddler). Це нормально в корпоративних середовищах. Builder допомагає, оскільки ви маєте повний Body у вигляді потоку і (у разі Spool-Datei) у вигляді файлу.
Перевірений підхід:
- Запишіть Spool-Body у тимчасовий файл.
- Запишіть у лог
Content-Typeвключно з Boundary таContent-Length. - За потреби створіть для Support/DevOps репро
curl: тут не потрібно відтворювати Body 1:1, але ви можете віддзеркалити параметри й файл(и).
Важливо: ніколи не записуйте в логи продуктивні токени або персональні дані. У багатьох бізнес‑інтеграціях саме це є питанням відповідності.
Варіанти: кілька файлів, необов’язкові поля, сервери з «дивними» очікуваннями
Кілька файлів під одним і тим же іменем поля
Багато API очікують files[] або багаторазово те саме ім’я. Builder підтримує це напряму: викликайте AddFile кілька разів з тим самим FieldName. Чи використовувати files, files[] чи attachments — це винятково серверна конвенція.
Сервер вимагає точно «application/json» як додаткову частину
Поширений шаблон: блок метаданих у JSON плюс файл. У такому випадку надсилайте JSON як Field-Part, але з Content-Type: application/json; charset=utf-8. Це не «form field» у сенсі UI, але в multipart це коректно відобразити:
MP.AddField('metadata', '{"documentType":"report","source":"delphi"}', 'application/json; charset=utf-8');
Legacy: Сервер приймає лише filename, а не filename*
Тоді допомагає фолбек через filename. Якщо ж сервер неправильно декодує не-ASCII у filename, часто єдиним надійним шляхом залишається ігнорувати ім’я файлу на стороні сервера і натомість додати додаткове поле originalName у JSON.
Контекст для модернізації та експлуатації
У сформованих Delphi-ландшафтах multipart часто знаходиться на периферії: інтерфейс до DMS, архіву, системи тикетингу, клієнтський портал або внутрішній REST-сервер. Саме там зростає тиск через нові вимоги безпеки (TLS, шлюзи, проксі) і через більші розміри файлів.
Представлений підхід особливо виправданий, коли:
- вам потрібно відтворювано налагоджувати завантаження (експлуатація/адміністрування)
- ви хочете/повинні уникати Chunked
- в реальній практиці зустрічаються проблеми з іменами файлів/кодуванням (Umlaute, пробіли, дужки)
- потрібно концептуально коректно вирішити повторні спроби / ідемпотентність
Він менш виправданий, якщо ви відправляєте виключно невеликі файли на толерантний сервер і вам не потрібна прозорість експлуатації. У такому випадку достатньо простого високорівневого рішення — поки з відділу не надійде перший «дивний» файл.
Висновок: Стабільний Multipart-Upload — це проблема стрімінгу та експлуатації
Коректний Multipart/Form-Data Upload у Delphi — це радше питання контролю, ніж «якої компоненти»: Boundary, CRLF, ім’я файлу, Content-Type і, насамперед, детермінований Body-стрім. Хто реалізує це правильно з самого початку, економить час на відлагодженні з API-Gateways і Reverse-Proxies пізніше.
Межі застосовності підходу: якщо вам потрібно завантажувати надзвичайно великі файли (кілька ГБ) без спулінгу і без Content-Length, стає актуально питання стрімінгу без попереднього обчислення – тоді цільові сервери та інфраструктура повинні надійно підтримувати Chunked, і вам знадобиться інша концепція відладки. Для багатьох інтеграцій у цифрових корпоративних рішеннях проте показаний тут Builder є саме тією прагматичною серединою між надійністю, відтворюваністю та контрольованим використанням ресурсів.
Якщо ви прив’язані до історично сформованої Delphi-інтеграції, при якій завантаження періодично зазнають невдачі або лише «для деяких файлів», це, як правило, вказує саме на ці граничні умови. Для цілеспрямованої підтримки в аналізі, модернізації або з’ясуванні питань експлуатації зв’яжіться з нами тут:
У фаховому середовищі також відіграють важливу роль Delphi Thttpclient та REST API завантаження файлів, коли інтеграції, потоки даних та подальший розвиток мають працювати узгоджено.