Від теми журналу до практики проєкту
Відповідні сторінки послуг і технічні сторінки до публікації
Чому 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 завантаження файлів, коли інтеграції, потоки даних та подальший розвиток мають працювати узгоджено.
Наступний крок
Якщо тема перетворюється на реальний проєкт, архітектуру, наявну інфраструктуру та експлуатацію слід розглядати разом на ранньому етапі.
Ми підтримуємо не лише в окремих питаннях, а й тоді, коли з уривків вихідного коду, питань, пов’язаних із legacy, або ідей порталу має вирости надійний корпоративний проєкт.
- Поточний стан, цільова архітектура та технічні ризики оцінюються спільно.
- REST, доступ до даних, портали та розгортання не відкладаються на пізніші етапи.
- Ви завчасно визначаєте, який підхід є економічно та операційно життєздатним.