ファイルアップロードは退屈に聞こえるかもしれませんが、本番環境で MultipartException: Failed to parse multipart servlet request のデバッグに午後を費やしたことのある人にはそうではありません。

Spring Boot では、ファイルアップロードには MultipartFileMultipartAutoConfigurationspring.servlet.multipart.* プロパティが関わり、サーブレットコンテナ上で動作しているという暗黙の前提があります。動作はしますが、多くの隠れた仕組みを抱えています。

Solon は異なるアプローチを取ります。コアクラスは UploadedFile で、余分な設定なしに Web レイヤーに存在し、マルチパート解析のタイミングと一時ファイルのクリーンアップを明示的に制御できます。

基本: UploadedFile

コントローラーメソッドに UploadedFile パラメータが含まれている場合、Solon は自動的にマルチパート解析をトリガーします。アノテーションは不要です。

@Controller
public class FileController {

    @Post
    @Mapping("/upload")
    public String upload(UploadedFile file) {
        try {
            file.transferTo(new File("/storage/uploads/" + file.name));
            return "uploaded: " + file.name + " (" + file.contentSize + " bytes)";
        } finally {
            file.delete(); // 必ずクリーンアップする
        }
    }
}

Enter fullscreen mode Exit fullscreen mode

最もよく使用する主なプロパティ:

プロパティ 説明
file.name 拡張子を含む完全なファイル名
file.extension 拡張子のみ(例: jpg
file.contentType MIME タイプ
file.contentSize バイト単位のファイルサイズ
file.content 生の InputStream
file.contentAsBytes byte[]
file.isEmpty() アップロードが空かどうか

注意点: フレームワークは一時ファイルを自動的にクリーンアップしませんdelete() を呼び出さない場合、それらのテンポラリファイルはディスクに残ります。上記の try/finally パターンはオプションではありません。

同じフィールド名での複数ファイル

クライアントが同じフィールド名で複数のファイルを送信する場合、UploadedFile[] を使用します(v2.3.8 以降でサポート):

@Post
@Mapping("/upload/batch")
public void uploadBatch(UploadedFile[] files) {
    for (UploadedFile file : files) {
        try {
            file.transferTo(new File("/storage/" + file.name));
        } finally {
            file.delete();
        }
    }
}

Enter fullscreen mode Exit fullscreen mode

フィールド名が一致しない場合

フォームのフィールド名がパラメータ名と異なる場合は、@Param を使用します:

@Post
@Mapping("/avatar")
public void setAvatar(@Param("user_avatar") UploadedFile file) {
    // フォームフィールド "user_avatar" を処理
}

Enter fullscreen mode Exit fullscreen mode

混合フォーム: ファイル + 通常フィールド

同じリクエストでファイルとテキストフィールドの両方を受信できます。パラメータインジェクションは両方を自動的に処理します:

@Post
@Mapping("/upload/with-meta")
public void uploadWithMeta(UploadedFile file, String description, int category) {
    try {
        // file: アップロードされたドキュメント
        // description, category: プレーンテキストのフォームフィールド
        saveFile(file, description, category);
    } finally {
        file.delete();
    }
}

Enter fullscreen mode Exit fullscreen mode

UploadedFile パラメータがない場合

マルチパートフォームにファイル添付がなく、テキストフィールドのみが含まれる場合があります。その場合、Solon は自動的にマルチパート解析をトリガーしません。マッピングで multipart = true を使用します:

@Post
@Mapping(path = "/submit", multipart = true)
public void submit(String username, int age) {
    // テキストフィールドのみのマルチパートフォーム
}

Enter fullscreen mode Exit fullscreen mode

より細かい制御が必要な場合は、Context を介して手動でファイルにアクセスすることもできます:

@Post
@Mapping(path = "/upload/manual", multipart = true)
public void uploadManual(String username, Context ctx) {
    UploadedFile file = ctx.file("attachment");
    UploadedFile[] extras = ctx.files("extras");
    // 処理...
}

Enter fullscreen mode Exit fullscreen mode

セキュリティ: autoMultipart の制御

デフォルトでは、autoMultiparttrue です。任意のマルチパートリクエストが自動的に解析されます。公開向けサービスでは、クライアントが任意のエンドポイントに大きなファイルを送信して解析オーバーヘッドを引き起こす可能性があります。

ルーターフィルタでこれを強化します:

Solon.start(App.class, args, app -> {
    app.router().filter(-1, (ctx, chain) -> {
        // アップロードパスでのみマルチパートを解析
        ctx.autoMultipart(ctx.path().startsWith("/upload"));
        chain.doFilter(ctx);
    });
});

Enter fullscreen mode Exit fullscreen mode

集中型のテンポラリファイルクリーンアップ(v2.7.3+)には、フィルタも効果的です:

@Component
public class MultipartCleanupFilter implements Filter {
    @Override
    public void doFilter(Context ctx, FilterChain chain) throws Throwable {
        try {
            chain.doFilter(ctx);
        } finally {
            if (ctx.isMultipartFormData()) {
                ctx.filesDelete(); // アップロードされたすべてのテンポラリファイルをクリーンアップ
            }
        }
    }
}

Enter fullscreen mode Exit fullscreen mode

注: このパターンを使用する場合、アップロードハンドラを非同期にしないでください。フィルタはリクエスト完了時に実行されますが、非同期ハンドラはクリーンアップが実行される時点でもまだ動作している可能性があります。

ファイルサイズの設定

# app.yml
server:
  request:
    maxBodySize: 2mb          # リクエストボディの最大サイズ(デフォルト: 2mb)
    maxFileSize: 20mb         # 単一ファイルの最大サイズ
    maxHeaderSize: 8kb        # ヘッダーの最大サイズ
    fileSizeThreshold: 512kb  # このサイズ未満: メモリ; 以上: テンポラリファイル(v3.6.0+)

Enter fullscreen mode Exit fullscreen mode

fileSizeThreshold 設定(v3.6.0 で導入)は、小さなファイルを自動的にメモリに、大きなファイルをディスクにルーティングします。v3.6.0 より前は、強制的なテンポラリファイルモードのために useTempfile: true がありましたが、このフラグは現在非推奨です。

ファイルのダウンロード

ファイルの返却も同様に簡潔です。バイト配列やストリームには DownloadedFile を返し、java.io.File を直接返すこともできます:

@Get
@Mapping("/download/report")
public DownloadedFile downloadReport() {
    byte[] pdf = reportService.generatePdf();
    return new DownloadedFile("application/pdf", pdf, "report.pdf");
}

@Get
@Mapping("/download/avatar/{userId}")
public File downloadAvatar(@Path String userId) {
    return new File("/storage/avatars/" + userId + ".jpg");
}

Enter fullscreen mode Exit fullscreen mode

より細かい制御(キャッシュ、インライン表示、ETag)を行う場合:

@Get
@Mapping("/preview/logo")
public DownloadedFile previewLogo() {
    DownloadedFile file = new DownloadedFile(new File("/assets/logo.png"));
    file.asAttachment(false);       // インライン表示(ダウンロードをトリガーしない)
    file.cacheControl(3600);        // 1 時間の 304 キャッシュ
    file.eTag("logo-v3");
    return file;
}

Enter fullscreen mode Exit fullscreen mode

DownloadedFile は HTTP Range もサポートしているため、追加設定なしでビデオストリーミングや大容量ファイルの再開可能なダウンロードに対応します。

追加の依存関係不要

上記のすべては solon-web に含まれており、別途マルチパートスターターや自動設定クラスを探す必要はありません:

<dependency>
    <groupId>org.noear</groupId>
    <artifactId>solon-web</artifactId>
</dependency>

Enter fullscreen mode Exit fullscreen mode

JDK HTTP、Jetty、Undertow、Grizzly、smarthttp など、任意のサーバーアダプタを選択できます。いずれも fileSizeThreshold を介してテンポラリファイルモードをサポートします。

メンタルモデル

Spring との違いは、主に明示性にあります:

  • Spring Boot: サーブレットレイヤーによって MultipartFile が注入され、MultipartAutoConfiguration がすべてを登録し、spring.servlet.multipart.* が制限を設定し、CommonsMultipartResolver はオプション。
  • Solon: UploadedFile パラメータ → 解析がトリガーされ、delete() を呼び出すまでテンポラリファイルは残り、autoMultipart によりパスレベルでの制御が可能。設定は server.request.* に存在。

ほとんどのプロジェクトでは違いはわずかです。重要なのは、解析のタイミング、メモリとディスクのどちらに存在するのか、クリーンアップの責任は誰にあるのかを明確に理解したい場合です。Solon はこれらの質問を自動設定の中に隠すのではなく、明示的にします。


参考: