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 メソッド)を修正する必要はない。

最近のコメント