JavaScriptのAjaxでバイナリデータをダウンロード
POSTでパラメータを受け、文字コードがSJIS-win(CP932)のCSVファイルをダウンロードとして出力するPHPのページを作ったのですが、このページにAJAX経由からアクセスしようとした際に実装に苦労したので方法を書き残しておきます。
手持ちの資源はjQueryだったので、jQueryの$.Ajaxでの方法を考えたのですがそれでは実現できずにFetchを使うことになりました。
Fetch APIは過去にAJAXについての記事でも触れましたが、近年のブラウザなら標準で利用できます。
PHPのコード
PHPのコードでは次のような感じで、エンコードSJIS、改行コードCRLFのCSVファイルを作成しています。
HTMLのformからこのファイル宛にPOSTすればダウンロードが始まるように設定されています。
ちなみに、ここではCSVファイルを生成していますですが、別記事で紹介しているPhpSpreadsheetを使えばエクセルファイルを生成することもできます。
download-page.php
<?php
// POSTされたパラメータを取得
$params = json_decode(file_get_contents("php://input"),true);
// 2次元配列のデータを取得
$data=getData($params);
// CSVデータ作成
// エクセルで表示できるようにエンコーディングをSJISに
// 改行コードをCRLFに
$contents=mb_convert_encoding("CSVデータ","SJIS-win");
$contents.="\r\n";
for ($i = 0; $i < count($data); $i++) {
for($j =0; $j < count($data[$i]); $j++) {
if ($j != 0) {
$contents.=",";
}
}
$contents.=mb_convert_encoding(str_replace(",","",$data[$i]),"SJIS-win");
}
$contents.="\r\n";
// content-type
header('Content-Type: application/octet-stream');
// ブラウザのMIMEタイプを判断を阻止
header('X-Content-Type-Options: nosniff');
// ファイルのサイズ
header('Content-Length: ' . strlen($contents));
// ファイル名はajax経由の場合jsで名付けます
header('Content-Disposition: attachment; filename="'.date('Y-m-d_H-i-s').'.csv"');
// keep aliveを無効に
header('Connection: close');
// 出力
echo $contents;
exit(0);
AJAXのコード
先のPHPのコードではPOSTされた値を「php://input」で取得するようにしていたので、Content-Typeをapplication/jsonにしています。
JavaScriptのFetch APIのレスポンスは、Promiseで返ります。
Promiseの使い方については過去の記事でも触れましたので合わせて読んでいただけると幸いです。
fetchで得られたレスポンスにはレスポンスの本文をArrayBuffer化するarrayBuffer()メソッドがあり、これを呼ぶとPromiseが連鎖します。
arrayBufferとして得られた値をapplication/octet-streamのBlobでイニシャライズした後、aエレメントにセットしてダウンロードを開始させています。
sample.html
<script>
// AJAXするアドレス
let strAjaxUrl='./download-page.php';
// POSTパラメタ
let params={ key: value };
fetch(strAjaxUrl,{
method: 'post',
headers:{
'Content-Type': 'application/json',
},
body: JSON.stringify(params)
}).then(res=>{
return res.arrayBuffer();
}).then(ab=>{
let blob = new Blob([ab],{type: 'application/octet-stream'});
let a = document.createElement('a');
a.download="ファイル名.csv";
a.href=window.URL.createObjectURL(blob);
a.click();
//ダウンロード後の片付け
blob=null;
a.remove();
}).catch((err) => {
alert(err);
console.error(err);
}).finally(() => {
//成功、失敗問わない最終処理
});
</script>
...
responseには Blobオブジェクトのプロミスを返す blob()メソッドもありますので、次のようにすることもできます。こちらの方がすっきりしますが、a.downloadを指定しないとファイルの内容によってはブラウザでそのまま表示されてしまいます。
sample.html
<script>
// AJAXするアドレス
let strAjaxUrl='./download-page.php';
// POSTパラメタ
let params={ key: value };
fetch(strAjaxUrl,{
method: 'post',
headers:{
'Content-Type': 'application/json',
},
body: JSON.stringify(params)
}).then(res=>{
return res.blob();
}).then(blob=>{
let a = document.createElement('a');
a.download="ファイル名.csv";
a.href=window.URL.createObjectURL(blob);
a.click();
//ダウンロード後の片付け
blob=null;
a.remove();
}).catch((err) => {
alert(err);
console.error(err);
}).finally(() => {
//成功、失敗問わない最終処理
});
</script>
...
ファイル名を元のデータから取得
ここまではダウンロードを優先してファイル名に関しては考慮していませんでしたが、Quiita:「Content-Disposition: attachment; filenameのrfc 6266形式」や、stack overflowなどを参考に、日本語のファイル名を考慮するコードを書くと次のようになります。
まず、PHP(サーバー)側のContent-Dispositionから修正する必要があります。
Content-Dispositionはコンテンツが、ブラウザとして表示するものなのか、ダウンロードして表示するものなのかを示すヘッダーとなります。
今回は途中の attachment によりそれが添付ファイルであることを明示しているのですが、attachment のかわりに inline とするとWebページで表示するという意味になります。
先ほどここに filename を設定しましたが、そのままでは日本語の名称を含ませることができないので、URLエンコードを利用する拡張形式に変更します。
拡張形式は filename*= という風に *= によって設定されます。新しいブラウザにおいては *= の表記があった場合はこちらを優先することになっています。拡張形式に対応していないブラウザ用は従来の filename= という表記のみ認識をするので、世代の心配がある場合は併記します。
拡張表記は次のようになります。
URLエンコードするので、ファイル名に空白があっても適切に処理されるためファイル名を囲うクオーテーションは不要です。
UTF-8の部分とファイル名の区切りにシングルクォートを2つ付けます。おそらく、UTF-8以外の文字エンコードは指定できないと思います。
download-page.php
...
header("Content-Disposition: attachment; filename*=UTF-8''".rawurlencode($fileName));
...
ブラウザ側では、fetchの レスポンスヘッダー(response.headers) から Content-Dispositionを取り出し、それが拡張型だったらURLエンコードを外すように変更します。
sample.html
//レスポンスヘッダーからファイル名取得
function getFileName(response){
let strFilename="";
let find=null;
let strDisposition = response.headers.get('Content-Disposition');
if (strDisposition && strDisposition.indexOf('attachment') !== -1) {
//拡張パターンの場合
find = /filename\*=([^']*'')?([^;]*)/.exec(strDisposition);
if(find!=null) {
//find[1]にはUTF-8が入っている
//URIデコード
strFileName = decodeURIComponent(find[2]);
//クオーテーションの除去
return strFileName.replace(/['"]/g,'');
}
//非拡張パターンの場合
find = /filename=([^;]*)/.exec(strDisposition);
if(find != null) {
strFileName = find[1];
return strFileName.replace(/['"]/g,'');
}
}
//名前が取得できなかった場合
return "attachment_file";
}
<script>
// AJAXするアドレス
let strAjaxUrl='./download-page.php';
// POSTパラメタ
let params={ key: value };
// ファイル名格納用
let strFileName ='';
fetch(strAjaxUrl,{
method: 'post',
headers:{
'Content-Type': 'application/json',
},
body: JSON.stringify(params)
}).then(res=>{
strFileName = getFileName(res);
return res.blob().then((blob)=>{
let a = document.createElement('a');
//取得したファイル名をここでセット
a.download=strFileName;
a.href=window.URL.createObjectURL(blob);
a.click();
//ダウンロード後の片付け
blob=null;
a.remove();
});
}).catch((err) => {
alert(err);
console.error(err);
}).finally(() => {
//成功、失敗問わない最終処理
});
</script>
...
参考にさせていただきましたサイトの皆様、ありがとうございました。