Laravelでの開発中、DBのデータが記載されたCSVファイルのダウンロード機能を実装する必要があり学習したのでまとめる。
ただし最後の1行だけ理解が及んでおらずのため悪しからず。。。
そもそもCSVとは?
「Comma Separated Value」の略。
直訳すると、カンマで区切った値となる。
散々CSVファイルを見てきたがカンマなど見たことがない、、、
どこがComma Separated Valueなんだと思ってしまった。
まず大前提として自分の認識が間違っていたのが、CSVとExcelはほぼ同じだと思っていたことだ。
というかむしろ何が違うんだくらいの認識だった。
ただこれこそが最大の勘違いで、正確には普段目にしているCSVファイルはExcelで開かれていたため全く同じに見えていたということだ。
となるとCSVファイルの実体は何なのか。
試しにCSVファイルをエディターに投げてみると以下のように表示される。
id,name\n
1,"taro"\n
2,"mike"\n
3,"john"\n
まさにカンマで区切られた値、すなわちComma Separated Valueなのである。
WindowsではCSVファイルは既定でExcelに関連付けられているので、CSVファイルをダブルクリックするとExcelが起動する。
そのためCSV=Excelのように思えてしまうのだ。
CSVダウンロード実装
ここから本題に入っていく。
大まかな流れとしては以下となる。
①書き込み用のファイルを開く
②ファイルにデータを書き込む
③書き込んだデータを文字列に変換・エンコードする
④ファイルを閉じる
⑤ファイルをCSVファイルに変換する
①書き込み用のファイルを開く
まずは空のファイルを作成し、データを書き込めるよう開いておく必要がある。
その際に使うのがfopen関数
である。
fopen関数
ファイルまたはURLをオープンする関数。
あるファイルの情報を新しいファイルに書き込む、DBのデータを取得してファイルに書き込むといった際、いずれもどのファイルに何をしたいのかを示す必要がある。
そこでまずはfopen関数
を使って対象のファイルとアクション(書き込み用
、読み込み用
、追記用
)を指定してファイルを準備する。
※書き込みと追記の違いは、ファイルの先頭から書き込むか終端から書き込むか
形式としては以下のような形となり、第一引数に開くファイル名、第二引数に開くモードを指定する。
$stream = fopen('php://temp', 'w'); // php://tempについては後述
モードについては以下参照。
そして上記の場合、変数$stream
にはファイルポインタというものが格納される。
ファイルポインタ
テキストエディタのカーソルのようなもので、ファイルに対するアクションをどこから始めるのかを指定したもの。
これを動かして処理の開始地点を変更することもできる。
CSVファイルを作成する際にどのファイルを使用するか
上記の例でphp://temp
という見慣れないファイルを使用しているがこれは何なのか。
php://memory および php://temp は読み書き可能なストリームで、一時データをファイルのように保存できるラッパーです。
両者の唯一の違いは、php://memory が常にデータをメモリに格納するのに対して php://temp は定義済みの上限 (デフォルトは 2 MB) に達するとテンポラリファイルを使うという点です。
https://www.php.net/manual/ja/wrappers.php.php
とある。
極論、CSVファイルがダウンロードされるたびに新しいファイルを作成することもできるが、毎回新しいファイルが作成されてしまうのは得策とは思えない。
そこで、ストリームに一時的に保存して対応しているのである。
ストリーム
プログラミングの分野では、データの入出力全般を扱う抽象的なオブジェクトやデータ型を意味する場合が多い。データが出入りする何らかの対象(メモリ領域やファイル、ネットワークなど)をプログラム中で扱えるように抽象化したもので、接続や切断、書き込みや読み込みなどを簡易な操作で行うことができる。
https://e-words.jp/w/%E3%82%B9%E3%83%88%E3%83%AA%E3%83%BC%E3%83%A0.html
いまいちピンとこないとは思うが、要は流動的なデータを一時的に留めておく場所だと思って良いかと思う。
テンポラリファイルも同じようなもので、一時的に作られるファイルのことを指す。
tempファイル
、tmpファイル
のように記載されることが多い。
②ファイルにデータを書き込む
fputcsv関数
を使用する。
第一引数にfopen関数
で開いたファイルポインタ、第二引数に書き込みたい内容を指定する。
記述例としては以下。
$users = User::all();
$stream = fopen('php://temp', 'w'); //書き込みモードで開く
$arr = array('id', 'name'); //CSVファイルのカラム(列)名の指定
fputcsv($stream, $arr); //1行目にカラム(列)名のみを書き込む(繰り返し処理には入れない)
foreach ($users as $user) {
$arrInfo = array(
'id' => $user->id,
'name' => $user->name
);
fputcsv($stream, $arrInfo); //DBの値を繰り返し書き込む
}
上記の場合、php://temp
の1行目にまずCSVファイルのカラム名を記載し、2行目以降にDBの内容を書き込んでいる。
また、ここで条件分岐させてデータがない場合は「該当データがありません。」などの記載をするのも一つのやり方だ。
③書き込んだデータを文字列に変換・エンコードする
ストリームに書き込んだデータは文字列ではないので、文字列に変換してあげる必要がある。
実際、fputcsv関数
で書き込まれたストリームをvar_dump
で確認してみると、resource(358) of type (stream)
と表示される。
これはリソース型というらしい。
リソース型 は PHPの外部世界 とやり取りを行うデータが格納されている型
リソース はデータベースや画像ファイルなどの 外部情報
リソース型 の種類は 特殊型
リソース型 のチェックは is_resource() 関数 で行う
https://wepicks.net/resourcetype/
このままではCSVファイルに変換ができないため、文字列に変換してあげる。
ここで使用するのがstream_get_contents関数
とmb_convert_encoding関数
。
fputcsv関数
で書き込まれたストリームをstream_get_contents関数
で取得し文字列に変換後、mb_convert_encoding関数
でエンコードするという流れになる。
stream_get_contents
stream_get_contents関数
とは、
file_get_contents() と似ていますが、 stream_get_contents() は既にオープンしている ストリームリソースに対して操作を行います。そして、指定した offset から始まる最大 maxlength バイトのデータを取得して文字列に保存します。
https://www.php.net/manual/ja/function.stream-get-contents.php
とのこと。
php://temp
は前段で記載の通りストリームであるためこの関数を使用する。
書き方と具体的な説明は以下。
stream_get_contents(resource $handle, int $maxlength = -1, int $offset = -1)
第一引数:fopen関数
で開いたストリーム(これさえ指定すれば第2・第3引数を指定せず使用可能)
第二引数:読み込むバイト数(指定しなければデフォルト値の-1が適用され、残りのバッファ全てを読み込む)
第三引数:読み込みを開始する前に移動する位置。負の数を指定した場合は移動が発生せず、現在位置から読み込みを開始する。
第一引数の説明は良いとして、第二引数と第三引数について見ていく。
第二引数の読み込むバイト数を指定するタイミングがあまり想像できなかったので、特に指定しなくても問題ないと思う。
では第三引数について。
今回であればストリームに書き込んだデータのどこからを文字列に変換したいかということだ。
ここで一つ考えなくてはならないのが、fputcsv関数
で書き込まれた後のファイルポインタがどこになるのかということだ。
結論としては、ファイルの終端となる。
そのため、第三引数を指定しないと以降の処理がファイルの終端から進んでしまう。
そして終端以降は当然何も書き込まれていないため何も起こらない。
そこで、stream_get_contents関数
の第三引数を指定し、ファイルポインタを先頭(0地点)に戻す必要がある。
以下のようにしてやる。
$csv = stream_get_contents($stream, -1, 0); //第三引数を指定するために第二引数も合わせて指定
また、別のやり方としてrewind関数
を使用する方法もある。
rewind関数
はファイルポインタを先頭に戻してくれるため、stream_get_contents関数
の前に記載することで、ストリームの最初から最後までを取得することができる。
以下のようにするだけ。
rewind($stream);
$csv = stream_get_contents($stream); //ファイルポインタの変更が必要なくなったので第一引数のみで問題なし
エンコーディング
ここまででストリームの取得ができたので、後はmb_convert_encoding関数
でエンコーディングしてあげるのみ。
今回の実装では以下のようにした。
$csv = mb_convert_encoding($csv, 'sjis-win', 'UTF-8');
第一引数が対象文字列、第二引数が変更後の文字エンコード、第三引数が変更前の文字エンコードだ。
ここでは、$csv
の文字エンコードをUTF-8
からsjis-win
に変更している。
CSVファイルをExcelが開く際には強制的にsjis-win
で開くのだが、PHPスクリプトはUTF-8
で書かれているため文字化けしてしまう。
これを防ぐためにエンコーディングをしている。
④ファイルを閉じる
ここまでの処理が完了すれば追加でファイルに手を加えることはないため、fclose関数
を使用してファイルを閉じる。
引数にストリームを指定するのみだ。
fclose($stream);
⑤ファイルをCSVに変換する
最後にファイルをCSVに変換しブラウザに返して終了となる。
まずはCSVに変換するためにヘッダーに持たせたい情報を指定する。
$headers = array(
'Content-Type' => 'text/csv',
'Content-Disposition' => 'attachment; filename=test.csv'
);
Content-Type
Content-Type
とは、そのファイルがどんな種類のものなのかを示すもの。
「タイプ/サブタイプ」という記載の仕方で、「タイプ」でデータの種類(テキスト、画像、動画など)を定義し、「サブタイプ」で具体的なデータ形式を定義している。
text/plain, text/html, application/jsonのようになる。
Content-Disposition
Content-Disposition
は、コンテンツをwebページの一部として表示するかダウンロードするかを指定する。
inline
を指定すればwebページとして、attachment
を指定すればダウンロードファイルとして表示する。
また、filename
でファイルの初期名を指定できる。
ブラウザにCSVファイルを返す
ここまでできたら、最後はブラウザにCSVファイルを返すのみ。
以下のようにする。
return Response::make($csv, 200, $headers); //$csvは最後にmb_convert_encoding関数でエンコードした変数
この最後の1行が理解できておらず、、、
他にも色々書き方があるみたいだが、とにかくこのようにすると生成したCSVファイルをダウンロードできる。
改めてコード全体を見ると以下のようになる。
$users = User::all();
$stream = fopen('php://temp', 'w'); //ストリームを書き込みモードで開く
$arr = array('id', 'name'); //CSVファイルのカラム(列)名の指定
fputcsv($stream, $arr); //1行目にカラム(列)名のみを書き込む(繰り返し処理には入れない)
foreach ($users as $user) {
$arrInfo = array(
'id' => $user->id,
'name' => $user->name
);
fputcsv($stream, $arrInfo); //DBの値を繰り返し書き込む
}
rewind($stream); //ファイルポインタを先頭に戻す
$csv = stream_get_contents($stream); //ストリームを変数に格納
$csv = mb_convert_encoding($csv, 'sjis-win', 'UTF-8'); //文字コードを変換
fclose($stream); //ストリームを閉じる
$headers = array( //ヘッダー情報を指定する
'Content-Type' => 'text/csv',
'Content-Disposition' => 'attachment; filename=test.csv'
);
return Response::make($csv, 200, $headers); //ファイルをダウンロードする
以上で完了となる。
今回はCSVファイルに絞って記載したが、他のファイルでも同じように実装することができる。
実際、icsファイルのダウンロード機能を実装した際にも同じ要領で進めることができた。
次回はicsファイルやカレンダー追加機能の実装について書きたいと思う。