Net-Base Журнал

27.05.2026

Загрузка Multipart/Form-Data в Delphi: надёжные потоки, контроль границ и отладка без догадок

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

27.05.2026

Почему Multipart в Delphi часто ломается только в эксплуатации

Ein Multipart/Form-Data Upload in Delphi быстро собирается кликом — но в реальных интеграциях терпит неудачу из-за деталей: неверный Content-Type для части, строка границы (Boundary-String), которая случайно встречается в payload, неподходящие переводы строк, имена файлов с не-ASCII-символами или серверы, которые отвергают chunked transfer encoding (HTTP без Content-Length). К этому добавляются типичные практические проблемы в индивидуальном корпоративном ПО: большие файлы (CAD, PDFs, сканы), нестабильные сети, обратные прокси, строгие API-шлюзы и требования администраторов к отладке.

Delphi поставляет вместе с System.Net.HttpClient годный стек, но примеры «Happy Path» оставляют важные граничные условия без внимания. Приведённый ниже фрагмент исходного кода намеренно идёт глубже: мы строим Multipart как поток детерминированно, корректно вычисляем Content-Length, поддерживаем RFC-5987 для имён файлов и предоставляем опцию отладки, которая делает запрос воспроизводимым без необходимости вскрывать TLS.

Архитектурное решение: THTTPClient вместо Indy — и когда это подводит

THTTPClient (System.Net) использует в зависимости от платформы разные бекэнды (под Windows типично WinHTTP/WinINet). Для корпоративных окружений это часто выгодно: политики прокси и TLS обычно совместимы с системными механизмами. Indy, в свою очередь, очень прозрачен и настраиваем, но приносит свои собственные привязки TLS и в эксплуатации иногда требует «отдельного сопровождения» (версии OpenSSL, Cipher-Suiten).

Подход в этой статье опирается на THTTPClient, поскольку он при модернизации часто уже используется (REST-Client, OAuth, загрузки). Если вам требуется жёсткий контроль над TLS‑рукопожатиями, клиентскими сертификатами в нестандартных формах или очень специфичными цепочками прокси, то Indy (или выделенный HTTP‑стек) может быть оправдан. Это мало меняет сам способ построения Multipart — но существенно влияет на отладку и эксплуатацию.

Multipart/Form-Data Upload in Delphi: поток, а не магия

Ключевая идея: Multipart в сущности — это просто байтовый поток. Если мы строим его сами, мы можем:

  • осознанно выбирать границу (Boundary) и надёжно её тестировать
  • корректно задавать заголовки для каждой части (включая Content-Disposition, Content-Type)
  • надёжно вычислять Content-Length (важно для серверов без поддержки chunked)
  • стримить большие файлы, не удерживая всё в оперативной памяти

Код: Multipart‑Builder с потоковой передачей и поддержкой RFC-5987 для имён файлов

Нижеописанный билдэр по выбору создаёт либо полностью основанный на памяти body (для небольших загрузок), либо spool‑файл на диске (для больших payload). Это выглядит «oldschool», но в эксплуатации чрезвычайно практично, поскольку избегает chunked и облегчает отладку. Spool‑подход означает: вы можете повторно использовать тот же 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);

// Собирает весь body в поток. Если 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
// Граница должна быть достаточно случайной. Важно: без пробелов.
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.

Что код намеренно делает по-другому

  • Kein „automatisches 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, будет загружена только оставшаяся часть. Поэтому в билдере принудительно выполняется Seek(0).
  • Chunked vs. Content-Length: некоторые шлюзы (или старые серверные стэки) отклоняют Chunked. Это частый кейс наследия в процессно-ориентированных решениях. В таких случаях Spool-to-File — прагматичное решение.
  • CRLF: multipart ожидает CRLF (#13#10), а не только LF. Некоторые серверы терпимы, другие — нет.
  • Content-Type для каждого файла: если вы отправляете по умолчанию application/octet-stream, это часто приемлемо. Если сервер выполняет проверку (например PDF), указывайте корректный тип. В Delphi вы можете реализовать MIME‑маппинг через собственную таблицу или системные функции, но не полагайтесь слепо на расширения файлов.

Отладка: воспроизводимый Wire-Dump без вмешательства в TLS

При HTTPS вы не видите body в прокси, если нельзя использовать MitM (например, сертификат Fiddler). Это нормально в корпоративной среде. Der Builder помогает тем, что вы полностью владеете потоком body и (в случае файла спула) имеете его в виде файла.

Рекомендуемая практика:

  1. Запишите тело спула в временный файл.
  2. Логируйте Content-Type включая Boundary и Content-Length.
  3. При необходимости создайте для Support/DevOps пример воспроизведения с помощью curl: здесь нет необходимости воспроизводить тело 1:1, но вы можете зеркалить параметры и файл(ы).

Важно: никогда не логируйте рабочие токены или персональные данные. Во многих интеграциях бизнес‑ПО именно этот аспект имеет значение для соответствия требованиям (compliance).

Варианты: несколько файлов, необязательные поля, сервер с «странными» ожиданиями

Несколько файлов под одним и тем же именем поля

Многие API ожидают files[] или повторное использование одного и того же имени. Der Builder поддерживает это напрямую: вызывайте AddFile несколько раз с тем же FieldName. Использовать ли files, files[] или attachments — это соглашение на стороне сервера.

Сервер требует точно «application/json» в качестве дополнительной части

Распространённая схема: блок метаданных в JSON плюс файл. В этом случае вы отправляете JSON как Field‑Part, но с Content-Type: application/json; charset=utf-8. Это не «форм‑поле» в UI‑смысле, но это корректно представимо в multipart:

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

Legacy: сервер принимает только filename, а не filename*

Тогда помогает fallback через filename. Если же сервер неверно декодирует не‑ASCII в filename, то чаще всего надёжный путь — игнорировать имена файлов на стороне сервера и вместо этого передавать дополнительное поле originalName в JSON.

Позиционирование в контексте модернизации и эксплуатации

В развитых Delphi-ландшафтах Multipart часто оказывается на периферии: интерфейс к DMS, архиву, системе тикетов, порталу клиентов или внутреннему REST-серверу. Именно там возникает давление со стороны новых требований безопасности (TLS, Gateways, Proxies) и вследствие роста размеров файлов.

Предложенный подход особенно оправдан, если:

  • вам нужно воспроизводимо отлаживать загрузки (эксплуатация/администрирование)
  • вы хотите/должны избегать Chunked
  • в практике действительно встречаются проблемы с именами файлов/кодировками (умлауты, пробелы, скобки)
  • нужно концептуально корректно решить повторные попытки/идемпотентность

Он меньше оправдан, если вы отправляете исключительно небольшие файлы на терпимый сервер и не нуждаетесь в прозрачности эксплуатации. В таком случае простое высокоуровневое решение достаточно — до появления первого «странного» файла из профильного подразделения.

Вывод: надёжная Multipart-Upload — это задача стриминга и эксплуатации

Корректная Multipart/Form-Data загрузка в Delphi — это меньше вопрос «какого компонента», и больше вопрос контроля: Boundary, CRLF, имя файла, Content-Type и прежде всего детерминированный поток body. Тот, кто с самого начала строит это правильно, экономит впоследствии время на циклах отладки с API‑шлюзами и reverse‑прокси.

Граница применимости подхода: если вам нужно загружать чрезвычайно большие файлы (несколько ГБ) без спулинга и без заголовка Content-Length, становится актуальной тема потоковой передачи без предварительного расчёта — тогда целевые серверы и инфраструктура должны надёжно поддерживать Chunked, и потребуется иной подход к отладке. Для многих интеграций в цифровых корпоративных решениях показанный здесь Builder именно представляет собой прагматичную середину между надёжностью, прослеживаемостью и контролируемым потреблением ресурсов.

Если у вас имеется существующая Delphi-интеграция, при которой загрузки спорадически терпят неудачу или происходят «только для некоторых файлов», это обычно указывает на описанные граничные условия. Для целевой поддержки при анализе, модернизации или прояснении вопросов эксплуатации свяжитесь с нами здесь:

В профессиональном контексте также важную роль играют Delphi Thttpclient и REST API загрузка файлов, когда интеграции, потоки данных и дальнейшее развитие должны работать согласованно.

Обсудить проект или задачу по модернизации с Net-Base.

Поделиться записью

Поделиться этой записью напрямую

LinkedIn, X, XING, Facebook, WhatsApp и E-Mail доступны сразу. Для Instagram мы сразу подготовим ссылку и краткий текст.

Электронная почта

Instagram открывается в новой вкладке. Ссылка и короткий текст предварительно копируются в буфер обмена.