1. Home
  2. PHP
  3. Laravel
  4. Laravelで大容量CSVファイルをSplFileObjectクラスで確実に処理する

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

  • 公開日
  • 更新日
  • カテゴリ:Laravel
  • タグ:PHP,Laravel,CSV,SplFileObject
Laravelで大容量CSVファイルをSplFileObjectクラスで確実に処理する

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

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

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

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

Contents

  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.php が laravel/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 ファイルを処理できるので、是非試してみてください。

Author

rito

  • Backend Engineer
  • Tokyo, Japan
  • PHP 5 技術者認定上級試験 認定者
  • 統計検定 3 級