11
13

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

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

Posted at

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

Horizon インストール

Horizon

routes/web.php

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

imports.blade.php

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

BootForm を使った書き方です。

ImportController

php artisan make:comntroller ImportController
<?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 artisan make:request ImportFormRequest
<?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 artisan make:import PostalCodeImport
<?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 を参考に、ShouldQueueインターフェイスを継承する。

ShouldQueue is only supported in combination with WithChunkReading.

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

<?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

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

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

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 でシートに書き込む
            }
       ];
    }
}
11
13
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
11
13

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?