Spring Boot でファイルアップロードエラーをハンドリングする

how-to-upload-file-with-spring-boot

Spring Boot におけるファイルアップロードと、ファイルアップロード時のエラーハンドリング方法に関して。サーブレットコンテナは Tomcat です。

はじめに

Spring Boot を利用したファイルアップロードの方法、及び、エラーハンドリングに関して記します ( メインはエラーハンドリング )。

別記事 ( Spring Boot 組み込みのTomcatのバージョンを変更する ) にも書きましたが、環境によってうまく動作しない場合があります。
動作が怪しい場合、サーブレットコンテナ ( のバージョン ) を変更して動作確認を行うと方法も試してみるといいかも。

※Spring Boot 組込みのTomcatとwarデプロイ時で動きが違う場合もあったので、そのあたりも確認すべし

環境

環境は以下

Spring Boot 1.3.5
Servlet Container Tomcat
Template Engine Thymeleaf

アップロード処理の実装

当記事のメインはエラーハンドリング廻りなんですが、とりあえずアップロード処理に関しても説明します。

入力画面の作成

先ずは入力画面を作成します。

<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org"
      xmlns:sec="http://www.thymeleaf.org/thymeleaf-extras-springsecurity4"
      lang="ja">
<head>
<meta charset="UTF-8"/>
<title>File upload</title>
</head>
<body>
  <form th:action="@{/upload/}" th:object="${uploadFileDto}" method="post" enctype="multipart/form-data">

    <label for="file">File</label>
    <div>
      <input type="file" name="file" id="file" th:field="*{file}"/>
	  <span th:if="${#fields.hasErrors('file')}" th:errors="*{file}" class="help-block"></span>
    </div>
    <label for="category">Category</label>
    <div>
      <input type="text" name="category" id="category" th:field="*{category}"/>
	  <span th:if="${#fields.hasErrors('category')}" th:errors="*{category}" class="help-block"></span>
    </div>
    <div>
      <input type="submit" value="Upload" />
    </div>
    </form>
</body>
</html>

form は method="post" enctype="multipart/form-data" を指定します。

入力エラーがあった場合、各入力項目の隣にエラーを出力するようにしています。( ${#fields.hasErros('...')} )

DTO (Form) の作成

作成必須ではないですが、一応フォームに対応するDTOクラスを作成します。
業務系のアプリケーションの場合、ファイルだけアップロードする場合よりも、他の値と併せて入力する場合が多いと思うので、それ想定です。今回は category というプロパティを追加しています。

category には @NotBlank アノテーションを付与し、指定されない場合エラーメッセージを出力するようにしています。

package jp.co.agilegroup.upload.dto;

import java.io.Serializable;

import org.hibernate.validator.constraints.NotBlank;
import org.springframework.web.multipart.MultipartFile;

public class UploadFileDto implements Serializable {

	/** serialVersionUID. */
	private static final long serialVersionUID = 1L;

	/** アップロードファイル. */
	MultipartFile file;
	
	/** カテゴリ. */
	@NotBlank
	String category;

	public MultipartFile getFile() {
		return file;
	}

	public void setFile(MultipartFile file) {
		this.file = file;
	}

	public String getCategory() {
		return category;
	}

	public void setCategory(String category) {
		this.category = category;
	}
}

コントローラの作成

リクエストを処理するコントローラを作成します。
実際に動作させる場合、ROOT は適当な実在するディレクトリに変更して下さい。

package jp.co.agilegroup.upload.controller;

import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Paths;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.validation.BindingResult;
import org.springframework.validation.Errors;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;

import jp.co.agilegroup.upload.dto.UploadFileDto;

@Controller
@RequestMapping("upload")
public class FileUploadController {

	public static final String ROOT = "upload-dir";
	
	/**
	 * Setup DTO (form).
	 */
    @ModelAttribute
	private UploadFileDto setUpForm() {
		return new UploadFileDto();
	}

    /**
     * Display InputForm.
     */
    @RequestMapping(method = RequestMethod.GET, value="/")
    public String index(Model model) {
        return "upload";
    }

    /**
     * Upload.
     */
	@RequestMapping(method = RequestMethod.POST, value = "/")
	public String fileUpload(@Validated UploadFileDto uploadFileDto,
			BindingResult result, Model model, Errors errors) {
		if (result.hasErrors()) {
			System.err.println(result.toString());
			return index(model);
		}

		if (!uploadFileDto.getFile().isEmpty()) {
			try {
				Files.copy(uploadFileDto.getFile().getInputStream(), Paths.get(ROOT, uploadFileDto.getFile().getOriginalFilename()));
			} catch (IOException|RuntimeException e) {
				e.printStackTrace();
			}
		} else {
			errors.rejectValue("file", "validation.uploadFile");
		}

		return "upload";
	}
}

messages.properties

コントローラでアップロードファイルが空だった場合に、エラーメッセージを設定 (rejectValue) していますが、このメッセージを messages.properties に記述しておく必要があります。

# validation
validation.uploadFile = File must be specified.

エラー発生時に問題

上記コードで Spring Boot におけるアップロード処理の大枠としては問題ないかと思いますが、実際にプロジェクトで実装している中で幾つかの問題に遭遇しました。

アップロードファイルサイズの問題

Spring Boot ではファイルアップロードサイズの上限がデフォルトで定義されており、このサイズを超えるファイルをアップロードするとエラー ( 例外が発生 ) になります。

また、このエラーに関してはコントローラ中ではハンドリングできません。
( 500 エラーが発生し、Tomcatのエラーページが表示される )。

※ちなみにサイズのデフォルト値は 1048576 bytes

エラーハンドリング

という事でエラーハンドリングを考えます。

application プロパティで最大サイズを指定

先ずは最も簡単な ( だが、逃げの ) 手順です。

Spring Boot では アップロードファイルの最大サイズを指定可能なので、これを変更し例外が発生しないようにします。
上限を変更しただけなので、ここで指定した値を超えれば当然上記問題は発生するので根本的な解決策ではありませんが、上限値として十分大きな値が設定されれば、例外が発生する可能性は殆どない状況にする事が可能かもしれません。

具体的には multipart.max-file-size、及び、multipart.max-request-size を指定します。

プロパティ説明
multipart.enabledマルチパートリクエストの利用。true or false
multipart.max-file-sizeアップロードファイルの最大サイズ
multipart.max-request-sizeリクエストの最大サイズ
複数ファイルを同時にアップロードする場合、max-file-size * ファイル数になる可能性があるため max-request-size >= max-file-size

application.yml

multipart:
  enabled: true
  max-file-size: 10Mb
  max-request-size: 10Mb

MultipartFilter を使う

application.yml の設定だけだと、"なんちゃって解決策" にしかならないので次の対策です。

Spring Boot では org.springframework.web.multipart.support.MultipartFilter というMaltipartException を処理するためのフィルタがあるのでこれを利用します。

Spring Boot でのフィルタの利用

ところで Spring Boot では web.xml を利用しませんが、フィルタを追加するにはどうすればよいでしょうか?
以下のようにする事で、フィルタを追加する事が可能です。

フィルタのプロパティを設定する場合、FilterRegistrationBean を利用します。setUrlPatterns メソッドでURIパターンや、setOrderメソッドでフィルタの順序を設定可能です。

※UrlPatterns を設定しない場合 "/*" で設定される。 order は指定しない場合最後 ( Spring Boot で既に組込み済のフィルタの後 ) に追加される。

package jp.co.agilegroup.upload;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.context.embedded.FilterRegistrationBean;
import org.springframework.context.annotation.Bean;
import org.springframework.web.multipart.support.MultipartFilter;

@SpringBootApplication
public class FileuploadTestApplication {

    public static void main(String[] args) {
        SpringApplication.run(FileuploadTestApplication.class, args);
    }

    @Bean
    public FilterRegistrationBean userInsertingMdcFilterRegistrationBean() {
        FilterRegistrationBean registrationBean = new FilterRegistrationBean();
        MultipartFilter filter = new MultipartFilter();
        registrationBean.setFilter(filter);
        // registrationBean.setOrder(Integer.MIN_VALUE);
        return registrationBean;
    }
}

Spring Boot のアプリケーションクラスで @Bean アノテーションを利用してフィルタを設定しています。
Configure 系のクラスに別途切り出してもよいでしょう。

ちなみに以下のように FilterRegistrationBean を利用しない方法も使えます。プロパティの設定が不要な場合、これでもいいでしょう。

    @Bean
    public Filter multipartFilter() {
        MultipartFilter filter = new MultipartFilter();
        return filter;
    }

動作確認

上記何れかの方法でフィルタを追加すると、Spring Boot 起動時にフィルタを追加した旨メッセージが出力されます。

サイズ上限を超過した場合、Tomcatエラーページではなく、Spring Boot の Whitelabel Error Page に遷移するようになります。

※但しコンテナ ( のバージョン ) によっては正しく機能しない場合あり。

HandlerExceptionResolver の実装

HandlerExceptionResolver を実装したクラスを用意する事で MultipleException が発生した場合の処理を行う事が可能です。

以下のコードでは、ファイルサイズが超過した場合 MultipartException (causeのcauseがFileSizeLimitExceededException) が発生するので、この場合にはファイルサイズ超過エラーである旨メッセージを設定して /error に飛ばすようになっています
( 実際のエラーハンドリングでは入力画面に戻す等の処理を行う事になるかと思います )。

※上記 MaltipartFilter とは併用できません。

package jp.co.agilegroup.upload;

import java.util.Date;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

import org.apache.tomcat.util.http.fileupload.FileUploadBase.FileSizeLimitExceededException;
import org.springframework.stereotype.Component;
import org.springframework.web.multipart.MultipartException;
import org.springframework.web.servlet.HandlerExceptionResolver;
import org.springframework.web.servlet.ModelAndView;

@Component
public class ExceptionResolver implements HandlerExceptionResolver {

    @Override
    public ModelAndView resolveException(HttpServletRequest request,
            HttpServletResponse response, Object handler, Exception e) {
        ModelAndView modelAndView = new ModelAndView();
		if (e instanceof MultipartException && e.getCause() instanceof IllegalStateException
				&& e.getCause().getCause() instanceof FileSizeLimitExceededException) {
            modelAndView.addObject("status", "500");
            modelAndView.addObject("error", e);
            modelAndView.addObject("message", "ファイルサイズ超過");
            modelAndView.setViewName("error");
            modelAndView.addObject("timestamp", new Date());
        } else {
            modelAndView.setViewName("error");
        }
        return modelAndView;
    }
}

※上で addObject している値は Whitelabel Error Page で必要になるプロパティ

動作確認

MaltipartFilter の場合にはデフォルトのエラーページや、500エラー発生時のエラーページに遷移する事になりますが、HandlerExceptionResolverの場合例外の種類等を判定して、遷移先ページを変更したり、メッセージを変更するといった事が可能になります。
上記コードの場合Whitelabel Error Page に遷移しますが、メッセージは独自に設定したものが表示される事を確認できます。

※フィルタで色々やりたい場合、独自フィルタを実装する手もある。

サーブレットコンテナ絡みの問題

上記のように MultipartFilter、あるいは、HandlerExceptionResolver を利用して、エラー処理を行える事は確認したのですが、サーブレットコンテナによってはこれらコードが正しく機能しない場合がありました。

ERR_CONNECTION_RESET が発生する

Chrome でテストを行っていたのですが、以下画面が表示されエラー表示が行われない問題に遭遇しました。

ERR_CONNECTION_RESET

Firefox 等でも正しく接続できません。

HandlerExceptionResolver を実装した際にデバッグ実行した限りでは、resolveException メソッドが実行されているのは確認できましたが、エラー画面は表示されないという...

事象

色々調査した結果、追加で以下の情報が得られました。

  • Tomcatのバージョンによっては発生しない。

    開発環境ではなくLinux上のテスト環境に warデプロイしたら、エラー画面が表示される事を確認できました。( Tomcat ver.7.0.54 )
    Spring Boot 組み込みのTomcatのバージョンを変更する の手順で組込みTomcatを変更した場合にも発生しないバージョンがあることを確認。

  • レスポンス自体が返ってきていない。

解決策

経過は端折って解決策を書きます。

「Tomcat コネクタの maxSwallowSize パラメータを調整する」

※ maxSwallowSize は 7.0.55で追加されたパラメータのようで、7.0.54 で上手くいったのはこれが理由だったらしい。

Tomcat8 の maxSwallowSize の記述は以下

The maximum number of request body bytes (excluding transfer encoding overhead) that will be swallowed by Tomcat for an aborted upload. An aborted upload is when Tomcat knows that the request body is going to be ignored but the client still sends it. If Tomcat does not swallow the body the client is unlikely to see the response. If not specified the default of 2097152 (2 megabytes) will be used. A value of less than zero indicates that no limit should be enforced.

https://tomcat.apache.org/tomcat-8.0-doc/config/http.html

上記記載からすると、多分以下のような感じ?

  • リクエストボディのバイト数が maxSwallowSize を超過する場合 aborted upload が発生する ( 場合がある )
  • aborted upload が発生した場合、クライアントに応答は返されない
  • maxSwallowSize のデフォルト値は2MB
  • マイナス値指定で無制限

アップロードするファイルのサイズが maxSwallowSize を超える場合に問題が発生しているようです。

組込みTomcatの maxSwallowSize を変更する

war デプロイする場合にはデプロイ先の Tomcat の maxSwallowSize を変更すればよいでしょう。

以下ではSpring Boot 組込みのTomcatを変更する方法を記します。

    @Bean
    public TomcatEmbeddedServletContainerFactory containerFactory() {
        return new TomcatEmbeddedServletContainerFactory() {
            @Override
            protected void customizeConnector(Connector connector) {
                super.customizeConnector(connector);
                if ((connector.getProtocolHandler() instanceof AbstractHttp11Protocol<?>)) {
                    ((AbstractHttp11Protocol<?>) connector.getProtocolHandler()).setMaxSwallowSize(-1);
                }
            }
        };
    }

上記コードをアプリケーションクラス辺りに追記する事で maxSwallowSize を設定可能です。
( 上では -1 = 無制限 で設定 )

まとめ

Spring Bootでファイルアップロード処理を実装するのは超簡単ですが、エラーハンドリングを行う場合、結構 "ややこしや~" 状態に。

実は上記以外にも Spring の CSRF トークンが有効になってたりすると、アップロードファイルサイズ超過でも CSRFのエラーメッセージが出力されるといった罠があったりします ( 適当に解決しましたけど )。

プロジェクトの要件や利用するコンテナのバージョン等を考慮し、適当なエラー処理を実装しましょう。