Net-Base Списание

27.05.2026

Multipart/Form-Data качване в Delphi: надеждни потоци, контрол на границите и отстраняване на грешки без догадки

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

27.05.2026

Защо Multipart в Delphi често се проваля едва при експлоатация

Един Multipart/Form-Data upload в Delphi се конфигурира бързо – и после в реални интеграции се проваля заради детайли: неправилен Content-Type за всяка част, 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). Това често е предимство в корпоративни среди: политиките за Proxy и TLS са по-вероятно съвместими със системата. Indy от своя страна е много прозрачен и адаптивен, но носи собствени TLS-биндинги и в експлоатация понякога изисква отделна поддръжка (версии на OpenSSL, Cipher-суити).

Подходът тук използва THTTPClient, защото той при модернизации често вече е внедрен (клиент на REST, OAuth, изтегляния). Ако обаче ви е необходим строг контрол върху TLS-handshakes, клиентски сертификати в специални форми или много специфични proxy-вериги, Indy (или специализиран HTTP-стек) може да е по-подходящ. Това променя малко при изграждането на Multipart – но съществено при дебъгване и експлоатация.

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

Основната идея: Multipart в крайна сметка е само байтов поток. Ако го изградим сами, можем да:

  • Съзнателно определяме Boundary и го тестваме стабилно
  • Правилно задаваме заглавни полета за всяка част (вкл. Content-Disposition, Content-Type)
  • Content-Length изчисляваме надеждно (важно за сървъри без Chunked-поддръжка)
  • Стриймваме големи файлове, без да задържаме всичко в RAM

Кодът: Multipart-Builder със стрийминг и имена на файлове по RFC-5987

Билдърът по-долу генерира по избор или изцяло паметно базирано тяло (за малки upload-ове), или файл за спулиране на диска (за големи payloads). Това изглежда „oldschool“, но в експлоатация е изключително практично, защото избягва Chunked и улеснява дебъгването. Спулирането означава: можете да използвате повторно същото тяло на заявката, дори когато е необходим retry.

Delphi
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<TObject>;
    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 в Stream. Ако 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;
    // Поле
    Value: string;
    // Файл
    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<TObject>.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''..." е значително по-robust за имена на файлове с не-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; // Собственикът остава при извикващия
  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“: Контролът върху Header, Encodings и Boundary остава при вас. Това често е решаващо при строги REST-APIs.
  • RFC-5987-Unterstützung über filename*: Sobald Dateinamen Umlaute enthalten (z. B. „Prüfbericht.pdf“), ist das der häufigste Interop-Bug. Manche Server ignorieren filename*, dann greift filename als Fallback.
  • Spool-to-File als Betriebsfeature: Für große Uploads und Retries ist ein wiederverwendbarer Body-Stream Gold wert.
  • Content-Length ist verfügbar, weil der Body vorab erzeugt wird. Das vermeidet Chunked-Encoding, falls das Zielsystem es nicht akzeptiert.

Request senden: Timeouts, Header und eine sinnvolle Retry-Strategie

Multipart selbst löst noch nicht die Integrationsprobleme: Sie brauchen Timeouts, Fehlerklassifikation und optional Retries. Wichtig ist die Unterscheidung zwischen idempotent und nicht idempotent: Uploads sind häufig nicht idempotent (Dubletten möglich). Retries sollten daher nur erfolgen, wenn der Server eine idempotente Semantik anbietet (z. B. Upload-ID, dedizierter Idempotency-Key Header) oder Sie serverseitig Deduplizierung haben.

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;

Stolperfallen in der Praxis

  • Stream-Position: Wenn der FileStream nicht auf Position 0 steht, laden Sie nur den Rest hoch. Im Builder wird daher Seek(0) erzwungen.
  • Chunked vs. Content-Length: Einige Gateways (oder ältere Server-Stacks) lehnen Chunked ab. Das ist ein häufiger Legacy-Fall in prozessnahen Softwarelösungen. Spool-to-File ist dann pragmatisch.
  • CRLF: Multipart erwartet CRLF (#13#10), nicht nur LF. Manche Server sind tolerant, andere nicht.
  • Content-Type pro Datei: Wenn Sie pauschal application/octet-stream senden, ist das oft ok. Wenn der Server prüft (z. B. PDF), setzen Sie korrekt. In Delphi können Sie MIME-Mapping über eigene Tabelle oder OS-Funktionen lösen, aber verlassen Sie sich nicht blind auf Dateiendungen.

Debugging: reproduzierbarer Wire-Dump ohne TLS-Aufbruch

При HTTPS проксито не вижда body, ако не може да се използва MitM (напр. Fiddler-Zertifikat). Това е нормално в корпоративни среди. Der Builder помага, защото притежавате целия Body stream-базиран и (при Spool-Datei) той е наличен като файл.

Препоръчано Vorgehen:

  1. Запишете den Spool-Body в временен файл.
  2. Loggen Sie Content-Type inkl. Boundary und Content-Length.
  3. Erzeugen Sie für Support/DevOps optional ein curl-Repro: Hier müssen Sie nicht den Body 1:1 wiedergeben, aber Sie können die Parameter und Datei(n) spiegeln.

Wichtig: Loggen Sie niemals produktive Tokens oder personenbezogene Inhalte. In vielen Business-Software-Integrationen ist genau das der compliance-relevante Teil.

Varianten: mehrere Dateien, optionale Felder, Server mit „komischen“ Erwartungen

Mehrere Dateien unter demselben Feldnamen

Viele APIs erwarten files[] oder mehrfach denselben Namen. Der Builder unterstützt das direkt: Rufen Sie AddFile mehrfach mit demselben FieldName auf. Ob Sie files, files[] oder attachments verwenden, ist reine Serverkonvention.

Server verlangt exakt „application/json“ als zusätzlichem Part

Ein verbreitetes Muster: Ein JSON-Metadatenblock plus Datei. Dann senden Sie das JSON als Field-Part, aber mit Content-Type: application/json; charset=utf-8. Das ist kein „Form Field“ im UI-Sinn, aber in Multipart sauber abbildbar:

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

Legacy: Server akzeptiert nur filename, nicht filename*

Dann hilft der Fallback über filename. Wenn der Server allerdings nicht-ASCII in filename falsch dekodiert, bleibt als robuster Weg häufig nur: Dateinamen serverseitig ignorieren und stattdessen ein zusätzliches Feld originalName im JSON mitsenden.

Einordnung für Modernisierung und Betrieb

In gewachsenen Delphi-Landschaften hängt Multipart oft am Rand: eine Schnittstelle zu DMS, Archiv, Ticketing, Портал за клиенти oder ein interner REST-сървър. Genau dort entsteht Druck durch neue Sicherheitsanforderungen (TLS, Gateways, Proxies) und durch höhere Dateigrößen.

Der vorgestellte Ansatz lohnt sich besonders, wenn:

  • Sie Uploads reproduzierbar debuggen müssen (Betrieb/Administration)
  • Sie Chunked vermeiden wollen/müssen
  • Dateinamen/Encodings in der Praxis wirklich auftreten (Umlaute, Leerzeichen, Klammern)
  • Retry/Idempotency konzeptionell sauber gelöst werden soll

Er lohnt sich weniger, wenn Sie ausschließlich kleine Dateien an einen toleranten Server schicken und keinerlei Betriebstransparenz brauchen. Dann ist eine einfache High-Level-Lösung ausreichend – bis die erste „komische“ Datei aus der Fachabteilung kommt.

Fazit: Stabiler Multipart-Upload ist ein Streaming- und Betriebsproblem

Ein sauberer Multipart/Form-Data Upload in Delphi ist weniger eine Frage von „welcher Komponente“ als von Kontrolle: Boundary, CRLF, Dateiname, Content-Type und vor allem ein deterministischer Body-Stream. Wer das früh sauber baut, spart später Zeit in Debugging-Schleifen mit API-Gateways und Reverse-Proxies.

Граница на приложимост на подхода: Ако трябва да качвате изключително големи файлове (няколко GB) без Spooling и без Content-Length, темата поточно предаване без предварително изчисляване става релевантна – в този случай целевите сървъри и инфраструктурата трябва надеждно да поддържат Chunked, и ви е необходим друг концепт за отстраняване на грешки. За много интеграции в дигитални корпоративни решения обаче показаният тук билдър е точно прагматичният компромис между устойчивост, проследимост и контролируемо използване на ресурси.

Ако разполагате със съществуваща Delphi-интеграция, при която качванията спорадично се провалят или само „при някои файлове“, това обикновено е индикатор именно за тези гранични условия. За целенасочена подкрепа при анализ, модернизация или уточняване на експлоатацията можете да се свържете с нас тук:

В експертния контекст Delphi Thttpclient и REST API за качване на файлове също играят важна роля, когато интеграциите, потоците от данни и последващото развитие трябва да взаимодействат коректно.

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

Сподели публикацията

Споделете тази публикация директно

LinkedIn, X, XING, Facebook, WhatsApp и имейл са незабавно достъпни. За Instagram ще подготвим връзка и кратък текст.

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

Instagram се отваря в нов раздел. Връзката и краткият текст се копират предварително в клипборда.