Laravel-excel を使い、非同期でインポートする
実装段階でかなりハマったポイントがあって苦労したので、共有しようと思います。
要件
- Queue を利用し、完了時にメールで通知する。
- 特定のレコードが登録できなかったとしても、処理を続行する。
- バリデーションで不正なデータを弾き、エラーレポートを出力する。
- 同じファイルをアップロードした場合には、 レコードは上書き処理をする。
ハマったのは最後の3,4です。
環境
- Nginx + php-fpm
- PHP7.3
- MySQL 5.7
- Laravel-excel 3.1
laravel-excel
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 でシートに書き込む
}
];
}
}