かもメモ

自分の落ちた落とし穴に何度も落ちる人のメモ帳

PHP / JavaScript ファイル生成してDLさせたいけどmax_input_varsにかかってしまう問題にパワープレイで立ち向かった話

PHP製の管理ツールで、CSVを生成してDLするシステムを作っていました。
データ量が多いとエラーになるということで調べたら、PHPmax_input_varsにかかって居るのが原因でした。
そして、使っているサーバーがhetemlでmax_input_varsの上限を変えることが出来なかったので管理ツールということもありパワープレイで対策してみた記録。

要件
form

<form id="js-form">
  <div>
    <input type="text" name="data[0][0]" value="データA-1">
    <input type="text" name="data[0][1]" value="データB-1">
    <input type="text" name="data[0][2]" value="データC-1">
    <input type="text" name="data[0][3]" value="データD-1">
  </div>
  <div>
    <input type="text" name="data[1][0]" value="データA-2">
    <input type="text" name="data[1][1]" value="データB-2">
    <input type="text" name="data[1][2]" value="データC-2">
    <input type="text" name="data[1][3]" value="データD-2">
  </div>
  <div>
    <input type="text" name="data[2][0]" value="データA-3">
    <input type="text" name="data[2][1]" value="データB-3">
    <input type="text" name="data[3][2]" value="データC-3">
    <input type="text" name="data[3][3]" value="データD-3">
  </div>
  <label><input type="checkbox" name="is_sjis" checked="checked" value="1"> Shift JIS</label><br>
  <input type="submit" value="CSV Download">
</form>
  • フォームデータはname="data[0][0]"のようなCSVの行列の連想配列形式
  • フォームをデータからCSVを作ってDLできる
  • CSVエンコードをShift-JIS / UTF-8 選択できる (エクセルで文字化けすると言われたのでSJIS対応)

結論

以下長いので、先に結論からか言えば、max_input_varsを回避してフォームが送れれば済む話なのと、JavaScriptShift_JISのファイルを作成してダウンロードさせる問題がかなり大変そうだったので、

Javascriptで新しいform elementを作成してinputタグのvalueに元のフォームデータをJSON化したものを入れて、新しく作ったフォームをform.submit()させるパワープレイで解決しました。

解決方法までジャンプ👇

方針 JavaScriptでフォームデータを1つの文字列にしてAjaxで送ってCSVをDL

フォームのinput数が多いのが問題なので、JavaScripeでデータをまとめて1つの文字列データにして送ってしまえばmax_input_varsにかかることはありません。
管理ツールにはjQueryが使われていたので、そのままjQueryを使いました。

フォームデータをJSON形式にまとめる

jQueryにはフォームデータをまとめる.serialize()メソッドがありますが、$form.serialize()シリアライズすると

const $form = $('#js-form');
const params = $form.serialize();
console.log( params );
// data%5B0%5D%5B0%5D=%E3%83%87%E3%83%BC%E3%82%BFA-1&data%5B0%5D%5B1%5D=%E3%83%87%E3%83%BC%E3%82%BFB-1&data%5B0%5D%5B2%5D=%E3%83%87%E3%83%BC%E3%82%BFC-1&data%5B0%5D%5B3%5D=%E3%83%87%E3%83%BC%E3%82%BFD-1&data%5B1%5D%5B0%5D=%E3%83%87%E3%83%BC%E3%82%BFA-2&data%5B1%5D%5B1%5D=%E3%83%87%E3%83%BC%E3%82%BFB-2&data%5B1%5D%5B2%5D=%E3%83%87%E3%83%BC%E3%82%BFC-2&data%5B1%5D%5B3%5D=%E3%83%87%E3%83%BC%E3%82%BFD-2&data%5B2%5D%5B0%5D=%E3%83%87%E3%83%BC%E3%82%BFA-3&data%5B2%5D%5B1%5D=%E3%83%87%E3%83%BC%E3%82%BFB-3&data%5B3%5D%5B2%5D=%E3%83%87%E3%83%BC%E3%82%BFC-3&data%5B3%5D%5B3%5D=%E3%83%87%E3%83%BC%E3%82%BFD-3&is_sjis=1

URLエンコードされ&区切りなので、戻すのが少し面倒そうです。

jQueryには.serializeArray()メソッドがあり、こちらはserialize()エンコード&区切りにされる前の配列の状態を取ることが出来ます。

const $form = $('#js-form');
const params = $form.serializeArray();
console.log( params );
// [0: {name: "data[0][0]", value: "データA-1"}, ..., 12: {name: "is_sjis", value: "1"}]

{name: inputタグのname: value: inputタグのvalue}というオブジェクトの配列になります。これをJSON.stringify()するとJSON形式のデータに変換することができます。
JS側でdata[][]連想配列に戻してからJSON化しても良いのですが、文字列の"data[n][n]"から連想配列に戻すのはパワーがかかりそうだったので、データの処理はAPI (PHP)側の任せることにしました。

JSON化したデータをAjaxで送り、API側でCSVを作成して書き出す

フォームのデータをJSON化してAjaxで送る処理
const maxInputVars = 1000;
const $form = $("#js-form");

function postJSONData(jsonData) {
  $.ajax({
    url: "api_url",
    type: "post",
    crossDomain: false,
    dataType: "text",
    data: {"form_data": jsonData}
  })
  .done(function(res) {
    console.log(res);
  })
  .fail(function(jqXHR, status, err) {
    console.log(status, err);
  });
}

$form.on('submit', function() {
  const postNum = $('input').length;
  if( (postNum + 50) >= maxPostVars ) {
    const params = JSON.stringify( $form.serializeArray() );
    postJSONData( params );
    return false;
  } else {
    // max_input_vars にかからそうな場合は通常にformを送信
    return true;
  }
});
CSVを作成して返すAPI (PHP)
<?php
if( isset($_POST)
 && isset($_POST['form_data'])
 && !empty($_POST['form_data']) ) {
  $data = [];
  // Shift_JIS化するかどうか
  // チェックボックスはチェックされないとそもそもデータが送られないのでデフォルト値を設定しておく
  $is_excel = false;
  // JSONを配列に戻す
  $postData = json_decode( $_POST['form_data'], true );
  // data[n][n] 文字列から連想配列に戻す
  foreach( $postData as $row ) {
    $key = $row['name'];
    $val = $row['value'];
    if( $key == 'is_sjis' ) {
      $is_excel = (bool) intval($val);
    } else {
      preg_match('/^data\[(\d+)\]\[(\d+)\]$/', $key, $matches);
      if( count($matches) == 3 ) {
        $dRow = intval($matches[1]);
        $dCol = intval($matches[2]);
        if( !isset( $data[ $dRow ] ) || !is_array( $data[ $dRow ] ) ) {
          $data[ $dRow ] = [];
        }
        $data[ $dRow ][$dCol] = $val;
      }
    }
  }
  if( count(data) > 0 ) {
    $fileName = "MY_DATA-" . date("Y-m-d") . ".csv"; 
    create_scvfile_by_array($data, $fileName, $is_excel);
  }
}

function create_scvfile_by_array($data, $fileName = 'data.csv', $is_excel = false) {
  $csv = '';
  foreach($data as $row) {
    if( is_array($row) ) {
      $csv .= join(',', $row);
    } else {
      $csv .= $row;
    }
    $csv .= "\n";
  }

  $charset = 'UTF-8';
  if( $is_excel ) {
    $csv = mb_convert_encoding($csv, 'SJIS', 'UTF-8');
    $charset = 'sjis-win';
  }

  header("Content-Type: application/octet-stream; charset={$charset}");
  header("Content-Disposition: attachment; filename={$fileName}");
  echo $csv;
  exit();
}
exit();

AjaxでPOSTした場合そのままファイルダウンロードはできないっぽい

上記で試してみた所、CSVファイルの生成自体は上手くいっているのですが、 Content-Disposition: attachmentCSVファイルをechoしてもダウンロードにはならずJS側に取得されるようです。

Ajaxで返されたデータをファイルにしてダウンロードさせる

Ajaxで返されたデータをファイルにしてDLさせる方法を模索します。
検索しているとだいたい2つのパターンに分かれるようでした。

  1. サーバーサイドで実ファイルを作成して、ファイルのパスを返しAjaxのdone内でwindow.location.href=file_pathとしてDLさせる
  2. Ajaxに返されたデータをBlob オブジェクト でファイル化してDLさせる

サーバーサイドに実ファイルを作成するのは、ファイル削除したり管理が面倒そうだったので、Blobでファイル化する方法を採用することにしました。

API側はCSVデータとファイル名をJSONで返すように変更します。
API (PHP)

<?php
// 略
function create_scvfile_by_array($data, $fileName = 'data.csv', $is_excel = false) {
  // 略
  header("Content-Type: text/javascript; charset={$charset}");
  echo json_encode([
    "csv" => $csv,
    "filename" => $fileName,
    "charset" => $charset,
  ]);
  exit();
}

JavaScript側はAjaxでデータが返されたあとの処理を追加します。
JS

function postJSONData(jsonData) {
  $.ajax({
    url: "api_url",
    type: "post",
    dataType: "json",
    data: {"form_data": jsonData}
  })
  .done(function(res) {
    console.log(res);
    const fileName = res.filename;
    const file = new Blob([res.csv], {
      type: `application/octet-stream; charset=${res.charset}`
    });

    if( window.navigator.msSaveOrOpenBlob ) {
      // IE
      navigator.msSaveBlob(file, fileName);
    } else {
      // window.location.href = URL.createObjectURL(file);
      // => DLできるけどファイル名の付け方が面倒そうだった。
      // ※ Blobの type: "text/csv;charset=utf-8" にすれば .csv 拡張子がついたファイルがDLされた
      const dlBtn = document.createElement('a')
      dlBtn.download = fileName;
      dlBtn.href = URL.createObjectURL(file);
      dlBtn.click();
    }
  })
  .fail(function(jqXHR, status, err) {
    console.log(status, err);
  });
}

HTML5を使えばaタグのdownload属性でファイル名を指定してDLさせることができます。(aタグ生成させる方法なら、IEで切り分ける必要無さそうな気もしますが...)
参考 HTML5 ファイルをダウンロードさせるリンクを作りたい。 - かもメモ

これでAjaxで受け取ったCSVファイルがダウンロードできるようになりました!

と、ここで完結すればよかったのですが...
データの中身が原因なのか不明な部分はありますが、Shift_JISに変換したCSVデータの入ったJSONが、Ajaxに戻ってきた時にパースエラーでエラーに流れてしまいました。(headerでcharsetも指定しているのですが...)

Shift_JIS 問題と戦う。

Shift_JIS対応ってのが、単に使ってる人がエクセルだとUTF-8が文字化けする(文字コードの概念を知らない)からって理由だけなのでShift_JISめ!! というかExcelめ!! という(# ゚Д゚) MSへのヘイトが高まる...

Ajaxで返されるデータの文字コードを指定してみる

AjaxbeforeSend 内でXMLHttpRequest.overrideMimeType文字コードを指定すれば文字化けが解消できるそうです。(ES2015なら直接XMLHttpRequest.overrideMimeTypeで良いんだろうけど)

Shift_JISに変更するチェックが有るときだけMineTypeを上書きするようにします。
JS

function postJSONData(jsonData) {
  $.ajax({
    url: "api_url",
    type: "post",
    dataType: "json",
    beforeSend: function(xhr) {
      const is_sjis = $('input[name="is_sjis"]').prop('checked');
      if( is_sjis ) {
        xhr.overrideMimeType("text/javascript; charset=shift_jis");
      }
    },
    data: {"form_data": jsonData}
  })
  .done(function(res) { /* 略 */})
  .fail(function(jqXHR, status, err) { /* 略 */})
}

こちらで試してみたのですが、Shif_JISに変換したCSVを組み込んだJSONだと相変わらずパースエラーになってしまいました。

APIからはCSVファイルだけを返すようにする

ファイル名をAPI側から渡したいという理由だけでJSONにしていたので、CSVのデータだけ返すようにしてしまえばShif_JISでもエラーにならないのでは?と思いAPIを元に戻すことに…

API (PHP)

<?php
// 略
function create_scvfile_by_array($data, $fileName = 'data.csv', $is_excel = false) {
  // 略
  header("Content-Type: text/csv; charset={$charset}");
  echo $csv;
  exit();
}

JavaScript側はJSONからファイル名や文字コードを取得していた部分を修正します
JS

function postJSONData(jsonData) {
  const const is_sjis = $('input[name="is_sjis"]').prop('checked');
  const charset = is_sjis? 'shift_jis' : 'utf-8';
  $.ajax({
    url: "api_url",
    type: "post",
    dataType: "text",
    beforeSend: function(xhr) {
      if( is_sjis ) {
        xhr.overrideMimeType("text/plan; charset=shift_jis");
      }
    },
    data: {"form_data": jsonData}
  })
  .done(function(res) {
    const fileName = "fileName.csv";
    const file = new Blob([res], {
      type: `application/octet-stream; charset=${charset}`
    });

    const dlBtn = document.createElement('a')
    dlBtn.download = fileName;
    dlBtn.href = URL.createObjectURL(file);
    dlBtn.click();
  })
  .fail(function(jqXHR, status, err) { /* 略 */})
}

こんかいはShift_JISに変換したデータでもAjaxでエラーにならずファイルがダウンロードされました!

... しかし ...

ダウロードされたファイルの中身がShif_JISになってない!! UTF-8CSVでしたw
原因は定かではない部分があるのですが、HTML・JS側の文字コードUTF-8なので、Ajaxに渡されたファイルがShif_JISでもUTF-8に解釈され直しているのではないかという推察です。

JavaScript文字コードを変換する。

字面にしただけで地雷そうな雰囲気がしているのですが、調べてみると先人がおられました。

とても大変そう…
ということで別の方法を模索することにしました。

解決 一方ロシアは鉛筆を使った作戦

JavaScript側で文字コードまで弄って頑張ってファイル生成をするのはtoo muchなように感じました。

Ajaxで受け取ったデータをファイルにしてDLさせる部分でaタグを生成してa.click()で動作させられるなら、JavaScriptでで、元のフォームデータをシリアライズ化したvalueを持ったinputタグをもった別のformを作ってform.submit()してしまえば、Ajaxすら不要で、JavaScript側でAjaxに渡されたデータからファイル作ってDLする機能すら不要になるのではないか?と閃いたのでした。
もともとサーバーの都合でmax_input_varsにかかってしまうと言うのがメインの問題だったのでJavaScriptで生成したformがそのまま送信できるのであれば、フロント側で頑張らなくても問題は解決する訳です!!
一方ロシアは鉛筆を使ったみたいなソリューション...

API側は当初のCSVを吐き出す状態に戻します
API (PHP)

<?php
if( isset($_POST)
 && isset($_POST['form_data'])
 && !empty($_POST['form_data']) ) {
  $data = [];
  // Shift_JIS化するかどうか
  // チェックボックスはチェックされないとそもそもデータが送られないのでデフォルト値を設定しておく
  $is_excel = false;
  // JSONを配列に戻す
  $postData = json_decode( $_POST['form_data'], true );
  // data[n][n] 文字列から連想配列に戻す
  foreach( $postData as $row ) {
    $key = $row['name'];
    $val = $row['value'];
    if( $key == 'is_sjis' ) {
      $is_excel = (bool) intval($val);
    } else {
      preg_match('/^data\[(\d+)\]\[(\d+)\]$/', $key, $matches);
      if( count($matches) == 3 ) {
        $dRow = intval($matches[1]);
        $dCol = intval($matches[2]);
        if( !isset( $data[ $dRow ] ) || !is_array( $data[ $dRow ] ) ) {
          $data[ $dRow ] = [];
        }
        $data[ $dRow ][$dCol] = $val;
      }
    }
  }
  if( count(data) > 0 ) {
    $fileName = "MY_DATA-" . date("Y-m-d") . ".csv"; 
    create_scvfile_by_array($data, $fileName, $is_excel);
  }
}

function create_scvfile_by_array($data, $fileName = 'data.csv', $is_excel = false) {
  $csv = '';
  foreach($data as $row) {
    if( is_array($row) ) {
      $csv .= join(',', $row);
    } else {
      $csv .= $row;
    }
    $csv .= "\n";
  }

  $charset = 'UTF-8';
  if( $is_excel ) {
    $csv = mb_convert_encoding($csv, 'SJIS', 'UTF-8');
    $charset = 'sjis-win';
  }

  header("Content-Type: application/octet-stream; charset={$charset}");
  header("Content-Disposition: attachment; filename={$fileName}");
  echo $csv;
  exit();
}
exit();

JS側はinputの数がmax_input_varsを超えそうなら新しいフォームを作成して、APIform.submit()するように変更します。

const maxInputVars = 1000;
const $form = $("#js-form");

function createNewFormAndPost(data) {
  cont $form = $('<form></form>')
       .attr('action', "api_url")
       .attr('method', 'post')
       .attr('enctype', 'multipart/form-data');
  cont $input = $('<input type="text" name="form_data">').val(data);
  $form.append($input).appendTo("body");
  $form.submit().remove();
}

$form.on('submit', function() {
  const postNum = $('input').length;
  if( (postNum + 50) >= maxPostVars ) {
    const params = JSON.stringify( $form.serializeArray() );
    createNewFormAndPost( params );
    return false;
  } else {
    // max_input_vars にかからそうな場合は通常にformを送信
    return true;
  }
});

これで試した所…
JavaScriptで生成したフォームをそのままJSでform.submitで送ることができ、Shift_JISCSVもそのままダウンロードですることができました!!
JSでフォーム生成してsubmitするのその内セキュリティの問題とかで規制されそうな気がしなくもないですが、とりあえず要件を満たすことが出来たので解決としました。

まとめ

すごい長い大作記事になったけど、ホント中身がありません。
文字コード問題はホント面倒なんで、Shift_JISとかEUC-JPとか滅んでUTFに統一されて欲しいって感想しか持てませんでした。JavaScriptのBlobオブジェクトでファイルが生成できるとか、頑張れば文字コードにも対応できるとか、JSで生成したaタグをクリックさせたり、form.submit()問題なく出来てしまうとか、色々と学びは有りました。 一つだけグローバル企業なMSがなぜ日本語版(?)のEXCELShift_JISを採用しているのかはとっても謎いです。(最近のはUTF-8で、このシステム使ってる人のEXCELが古いだけとかって可能性もありそうですが...


[参考]

Ajaxでファイルダウンロード

文字コード関係

Spreadsheetはイイゾ...