1. はじめに
今回はTERASOLUNA5.x(=SpringMVC)
でデータサイズの大きなファイルアップを行う方法について説明します。データサイズが小さい場合は特に気にする必要はありませんが、大きい場合はサーバリソースについて注意する必要が出てきます。
(ポイント)
- アップロードのファイルデータバイナリにストリームとしてアクセスし、バッファリングしながらファイル保存の処理を行う
-
multipart/form-data
ではなく、ファイル種別に応じた任意のcontent-type
でデータを送信する - HTTPリクエストのBODYはファイルデータそのもの(バイナリ)となり、
multipart/form-data
(たとえばbase64)のエンコード、デコード処理が不要となる -
HttpServletRequest
をControllerから隠蔽するため、ModelAttributeMethodProcessor
を利用する
なお、ダウンロードについては「TERASOLUNA5.x(=SpringMVC)で巨大ファイルダウンロードを実現する方法」を参照ください。
2. ソースコード
2.1. いまいちなコード
@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
を扱うため、テスタビリティが悪いことです。
@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
を利用します。
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
の入力チェック用のアノテーションをフィールドに付与します。
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のコンストラクタ を呼び出します。
今回はデフォルトのfalse
でオブジェクトを生成します。
★ポイント3
org.springframework.web.method.support.HandlerMethodArgumentResolver
のsupportsParameter
メソッドです。
ハンドラメソッドの引数のデータ型がStreamFile.class
の場合に処理を行うようにします。
★ポイント4
今回の方法のポイントです。bindRequestParameters
メソッドにHTTPリクエストから取得したデータを、ハンドラメソッドの引数のオブジェクト(今回はStreamFile
)にバインド(設定)する処理を実装します。
つまり、HTTPリクエストのBODYの入力ストリームとHTTPリクエストヘッダの値を取得し、WebDataBinder
にバインド(設定)します。
★ポイント5
org.springframework.beans.PropertyValuesインターフェースの実装クラスであるorg.springframework.web.bind.ServletRequestParameterPropertyValuesクラスのオブジェクトを生成します。
★ポイント6
ServletRequestParameterPropertyValues
のadd
メソッドでHTTPリクエストヘッダから取得した値を設定します。
- 第1引数 : ハンドラメソッドの引数のオブジェクト(今回は
StreamFile
)の設定対象となるフィールド名 - 第2引数 : 設定する値
★ポイント7
★ポイント6と同様にHTTPリクエストのBODYの入力ストリームを設定します。IOException
が発生した場合はnull
を設定することにしました。
★ポイント8
WebDataBinder
のbind
メソッドで★ポイント5で生成したPropertyValues
をバインド(設定)します。
これでハンドラメソッドの引数のオブジェクト(今回はStreamFile
)に★ポイント6,7の値が設定されることになります。
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でレスポンスを返すことにします。
<!-- 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
ファイルに定義します。
<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でマルチパートを使わずにファイルアップロードする方法」で実装したテストでは、400MB弱のファイルを6秒でアップロードすることができました。