今回は3週間ほど迷走を極めたCSVファイルのダウンロード機能のお話です。
- !!注意!!
-
この記事に書かれているコードのご利用はお控えください。
この記事、まだ前編なので…。察してください。
事の始まり
とあるシステム(Laravel製)のとあるデータ(DBに保存してあるもの)をCSVファイルでダウンロードできるようにしてほしいと依頼を受けたので担当することにしました。
実はここだけの話、入社前にPHPでのCSVファイルの出力やったことあるんです。
その時は指定のディレクトリに作成で、ブラウザからのダウンロードではなかったんですが、まあ似たようなもんだしできるでしょと思っていたんです。このときまでは…
ライブラリを探してみる
自分で一から作らなくても世の中便利なもので、すでにあったりするんですよね。なので「Laravel csv ダウンロード」とかでいろいろ検索して、このライブラリを見つけました!
Laravel Excel
うーん。ドキュメントが全部英語だー。
ミジンコほどの英語力とGoogle翻訳を頼りに訳してみて使い方を把握しようとしたんですが、どうも今回の仕様にそぐわないようで使うのやめました。
このライブラリはまた今度機会があったら使ってみます。
とりあえず作ってみる
見つけたライブラリは使えそうになかったので、自分で一から書くことにしました。
要件はだいたいこんな感じです。
- 一時ファイルは作らない
- ヘッダーつける
- 文字コードはUTF8
- 区切り文字のデフォルトは「,」(「(タブ)」も選択可)
- 囲み文字のデフォルトは「”」(「’」も選択可)
- 改行コードのデフォルトは「LF」(「CRLF」も選択可)
さて、作ってみましょー。
ダウンロード用のクラスはこんな感じかしらー?
あらよっと。
class CsvDownload
{
/**
* CSVダウンロード
*
* @param array $outputData 出力データ
* @param array $csvHeaders ヘッダー行
* @param array $option 出力設定
* 'delimiter' : 区切り文字 デフォルトはカンマ区切り
* 'enclosure' : 囲み文字 デフォルトはダブルクォーテーション
* 'lfCode' : 改行コード デフォルトはLF
* @return response
*
*/
public function download($outputData, $csvHeaders, $filename, $option = null) {
# デフォルトの出力設定
$defaultOption = [
'delimiter' => ',', // ほんとは定数呼び出してるけど便宜上この書き方にしてます
'enclosure' => "\"" // 同上
'lfCode' => "\n", // 同上
];
# オプション設定
if (is_null($option)) {
// 未設定の場合はデフォルト設定使う
$option = $defaultOption;
} else {
$option = array_merge($defaultOption, $option);
}
$stream = fopen('php://output', 'w');
# ヘッダー行追加
array_unshift($outputData, $csvHeaders);
# 出力データ
foreach ($outputData as $data) {
// 囲み文字追加
$enclosureAddedData = $this->addEnclosure($data, $option['enclosure']);
// 区切り文字追加
$row = implode($option['delimiter'], $enclosureAddedData);
// 書き込み
fwrite($stream, $row . $option['lfCode']);
}
$csv = stream_get_contents($stream);
fclose($stream);
# HTTPヘッダー情報
$headers = [
'Content-Type' => 'application/octet-stream',
'Content-Disposition' => 'attachment; filename=' . $filename . '.csv',
];
return response($csv, 200)->withHeaders($headers);
}
/**
* 囲み文字追加
*
* @param array $data 囲み文字つけるデータ
* @param string $enclosure 囲み文字
* @return array $enclosureAddedData 値に囲み文字ついた配列
*
*/
private function addEnclosure($data, $enclosure) {
# エスケープ対象
$escapeData = [
'double' => "\"",
'single' => '\'',
];
foreach ($data as $key => $val) {
// 値に含まれる囲み文字を二重にしてエスケープ
$val = str_replace($enclosure, $enclosure . $enclosure, $val);
// 囲み文字がシングルクォーテーションだった場合
if ($enclosure === $escapeData['single']) {
// 値に含まれているダブルクォーテーションもエスケープ
$val = str_replace($escapeData['double'], $escapeData['double'] . $escapeData['double'], $val);
}
# 値に囲み文字追加
$enclosureAddedData[$key] = $enclosure . $val . $enclosure;
}
return $enclosureAddedData;
}
}
画面側にリンク用意して、出力用データも取得して、もろもろ準備OK!
実行してみよー。リンククリック!
動いたー!ってことで、レビューをお願いしました。
レビューと修正
私「レビューお願いします!」
きんさん「はーい」
~レビュー中~
きんさん「出力設定($option)の設定内容に不備がないかチェックする関数作ろうか」
私「うっす(作ろうとして力尽きたんだよな…)」
※会話はイメージです
ということでチェック用の関数を作りました。
あと、区切り文字などもダウンロードクラスの中で定義することにしました(レビュー前は定数で記述してた)
class Csvdownload
{
# 区切り文字
protected $delimiterList = [
'comma' => ',',
'semicolon' => ';',
'tab' => "\t",
];
# 囲み文字
protected $enclosureList = [
'double' => "\"",
'single' => '\'',
];
# 改行コード
protected $lfCodeList = [
'lf' => "\n",
'crlf' => "\r\n",
];
~略~
/**
* 出力設定確認
*
* @param array $option 出力設定
* @param array $defaultOption デフォルト設定
* @return bool 設定が全て正しければtrue / 一つでも間違ってたらfalse
*
*/
private function optionCheck ($option, $defaultOption) {
// $optionが配列かどうか
if (!empty($option) && is_array($option)) {
// 配列のキーを比較して差分を確認
$diff = array_diff_key($option, $defaultOption);
if (empty($diff)) {
// 差分なかったらキーは問題なし
foreach ($option as $key => $symbol) {
// 値の確認
$allowed = $key . 'List';
if (!array_search($symbol, $this->$allowed)) {
// 値が間違ってるよ
return false;
}
}
} else {
// キーが間違ってるよ
return false;
}
} else {
// 配列以外または空の配列が指定されてるよ
return false;
}
// おっけー
return true;
}
このコードで再レビューも通ったので本番環境に反映します。
ちなみに修正後もちゃんと動いてます。
ほらね。
画像ほぼ使いまわしだからよくわからないけど
そう上手くはいかない
本番環境に反映しました。
ちゃんとダウンロード用のリンク表示されてるぞ~。
さて、ダウンロード自体は大丈夫かな?
えいっ(リンククリック)
!?!?
出力データが画面に出てる…
肝心のデータは落とせてない…
Why????
開発環境では動いてたよ????
次回へ続く
問題です。今回のコードのダメな部分はどこでしょう?
似たようなものを作られた方は、上記のコード見た瞬間「これ、ダメなやつや…」って思ったかもしれません。
そうです。そこがダメだったんです。
どこがダメだったかは次回までのお楽しみということで、後編(仮)でお待ちしてます。
それじゃあ、またね~~(^▽^)ノ