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


1. はじめに

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

まずはControllerの実装をポイントにファイルダウンロードの実現方法について整理してみたいと思います。なお、ServletFilterViewを利用する方法もありますが、今回は対象外とします。


1.1. HttpServletResponse を引数として受け取る

ServletStrutsActionでお馴染みの昔ながらの方法です。ハンドラメソッドの引数として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> を戻り値として返す

ResponseEntityStreamingResponseBodyを合わせて利用する方法です。

バッファリングしながらHTTPレスポンスにデータを書き込むことができ、かつ、HTTPレスポンスヘッダも設定することが可能です。今回はこの実装方法について説明します。


2. ソースコード


LargeFileStreamingResponseBody.java

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リクエストを受け付けるスレッドとは別に非同期で実行されます。

(参考)

今回は実装していませんが、もし必要であればデータの書き込みが終了した後、ファイルを削除することも可能です。


FileDownloadController.java

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で生成したオブジェクトをコンストラクタの引数に指定して生成します。


web.xmlに追加

<!-- ★ポイント10 -->

<!-- enable async -->
<async-supported>true</async-supported>

★ポイント10

web.xmlでServlet 3.0の非同期処理を有効にします。ServletだけでなくFilterについても設定します。


fileInfo.json

{

"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にはResponseEntityStreamingResponseBodyといった便利なクラスが用意されているので、積極的に利用していきたいですね。