RitoLabo

Laravelで大容量CSVファイルをSplFileObjectクラスで確実に処理する

  • 公開:
  • 更新:
  • カテゴリ: PHP Laravel
  • タグ: PHP,Laravel,5.5,5.4,5.3,CSV,5.6,SplFileObject

LaravelなどのPHPフレームワークで業務システムを構築していると必ずと言って良いほどついて回るのがCSVを扱う機能です。

CSVアップロード・インポート・エクスポート・ダウンロード…と、クラウド基幹システム系には切っても切り離せない機能だと思います。

ただ、PHPでCSVファイルを扱う場合にいくつかの方法がありますが、どんな方法で処理を実装するのがベストプラクティスなのか。というのもあります。

今回はLaravelでのCSVデータ処理について、ファイルサイズに左右されずに安定して処理を回せる機能を実装します。

アジェンダ
  1. 開発環境
  2. 使用するCSVデータについて
  3. PHPでのCSVデータの処理について
    1. ファイル容量に左右されない事
    2. 文字コードに柔軟に対応する事
  4. コントローラの生成
  5. CSVファイルの読み込みと書き出し処理
  6. ルーティング

開発環境

今回の開発環境は以下の通りです。

  • Linux CentOS 7
  • Apache 2.4
  • PHP 7.2/7.1
  • Laravel

Laravelのバージョンについては、5.6/5.5/5.4/5.3にて動作確認済みです。

また、Laravelのルートディレクトリを「laravel/」とします。

途中、コントローラファイルの生成でartisanコマンドを叩いていますが、何らかの事情で使えない場合は、本記事生成後に掲載する初期ソースをそのまま貼ってもらえればOKです。

使用するCSVデータについて

今回ですが、サンプルデータとして、住所.jpの住所データ全国版を使います。

容量にて18.3MB、1列あたり22項目、全149,086行になります。

ヘッダは以下の通り。

  • 住所CD
  • 都道府県CD
  • 市区町村CD
  • 町域CD
  • 郵便番号
  • 事業所フラグ
  • 廃止フラグ
  • 都道府県
  • 都道府県カナ
  • 市区町村
  • 市区町村カナ
  • 町域
  • 町域カナ
  • 町域補足
  • 京都通り名
  • 字丁目
  • 字丁目カナ
  • 補足
  • 事業所名
  • 事業所名カナ
  • 事業所住所
  • 新住所CD

今回はこのCSVファイルを、文字コードはShift_JISのまま設置し、処理を行っていきます。

例えばフォームなどからアップロードしたというシーンを想定し、laravel/storage/app 配下に予め設置しておきます。

laravel
├─ storage
│   ├─ app
│   │   ├─ public
│   │   └─
sample_address.csv

今回はCSVファイルそのものの処理がメインなので、フォームアップロードなどは割愛します。

PHPでのCSVデータの処理について

コーディングに入る前に、これから実装する処理について少し書いておきます。

CSVファイルを扱う場合に、最低限担保しておくべきは以下の2点です。

  • ファイル容量に左右されない事
  • 文字コードに柔軟に対応する事

ファイル容量に左右されない事

CSVファイルの読み込みについて、CSVファイルを扱うWEBアプリケーションを構築する場合には、結構な確率でそのCSVファイルがどの程度の容量であるのかが100%確実に決まっているわけではない場合がほとんどです。

つまり、使う人間は我々ではなく、自分以外の誰かがCSVファイルをフォームからアップロードし、その処理を行う場合がほとんどであると思います。

そしてその場合、大抵聞いていた話よりも結構大きなサイズのCSVファイルがアップロードされたりします。

こんなケースもよくある事と想定される中で我々が最もやってはいけないのは、
CSVファイル内の全データを一度に全て読み込もうとする
という事です。

例えばfile_get_contents()メソッドなどでCSVファイルを読み込むのはかなりおすすめできません。

全データを一度に全て読み込もうとした場合、CSVファイルの容量によってはメモリ不足でエラーが発生しアプリケーションはその処理を停止します。

何もわからないユーザはエラー画面に困惑し、我々開発者に泣きつくか苦情を入れるかもしれません。

かと言って、メモリと相談しファイルアップロード時にバリデーションでCSVファイルの容量制限などを掛けた場合、かなり使いにくいアプリケーションになる可能性も高いです。

使うのは我々ではなく第三者なので、あくまでも要件に沿った、どんな容量のCSVファイルであっても問題なく処理を行える機能を実装する事が大切です。

結論として、CSVファイルを読み込んで処理する場合には全てを読み込むのではなく、1行ずつ読み込んで処理を行うのがベストプラクティスです。

文字コードに柔軟に対応する事

PHPでCSVファイルの処理を行うなら主流はUTF-8ですが、例えば、日本の営業マンやマーケターがエクセルから作成したCSVファイルはほぼすべてがShift_JISだと思います。

ただ結局のところ、文字コードを変換しなければ文字化けしますので我々は実装時に漏れなく文字コードの変換を行っていると思いますが、まれにUTF-8でしかCSVファイルのアップロードを受け付けていないシステムや、CSVでデータをエクスポートした際にUTF-8で出力され、開いたら文字化け!なんてシステムに遭遇します。

CSVファイルをアップロードする第三者ユーザは、CSVファイルの文字コードなんか気にも留めていません。文字コードの存在すら知らない人がほとんどだと思います。使う人の立場に立って文字コードにも柔軟に対応したいですね。というこれはちょっと小話です。

以上の様な思想に基づいてこれから処理を実装していきます。

コントローラの生成

まずはコントローラクラス(ファイル)を生成します。

CsvControllerクラスを生成する為にLaravelルートディレクトリへ移動し、以下のartisanコマンドを叩きます。

# laravelのルートディレクトリへ移動
cd /path/to/laravel

# artisanコマンドでコントローラを生成
php artisan make:controller CsvController

# 実行結果
[demo@localhost laravel]# php artisan make:controller CsvController
Controller created successfully.

CsvController.phplaravel/app/Http/Controllers 配下に生成されます。

laravel
├─ app
│   ├─ Http
│   │   ├─ Controllers
│   │   │   ├─ Controller.php
│   │   │   ├─
CsvController.php
laravel/app/Http/Controllers/CsvController.php
<?php

namespace App\Http\Controllers;

use Illuminate\Http\Request;

class CsvController extends Controller
{
//
}

CSVファイルの読み込みと書き出し処理

今回は先ほど述べた思想に基づき、CSVファイルを1行ずつ取り出し、それを別のCSVファイルに書き出していくという処理を実装します。

Shift_JISでアップロードしてもUTF-8に変換して処理し、出力時にはShift_JISに戻します。そしてそれが何行あっても(という意味での容量無制限で)処理します。

<?php

namespace App\Http\Controllers;

use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Exception;

class CsvController extends Controller
{
protected $head;
protected $csvFilePath;

public function __construct()
{
// ヘッダ項目の設定
$this->head = $this->getHead();
}

public function index()
{
// DB:beginTransaction();
try {
// ファイルの読み込み
$file = new \SplFileObject(storage_path('app/sample_address3.csv'));

$file->setFlags(
\SplFileObject::READ_CSV | // CSV 列として行を読み込む
\SplFileObject::READ_AHEAD | // 先読み/巻き戻しで読み出す。
\SplFileObject::SKIP_EMPTY | // 空行は読み飛ばす
\SplFileObject::DROP_NEW_LINE // 行末の改行を読み飛ばす
);

// 書き込み用 CSV ファイルの作成
$this->csvFilePath = $this->createCsvFile();

// 読み込んだCSVデータをループ
$i = 0;
foreach ($file as $line) {
// 文字コードを UTF-8 へ変換
mb_convert_variables('UTF-8', 'sjis-win', $line);

// ヘッダーチェック
if($i==0 && !$this->checkHeaders($line)) {
// ここにヘッダーチェックエラー時の処理
// フォームからのアップロードであればヘッダーチェックは
// リクエストクラスで実装するのがおすすめ
throw new Exception('ヘッダーが合致しません');
}

// CSVファイルへ書き込み
$this->writeCsv($line);
$i++;
}

//DB::commit();
} catch (Exception $e) {
//DB::rollback();
unlink($this->csvFilePath);
throw $e;
}
}

/**
* CSVファイルに書き出す
* @param $records
*/
private function writeCsv($records)
{
$res = fopen($this->csvFilePath, 'a');

// 文字コード変換
mb_convert_variables('sjis-win', 'UTF-8', $records);

// ファイルに書き出し
fputcsv($res, $records);

fclose($res);
}

/**
* CSVファイルを作成しファイルパスを返却する
* @return string
*/
private function createCsvFile()
{
$csvFilePath = storage_path(sprintf('app/result_%s.csv', date('ymd_his')));
mb_convert_variables('sjis-win', 'UTF-8', $csvFilePath);

$res = fopen($csvFilePath, 'w');
if ($res === FALSE) {
throw new Exception('ファイルの書き込みに失敗しました。');
}
fclose($res);

return $csvFilePath;
}

/**
* ヘッダー項目が合致しているかを返却する
* @param $array
* @return bool
*/
private function checkHeaders($array)
{
return ($this->head == $array);
}

/**
* ヘッダ項目を返す
* @return array
*/
private function getHead()
{
$head = [
"住所CD",
"都道府県CD",
"市区町村CD",
"町域CD",
"郵便番号",
"事業所フラグ",
"廃止フラグ",
"都道府県",
"都道府県カナ",
"市区町村",
"市区町村カナ",
"町域",
"町域カナ",
"町域補足",
"京都通り名",
"字丁目",
"字丁目カナ",
"補足",
"事業所名",
"事業所名カナ",
"事業所住所",
"新住所CD",
];

return $head;
}
}

そこそこ書きましたが、最も肝なのは序盤の読み込みの処理です。

// ファイルの読み込み
$file = new \SplFileObject(storage_path('app/sample_address.csv'));
$file->setFlags(
\SplFileObject::READ_CSV | // CSV 列として行を読み込む
\SplFileObject::READ_AHEAD | // 先読み/巻き戻しで読み出す。
\SplFileObject::SKIP_EMPTY | // 空行は読み飛ばす
\SplFileObject::DROP_NEW_LINE // 行末の改行を読み飛ばす
);

CSVファイルはSplFileObjectクラスで読み込んでいます。こうする事でループさせる際に1行つづデータを取り出せるようになります。

特徴的なのは、SplFileObjectクラス自体はPHP固有の、ファイルのためのオブジェクト指向インターフェイスなので、Laravelでインスタンス化を行う場合は先頭に「」をつける必要があります。をつけないと通常のLaravel上のクラスファイルと同じ扱いをされるので、not foundエラーが発生してしまいます。

ルーティング

最後に、ルーティングを行います。

Route::get('sample/csv', 'CsvController@index');

http://YOUR-DOMAIN/sample/csv
へのアクセス時に、CsvControllerのindexアクションが動くように記述しています。

あとはブラウザからアクセスすれば処理が動きます。

まとめ

作業は以上です。

ちなみに、処理を回したマシン自体のスペックは対して高くもなくおまけに仮想環境ですが、今回使用したサンプルのCSVファイルを処理するのに5秒弱程度でした。

実験で100万行強、124MBのCSVデータも処理してみましたが、大体30秒前後といったところでした。

今回は読み込んで別のCSVに書き込んだだけなのであれですが、ここにまた別の処理を入れ、データベースへ格納して…などが入るはずなので、そうなるとPHPの実行時間に注意が必要です。

デフォルトではPHPの最大実行時間は30秒になっていますが、最大実行時間と相談して、処理が長くなりそうな場合は非同期処理にしてAPI的に処理するとか、最大実行時間を延ばすとかして対応が必要です。

そこを対応させれば本当に無敵の領域ですが、とはいえ、SplFileObjectクラスで効率的に大きなCSVファイルを処理できるので、是非試してみてください。