【Laravel】CSVファイルのデータインポート＆エクスポートする方法

Laravel でテストケースを書く際にCSVファイルのデータを挿入する機会があった。その際は、Seederファイルを作成して、そのテストケース用のデータを挿入した。しかし、実際に利用するCSVファイルからデータを挿入する方が早いと思ったため、今回その機能を実装してみることにした。

また、挿入したデータをCSVファイルとして出力できたら、相互的にデータのやりとりができると思い、CSVファイルのエクスポートも実装してみた。

まずは、CSVファイルファイルをインポートし、データを挿入することから行う。

# CSVファイルからのインポート
処理の流れとして、フォーム欄からCSVファイルのアップロードを行、CSVファイルをストレージに保存する。その内容をもとにコレクションを作成し、データベースに挿入するという流れで実装する。
まとめると、以下のようになる。

**処理の流れ** 
①フォーム欄からCSVファイルの取得
②取得したCSVファイルをコレクションに置き換える
③置き換えたコレクションに対して不正なデータがないかの確認
④置き換えたコレクションを配列にしてデータベースに挿入

## 定義したテーブル
今回はitemsというテーブルを定義し、そのテーブルのデータに関して、CSVファイルの相互的やりとりを行った。
リレーションに関して、対応するcategoryのデータも取得できるが、今回は省略した。

|カラム  |型  |PK  |
|---|---|---|
|id  |bigint  |○  |
|name  |varchar(255)  |-  |
|category_id  |bigint  |-  |
|description  |varchar(255)  |-  |

### ①フォーム欄からCSVファイルの取得

CSVファイルをインポートするフォーム欄をBladeに用意する。

```php:〇〇blade.php
<form method="post" action="{{ route('csv.upload') }}" enctype="multipart/form-data">
    @csrf
    <label name="csvFile">csvファイル</label>
    <input type="file" name="csvFile" class="" id="csvFile"/>
    <input type="submit"></input>
</form>
<input type="submit">
```
ここでのポイントはidにcsvFileを指定する点である。のちに利用する `hasFile()`ではこのinputタグからアップロードしたファイルのidをもとに参照する。
また、type属性をfileに指定することと enctype属性をmultipart/form-dataに指定することも必要である。

Formファサードを利用すると、以下のように記述できる。
Formファザードの場合は、 `Form::file()` の引数でidも指定できているようであった。
```php:〇〇blade.php
{{Form::open(['route'=>'csv.upload','enctype'=>'multipart/form-data'])}}
  @csrf
  {{Form::label('csvFile', 'csvファイル')}}
  {{Form::file('csvFile')}}
  {{Form::submit()}}
{{Form::close()}}
{{Form::open(['route'=>'csv.download','enctype'=>'multipart/form-data' ])}}
```
##  CSVファイルの処理部分
次に、アップロードしたCSVファイルに関する処理を追加していく。
コントローラーに処理を実装していくかユースケース等を利用するかで `namespace` 等は異なると思うが、全体として、以下のような記述をした。

itemsテーブルに関わる一部のメソッドはモデル内の `Item.php` に定義している。

```php:〇〇.php
<?php

namespace App\Usecases\Csv;

use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
use App\Models\Item;
use Illuminate\Http\Request;

class UploadUsecase
{
    public function run(Request $request)
    {
        $item = new Item();
        // CSVファイルが存在するかの確認
        if ($request->hasFile('csvFile')) {
            //拡張子がCSVであるかの確認
            if ($request->csvFile->getClientOriginalExtension() !== "csv") {
                throw new Exception('不適切な拡張子です。');
            }
            //ファイルの保存
            $newCsvFileName = $request->csvFile->getClientOriginalName();
            $request->csvFile->storeAs('public/csv', $newCsvFileName);
        } else {
            throw new Exception('CSVファイルの取得に失敗しました。');
        }
        //保存したCSVファイルの取得
        $csv = Storage::disk('local')->get("public/csv/{$newCsvFileName}");
        // OS間やファイルで違う改行コードをexplode統一
        $csv = str_replace(array("\r\n", "\r"), "\n", $csv);
        // $csvを元に行単位のコレクション作成。explodeで改行ごとに分解
        $uploadedData = collect(explode("\n", $csv));

        // テーブルとCSVファイルのヘッダーの比較
        $header = collect($item->csvHeader());
        $uploadedHeader = collect(explode(",", $uploadedData->shift()));
        if ($header->count() !== $uploadedHeader->count()) {
            throw new Exception('Error:ヘッダーが一致しません');
        }

        // 連想配列のコレクションを作成
        //combine 一方の配列をキー、もう一方を値として一つの配列生成。haederをキーとして、一つ一つの$oneRecordと組み合わせて、連想配列のコレクション作成
        try {
            $items = $uploadedData->map(fn($oneRecord) => $header->combine(collect(explode(",", $oneRecord))));
        } catch (Exception $e) {
            throw new Exception('Error:ヘッダーが一致しません');
        }

        // アップロードしたCSVファイル内での重複チェック
        if ($items->duplicates("id")->count() > 0) {
            throw new Exception("Error:idの重複：" . $items->duplicates("id")->shift());
        }

        // 既存データとの重複チェック.pluckでDBに挿入したい$itemsのidのみ抽出
        $duplicateItem = DB::table('items')->whereIn('id', $items->pluck('id'));
        if ($duplicateItem->count() > 0) {
            throw new Exception("Error:idの重複：" . $duplicateItem->shift()->id);
        }

        // $itemsコレクションを配列にして、一括挿入
        DB::table('items')->insert($items->toArray());
    }
}

```

モデルに加えたメソッドは以下の通りである。

```php:Item.php

 public function csvHeader(): array
    {
        return [
            'id',
            'name',
            'category_id',
            'description',
        ];
    }

    public function getCsvData(): \Illuminate\Support\Collection
    {
        $data = DB::table('items')->get();
        return $data;
    }
    public function insertRow($row): array
    {
        return [
            $row->id,
            $row->name,
            $row->category_id,
            $row->description,
        ];
    }
```

### ②取得したCSVファイルをコレクションに置き換える

```php:〇〇.php
        $item = new Item();
        // CSVファイルが存在するかの確認
        if ($request->hasFile('csvFile')) {
            //拡張子がCSVであるかの確認
            if ($request->csvFile->getClientOriginalExtension() !== "csv") {
                throw new Exception('不適切な拡張子です。');
            }
            //ファイルの保存
            $newCsvFileName = $request->csvFile->getClientOriginalName();
            $request->csvFile->storeAs('public/csv', $newCsvFileName);
        } else {
            throw new Exception('CSVファイルの取得に失敗しました。');
        }
        //保存したCSVファイルの取得
        $csv = Storage::disk('local')->get("public/csv/{$newCsvFileName}");
        // OS間やファイルで違う改行コードをexplode統一
        $csv = str_replace(array("\r\n", "\r"), "\n", $csv);
        // $csvを元に行単位のコレクション作成。explodeで改行ごとに分解
        $uploadedData = collect(explode("\n", $csv));
```
まずは、条件分岐でインポートしたCSVファイルが適切なファイルかどうか確認を行っている。適切なものであれば、ストレージに保存する。
ストレージを利用するので、シンボリックリンクを作成するのを忘れないように気をつけなければならない。

```:ターミナル
$ php artisan storage:link
```

保存したCSVファイルの改行コードを調整した後に `$uploadedData`　では `collect()` でヘッダー行 ＋ 1レコード単位のコレクションを作成していく。

### ③置き換えたコレクションに対して不正なデータがないかの確認
```php:〇〇.php
       // テーブルとCSVファイルのヘッダーの比較
        $header = collect($item->csvHeader());
        $uploadedHeader = collect(explode(",", $uploadedData->shift()));
        if ($header->count() !== $uploadedHeader->count()) {
            throw new Exception('Error:ヘッダーが一致しません');
        }

        // 連想配列のコレクションを作成
        //combine 一方の配列をキー、もう一方を値として一つの配列生成。haederをキーとして、一つ一つの$oneRecordと組み合わせて、連想配列のコレクション作成
        try {
            $items = $uploadedData->map(fn($oneRecord) => $header->combine(collect(explode(",", $oneRecord))));
        } catch (Exception $e) {
            throw new Exception('Error:ヘッダーが一致しません');
        }

        // アップロードしたCSVファイル内での重複チェック
        if ($items->duplicates("id")->count() > 0) {
            throw new Exception("Error:idの重複：" . $items->duplicates("id")->shift());
        }

        // 既存データとの重複チェック.pluckでDBに挿入したい$itemsのidのみ抽出
        $duplicateItem = DB::table('items')->whereIn('id', $items->pluck('id'));
        if ($duplicateItem->count() > 0) {
            throw new Exception("Error:idの重複：" . $duplicateItem->shift()->id);
        }
```
ここでは、作成したコレクションを元に、追加してよいデータであるかの確認をしている。
定義したテーブルとCSVファイル内のヘッダーが等しい形式であるか、アップロードしたCSVファイル内にPKが重複する不正なデータが存在しないか、既にテーブルに挿入する予定のデータが存在しないかなどを確認している。

ヘッダーに関してはアップロードした際のCSVファイルの形式に応じて適宜調整する必要がある。

また、ここで、ItemsテーブルのヘッダーとCSVファイルのデータを組み合わせて、最後に挿入する連想配列も作成している。


### ④置き換えたコレクションを配列にしてデータベースに挿入
```php:〇〇.php
        DB::table('items')->insert($items->toArray());
```

全てのエラーチェックを通ったら、コレクションを配列に変換してテーブルに挿入する。
これにより、CSVファイルからのデータのインポートが実行できる。

## CSVファイルのエクスポート
次に、CSVファイルのエクスポートである。
ItemsテーブルのデータをCSVファイルに出力するときはStreamedResponseを用いる。このクラスにより、クライアント側のレスポンスにストリーム化を施すことができる。このストリームとはファイルにアクセスする際のインターフェースのことを指し、ファイルの書き込みやデータ取得などの処理を行う際に利用する。
そして、`fopen()` でファイルの記述を行い、データの書き込みを行い、レスポンスヘッダの設定を行った後に、レスポンスを返すことでCSVファイルをダウンロードすることが可能である。

**処理の流れ**
①StreamedResponseの定義
②fopenでCSVファイルを展開し、エクスポートするデータの取得
③CSVファイルに書き込むデータの挿入
④レスポンスヘッダの設定


先にエクスポート処理の全体部分を記述します。先ほどと同じようにモデル内の `Item.php` も利用する。 

```php:〇〇.php
<?php

namespace App\Usecases\Csv;

use App\Models\Item;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\StreamedResponse;

class DownloadUsecase
{
    public function run(Request $request)
    {
        $item = new Item();
        $response = new StreamedResponse (function () use ($request, $item) {
            $stream = fopen('php://output', 'w');

            //　文字コードを変換して、文字化け回避
            stream_filter_prepend($stream, 'convert.iconv.utf-8/cp932//TRANSLIT');

            // CSVファイルにヘッダーを追加
            fputcsv($stream, $item->csvHeader());

            //CSVファイルに挿入するデータの取得
            $insertData = $item->getCsvData();

            // データの挿入
            if (empty($insertData[0])) {
                fputcsv($stream, [
                    'データが存在しません。',
                ]);
            } else {
                foreach ($insertData as $row) {
                    // 各列に行を追加（カラムに値）
                    fputcsv($stream, $item->insertRow($row));
                }
            }
            fclose($stream);
        });
        $response->headers->set('Content-Type', 'application/octet-stream');
        //filenameは自由に指定可
        $response->headers->set('Content-Disposition', 'attachment; filename="sample.csv"');

        return $response;
    }
}
```
### ①StreamedResponseの定義
```php:〇〇.php
        $item = new Item();
        $response = new StreamedResponse (function () use ($request, $item) {
        //
    }
```
StreamedResponseの引数には function内で利用するインスタンスを記述する。

### ②fopenでCSVファイルを展開し、エクスポートするデータの取得
```php:〇〇.php
            $stream = fopen('php://output', 'w');

            //　文字コードを変換して、文字化け回避
            stream_filter_prepend($stream, 'convert.iconv.utf-8/cp932//TRANSLIT');

            // CSVファイルにヘッダーを追加
            fputcsv($stream, $item->csvHeader());

            //CSVファイルに挿入するデータの取得
            $insertData = $item->getCsvData();
```
`fopen` の第二引数はファイルに対するアクセス形式である。この記述でファイルを新たに作成して、そのファイルに対して書き込みが可能になっている。
次に、`stream_filter_prepend()` で文字コードの変更により日本語にも対応できるようにする。
あとは、 `Item.php` に定義した関数をもとに、ヘッダーの追加と挿入するテーブルのデータを取得している。

### ③CSVファイルに書き込むデータの挿入
```php:〇〇.php
           if (empty($insertData[0])) {
                fputcsv($stream, [
                    'データが存在しません。',
                ]);
            } else {
                foreach ($insertData as $row) {
                    // 各列に行を追加（カラムに値）
                    fputcsv($stream, $item->insertRow($row));
                }
            }
            fclose($stream);
```
②でも出てきた `fputcsv()` で配列の形でデータをCSVファイルに書き込んでいく。
`foreach()` で1レコード単位の配列に置き換えて、データを順番に挿入している。

### ④レスポンスヘッダの設定
```php:〇〇.php
        $response->headers->set('Content-Type', 'application/octet-stream');
        //filenameは自由に指定可
        $response->headers->set('Content-Disposition', 'attachment; filename="sample.csv"');

        return $response;
```
レスポンスヘッダはクライアントからのリクエストに返答するレスポンスの付加情報となる。 `Content-Type` に添付するファイル情報を載せる。この `application/octet-stream` は一般的な  `text` や  `image` などのファイル形式に当てはまらないものに適用される。
そして、`filename`　でエクスポートされる際のファイル名を指定している。

最後に、 `$response` を返すことでファイルのエクスポートが行われる。
## 参考記事
https://qiita.com/gohandesuyo/items/18ad77bad0b7e068d6f7

https://coinbaby8.com/php-csv-export.html

https://tech.arms-soft.co.jp/entry/2019/10/23/090000

https://www.php.net/manual/ja/function.fopen.php

https://www.php.net/manual/ja/function.stream-filter-prepend.php