📑

Laravelで一万行あるCSVデータのインポートを行う方法。

2023/09/15に公開

始めに

今回業務でlaravelでフォームからのCSVインポート作業の実装を行いました。
そこまで容量がないCSVなら、時間がかからず実装できると思うのですが、
今回は最大で一万行あるCSVのデータをタイムアウトしないでインポートする必要があった為、
実装に一ヶ月程かかってしまいました。。
今後も同じような実装をするかもしれない為、記事にまとめてみました。
誰かの参考になれば幸いです。

目次

タイトル 備考
始めに
1 設計 インポートするCSVの情報と設計の話
2 作成 一時テーブルを作成し、CSVデータを読み込む方法
3 バリデーション処理 実際のバリデーション処理の話
4 保存する 一時テーブルを利用してDBに保存する方法
5 終わりに
6 参考文献 今回参考にさせていただいた文献まとめです

1設計

今回使用している技術は以下になります。(一部)
laravel 9.44.0
PHP 8.1.8
Composer 2.4.4
MySQL 8.0.28

今回は例として、
とある図書館利用者とその利用者が使用している図書館カードの情報を、
CSVから一万件登録すると仮定します。
登録できるのは最大一万件であり、一万件より多い場合はエラーが出るようにします。
テーブル設計は下記の通りになっています。
ER図.jpg

CSVデータはこうなっていると仮定します。
スクリーンショット 2023-06-19 9.51.47.png

この複数テーブルで一万件登録する際に面倒なことがいくつかあります。
1. emailはuniqueでないといけない為、バリデーションチェックに時間がかかる
2. library_numはuniqueでないといけない為、バリデーションチェックに時間がかかる
3. library_status_list_idは存在するidでないと登録できない為、バリデーションチェックに時間がかかる

もし十件ぐらいしかデータがないのであれば、バリデーションチェックにそこまで時間はかからないでしょう。
しかしそれが一万件あった場合、
一万回バリデーションチェックを行うことで、一万件のSQLが発行されてしまい、
膨大な時間がかかってしまいます。
非同期処理を行うにしても、できるだけ時間がかからない方が良いでしょう。
またCSVデータの中身をFormRequest内で行うことは、
バリデーション処理のみで最大一万ループを行ってしまうので、アンチパターンになります。。
FormRequestで行うバリデーション処理は、
比較的軽量なCSV自体のバリデーションのみとかにした方が良いです。
ではこのように時間がかかるバリデーションチェックはどのように行ったのか。
今回は一時テーブルを作成しその中にCSVのデータをloadした後、すぐにバリデーションチェックを行いました。
この作業によって一回のSQLで終わらせることができ、ループ処理の中で行う必要がなくなります!

2作成

一時テーブルとは、セッション単位で利用できるテーブルです。
セッション単位の為、セッションが切れてしまうとその中に格納されている情報も削除されてしまいます。
まあ名前の通りですね。
MySQLでは一時テーブルを利用するのに便利なCREATE TEMPORARY TABLE構文があります。
普通のテーブルと同じようにカラム名とカラム型を指定することで作成できます。

作成例
CREATE TEMPORARY TABLE テーブル名 (
    -> id INT,
    -> name varchar(256)

参照サイト

https://gihyo.jp/dev/serial/01/mysql-road-construction-news/0107

laravelで一時テーブルを作成する為には、DB::statementを使用して直接SQL文を実行する必要があります。
またDB::rawで生のSQL式を埋め込みます。

参照

https://readouble.com/laravel/8.x/ja/database.html

それではバリデーションチェックに使用する情報を入れる為の一時テーブルを作成してみましょう。

一時テーブル作成の処理
// 一時テーブルの名前を作成
$tableName = 'tmp_' . time();
// 一時テーブルを作成
DB::statement(DB::raw("CREATE TEMPORARY TABLE $tableName(
   email varchar(255),
   library_num varchar(255),
   library_status_list_id INT) 
// MySQLでのテーブル作成オプション
   ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_unicode_ci;"));

これだけで一時テーブルが作成できます!
それでは次にCSV内のデータを一時テーブルに保存してみましょう。
LOAD DATA LOCAL INFILEを使用して一時テーブルにデータをインポートします。
これは通常のインポートより高速にインポートが可能になります。
LOCALはFILE権限なしで、ローカルファイルの読み込みが可能になります。

参照サイト

https://qiita.com/oden141/items/239a7ce3cfe3197a3ba7

https://dev.mysql.com/doc/refman/8.0/ja/load-data.html

https://mita2db.hateblo.jp/entry/2020/01/13/163218

一時テーブルにデータを入れる処理
 DB::statement(DB::raw("LOAD DATA LOCAL
     // ファイルの実際の物理パスを入れる
    INFILE '{file_path}'
    INTO TABLE $tableName
     // 区切り文字を指定する。今回はカンマで区切る。
    FIELDS TERMINATED BY ','
    // データファイル内の行の終了文字を指定する。今回は各行が改行文字 (\n) で終了していると認識される。
    LINES TERMINATED BY '\n'
         // ヘッダー情報を無視するために一行目を読み飛ばす
    IGNORE 1 LINES
         //一時テーブルに保存するためには、すべてのCSVの列数が必要 
     (@1,@2,@3,@4,@5,@6,@7,@8,@9,@10,@11,@12,@13)
     // そのうち必要な番号だけをカラムに読み込むよう指定する
     SET email=@6,
             // library_idの後ハイフンをつけ、7桁の数字が生成されるようにする 例:1-0000001
        library_num=CONCAT('{$libraryId}', '-', LPAD(@10,'7', '0')),
        library_status_list_id=@13;
     "));

これで一時テーブルを作成し、CSVのデータを一時テーブルにインポートすることができました!

3バリデーション処理

それでは次に一時テーブルを利用してCSV内のデータのバリデーション処理を行っていきましょう。
今回行いたいバリデーション処理は4つです。

  • CSVの行数(一万行以内か)の確認
  • emailのunique確認
  • library_numのunique確認
  • library_status_list_idが存在するidかの確認

実際のコードを見ていきましょう。

一万行以内かのバリデーション処理
// 最大行数の値を指定
private static $MAX_COUNT = 10000;
// 一時テーブルの行数をカウントし、変数に格納
$queryResultCount = DB::table($tableName)->count
// 変数が最大値を超える場合は例外をスローする
if ($queryResultCount > self::$MAX_COUNT) {
return new \Exception("CSVファイルの行数が最大値(10000行)を超えています", 422);
 }
emailのunique確認のバリデーション処理
// 一時テーブルの'email'カラムと`library_users`テーブルの'email'カラムが一致する場合は結合し、その後結合された結果セットの行数をカウントして、変数に格納
 $emailDuplicationCount = DB::table($tableName)
       ->join('library_users', "$tableName.email", '=', 'library_users.email')
       ->count();
// 結合された結果セットの行数が0より大きい場合は例外をスローする
if ($emailDuplicationCount > 0) {
      return new \Exception("このメールアドレスはすでに登録されています", 422);
 }
library_numのunique確認のバリデーション処理
// 一時テーブルの'library_num'カラムと`library_users`テーブルの'library_num'カラムが一致する場合は結合し、その後結合された結果セットの行数をカウントして、変数に格納
$libraryNumDuplicationCount = DB::table($tableName)
      ->join('library_users', "$tableName.library_num", '=','library_users.library_num')
      ->count();
// 結合された結果セットの行数が0より大きい場合は例外をスローする
if ($libraryNumDuplicationCount > 0) {
      return new \Exception("この図書館番号はすでに登録されています", 422);
}
library_status_list_idが存在するidかのバリデーション確認
// 一時テーブルの'library_status_list_id'カラムと`library_cards`テーブルの'library_status_list_id'カラムが一致していれば左外部結合し、その後、library_cards テーブルの library_status_list_id カラムが NULL である行を抽出し、その数をカウントして、変数に格納
$libraryStatusExistenceCount = DB::table($tableName)
    ->leftJoin('library_cards', 'library_cards.library_status_list_id', '=', "$tableName.library_status_list_id")
    ->whereNull("library_cards.library_status_list_id")
    ->count();
if ($libraryStatusExistenceCount > 0) {
    return new \Exception("図書館カードのステータスが存在しません", 422);
}

これまでのバリデーション処理だと一万件あったら、DBの情報を最大一万回確認をしないといけませんでしたが、
この方法だと1回ずつで処理が完了し、高速化できます!

参照

https://qiita.com/gone0021/items/c613ef7e006b6f5d47ce

4保存する

今回のCSV処理では、library_usersとlibrary_cardsを同時に作成する必要があり、
library_cardsでは外部キーとしてlibrary_users_idを持たせる必要があります。
ER図.jpg
このidを付与する為に、foreachでidを取得した後、
付与していく作業を行なっていたのですが、
それだとバリデーション処理をいくら短縮化しても、
その付与作業で一万回のループが発生してしまい、
意味がなくなっていました。
そのためそのループ処理をなくす為に、
一時テーブルにCSVのデータを保存し、
DBのテーブルに直接インサートしました。
そのため一時テーブルでデータを入れる処理を少し変更します。
バリデーションしたい内容+図書館カードのデータを一時テーブルに入れます。

一時テーブル作成の処理
$tableName = 'tmp_' . time();
DB::statement(DB::raw("CREATE TEMPORARY TABLE $tableName(
   email varchar(255),
   library_num varchar(255),
   last_confirmed_date,
   expired_date date,
   library_status_list_id INT) 
   ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_unicode_ci;"));
一時テーブルにデータを入れる処理
 DB::statement(DB::raw("LOAD DATA LOCAL
    INFILE '{file_path}'
    INTO TABLE $tableName
    FIELDS TERMINATED BY ','
    LINES TERMINATED BY '\n'
    IGNORE 1 LINES
     (@1,@2,@3,@4,@5,@6,@7,@8,@9,@10,@11,@12,@13)
    SET email=@6,
        library_num=CONCAT('{$libraryId}', '-', LPAD(@10,'7', '0')),
        last_confirmed_date=@11,
        expired_date date=@12,
         library_status_list_id=@13;
      "));
一時テーブルのデータをlibrary_cardsテーブルにinsertする
 DB::statement(
            "INSERT INTO library_cards
                 (library_num,
                  last_confirmed_date,
                  expired_date,
                  library_status_list_id;
                  library_users_id,
                  created_at,
                   updated_at)
                           // テーブルを結合し、特定の列を選択する
             SELECT
                 {$tableName}.library_num,
                 {$tableName}.last_confirmed_date,
                 {$tableName}.expired_date,
                 {$tableName}.library_status_list_id,
                          // 'library_users'テーブルから'id'列を選択し、library_user_idという別名をつける
                 library_users.id as library_user_id,
                 now() as created_at,
                 now() as updated_at
             FROM {$tableName}
                 INNER JOIN lirbrary_users ON {$tableName}.email = library_users.email");

一時テーブルからデータを直接insertするだけで、
一々library_userのidを取得して付与する必要がなくなります!
これで処理が大幅に早くなりました。
今回の処理は一時テーブルを扱う用のRepository層に記載し、
Service層からメソッドを呼び出すようにしています。
ただ会社のコード規約などで書き方が決まっていると思われますので、
あくまで参考程度にしてください。

5終わりに

入社して3,4ヶ月目の人がやるタスクではなかったらしく、
本当に実装に時間がかかってしまったのですが、
このタスクを行うことでとても勉強になりましたので、実装させて貰えて本当に良かったです。
長期間教えて頂いたつよつよの先輩に頭が上がりません、本当にありがとうございました。
今後量が多いデータを扱う際には、この方法を思い出して実装していきたいと思います。
ここまでお読み下さりありがとうございました。
誰かの知見になれば幸いです。

6参考文献

この記事は以下の記事を参考にして執筆させて頂きました。

第107回 CREATE TEMPORARY TABLEによる一時テーブルの利用
Laravel 8.x データベース:準備
【MySQL】csvファイルをDBにインポートする方法
13.2.7 LOAD DATA ステートメント
MySQL 8.0 の LOAD DATA で The used command is not allowed with this MySQL version エラー
【laravel】Validatorによるバリデーション

Arsaga Developers Blog

Discussion