1. はじめに
今回はTERASOLUNA5.x(=SpringMVC)
でデータサイズの大きなファイルダウンロードを行う方法について説明します。データサイズが小さい場合は特に気にする必要はありませんが、大きい場合はサーバリソースについて注意する必要が出てきます。
まずはController
の実装をポイントにファイルダウンロードの実現方法について整理してみたいと思います。なお、ServletFilter
やView
を利用する方法もありますが、今回は対象外とします。
1.1. HttpServletResponse
を引数として受け取る
Servlet
やStruts
のAction
でお馴染みの昔ながらの方法です。ハンドラメソッドの引数としてHttpServletResponse
を受け取り、ダウンロードのファイルデータを書き込んでいきます。
この方法のデメリットは、生のHttpServletResponse
を扱うため、テスタビリティが悪いことです。
1.2. ResponseEntity<byte[]>
を戻り値として返す
SpringFramework
が用意したHTTPレスポンスの実装を隠ぺいしたResponseEntity
を戻り値とする方法です。HTTPレスポンスヘッダも設定できるため、HTTPレスポンスを扱うにはぴったりの方法です。
HTTPレスポンスのBODYに設定するデータを<>
に指定しますが、今回はダウンロードのファイルデータとなるため、巨大なファイルの場合はOutOfMemoryが発生する危険があります。
1.3. StreamingResponseBody
を戻り値として返す
HTTPレスポンスのBODYに直接データを書き込むStreamingResponseBody
を戻り値とする方法です。
Servlet 3.0からサポートされた非同期処理を利用し、非同期処理の処理中にHTTPレスポンスを返す方法になります。バッファリングしつつデータを書き込めるため、巨大なファイルでも問題ありません。
デメリットはHTTPレスポンスヘッダを設定できないことです。
1.4. ResponseEntity<StreamingResponseBody>
を戻り値として返す
ResponseEntity
とStreamingResponseBody
を合わせて利用する方法です。
バッファリングしながらHTTPレスポンスにデータを書き込むことができ、かつ、HTTPレスポンスヘッダも設定することが可能です。今回はこの実装方法について説明します。
2. ソースコード
package todo.app.largefile;
import java.io.BufferedInputStream;
import java.io.FileInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody;
/**
* ★ポイント1
* Implements StreamingResponseBody class for large file download
*/
public class LargeFileStreamingResponseBody implements StreamingResponseBody {
/**
* LOGGER
*/
private static final Logger LOGGER = LoggerFactory
.getLogger(LargeFileStreamingResponseBody.class);
/**
* ★ポイント2
* buffer size 1MB
*/
private static final int BUFFER_SIZE = 1 * 1024 * 1024;
/**
* filePath of download file
*/
private final String filePath;
/**
* ★ポイント3
* Constructor
* @param filePath filePath of download file
*/
public LargeFileStreamingResponseBody(String filePath) {
this.filePath = filePath;
}
/**
* ★ポイント4
* asynchronous writing for buffered content data
* @see org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody#writeTo(java.io.OutputStream)
*/
@Override
public void writeTo(OutputStream out) throws IOException {
LOGGER.debug("Start Async processing.");
try (InputStream input = new BufferedInputStream(
new FileInputStream(filePath))) {
byte[] buffer = new byte[BUFFER_SIZE];
long total = 0;
int len = 0;
while ((len = input.read(buffer)) != -1) {
out.write(buffer, 0, len);
out.flush();
total = total + len;
LOGGER.debug("writed : " + total);
}
}
LOGGER.debug("End Async processing.");
}
}
★ポイント1
org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody
インターフェースを実装したクラスを定義します。
今回は他でも利用できるように別クラスとして定義しましたが、①アロー演算子(ラムダ式)、②インナークラス(匿名クラス)として、Controllerの中に書くことも可能です。
★ポイント2
バッファリングするデータサイズを定義します。今回は1MB
としました。
★ポイント3
コンストラクタでダウンロード対象となるファイルのファイルパスを引数として受け取ります。
★ポイント4
HTTPレスポンスのBODYにデータを書き込む処理をwriteTo
メソッドに実装します。
引数のOutputStream
がレスポンスのBODYに書き込むためのストリームです。
このメソッドの処理がHTTPリクエストを受け付けるスレッドとは別に非同期で実行されます。
(参考)
今回は実装していませんが、もし必要であればデータの書き込みが終了した後、ファイルを削除することも可能です。
package todo.app.largefile;
import java.io.File;
import java.io.IOException;
import javax.inject.Inject;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody;
import com.fasterxml.jackson.databind.ObjectMapper;
@Controller
@RequestMapping("download")
public class FileDownloadController {
/**
* LOGGER
*/
private static final Logger LOGGER = LoggerFactory
.getLogger(FileDownloadController.class);
/**
* [dummy] ファイル情報を読み込むためのobjectMapper
*/
@Inject
ObjectMapper objectMapper;
/**
* [dummy] ダウンロードファイルの情報を格納したjsonファイル
*/
@Value("${app.sample.fileInfo:C:/temp/download/fileInfo.json}")
private File downloadFileInfo;
/**
* ★ポイント5
* @return ResponseEntity of StreamingResponseBody
*/
@RequestMapping(path = "chunked", method = RequestMethod.GET)
public ResponseEntity<StreamingResponseBody> chunkedDownload() {
LOGGER.debug("[start]");
// ★ポイント6
// 1. [dummy] get file info
FileInfo fileInfo = getDownloadFile();
// ★ポイント7
// 2. create StreamingResponseBody object
StreamingResponseBody responseBody = new LargeFileStreamingResponseBody(
fileInfo.getFilePath());
// ★ポイント8
// 3. create httpresponse header object
HttpHeaders responseHeaders = new HttpHeaders();
responseHeaders.set("Content-Type", fileInfo.getContentType());
// Transfer-Encoding: chunked になるので設定してはダメ
// responseHeaders.set("Content-Length",
// Long.toString(fileInfo.getContentLength()));
responseHeaders.set("X-SHA1-CheckSum", fileInfo.getCheckSum());
responseHeaders.set("Content-Disposition",
"attachment; filename=" + fileInfo.getDownloadFileName());
// ★ポイント9
// 4. create ResponseEntity object
ResponseEntity<StreamingResponseBody> responseEntity = new ResponseEntity<StreamingResponseBody>(
responseBody, responseHeaders, HttpStatus.OK);
LOGGER.debug("[end]");
return responseEntity;
}
/**
* ★ポイント6
* [dummy] jsonファイルからダウンロードファイルのファイル情報を取得する
* @return ファイル情報
*/
private FileInfo getDownloadFile() {
FileInfo fileInfo = null;
try {
fileInfo = objectMapper.readValue(downloadFileInfo, FileInfo.class);
} catch (IOException e) {
LOGGER.error(e.getMessage());
}
return fileInfo;
}
}
★ポイント5
Controller
のハンドラメソッドの戻り値をResponseEntity<StreamingResponseBody>
とします。
★ポイント6
ダウンロードの対象とするファイルのファイル情報を取得します。
今回のサンプルではファイル情報をjsonファイルから取得することにしました。
jacksonのObjectMapperを利用しているのはそのためです。
★ポイント7
ダウンロードファイルのファイルパスを引数としてLargeFileStreamingResponseBody
クラスのオブジェクトを生成します。
★ポイント8
HTTPレスポンスヘッダを設定するためorg.springframework.http.HttpHeaders
クラスのオブジェクトを生成します。
今回のサンプルでは以下のヘッダを設定しました。ここはシステム要件に応じて設定してください。
-
Content-Type
: コンテントタイプ Content-Length
: ダウンロードファイルのデータサイズ(単位はByte)-
X-SHA1-CheckSum
: ダウンロードファイルのハッシュ値(チェックサム) -
Content-Disposition
: ダウンロードファイルのファイル名
(2018/11/10 追記)
Transfer-Encoding: chunked
になるのでContent-Length
は付与するのは間違いでした。Content-Length
を付与すると正しく動作しない場合があります。
https://developer.mozilla.org/ja/docs/Web/HTTP/Headers/Transfer-Encoding
★ポイント9
戻り値となるorg.springframework.http.ResponseEntity
クラスのオブジェクトを、★ポイント7、★ポイント8で生成したオブジェクトをコンストラクタの引数に指定して生成します。
<!-- ★ポイント10 -->
<!-- enable async -->
<async-supported>true</async-supported>
★ポイント10
web.xml
でServlet 3.0の非同期処理を有効にします。ServletだけでなくFilterについても設定します。
{
"filePath" : "C:/Temp/download/sonarqube-5.6.6.zip",
"downloadFileName" : "sonarqube.zip",
"contentType" : "application/zip",
"checkSum" : "3f31020b65ffc7b230cfb3d4f87a792000f41784",
"contentLength" : 118065523
}
手元にデータサイズの大きなファイルがなかったので、今回はSonarQubeのZIPファイル(約100MB)を対象にしました。
チェックサムの確認方法については「Pythonで巨大ファイルのハッシュ値(チェックサム)を求める方法」を参照ください。
3. さいごに
今回はTERASOLUNA5.x(=SpringMVC)
でResponseEntity<StreamingResponseBody>
を戻り値として、非同期処理でバッファリングしながらデータサイズの大きなファイルダウンロードを行う方法について説明しました。
SpringFramework
にはResponseEntity
やStreamingResponseBody
といった便利なクラスが用意されているので、積極的に利用していきたいですね。