Net-Base Журнал

27.05.2026

Завантаження Multipart/Form-Data у Delphi: надійні потоки, контроль роздільника (boundary) та налагодження без припущень

Завантаження Multipart/Form-Data виглядають тривіальними, але в Delphi вони швидко дають збій при роботі з потоками, іменами файлів, Content-Type, Boundary-Handling та таймаутами. Цей фрагмент вихідного коду демонструє надійну, зручно відлагоджувану реалізацію з THTTPClient — включно з коректно обчисленим Content-Length...

27.05.2026

Чому 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.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 має бути достатньо випадковим. Важливо: жодних пробілів.
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) або якщо у вас є серверна дедуплікація.

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;

Підводні камені на практиці

  • Позиція потоку: Якщо 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) у вигляді файлу.

Перевірений підхід:

  1. Запишіть Spool-Body у тимчасовий файл.
  2. Запишіть у лог Content-Type включно з Boundary та Content-Length.
  3. За потреби створіть для 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 це коректно відобразити:

Delphi
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 завантаження файлів, коли інтеграції, потоки даних та подальший розвиток мають працювати узгоджено.

Обговорити проект або ініціативу з модернізації з Net-Base.

Поділитися дописом

Поділитися цим дописом безпосередньо

LinkedIn, X, XING, Facebook, WhatsApp та електронна пошта доступні негайно. Для Instagram ми готуємо посилання та короткий текст безпосередньо.

Електронна пошта

Instagram відкривається в новій вкладці. Посилання та короткий текст попередньо копіюються у буфер обміну.