Spring Boot 3 における MultipartFile 未受信問題と Servlet Filter によるストリーム消費の衝突

問題の概要

Spring Boot 3.x 環境において、統一ファイルアップロード機能を実装する際、コントローラーの引数として定義した MultipartFile が常に null となり、ファイルデータが受信できない事象が発生しました。

エラー内容と初期調査

API テストツールを使用してエンドポイントへリクエストを送信すると、以下のようなエラーレスポンスが返却されました。

{
    "code": 500,
    "message": "Required part 'file' is not present.",
    "result": null,
    "type": "error"
}

このエラーは、Servlet リクエスト本体内にファイルストリームパラメータが存在しないことを示しています。Spring Boot ではファイルアップロード機能はデフォルトで有効であり、明示的な有効化設定は通常不要です。

spring:
  servlet:
    multipart:
      enabled: true
      max-file-size: 20MB
      max-request-size: 20MB

設定ファイルに上記のような構成があっても問題が解決しない場合、以下の要因が考えられます。

  • フロントエンドとバックエンドでのパラメータ名不一致
  • multipart サポートの無効化
  • ファイルサイズ制限の設定ミス
  • embedded コンテナ(Tomcat など)の設定差異
  • 一時保存ディレクトリのパス不存在
  • HttpServletRequest の入力ストリームの多重読み込み
  • AutoConfiguration の除外設定による影響

根本原因:入力ストリームの事前消費

上記の項目を逐一確認した結果、問題の原因は「HttpServletRequest の入力ストリームがインターセプターまたはフィルター内で事前に読み込まれていたこと」でした。

システムには验证码(キャプチャ)検証用のフィルターが存在し、リクエストボディを解析するためにカスタムのラッパークラスを使用していました。このラッパーはリクエスト本体をバイト配列にキャッシュし、複数回の読み込みを可能にするものでしたが、ファイルアップロード時には Spring の MultipartResolver がストリームを読み取る前にこのフィルターがストリームを消費してしまっていました。

問題となっていたラッパークラスの構造は以下の通りです。

import java.io.IOException;
import java.io.InputStream;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletRequestWrapper;

/**
 * リクエストボディをキャッシュし、複数回の読み取りを可能にするラッパー
 */
public class ReusableInputStreamRequest extends HttpServletRequestWrapper {

    private final byte[] bodyContent;

    public ReusableInputStreamRequest(HttpServletRequest request) throws IOException {
        super(request);
        // 入力ストリームをすべて読み込み、メモリ上に保持
        InputStream originalStream = request.getInputStream();
        this.bodyContent = originalStream.readAllBytes();
    }

    @Override
    public InputStream getInputStream() throws IOException {
        return new java.io.ByteArrayInputStream(bodyContent);
    }
}

このクラスはログイン処理などで JSON ボディを複数回解析する場合には有効ですが、multipart/form-data のようなストリーム処理が必要なケースでは競合を起こします。

解決策:フィルターの適用範囲の制限

対策として、このリクエストラッパーを適用するフィルターを、JSON ボディの解析が必要な特定のエンドポイント(例:ログイン認証)のみに限定するように修正しました。ファイルアップロードを含む他のパスでは、標準の HttpServletRequest をそのまま通過させるようにします。

修正後のフィルター実装例は以下のようになります。

@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) 
        throws ServletException, IOException {
    
    String path = request.getRequestURI();
    String method = request.getMethod();
    String contentType = request.getContentType();

    // JSON コンテンツであり、かつ特定の認証エンドポイントの場合のみキャッシュを適用
    boolean isJsonRequest = contentType != null && contentType.contains("application/json");
    boolean isAuthEndpoint = "/api/auth/signin".equals(path) && "POST".equals(method);

    if (isAuthEndpoint && isJsonRequest) {
        try {
            ReusableInputStreamRequest cachedRequest = new ReusableInputStreamRequest(request);
            
            // ボディから DTO を取得し验证码を検証
            // 実際の検証ロジックは省略
            validateCaptcha(cachedRequest); 
            
            chain.doFilter(cachedRequest, response);
        } catch (Exception e) {
            // 認証失敗ハンドラーへ委譲
            handleAuthFailure(response, e);
            return;
        }
    } else {
        // ファイルアップロードなどはそのまま通過
        chain.doFilter(request, response);
    }
}

この修正により、ファイルアップロードリクエストでは入力ストリームが消費されずに Spring の multipart 処理へ渡されるようになり、正常にファイルが受信できるようになりました。

{
    "code": 0,
    "message": "操作成功",
    "result": "https://storage.example.com/files/uploaded_image.png",
    "type": "success"
}

タグ: spring-boot-3 servlet-filter multipart-form-data request-wrapper java-io

5月31日 13:05 投稿