LaravelでCSVからデータをインポートする

### やりたいこと
**リクエストで送られてきたCSVファイルの内容を取り込んでテーブルにインサートしたい**

これがやりたくてLaravel（PHP）でのCSV取り込み処理を色々と調べていたのですが、fgetcsv関数を使ってファイルを１行ずつ読み込みながらループしながら処理していく流れの記事が比較的多かったような印象を受けました。
けどせっかくLaravelを使うならCSVの内容をコレクションに変換して処理していく方が簡単なのでは？
と思ったので、そっちの方法で実装試してみました。
結果、思ったより楽に実装できたので、LaravelでCSV取り込みを実装する必要がある場合に、実装例の１つとして参考にしてみてください。

Laravelのコレクションの詳細は[こちら](https://readouble.com/laravel/8.x/ja/collections.html#method-chunk)

※ここではバックエンドの実装例のみ載せます。フロントエンドの技術は何でも良いですが、CSVファイルがpostで送信されてくる想定で話を進めます。

### テーブル定義
ここではusersテーブルを用意して、そこに対してCSVからデータを取り込む処理を実装する例とします。
テーブルの定義は以下の通りとします。

|カラム|型|PK|not null|unique|デフォルト値|CSV読み込み対象|
|:--:|:--:|:--:|:--:|:--:|:--:|:--:|
|id|int|〇|〇|〇|自動採番||
|login_id|varchar(20)||〇|〇||〇|
|password|varchar(255)||〇|||〇|
|name|varchar(255)||〇|||〇|
|email|varchar(255)||〇|||〇|
|created_at|timestamp||〇||current_timestanp||
|updated_at|timestamp||〇||null||

idは自動採番、created_atとupdated_atは自動でシステム日時がセットされる想定。
残りの項目をCSVの取り込み対象にします。

### 実装例

ここではサービスクラスを作ってコントローラから呼ばれる想定で実装します。
処理の主な流れとしては以下の通り。

1. リクエストからCSVファイルを取得する
* ファイルをストレージに保存する
* 保存したファイルを読み込んでコレクションに変換
* バリデーションチェック
* 重複チェック
* データの加工
* テーブルに一括でインサート


```UserImportService.php
<?php
namespace App\Services;

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

class UserCsvService {

    const CSV_HEADER = [
        "login_id",
        "password",
        "name",
        "email",
    ];

    /**
     * インポート処理
     */
    public function userImport(Request $request)
    {
        // ファイルを保存
        if($request->hasFile('usersCsv')) {
            if($request->usersCsv->getClientOriginalExtension() !== "csv") {
                throw new Exception("拡張子が不正です。");
            }
            $request->usersCsv->storeAs('public/', "users.csv");
        } else {
            throw new Exception("CSVファイルの取得に失敗しました。");
        }
        // ファイル内容取得
        $csv = Storage::disk('local')->get('public/users.csv');
        // 改行コードを統一
        $csv = str_replace(array("\r\n","\r"), "\n", $csv);
        // 行単位のコレクション作成
        $data = collect(explode("\n", $csv));

        // header作成と項目数チェック
        $header = collect(self::CSV_HEADER);
        $fileHeader = collect(explode(",", $data->shift()));
        if($header->count() !== $fileHeader->count()) {
            throw new Exception("項目数エラー");
        }
        // 連想配列のコレクションを作成
        try {
            $users = $data->map(function ($oneline) use ($header) {
                return $header->combine(collect(explode(",", $oneline)));
            });
        } catch (Exception $e) {
            throw new Exception("項目数エラー");
        }

        // バリデーションチェック
        $users->each(function($user) {
            if(!$this->validate($user)) {
                return false;
            }
        });

        // エラーの取得と通知は検討の余地あり
        // きっともっといいやり方あるはず
        $errorItem = $users->filter(function($item) {
            return $item->has("error");
        });
        if($errorItem->count() > 0) {
            throw new Exception($errorItem->shift()->error);
        }

        // CSV内での重複チェック
        if($users->duplicates("login_id")->count() > 0) {
            throw new Exception("重複エラー login_id：" . $users->duplicates("login_id")->shift());
        }

        // 既存データとの重複チェック
        $duplicateUser = DB::table('users')->whereIn('login_id', $users->pluck('login_id'));
        if($duplicateUser->count() > 0) {
            throw new Exception("重複エラー login_id：" . $duplicateUser->shift()->login_id);
        }

        // パスワードはハッシュ化しておくといいかも
        $users->each(function($user) {
            $user["password"] = Hash::make($user["password"]);
        });

        // 一括インサート
        DB::table('users')->insert($users->toArray());

    }

    /**
     * バリデーションチェック
     */
    private function validate($user)
    {
        // 必須チェック
        // 必要に応じて他の項目にも適用
        if(empty($user['login_id'])) {
            $user->put('error', '必須項目エラー');
            return false;
        }

        // その他、桁数チェック・値の妥当性チェックなどを必要に応じて

        return true;
    }
}

```

### 所感
ポイントはcombineをつかって通常の配列を連想配列にした部分ですかね。
CSVの内容を連想配列にしたことでEloquentモデルとほぼ変わらない操作でエラーチェックができたので、処理内容がかなりわかりやすくなって拡張もしやすくなったように思います。また、Laravelのコレクションのメソッドが使えることで柔軟にチェック処理やデータの加工ができるので、コレクション操作になれているなら確実にこっちのやり方の方が便利。
ただし、CSVのデータ件数が多くなったときに速度にどの程度影響がでるかは検証していないので課題が残るところ。特にバリデーションのチェックはもっと効率良い方法がないか模索中。
データ量の増加で速度が遅くなる場合は一度一時テーブルなどに入れて、Eloquentモデルのchunkとか使って処理していく方が良いのかもしれない。

### 注意事項
* ここで書いたソースコードは実際の開発で使用したコードを元に、テーブルやエラーチェックを簡略化して書き直したものになります。そのため動作検証はしておりませんので、そのままコピペして正常に動作する保証はありません。ご了承ください。
* エラーハンドリングの処理はかなり手を抜いているので、使用する際には適宜適切なエラー処理を行ってください。