なぜ Delphi での Multipart が本番運用で「壊れる」ことが多いのか
Ein Multipart/Form-Data Upload in Delphi は簡単に組めますが、実運用の統合では細部で失敗することが多くあります: 各パートの不正な Content-Type、ペイロード内に誤って現れる Boundary 文字列、不適切な改行、非ASCIIのファイル名、あるいは chunked transfer encoding(Content-Length なしの HTTP)を受け付けないサーバーなどです。これに加え、個別開発の企業向けソフトウェアで典型的な運用上の問題が存在します: 大きなファイル(CAD、PDF、スキャン)、不安定なネットワーク、リバースプロキシ、厳格な API ゲートウェイ、そして管理者側のデバッグ要件です。
Delphi は System.Net.HttpClient を含む実用的なスタックを提供しますが、いわゆる「Happy Path」の例は重要な周辺条件を省いています。以下のソース断片は意図的に深く掘り下げます: Multipart をストリームとして決定論的に構築し、Content-Length を正確に算出し、ファイル名については RFC-5987 をサポートし、TLS を解体することなくリクエストを再現可能にするデバッグオプションを提供します。
アーキテクチャの判断: THTTPClient を採用する理由と、問題が顕在化する場面
THTTPClient(System.Net)はプラットフォームに応じて異なるバックエンドを使います(Windows 上では典型的に WinHTTP/WinINet)。企業環境ではこれが利点になることが多く、プロキシや TLS ポリシーがシステムと整合しやすくなります。Indy は透明性が高くカスタマイズ性に富みますが、独自の TLS バインディングを伴い、運用時に「個別に保守」する必要が生じる場合があります(OpenSSL-Versionen、Cipher-Suiten)。
ここでのアプローチは THTTPClient を利用します。理由は、モダナイゼーションの過程で既に使われていることが多いためです(REST-クライアント、OAuth、ダウンロード等)。ただし、TLS ハンドシェイクの厳密な制御や特殊な形式のクライアント証明書、非常に特殊なプロキシ経路を必要とする場合は、Indy(または専用の HTTP スタック)が適切な選択となることがあります。Multipart の組み立て自体には大きな差はありませんが、デバッグや運用面での扱いが変わります。
Multipart/Form-Data Upload in Delphi: 単なるストリーム、魔法ではない
核心は単純です: Multipart は結局のところバイトストリームに過ぎません。自分でそれを組み立てれば、次のことが可能になります:
- Boundary を意図的に選定し、安定してテストする
- 各パートのヘッダを正しく設定する(
Content-Disposition,Content-Typeを含む) Content-Lengthを確実に算出する(Chunked をサポートしないサーバーで重要)- 大きなファイルを全て RAM に保持せずストリームする
コード: ストリーミングと RFC-5987 ファイル名対応の Multipart ビルダー
以下のビルダーは、用途に応じて完全にメモリベースのボディ(小さなアップロード向け)か、ディスク上の Spool-Datei(大きなペイロード向け)を生成します。一見「oldschool」に見えるアプローチですが、運用上は非常に実用的で、Chunked を回避しデバッグを容易にします。Spool することで、リトライが必要な場合でも同一のリクエストボディを再利用できます。
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);
// ボディ全体をストリームに構築します。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<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''..." は、非 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
// 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);
// 重要: 先頭にシークしないと、ファイルの残り分しかアップロードされません。
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のサポート über
filename*: ファイル名にウムラウトが含まれる(z. B. „Prüfbericht.pdf“)場合、これが最も一般的な相互運用バグです。サーバーによってはfilename*を無視し、その場合はフォールバックとしてfilenameが使われます。 - Spool-to-File を運用機能として: 大容量アップロードやリトライ時に再利用可能なボディストリームは非常に有用です。
- Content-Length を利用可能にする: ボディを事前に生成するため、Content-Length が利用可能です。これにより、ターゲットシステムが受け付けない場合に Chunked-Encoding を回避できます。
リクエスト送信: タイムアウト、ヘッダ、および妥当なリトライ戦略
Multipart自体で統合上の問題が解決するわけではありません: タイムアウト、エラー分類、任意のリトライが必要です。重要なのは 冪等 と 非冪等 の区別です: アップロードは多くの場合非冪等(重複が発生し得る)です。したがって、リトライはサーバーが冪等性を提供する場合(z. B. Upload-ID、dedizierter Idempotency-Key Header)か、サーバー側で重複排除を行っている場合にのみ行うべきです。
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;
実務上の落とし穴
- ストリーム位置: Wenn der FileStream nicht auf Position 0 steht, laden Sie nur den Rest hoch. Im Builder wird daher
Seek(0)erzwungen. - Chunked と Content-Length: 一部のゲートウェイ(または古いサーバースタック)は Chunked を拒否します。これはプロセスに近いソフトウェアソリューションでよくあるレガシーケースです。その場合、Spool-to-File が実用的です。
- CRLF: Multipart erwartet CRLF (
#13#10), nicht nur LF. Manche Server sind tolerant, andere nicht. - ファイルごとの Content-Type: Wenn Sie pauschal
application/octet-streamsenden, 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.
デバッグ: TLS 中断なしで再現可能なワイヤーダンプ
HTTPSでは、MitM(例: Fiddler-Zertifikat)を使用できない場合、プロキシでボディを確認できません。これは企業環境では通常です。Der Builderは、完全なボディをストリームベースで保持し(スプールファイルの場合は)ファイルとして扱えるため役立ちます。
推奨手順:
- スプールボディを一時ファイルに書き込む。
- Boundary を含む
Content-TypeとContent-Lengthをログに残す。 - Support/DevOps 向けにオプションで
curl再現手順を作成する:ここでボディを 1:1 再現する必要はないが、パラメータやファイルを反映できるようにする。
重要: 本番のトークンや個人データを決してログに残さないこと。多くの業務用ソフト統合では、まさにそれがコンプライアンス上重要な要素です。
バリエーション: 複数ファイル、オプションフィールド、サーバの「特殊な」要件
同一フィールド名での複数ファイル
多くの API は files[] または同一の名前の複数回を期待します。Der Builder はこれを直接サポートします: 同じ FieldName で AddFile を複数回呼び出してください。files、files[]、attachments のいずれを使うかはサーバ側の慣習です。
サーバが追加パートとして正確に „application/json“ を要求する場合
よくあるパターン: JSON メタデータブロックとファイルの組み合わせ。JSON をフィールドパートとして送る際には Content-Type: application/json; charset=utf-8 を指定します。これは UI 上の「フォームフィールド」ではありませんが、Multipart では問題なく表現できます:
MP.AddField('metadata', '{"documentType":"report","source":"delphi"}', 'application/json; charset=utf-8');
レガシー: サーバが filename のみを受け付け、filename* を受け付けない場合
その場合は filename を使うフォールバックが有効です。しかしサーバが 非ASCII を filename 内で誤デコードする場合、堅牢な方法としては多くの場合、サーバ側でファイル名を無視し、代わりに JSON の追加フィールド originalName を送ることになります。
モダナイゼーションと運用における位置づけ
長年にわたり成長した Delphi 環境では、Multipart はしばしば周辺機能として存在します: DMS、アーカイブ、チケッティング、顧客ポータル へのインターフェース、あるいは内部の REST-サーバ。まさにそこで、TLS、ゲートウェイ、プロキシなどの新たなセキュリティ要件や、より大きなファイルサイズによる負荷が生じます。
紹介したアプローチが特に有効なのは次の場合です:
- アップロードを 再現可能に デバッグする必要がある場合(運用/管理)
- Chunked を 回避 したい/しなければならない場合
- ファイル名/エンコーディングが実際に発生する場合(ウムラウト、空白、括弧)
- Retry/Idempotency を概念的に適切に解決する必要がある場合
ただし、寛容なサーバにのみ小さなファイルを送り、運用上の透明性を全く必要としないのであれば、恩恵は小さいです。その場合はシンプルなハイレベルなソリューションで十分でしょう – 専門部門から最初の「変わった」ファイルが来るまでは。
結論: 安定した Multipart-Upload はストリーミングと運用の問題である
Delphi におけるきれいな Multipart/Form-Data アップロードは、「どのコンポーネントか」という問題というよりも、むしろ コントロール の問題です: Boundary、CRLF、ファイル名、Content-Type、そして何より決定論的なボディストリーム。これを早期に正しく設計しておけば、後々 API ゲートウェイやリバースプロキシでのデバッグループに費やす時間を節約できます。
このアプローチの適用限界:スプーリングや Content-Length を伴わずに極めて大きなファイル(数 GB 単位)をアップロードする必要がある場合、事前計算なしのストリーミング の課題が重要になります — その場合、宛先サーバとインフラが chunked を確実にサポートする必要があり、別のデバッグ概念が求められます。多くのデジタル企業向け統合においては、ここで示した Builder が堅牢性、追跡可能性、制御可能なリソース消費という点で実務的な中庸となります。
既存の Delphi-統合に依存しており、アップロードが断続的に失敗する、あるいは「一部のファイルのみ」で失敗する場合、それは概ねまさにこれらの境界条件を示す兆候です。分析、モダナイゼーション、または運用の明確化に関する個別支援が必要な場合は、こちらからご連絡ください:
専門的な文脈では、統合、データフロー、継続的な開発が整合して動作する必要がある場合、Delphi Thttpclient と REST API によるファイルアップロードも重要な役割を果たします。