Spring Boot 4 で作成したファイルダウンロード画面。「ダウンロード」ボタン押下で DB を読んで CSV ファイルを生成し、そのファイルがクライアントPCにダウンロードされる。
Controller はこんな感じ。
// ボタン押下で実行される処理
@PostMapping("/createFile")
public ResponseEntity<Resource> downloadFile() throws Exception {
Path path = HogeService.make_file_path(); // ダウンロードされるファイルを作成するパス
HogeService.logic_make(path.toString()); // ダウンロードファイルを作成
Resource resource = new FileSystemResource(path);
HttpHeaders headers = new HttpHeaders();
headers.add(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + resource.getFilename() + "\"");
headers.add(HttpHeaders.ACCESS_CONTROL_EXPOSE_HEADERS, HttpHeaders.CONTENT_DISPOSITION);
return ResponseEntity.ok()
.contentType(getContentType(path))
.contentLength(resource.contentLength())
.headers(headers)
.body(resource);
}
// ContentType の取得
private MediaType getContentType(Path path) throws IOException {
try {
return MediaType.parseMediaType(Files.probeContentType(path));
}
catch (IOException e) {
return MediaType.APPLICATION_OCTET_STREAM;
}
}
これで、例えば
<form action="/createFile" method="post" id="form" name="form">
<input type="submit" name="download-btn" class="btn-primary" id="download-btn" value="ダウンロード">
みたいな形にすれば、「ダウンロード」ボタン押下でブラウザのダウンロード窓が開いてダウンロードは行われる。
しかし、例えばダウンロードに失敗したときは Web 画面上にエラーメッセージを表示したいとか、成功したら画面に「正常にダウンロードできました」などと表示したい場合はこれではダメだ。
Controller の各メソッドで返却できる値は 1種類である。
上の例のようにデータ(ダウンロードファイル)を出力するか(ダウンロード)、HTML を出力するか(画面表示)、生のテキストや JSON データなどを出力するか(API など)・・・のいずれかひとつのことしかできない。
「先にファイルを出力して、その次に結果を伝える HTML を出力する」という二段構えの出力はできない。ファイルを出力すれば画面(HTML)は表示できないし、画面(HTML)を表示(出力)すればファイルは出力できない。
・・・が、ファイルダウンロードをして結果を Web 画面に表示したい要件ってあるよねえ。
そういうときは、直接ファイルダウンロードの画面を呼び出すのではなく、JavaScript の中でファイルダウンロード画面を呼び出し一旦ファイルを受信しするようにする。そして、そのデータをクライアントに出力し、その後画面の編集をすればいい。JavaScript で処理すれば「ファイル出力か HTML 画面表示かどちらかしかできない」なんて制限ないからね。
具体的には、↓こんな感じの JavaScript。
document.getElementById('download-btn').addEventListener('click', async (e) => {
e.preventDefault();
const btn = document.getElementById('download-btn');
const errMesg = document.getElementById('error_msg');
const sucMesg = document.getElementById('success_msg');
errMesg.style.display = 'none'; // メッセージ非表示
sucMesg.style.display = 'none'; // メッセージ非表示
try {
btn.disabled = true; // ボタンが押されたら、二度押し防止のため非活性化
// ダウンロード処理呼び出し
const action_url = /*[[@{/createFile}]]*/ '';
const response = await fetch(action_url, { // fetch(非同期通信(HTTPリクエスト))でダウンロードデータ取得
method: 'POST'
});
if (!response.ok) { // ダウンロード画面からデータが取れなければ例外発生
throw new Error()
}
const blob = await response.blob(); // データをBlob(バイナリの塊)として取得
const disposition = response.headers.get('Content-Disposition') ; // レスポンスヘッダーからファイル名取得
let filename = 'download.csv'; // ファイル名が取れなかったときのディフォルト値
if (disposition && disposition.indexOf('attachment'!== -1)) {
const fileMatch = /filename="?([^; \n"]*)"?/.exec(disposition); // 正規表現でファイル名取得
if (fileMatch && fileMatch[1]) {
filename = fileMatch[1];
}
}
const url = window.URL.createObjectURL(blob); // ダウンロード用の疑似リンク作成
const a = document.createElement('a'); // 疑似アンカータグ作成
a.href = url; // アンカータグの href 属性に疑似URLを紐づける
a.download = filename; // アンカータグの download 属性にファイル名をセット
document.body.appendChild(a); // 作成した疑似アンカータグを一時的に(操作のため)実体化
a.click(); // 実体化したアンカータグを画面の前の人間に代わってクリック(ダウンロードスタート)
a.remove(); // もう必要ないのでアンカータグ削除
window.URL.revokeObjectURL(url); // メモリに残った疑似リンク(blobデータ含む)を削除
btn.disabled = false; // ボタン使用可能に
sucMesg.textContent = "正常にダウンロードが終了しました。";
sucMesg.style.display = 'block'; // メッセージをセットして表示
}
catch(error) {
btn.disabled = false; // ボタン使用可能に
errMesg.textContent = "ダウンロードに失敗しました。";
errMesg.style.display = 'block'; // メッセージをセットして表示
}
finally {
}
});
ダウンロードボタンが押されたらイベントリスナーによりこの処理が実行される。
コメントに書いているとおり、JavaScript 内で(画面の前の人間には見えないところで)実際のダウンロード画面にアクセスしデータを取得。そのデータをダウンロードするための疑似リンクを作って、そのリンクに(画面の前の人に代わって)アクセスして画面の前の人のパソコンにデータがダウンロードされるという動き。
なんでこういうことをするかというと、JavaScript 内でダウンロードの成否を判断して処理を行うため。
ダウンロードに失敗したときは例外を発生させ「失敗」メッセージを表示。最後まで問題なく処理が終われば「正常」メッセージを表示・・・となる。(直接ブラウザでダウンロードをした場合は、成否の情報は画面(JavaScript)には伝わってこないので何もできない。)
Controller のファイルダウンロードのメソッド(上の例では downloadFile メソッド)を修正する必要はない。