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

## 1. はじめに

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

**（ポイント）**

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

なお、ダウンロードについては「[TERASOLUNA5.x(=SpringMVC)で巨大ファイルダウンロードを実現する方法](https://qiita.com/5zm/items/0dd239bde353cf6dddae)」を参照ください。

## 2. ソースコード

### 2.1. いまいちなコード

```java:（いまいちなコード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
}
```

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

```java:（いまいちなコード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 Validation`と`BindingResult`を利用した入力チェックを利用することができません。

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

### 2.2. 紹介するコード

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

```java: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`の入力チェック用のアノテーションをフィールドに付与します。

```java: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`を拡張したクラスを定義します。
`ModelAttributeMethodProcessor`は`org.springframework.web.method.support.HandlerMethodArgumentResolver`インターフェースを実装したクラスです。

**★ポイント2**
[ModelAttributeMethodProcessorのコンストラクタ](https://docs.spring.io/autorepo/docs/spring-framework/4.3.5.RELEASE/javadoc-api/org/springframework/web/method/annotation/ModelAttributeMethodProcessor.html#ModelAttributeMethodProcessor-boolean-) を呼び出します。
今回はデフォルトの`false`でオブジェクトを生成します。

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

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

**★ポイント5**
[org.springframework.beans.PropertyValues](https://docs.spring.io/autorepo/docs/spring-framework/4.3.5.RELEASE/javadoc-api/org/springframework/beans/PropertyValues.html)インターフェースの実装クラスである[org.springframework.web.bind.ServletRequestParameterPropertyValues](https://docs.spring.io/autorepo/docs/spring-framework/4.3.5.RELEASE/javadoc-api/org/springframework/web/bind/ServletRequestParameterPropertyValues.html)クラスのオブジェクトを生成します。

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

* 第１引数　：　ハンドラメソッドの引数のオブジェクト（今回は`StreamFile`）の設定対象となるフィールド名
* 第２引数　：　設定する値

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

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

```java: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**
ハンドラメソッドの引数として入力チェックが有効になるように`@Validated`と`BindingResult`を指定します。
★ポイント1で定義した`StreamFileModelAttributeMethodProcessor`の処理により、入力チェックを実施した`StreamFile`を引数にとることが可能になります。

**★ポイント11**
通常の入力チェックと同様に`BindingResult`の`hasErrors`メソッドで入力チェックのエラー有無を確認します。
今回は入力エラーの場合、`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でレスポンスを返すことにします。


```xml: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`ファイルに定義します。

```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の場合、`maxPostSize`、`maxSavePostSize`、`maxSwallowSize`等がこの設定に関係します。今回は制限なしを意味する`-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でマルチパートを使わずにファイルアップロードする方法](https://qiita.com/5zm/items/43d78236a295819c239a)」で実装したテストでは、400MB弱のファイルを6秒でアップロードすることができました。**