Net-Base Περιοδικό

27.05.2026

Μεταφόρτωση Multipart/Form-Data σε Delphi: ανθεκτικά streams, έλεγχος ορίων (boundary) και αποσφαλμάτωση χωρίς εικασίες

Οι μεταφορές Multipart/Form-Data φαίνονται απλές, αλλά σε Delphi καταρρέουν γρήγορα όταν εμπλέκονται Streams, ονόματα αρχείων, Content-Type, Boundary-Handling και Timeouts. Αυτό το απόσπασμα πηγαίου κώδικα παρουσιάζει μια ανθεκτική, ευανάγνωστη και ευδιάγνωστη υλοποίηση με THTTPClient – συμπεριλαμβανομένου του σωστά υπολογισμένου Content-Length...

27.05.2026

Γιατί το Multipart σε Delphi συχνά αποτυγχάνει μόνο κατά τη λειτουργία

Ένας Multipart/Form-Data Upload σε Delphi στήνεται γρήγορα με μερικά κλικ — και στη συνέχεια αποτυγχάνει σε πραγματικές ενσωματώσεις λόγω λεπτομερειών: λανθασμένος Content-Type ανά μέρος, ένας Boundary-String που εμφανίζεται κατά λάθος στο payload, ακατάλληλοι χαρακτήρες νέας γραμμής, ονόματα αρχείων μη ASCII ή servers που απορρίπτουν chunked transfer encoding (HTTP χωρίς Content-Length). Σε αυτά προστίθενται τυπικά πρακτικά προβλήματα σε εξατομικευμένο εταιρικό λογισμικό: μεγάλα αρχεία (CAD, PDFs, Scans), μεταβαλλόμενα δίκτυα, Reverse-Proxies, αυστηρά API-Gateways και απαιτήσεις διαχειριστών για debugging.

Delphi παρέχει με το System.Net.HttpClient ένα λειτουργικό stack, αλλά τα παραδείγματα του «Happy Path» αφήνουν εκτός σημαντικές γωνιακές περιπτώσεις. Το παρακάτω απόσπασμα κώδικα μπαίνει σκόπιμα πιο βαθιά: κατασκευάζουμε το Multipart ως stream ντετερμινιστικά, υπολογίζουμε σωστά το Content-Length, υποστηρίζουμε RFC-5987 για ονόματα αρχείων και προσφέρουμε μια επιλογή debug που κάνει το request αναπαραγώγιμο χωρίς να χρειάζεται να σπάσετε το TLS.

Αρχιτεκτονική επιλογή: THTTPClient αντί για Indy — και πότε αυτό παρουσιάζει προβλήματα

THTTPClient (System.Net) χρησιμοποιεί, ανάλογα με την πλατφόρμα, διαφορετικά backends (υπό Windows τυπικά WinHTTP/WinINet). Αυτό είναι συχνά πλεονεκτικό σε επιχειρησιακά περιβάλλοντα: οι πολιτικές proxy και TLS είναι γενικά πιο συμβατές με το σύστημα. Το Indy από την άλλη είναι πολύ διαφανές και προσαρμόσιμο, αλλά φέρει δικούς του TLS-bindings και στο πεδίο μπορεί να χρειάζεται «ξεχωριστή συντήρηση» (εκδόσεις OpenSSL, Cipher-Suiten).

Η προσέγγιση εδώ χρησιμοποιεί THTTPClient, επειδή σε έργα εκσυγχρονισμού συχνά ήδη υπάρχει (REST-Client, OAuth,Downloads). Αν όμως χρειάζεστε αυστηρό έλεγχο επί των TLS-handshakes, client certificates σε ειδικές μορφές ή πολύ ειδικές αλυσίδες proxy, τότε το Indy (ή ένας εξειδικευμένος HTTP-stack) μπορεί να είναι προτιμητέο. Αυτό αλλάζει ελάχιστα την κατασκευή του Multipart — επηρεάζει όμως το debugging και τον λειτουργικό χειρισμό.

Multipart/Form-Data Upload σε Delphi: ένα Stream, καμία μαγεία

Η βασική ιδέα: το Multipart στο τέλος είναι απλώς ένα byte-stream. Αν το κατασκευάσουμε εμείς, μπορούμε να:

  • επιλέξουμε το Boundary με πρόθεση και να το δοκιμάσουμε σταθερά
  • ορίσουμε σωστά τους header ανά Part (συμπεριλαμβανομένων Content-Disposition, Content-Type)
  • υπολογίσουμε αξιόπιστα το Content-Length (σημαντικό για servers χωρίς υποστήριξη chunked)
  • stream-άρουμε μεγάλα αρχεία χωρίς να τα φορτώνουμε όλα στη RAM

Ο κώδικας: Multipart-Builder με streaming και RFC-5987 ονόματα αρχείων

Ο builder παρακάτω παράγει είτε ένα αποκλειστικά μνήμη-βασισμένο body (για μικρά uploads) είτε μια Spool-Datei στο δίσκο (για μεγάλα payloads). Αυτό φαίνεται «oldschool», αλλά στην πράξη είναι εξαιρετικά πρακτικό, επειδή αποφεύγει το chunked και διευκολύνει το debugging. Το spoolen σημαίνει: μπορείτε να επαναχρησιμοποιήσετε το ίδιο request-body, ακόμη και αν χρειαστεί 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);

    // Baut den kompletten Body in einen Stream. Wenn ASpoolToFile leer ist,
    // wird ein TMemoryStream verwendet; sonst eine Datei erzeugt.
    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<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 sollte hinreichend zufällig sein. Wichtig: keine Leerzeichen.
  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-Header sind ASCII. Für Werte im Body (z. B. UTF-8) setzen wir Content-Type pro Part.
  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''..." ist für Nicht-ASCII-Dateinamen deutlich robuster als nur 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 darf nicht nil sein');

  if (FileStream is TCustomMemoryStream) and (FileStream.Size = 0) then
    ; // erlaubt, aber oft ein Fehler: leere Datei

  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
    // Achtung: Streamposition wird konsumiert.
    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
        // Zwei Dateiname-Parameter: filename (für alte Server) und 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);

        // Wichtig: Position auf Anfang setzen, sonst werden nur RESTe hochgeladen.
        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-APIs.
  • Υποστήριξη RFC-5987 μέσω filename*: Μόλις τα ονόματα αρχείων περιέχουν Umlaute (π.χ. „Prüfbericht.pdf“), αυτό είναι το πιο συχνό σφάλμα interoperability. Κάποιοι servers αγνοούν filename*, οπότε το filename λειτουργεί ως fallback.
  • Spool-to-File ως λειτουργικό χαρακτηριστικό: Για μεγάλες μεταφορτώσεις και επανπροσπάθειες, ένας επαναχρησιμοποιήσιμος body-stream έχει μεγάλη αξία.
  • Content-Length είναι διαθέσιμο, επειδή το body παράγεται εκ των προτέρων. Αυτό αποφεύγει το Chunked-Encoding όταν το σύστημα-προορισμός δεν το αποδέχεται.

Αποστολή αιτήματος: Timeouts, επικεφαλίδες και μια ορθή στρατηγική επανπροσπάθειας

Το Multipart από μόνο του δεν λύει τα προβλήματα ολοκλήρωσης: χρειάζεστε timeouts, ταξινόμηση σφαλμάτων και προαιρετικές επανπροσπάθειες. Σημαντική είναι η διάκριση μεταξύ idempotent και μη idempotent: τα uploads συχνά δεν είναι idempotent (μπορεί να προκύψουν διπλότυπα). Επανπροσπάθειες πρέπει να γίνονται μόνο όταν ο server προσφέρει idempotente σημασιολογία (π.χ. Upload-ID, αφιερωμένος header Idempotency-Key) ή όταν υπάρχει server-side deduplizierung.

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;

Παγίδες στην πράξη

  • Θέση του Stream: Εάν το FileStream δεν βρίσκεται στη θέση 0, θα μεταφορτωθεί μόνο το υπόλοιπο. Στον Builder επιβάλλεται επομένως Seek(0).
  • Chunked vs. Content-Length: Ορισμένα Gateways (ή παλαιότερα Server-Stacks) απορρίπτουν το Chunked. Αυτό είναι συχνή περίπτωση legacy σε προσεγγίσεις κοντά στη διαδικασία. Το Spool-to-File είναι τότε μια πρακτική λύση.
  • CRLF: Το Multipart αναμένει CRLF (#13#10), όχι μόνο LF. Κάποιοι servers είναι ανεκτικοί, άλλοι όχι.
  • Content-Type ανά αρχείο: Εάν στέλνετε γενικά application/octet-stream, συχνά είναι αποδεκτό. Αν ο server ελέγχει (π.χ. PDF), ορίστε σωστά. Στο Delphi μπορείτε να υλοποιήσετε MIME-mapping μέσω δικής σας πίνακας ή λειτουργιών του OS, αλλά μην βασίζεστε τυφλά στις επεκτάσεις αρχείων.

Debugging: αναπαραγόμενος Wire-Dump χωρίς αποκρυπτογράφηση TLS

Σε HTTPS δεν βλέπετε το Body στον proxy, εάν δεν επιτρέπεται η χρήση MitM (π.χ. Fiddler-πιστοποιητικού). Αυτό είναι συνηθισμένο σε εταιρικά περιβάλλοντα. Ο Builder βοηθά, επειδή έχετε το πλήρες Body ως stream και (σε περίπτωση Spool-Datei) διαθέσιμο και ως αρχείο.

Συνιστώμενη Vorgehensweise:

  1. Γράψτε το Spool-Body σε ένα προσωρινό αρχείο.
  2. Καταγράψτε τα Content-Type συμπεριλαμβανομένου του Boundary και το Content-Length.
  3. Δημιουργήστε προαιρετικά για Support/DevOps ένα curl-Repro: Εδώ δεν χρειάζεται να αναπαράγετε το Body 1:1, αλλά μπορείτε να αντικατοπτρίσετε τις παραμέτρους και το/τα αρχείο/αρχείa.

Σημαντικό: Μην καταγράφετε ποτέ παραγωγικά διακριτικά (Tokens) ή προσωπικά δεδομένα. Σε πολλές ενσωματώσεις επιχειρηματικού λογισμικού αυτό είναι ακριβώς το κομμάτι που αφορά τη συμμόρφωση.

Παραλλαγές: πολλαπλά αρχεία, προαιρετικά πεδία, διακομιστής με «ασυνήθιστες» απαιτήσεις

Πολλαπλά αρχεία με το ίδιο όνομα πεδίου

Πολλές APIs αναμένουν files[] ή επανειλημμένα το ίδιο όνομα. Ο Builder το υποστηρίζει άμεσα: καλέστε AddFile επανειλημμένα με το ίδιο FieldName. Το αν θα χρησιμοποιήσετε files, files[] ή attachments είναι καθαρά σύμβαση του server.

Ο διακομιστής απαιτεί ακριβώς „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*

Τότε βοηθά το fallback μέσω filename. Αν όμως ο server αποκωδικοποιεί λανθασμένα μη-ASCII στο filename, ως πιο ανθεκτικός τρόπος συχνά μένει μόνο: να αγνοηθεί το όνομα αρχείου server-side και να σταλεί επιπλέον πεδίο originalName μέσα στο JSON.

Συμφραζόμενα για εκσυγχρονισμό και λειτουργία

Σε ανεπτυγμένα Delphi-περιβάλλοντα το Multipart συχνά βρίσκεται στην περίμετρο: μια διεπαφή προς DMS, αρχείο, ticketing, Πύλη πελατών ή ένας εσωτερικός REST-διακομιστής. Εκεί ακριβώς δημιουργείται πίεση από νέες απαιτήσεις ασφάλειας (TLS, Gateways, Proxies) και από αυξανόμενα μεγέθη αρχείων.

Η περιγραφόμενη προσέγγιση αποδίδει ιδιαίτερα όταν:

  • Πρέπει να κάνετε αναπαραγώγιμη αποσφαλμάτωση των uploads (λειτουργία/διαχείριση)
  • Θέλετε/πρέπει να αποφύγετε το Chunked
  • Στην πράξη εμφανίζονται ονόματα αρχείων/κωδικοποιήσεις (π.χ. Umlaute, κενά, παρενθέσεις)
  • Το retry/η idempotency πρέπει να λυθεί εννοιολογικά καθαρά

Αποδίδει λιγότερο όταν στέλνετε αποκλειστικά μικρά αρχεία σε έναν ανεκτικό server και δεν χρειάζεστε καμία διαφάνεια λειτουργίας. Τότε μια απλή λύση υψηλού επιπέδου είναι επαρκής — μέχρι να εμφανιστεί το πρώτο «περίεργο» αρχείο από την Fachabteilung.

Συμπέρασμα: Ένας σταθερός Multipart-Upload είναι ζήτημα streaming και λειτουργίας

Ένας καθαρός Multipart/Form-Data Upload σε Delphi είναι λιγότερο ζήτημα «ποιας συνιστώσας» και περισσότερο θέμα ελέγχου: Boundary, CRLF, όνομα αρχείου, Content-Type και—πρωτίστως—ένας ντετερμινιστικός Body-Stream. Όποιος το υλοποιήσει σωστά από νωρίς εξοικονομεί χρόνο αργότερα σε κύκλους αποσφαλμάτωσης με API-Gateways και Reverse-Proxies.

Όρια εφαρμογής της προσέγγισης: Εάν πρέπει να ανεβάσετε εξαιρετικά μεγάλα αρχεία (αρκετά GB) χωρίς spooling και χωρίς Content-Length, γίνεται σχετικό το ζήτημα του Streaming χωρίς προκαταρκτικό υπολογισμό – τότε οι διακομιστές προορισμού και η υποδομή πρέπει να υποστηρίζουν αξιόπιστα chunked, και χρειάζεστε ένα διαφορετικό σχέδιο αποσφαλμάτωσης. Για πολλές ενσωματώσεις σε ψηφιακές επιχειρησιακές λύσεις είναι ο εδώ παρουσιαζόμενος Builder όμως ακριβώς η πρακτική μέση λύση ανάμεσα σε ανθεκτικότητα, ιχνηλασιμότητα και ελεγχόμενη κατανάλωση πόρων.

Εάν βασίζεστε σε μια ανεπτυγμένη Delphi-Integration, στην οποία τα Uploads σποραδικά αποτυγχάνουν ή μόνο «σε ορισμένα αρχεία», αυτό συνήθως αποτελεί ένδειξη για αυτές ακριβώς τις οριακές συνθήκες. Για στοχευμένη υποστήριξη στην ανάλυση, τον εκσυγχρονισμό ή τη διευκρίνιση λειτουργίας μπορείτε να επικοινωνήσετε μαζί μας εδώ:

Στο τεχνικό περιβάλλον παίζουν επίσης σημαντικό ρόλο ο Delphi Thttpclient και το REST API μεταφόρτωσης αρχείων, όταν ενσωματώσεις, ροές δεδομένων και περαιτέρω ανάπτυξη πρέπει να συνεργάζονται με σαφήνεια.

Συζητήστε έργο ή σχέδιο εκσυγχρονισμού με Net-Base.

Κοινοποίηση δημοσίευσης

Μοιραστείτε αυτήν την ανάρτηση απευθείας

LinkedIn, X, XING, Facebook, WhatsApp und E‑Mail είναι άμεσα διαθέσιμα. Για το Instagram ετοιμάζουμε άμεσα τον σύνδεσμο και το σύντομο κείμενο.

Ηλεκτρονικό ταχυδρομείο

Το Instagram ανοίγει σε μια νέα καρτέλα. Ο σύνδεσμος και το σύντομο κείμενο αντιγράφονται πρώτα στο πρόχειρο.