|||||||||||||||||||||

なんぶ電子

- 更新: 

JavaScriptのAjaxでバイナリデータをダウンロード

Binay file download with 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= という表記のみ認識をするので、世代の心配がある場合は併記します。

拡張表記は次のようになります。

filename*=UTF-8''URLエンコードされたファイル名

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>
...

参考にさせていただきましたサイトの皆様、ありがとうございました。

筆者紹介


自分の写真
がーふぁ、とか、ふぃんてっく、とか世の中すっかりハイテクになってしまいました。プログラムのコーディングに触れることもある筆者ですが、自分の作業は硯と筆で文字をかいているみたいな古臭いものだと思っています。 今やこんな風にブログを書くことすらAIにとって代わられそうなほど技術は進んでいます。 生活やビジネスでPCを活用しようとするとき、そんな第一線の技術と比べてしまうとやる気が失せてしまいがちですが、おいしいお惣菜をネットで注文できる時代でも、手作りの味はすたれていません。 提示されたもの(アプリ)に自分を合わせるのでなく、自分の活動にあったアプリを作る。それがPC活用の基本なんじゃなかと思います。 そんな意見に同調していただける方向けにLinuxのDebianOSをはじめとした基本無料のアプリの使い方を紹介できたらなと考えています。

広告