Laravel-excel を使い、非同期でインポートする

# Laravel-excel を使い、非同期でインポートする

実装段階でかなりハマったポイントがあって苦労したので、共有しようと思います。

##  要件

1. Queue を利用し、完了時にメールで通知する。
2. 特定のレコードが登録できなかったとしても、処理を続行する。
3. バリデーションで不正なデータを弾き、エラーレポートを出力する。
4. 同じファイルをアップロードした場合には、 レコードは上書き処理をする。

ハマったのは最後の3,4です。

## 環境

- Nginx + php-fpm
- PHP7.3
- MySQL 5.7
- Laravel-excel 3.1

## laravel-excel

[laravel-excel Github](https://github.com/maatwebsite/Laravel-Excel)

## Horizon インストール

[Horizon](https://readouble.com/laravel/5.8/ja/horizon.html)

## routes/web.php

```php
Route::get('imports', 'ImportController@index')->name('imports');
Route::post('imports', 'ImportController@store')->name('imports.store');
```

## imports.blade.php

```php
{{ BootForm::inline(['method' => 'post', 'url' => route('import.store'), 'files' => true]) }}
{{ BootForm::file('upfile') }}
{{ BootForm::submit() }}
{{ BootForm:::close() }}
```

※ [BootForm](https://github.com/dwightwatson/bootstrap-form) を使った書き方です。

## ImportController

```php
php artisan make:comntroller ImportController
```

```php
<?php

namespace App\Http\Controllers;

use App\Http\Requests\ImportFormRequest;
use Illuminate\Contracts\View\Factory;
use Illuminate\Http\RedirectResponse;
use Illuminate\View\View;

class ImportController extends Controller
{
    public function index()
    {
        return view('imports');
    }

    public function store(ImportFormRequest $request)
    {
        return redirect()->route('imports');
    }
}

```

フォームリクエストでバリデーション処理を記述します。

```php
php artisan make:request ImportFormRequest
```

```php
<?php

namespace App\Http\Requests;

use Illuminate\Foundation\Http\FormRequest;

class ImportFormRequest extends FormRequest
{
    /**
     * Determine if the user is authorized to make this request.
     *
     * @return bool
     */
    public function authorize()
    {
        return true;
    }

    /**
     * Get the validation rules that apply to the request.
     *
     * @return array
     */
    public function rules()
    {
        return [
            'upfile' => [
                'required',
                'mimetypes:application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
                'mimes:xlsx',
            ]
        ];
    }
}
```

## インポート部分の実装

`artisan` コマンドで import 部分を作成

```php
php artisan make:import PostalCodeImport
```

```php
<?php

namespace App\Imports;

use Maatwebsite\Excel\Concerns\ToCollection;

class PostalCodeImport implements ToCollection
{
    /**
    * @param Collection $collection
    */
    public function collection(Collection $collection)
    {
        //
    }
}
```

雛形がこのようにできる。

`Queue` を利用するので、[https://docs.laravel-excel.com/3.1/imports/queued.html](https://docs.laravel-excel.com/3.1/imports/queued.html) を参考に、`ShouldQueue`インターフェイスを継承する。

> ShouldQueue is only supported in combination with WithChunkReading.

`ShouldQueue` を使う時には、`WithChunkReading` も指定する必要があるため、以下のようになる。


```php
<?php

namespace App\Imports;

use Maatwebsite\Excel\Concerns\ToCollection;
use Illuminate\Contracts\Queue\ShouldQueue;
use Maatwebsite\Excel\Concerns\WithChunkReading;

class PostalCodeImport implements ToCollection, ShouldQueue, WithChunkReading
{

    use Queueable, SerializesModels;

    /**
    * @param Collection $collection
    */
    public function collection(Collection $collection)
    {
        //
    }

    public function chunkSize(): int
    {
        return 1000;
    }
}
```

エクセルの行ごとの値を検証する必要があるので、`WithValidation` が必要。
このとき、バリデーションでエラーがあった時に処理を中断するか、続行するかの設定をする。
処理を続行する時には`SkipsOnFailure`を指定する。

エラー時に何もせずに処理を続行するのであれば、`SkipsFailures` トレイトを `use` するだけでいいが、何かの処理を加えたい場合は、`SkipsOnFailure`を継承するだけでいい。

また、処理の最後にエラ〜レポートを出力したいので、`WithEvents`も継承する。


```php
<?php

namespace App\Imports;

use App\PostalCode;
use Maatwebsite\Excel\Concerns\ToCollection;
use Illuminate\Contracts\Queue\ShouldQueue;
use Maatwebsite\Excel\Concerns\WithChunkReading;
use Maatwebsite\Excel\Concerns\SkipsOnFailure;
use Maatwebsite\Excel\Concerns\WithValidation;
use Maatwebsite\Excel\Concerns\WithEvents;
use Maatwebsite\Excel\Events\AfterImport;

class PostalCodeImport implements ToCollection,
    ShouldQueue,
    WithChunkReading,
    WithValidation,
    SkipsOnFailure,
    WithEvents
{

    use Queueable, SerializesModels;

   /**
    * @param Collection $collection
    */
    public function collection(Collection $collection)
    {
        $collection->each(function(array $row) {
            PostalCode::query()
                ->updateOrCreate([
                    'id' => $row[0]
                ], [
                    'id' => $row[0],
                    'name' => $row[1],
                    // ...
                ]);
        });
    }

   /**
    * 行の分割サイズ
    * @return int
    */
    public function chunkSize(): int
    {
        return 1000;
    }

    /**
     * バリデーションルール
     * @return array
     */
    public function rules(): array
    {
        // 書き方は通常のバリデーションと同じ。
        return [
            0 => ['required', 'int'],
            1 => ['required', 'string'],
            2 => ['required', 'numeric'],
            3 => ['required', 'numeric'],
        ];
    }

    /**
     * バリデーションエラー時の処理
     * @param Failure ...$failures
     */
    public function onFailure(Failure ...$failures)
    {
        foreach ($failures as $failure) {
            //
        }
    }

    /**
     * WithEvents interface needs `registerEvents()`
     */
    public function registerEvents(): array
    {
        return [
            // Handle by a closure.
            AfterImport::class => function(AfterImport $event) {
                // エラーを纏めて、Excelに出力する
            }
        ];
    }
}
```

## 行ごとのバリデーション・エラーをどうやって格納するか

Hirizon を確認すると、ImportQueue のあとで、ChunkReading と言う Queue が走るのだが、PostalCodeImport に持ったプロパティでは、各Queue を跨いで情報を共有することができない。

そこで、エラーを管理するクラスを登場させる。インスタンスは書くQueue間で一意にしたいのでシングルトンで実装する。

```php
<?php

namespace App\Imports;

use Maatwebsite\Excel\Validators\Failure;

class ImportErrors
{
    private static $singleton;
    public $collection;

    public static function getInstance()
    {
        if (!isset(self::$singleton)) {
            self::$singleton = new ImportErrors();
        }
        return self::$singleton;
    }

    private function __construct()
    {
        $this->collection = collect();
    }

    /**
     * @param Failure $failure
     */
    public static function add(Failure $failure)
    {
        $error = collect([
            'row' => $failure->row(),
            'col' => $failure->attribute(),
            'value' => implode(',', $failure->values()),
            'message' => implode(',', $failure->errors())
        ]);
        self::getInstance()->collection->push($error);
    }

    /**
     * @return \Illuminate\Support\Collection
     */
    public static function all()
    {
        return self::getInstance()->collection;
    }
}
```

```php
<?php

class PostalCodeImport implements ToCollection,
    ShouldQueue,
    WithChunkReading,
    WithValidation,
    SkipsOnFailure,
    WithEvents
{

    use Queueable, SerializesModels;

    public function onFailure(Failure ...$failures)
    {
        foreach ($failures as $failure) {
            ImportErrors::add($failure);
        }
    }

    public function registerEvents(): array
    {
       return [
            // Handle by a closure.
            AfterImport::class => function(AfterImport $event) {
                // エラーを纏めて、Excelに出力する
                $errors = ImportErrors::all();
                // $errors は Collection なので、
                // 通常の通り Laravel-excel でシートに書き込む
            }
       ];
    }
}
```