Почему 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
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) или если на стороне сервера реализована дедупликация.
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 и (в случае файла спула) имеете его в виде файла.
Рекомендуемая практика:
- Запишите тело спула в временный файл.
- Логируйте
Content-Typeвключая Boundary иContent-Length. - При необходимости создайте для 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:
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 загрузка файлов, когда интеграции, потоки данных и дальнейшее развитие должны работать согласованно.