4
8

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

TERASOLUNA5.x(=SpringMVC)で巨大ファイルアップロードを実現する方法

Posted at

1. はじめに

今回はTERASOLUNA5.x(=SpringMVC)でデータサイズの大きなファイルアップを行う方法について説明します。データサイズが小さい場合は特に気にする必要はありませんが、大きい場合はサーバリソースについて注意する必要が出てきます。

(ポイント)

  • アップロードのファイルデータバイナリにストリームとしてアクセスし、バッファリングしながらファイル保存の処理を行う
  • multipart/form-dataではなく、ファイル種別に応じた任意のcontent-typeでデータを送信する
  • HTTPリクエストのBODYはファイルデータそのもの(バイナリ)となり、multipart/form-data(たとえばbase64)のエンコード、デコード処理が不要となる
  • HttpServletRequestをControllerから隠蔽するため、ModelAttributeMethodProcessorを利用する

なお、ダウンロードについては「TERASOLUNA5.x(=SpringMVC)で巨大ファイルダウンロードを実現する方法」を参照ください。

2. ソースコード

2.1. いまいちなコード

(いまいちなコード1)HTTPリクエストを引数にとる場合
@RequestMapping(path = "chunked", method = RequestMethod.POST)
public ResponseEntity<String> streamUpload(HttpServletRequest httpRequest) {
            
        // You should be getted 5 parameters from HttpServletRequest and validated it
            
        // omitted
}

ServletStrutsActionでお馴染みの昔ながらの方法です。ハンドラメソッドの引数としてHttpServletRequestを受け取り、アップロードのファイルデータをBODYから読み込んでいきます。
この方法のデメリットは、生のHttpServletRequestを扱うため、テスタビリティが悪いことです。

(いまいちなコード2)パラメータを個別に定義した場合
@RequestMapping(path = "chunked", method = RequestMethod.POST)
public ResponseEntity<String> streamUpload(InputStream input,
        @RequestHeader(name = "Content-Type", required = false) String contentType,
        @RequestHeader(name = "Content-Length", required = false) Long contentLength,
        @RequestHeader(name = "X-SHA1-CheckSum", required = false) String checkSum,
        @RequestHeader(name = "X-FILE-NAME", required = false) String fileName) {
            
        // You should be validated 5 parameters
            
        // omitted
}

前述のコードに比べると@RequestHeaderを利用する等、spring-mvcの機能を利用するようになりましたが、パラメータが多いため煩雑な感じがします。
また、この方法ではBean ValidationBindingResultを利用した入力チェックを利用することができません。

(注意)
ハンドラメソッドの引数にInputStreamを指定すると、HTTPリクエストのBODYのデータを入力ストリームとして受け取ることができます。つまり、アップロードされたバイナリのファイルデータになります。

2.2. 紹介するコード

ModelAttributeMethodProcessorを利用して、必要なデータを格納したクラスをハンドラメソッドの引数として受け取るようにしたいと思います。
似た機能としてHandlerMethodArgumentResolverインターフェースがありますが、こちらは入力チェックができないためModelAttributeMethodProcessorを利用します。

StreamFile.java
public class StreamFile implements Serializable {

    private static final long serialVersionUID = 1L;

    @NotNull
    private InputStream inputStream;

    @Size(max = 200)
    @NotEmpty
    private String contentType;

    @Min(1)
    private long contentLength;

    @Size(max = 40)
    @NotEmpty
    private String checkSum;

    @Size(max = 200)
    @NotEmpty
    private String fileName;

    // omitted setter, getter
}

Formクラス(form backing bean)の入力チェックと同様、Bean Validationの入力チェック用のアノテーションをフィールドに付与します。

StreamFileModelAttributeMethodProcessor.java
package todo.app.largefile;

import java.io.IOException;
import javax.servlet.http.HttpServletRequest;

import org.springframework.core.MethodParameter;
import org.springframework.web.bind.ServletRequestParameterPropertyValues;
import org.springframework.web.bind.WebDataBinder;
import org.springframework.web.context.request.NativeWebRequest;
import org.springframework.web.method.annotation.ModelAttributeMethodProcessor;

// ★ポイント1
public class StreamFileModelAttributeMethodProcessor extends
                                                           ModelAttributeMethodProcessor {

    // ★ポイント2
    public StreamFileModelAttributeMethodProcessor() {
        super(false);
    }

    // ★ポイント3
    @Override
    public boolean supportsParameter(MethodParameter parameter) {
        return StreamFile.class.equals(parameter.getParameterType());
    }

    // ★ポイント4
    @Override
    protected void bindRequestParameters(WebDataBinder binder,
            NativeWebRequest request) {

        // ★ポイント5
        HttpServletRequest httpRequest = request
                .getNativeRequest(HttpServletRequest.class);
        ServletRequestParameterPropertyValues pvs = new ServletRequestParameterPropertyValues(
                httpRequest);

        // ★ポイント6
        pvs.add("contentType", httpRequest.getContentType());
        pvs.add("contentLength", httpRequest.getContentLengthLong());
        pvs.add("checkSum", httpRequest.getHeader("X-SHA1-CheckSum"));
        pvs.add("fileName", httpRequest.getHeader("X-FILE-NAME"));

        // ★ポイント7
        try {
            pvs.add("inputStream", httpRequest.getInputStream());
        } catch (IOException e) {
            pvs.add("inputStream", null);
        }

        // ★ポイント8
        binder.bind(pvs);
    }
}

★ポイント1
org.springframework.web.method.annotation.ModelAttributeMethodProcessorを拡張したクラスを定義します。
ModelAttributeMethodProcessororg.springframework.web.method.support.HandlerMethodArgumentResolverインターフェースを実装したクラスです。

★ポイント2
ModelAttributeMethodProcessorのコンストラクタ を呼び出します。
今回はデフォルトのfalseでオブジェクトを生成します。

★ポイント3
org.springframework.web.method.support.HandlerMethodArgumentResolversupportsParameterメソッドです。
ハンドラメソッドの引数のデータ型がStreamFile.classの場合に処理を行うようにします。

★ポイント4
今回の方法のポイントです。bindRequestParametersメソッドにHTTPリクエストから取得したデータを、ハンドラメソッドの引数のオブジェクト(今回はStreamFile)にバインド(設定)する処理を実装します。
つまり、HTTPリクエストのBODYの入力ストリームとHTTPリクエストヘッダの値を取得し、WebDataBinderにバインド(設定)します。

★ポイント5
org.springframework.beans.PropertyValuesインターフェースの実装クラスであるorg.springframework.web.bind.ServletRequestParameterPropertyValuesクラスのオブジェクトを生成します。

★ポイント6
ServletRequestParameterPropertyValuesaddメソッドでHTTPリクエストヘッダから取得した値を設定します。

  • 第1引数 : ハンドラメソッドの引数のオブジェクト(今回はStreamFile)の設定対象となるフィールド名
  • 第2引数 : 設定する値

★ポイント7
★ポイント6と同様にHTTPリクエストのBODYの入力ストリームを設定します。IOExceptionが発生した場合はnullを設定することにしました。

★ポイント8
WebDataBinderbindメソッドで★ポイント5で生成したPropertyValuesをバインド(設定)します。
これでハンドラメソッドの引数のオブジェクト(今回はStreamFile)に★ポイント6,7の値が設定されることになります。

FileUploadController.java
package todo.app.largefile;

import java.io.BufferedOutputStream;
import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Controller;
import org.springframework.validation.BindingResult;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;

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

    /**
     * LOGGER
     */
    private static final Logger LOGGER = LoggerFactory
            .getLogger(FileUploadController.class);

    /**
     * ★ポイント9
     * define max upload file size
     */
    private static final long MAX_FILE_SIZE = 500 * 1024 * 1024;

    /**
     * ★ポイント9
     * buffer size 1MB
     */
    private static final int BUFFER_SIZE = 1 * 1024 * 1024;

    // ★ポイント10
    @RequestMapping(path = "chunked", method = RequestMethod.POST)
    public ResponseEntity<String> streamUpload(
            @Validated StreamFile streamFile,
            BindingResult result) {

        // ★ポイント11
        if (result.hasErrors()) {
            LOGGER.debug("validated error = {}", result.getAllErrors());
            return new ResponseEntity<String>("validated error!",
                    HttpStatus.BAD_REQUEST);
        }

        // ★ポイント12
        if (MAX_FILE_SIZE < streamFile.getContentLength()) {
            return fileSizeOverEntity();
        }

        // ★ポイント13
        try {
            File uploadFile = File.createTempFile("upload", null);
            InputStream input = streamFile.getInputStream();
            try (OutputStream output = new BufferedOutputStream(
                    new FileOutputStream(uploadFile))) {
                byte[] buffer = new byte[BUFFER_SIZE];
                long total = 0;
                int len = 0;
                while ((len = input.read(buffer)) != -1) {
                    output.write(buffer, 0, len);
                    output.flush();
                    total = total + len;
                    LOGGER.debug("writed : " + total);
                    // ★ポイント12
                    if (MAX_FILE_SIZE < total) {
                        return fileSizeOverEntity();
                    }
                }
            }
            LOGGER.debug(uploadFile.getAbsolutePath());
            // ★ポイント14
            return new ResponseEntity<String>("success!", HttpStatus.CREATED);
        } catch (IOException e) {
            LOGGER.error(e.getMessage());
            // ★ポイント15
            return new ResponseEntity<String>("error!",
                    HttpStatus.INTERNAL_SERVER_ERROR);
        }
    }

    /**
     * ★ポイント12
     * @return ファイルサイズ超過エラー時のResponseEntity
     */
    private ResponseEntity<String> fileSizeOverEntity() {
        return new ResponseEntity<String>(
                "file size is too large. " + MAX_FILE_SIZE + "(byte) or less",
                HttpStatus.BAD_REQUEST);
    }

    /**
     * アップロードフォーム画面を表示する
     * @return アップロードフォーム画面
     */
    @RequestMapping(path = "form", method = RequestMethod.GET)
    public String form() {
        return "upload/form";
    }
}

★ポイント9
アップロードファイルのデータサイズの上限、およびファイルに保存する際に利用するバッファサイズを定義します。

★ポイント10
ハンドラメソッドの引数として入力チェックが有効になるように@ValidatedBindingResultを指定します。
★ポイント1で定義したStreamFileModelAttributeMethodProcessorの処理により、入力チェックを実施したStreamFileを引数にとることが可能になります。

★ポイント11
通常の入力チェックと同様にBindingResulthasErrorsメソッドで入力チェックのエラー有無を確認します。
今回は入力エラーの場合、HttpStatus.BAD_REQUESTつまりHTTPレスポンスステータスコードの400でレスポンスを返すことにします。

★ポイント12
アップロードファイルのデータサイズが★ポイント9で定義した上限を超過していないかチェックします。
データサイズのチェックは①Content-Lengthヘッダの値、②実際に読み込んだデータサイズの二ヶ所でチェックします。
上限を超過していた場合、HttpStatus.BAD_REQUESTつまりHTTPレスポンスステータスコードの400でレスポンスを返すことにします。、

★ポイント13
HTTPリクエストのBODYの入力ストリームから★ポイント9で定義したバッファサイズでデータを読み込み、アップロードファイルをファイルとして保存します。
今回は一時ディレクトリに保存することにしました。ここは業務要件に応じて修正してください。

★ポイント14
アップロードファイルがサーバ上にファイルとして保存できたため、HttpStatus.CREATEDつまりHTTPレスポンスステータスコードの201でレスポンスを返すことにします。

(注意)
今回はアップロードファイルのメタデータ(ファイル名、コンテントタイプ、データサイズ、チェックサム(ハッシュ値)等)を保存していません。
実際のシステムではアップロードファイルにアクセス(ダウンロード、アプリで開く等)する際、メタデータが必要なるためメタデータをデータベース等に保存します。

★ポイント15
処理中にIOExceptionが発生した場合、今回はHttpStatus.INTERNAL_SERVER_ERRORつまりHTTPレスポンスステータスコードの500でレスポンスを返すことにします。

SpringのBean定義ファイル(spring-mvc.xml)
<!-- omitted -->
<mvc:annotation-driven>
    <mvc:argument-resolvers>
        <!-- omitted -->
        <bean class="todo.app.largefile.StreamFileModelAttributeMethodProcessor"/>
    </mvc:argument-resolvers>
</mvc:annotation-driven>

今回実装したStreamFileModelAttributeMethodProcessorを有効にするため、<mvc:argument-resolvers>に★ポイント1のBeanを追加します。
TERASOLUNA5.xの場合はspring-mvc.xmlファイルに定義します。

アプリケーションサーバの設定(例:Tomcatのserver.xml)
<Connector connectionTimeout="200000" port="8090" 
    protocol="HTTP/1.1" redirectPort="8443"
    maxPostSize="-1" maxParameterCount="-1"
    maxSavePostSize="-1" maxSwallowSize="-1"
    socket.appReadBufSize="40960"/>

巨大ファイルをアップロードする場合、アプリケーションサーバの設定についても確認する必要があります。

  • タイムアウト

  • データサイズが大きい場合、それだけデータ送信に時間が掛かります。データ送信が完了する前にタイムアウトにならないように設定する必要があります。

  • Tomcat8.0の場合、connectionTimeoutで設定します。単位はミリ秒でデフォルトは20000(20秒)です。

  • リクエストのデータサイズ

  • 一般的にアプリケーションサーバではリクエストのデータサイズの上限が設定されています。アップロードを許可するデータサイズに合わせて設定する必要があります。この設定を行わないと上限に引っ掛かり、アプリケーションサーバがリクエストを切断してしまいます。

  • ファイルアップロード(multipart/form-data)のHTTPリクエストの場合は別のデータサイズを設定できるサーバもあります。ただし、今回の方法はmultipart/form-dataではないため、普通のHTTPリクエストと判断されます。

  • Tomcat8.0の場合、maxPostSizemaxSavePostSizemaxSwallowSize等がこの設定に関係します。今回は制限なしを意味する-1としました。

  • リクエストのデータの読み込みバッファサイズ

  • リクエストの入力ストリームから指定したバッファサイズ(★ポイント9のBUFFER_SIZE)でデータが取得できないことで判明しました。

  • Tomcat8.0の場合、socket.appReadBufSizeで設定します。単位はバイトでフォルトは8192です。★ポイント9のBUFFER_SIZEの値と併せてチューニングする必要があります。

Tomcat8.0の場合、https://tomcat.apache.org/tomcat-8.0-doc/config/http.html を参照ください。

3. さいごに

今回はTERASOLUNA5.x(=SpringMVC)でデータサイズの大きなファイルアップを行う方法について説明しました。
ポイントはModelAttributeMethodProcessorを利用した引数の設定と、データサイズが大きいことによるアプリケーションサーバのチューニングの必要性です。
クライアントを「ajaxでマルチパートを使わずにファイルアップロードする方法」で実装したテストでは、400MB弱のファイルを6秒でアップロードすることができました。

4
8
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
4
8

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?