はじめに
このエントリーについて
この記事は「Laravelでウェブアプリケーションをつくるときのベストプラクティスを探る」シリーズの一編です。
他の記事は目次からアクセスしてください。
イベントとジョブに関してはまだ私自身が実際のプロジェクトで使っている例も少なく、正直ベストプラクティスがこれだという自信もないんですが、この記事を書くにあたって、あれこれ実験もしてみたので、ある程度は実用に耐えうるんではないかと思っております。
あと、範囲をどこまでにするか悩んだんですが、イベント・ジョブ・通知・キューとまとめてとりあげることにします。イベントとジョブのアーキテクチャはすごく似ていて、なんらかの処理に依存する別の処理を切り出して、依存関係を隠す、という点において、共通して書けるなと思ったのが一点と、イベントとジョブはどちらも通知を行うのに適していて、関連して書けそうだ、というのと、キューを利用することで非同期にしたり別のアプリケーションで処理をしたりできる、という点も共通しています。
ちょっと長くなりますがご容赦ください。
サンプルコードを動かす場合は、事前に利用するキュードライバーのセットアップ (database なら マイグレーション、redis ならサーバーやクライアントパッケージのインストール、など) を行っておいてください。
環境
- PHP 5.6
- Laravel 5.3
公式リファレンス
Events - Laravel - The PHP Framework For Web Artisans
Notifications - Laravel - The PHP Framework For Web Artisans
Queues - Laravel - The PHP Framework For Web Artisans
詳細
よくあるシナリオ
- なんらかの処理の後、結果をだれかに通知したい
- なんらかの処理の後、関連するエンティティのステータスを変更したい
- なんらかの処理の後、関連する別の処理を行いたい
ガイドライン
- 時間のかかる処理をする場合はキューを使いましょう
- イベントの名前は、処理の完了であれば過去形、処理の途中であれば現在進行形にしましょう
- イベントリスナーの名前は、そのリスナーが担当する処理の名前にしましょう
- ジョブの名前は、そのジョブが担当する処理の名前にしましょう
- 通知の名前はイベントの名前に合わせましょう
- イベントもジョブも、キューに送る場合は failed メソッドを実装してエラーを記録もしくは通知しましょう
- ひとつのイベントに対して複数の処理が必要であれば、複数のイベントリスナーを登録しましょう
名前に関しては、できるだけ公式ドキュメントに沿ってみましたが、一点修正を加えた点があって、イベントリスナーの名前が公式では 'Notification' となっているのですが、本項では 'Notifier' にしています。これは、通知クラスが 'Notification' になっているので、名前がバッティングすることがあるからです。
例)
- イベント: App\Events\ContactReceived
- リスナー: App\Listeners\ContactReceivedNotifier
- 通知: App\Notifications\ContactReceived
上の例で、イベントと通知をイベントリスナー内で use すると被ってしまうので、以下のようにします。
use App\Events\ContactReceived;
use App\Notifications\ContactReceived as ContactReceivedNotification;
class ContactReceivedNotifier implements ShouldQueue
{
}
このルールにしておけば、イベント・イベントリスナー・通知が組み合わさったときに、どれがどれと組み合わさっているかが一目瞭然で、かつ名前衝突も回避できるので、いいのではないかと思っています。
処理の途中で送るイベントは思い浮かばなかったんですが、複数の画像が一度にアップロードされてきて、それをサーバーで複数サイズにリサイズして zip に圧縮する、みたいな処理を一つずつ非同期に処理する、とかそういうので使うのかな…ちょっといい実例が思い浮かばないので「こんなかんじで使ってるよ」というのがあったら教えてください 。
まぁ、とにかく、時間のかかる処理を同期的に行わず、キューに投げておいて、レスポンスを早く返しましょう、ということですね。
キューを処理するサーバーは同じである必要はないので、別のサーバーを用意しておいて、キューリスナーをそちらで動かしていれば、処理を分散させることもできます。
イベントのサンプルコード
コントローラー編で使用した、お問い合わせのサンプルを流用します。
public function send(ContactRequest $request, Contact $contact)
{
$params = $request->except('_token');
$contact = $contact->create($params);
event(new ContactReceived($contact));
return redirect()->route('home')->with('message', 'お問い合わせを受け付けました。メールにてご回答をお送りします。');
}
イベントクラスは php artisan make:event HogeEvent
、イベントリスナーは php artisan make:listener HogeListener
とすると app/Events 以下に自動でつくられるのですが、後述の event:generate
でまとめてつくることもできるので、場合に応じて使い分けていきましょう。
イベントリスナーの登録は EventServiceProvider クラス $listen
プロパティに登録することで行います。
<?php
namespace App\Providers;
use Illuminate\Support\Facades\Event;
use Illuminate\Foundation\Support\Providers\EventServiceProvider as ServiceProvider;
class EventServiceProvider extends ServiceProvider
{
protected $listen = [
'App\Events\ContactReceived' => [
'App\Listeners\ContactReceivedNotifier',
],
'App\Events\JobFailed' => [
'App\Listeners\JobFailedNotifier',
],
];
}
$listen
プロパティにイベントとイベントリスナーのペアを追加して、 php artisan event:generate
コマンドを実行すると、スケルトンを生成してくれます。
イベントリスナーはその責務が分かる名前がいいですね、ひとつのイベントに対して処理が複数あるのであれば、複数のイベントリスナーを登録しましょう。
生成されたスケルトンに手を加えたものがこちらです。
<?php
namespace App\Events;
use Illuminate\Queue\SerializesModels;
use App\Contact;
class ContactReceived
{
use SerializesModels;
public $contact;
public function __construct(Contact $contact)
{
$this->contact = $contact;
}
}
イベントクラスは単なるコンテナで、ドメインクラスを public で保持するだけです。 public プロパティを使いたくなければ、適宜アクセサを用意してください。
イベントリスナーは、キューに登録する方式にしました ( implements ShouldQueue
にすればイベントはキューに入ります)。
<?php
namespace App\Listeners;
use Exception;
use Illuminate\Contracts\Queue\ShouldQueue;
use App\Events\ContactReceived;
use App\Events\JobFailed;
use App\Notifications\ContactReceived as ContactReceivedNotification;
use App\Notifications\Admin\ContactReceived as ContactReceivedNotificationForAdmin;
use App\Admin;
class ContactReceivedNotifier implements ShouldQueue
{
private $admin;
public function __construct(Admin $admin)
{
$this->admin = $admin;
}
public function handle(ContactReceived $event)
{
$contact = $event->contact;
$contact->notify(new ContactReceivedNotification($contact));
$admin->notify(new ContactReivedNotificationForAdmin($contact));
}
public function failed(ContactReceived $event, Exception $e)
{
event(new JobFailed($event, $e));
}
}
handle メソッドでは、お問い合わせを受け取った (Events\ContactReceived) ら、送信者とサイト管理者に受け取ったことを通知します (Notifications\ContactReceived, Notifications\Admin\ContactReceived)。 Admin クラスは DI しています。
failed メソッドでは、キューの処理が失敗した場合に呼ばれるので、ジョブが失敗した (JobFailed) イベントを発火します (ログに書くなり、管理者にメールを飛ばすなり、Slack に投稿するなり、JobFailedNotifier 側で適宜実装します)。発生した例外は第二引数に渡ってくるので、記録や通知で利用しましょう。
キューに問題があるケースも考慮して、JobFailedNotifier はキューを使わないようにしておいた方がいいかもしれません。
failed メソッドは Event 基底クラスをつくってそちらに移すか、トレイトにするかした方がいいでしょう。
ちなみに、notify できるクラスは Notifiable トレイトを使ったクラスで、上の例の Admin クラスは下記のようにシンプルなものです。
<?php
namespace App;
use Illuminate\Notifications\Notifiable;
class Admin
{
use Notifiable;
public function routeNotificationForMail()
{
return config('mail.admin_email');
}
}
routeNotificationForMail メソッドをオーバーライドすれば、送信先のメールアドレスを指定できます (Notifiable なクラスが Eloquent なクラスか、あるいは public なフィールドに email を持っていればオーバーライドする必要はありません)。
通知クラスも載せておきます。
イベント失敗時にメールを送る処理です。
<?php
namespace App\Notifications;
use Illuminate\Bus\Queueable;
use Illuminate\Notifications\Notification;
use Illuminate\Notifications\Messages\MailMessage;
class JobFailed extends Notification
{
use Queueable;
private $event;
public function __construct($event)
{
$this->event = $event;
}
public function via($notifiable)
{
return ['mail'];
}
public function toMail($notifiable)
{
$message = new MailMessage();
$message->view = [
'notifications::admin.event-failed',
'notifications::admin.event-failed-plain'
];
return $message->subject('Event Failed')
->line('Event:' . serialize($this->event->event))
->line('Exception:' . $this->event->exception->getMessage())
->line('File:' . $this->event->exception->getFile())
->line('Line:' . $this->event->exception->getLine())
;
}
}
メールテンプレートをカスタマイズする場合は、事前に php artisan vendor:publish --tag=laravel-notifications
を実行しておいてください。
toMail メソッドの引数 $notifiable
には Notifiable なクラス (上の例では Admin ) のインスタンスが入ってきます。メールに送信先の名前を埋め込むときとかに適宜呼び出してください。
ジョブのサンプルコード
送信されたお問い合わせを GitHub のイシューとして登録するシナリオを考えてみます (本来であれば別のイベントリスナーをつくってそちらでディスパッチを行う方がいいとは思いますが、サンプルなのでご容赦を)。
public function handle(ContactReceived $event)
{
$contact = $event->contact;
$contact->notify(new ContactReceivedNotification($contact));
$admin->notify(new ContactReceivedNotificationForAdmin($contact));
// 追加
dispatch(new AddContactAsGitHubIssue($contact));
}
GitHub API のラッパーはこちらのライブラリを使っています。
GitHub - GrahamCampbell/Laravel-GitHub: A GitHub bridge for Laravel 5
<?php
namespace App\Jobs;
use GitHub;
use Illuminate\Bus\Queueable;
use Illuminate\Queue\SerializesModels;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Contracts\Queue\ShouldQueue;
use App\Contact;
class AddContactAsGitHubIssue implements ShouldQueue
{
use InteractsWithQueue, Queueable, SerializesModels;
private $contact;
public function __construct(Contact $contact)
{
$this->contact = $contact;
}
public function handle()
{
GitHub::issues()
->create('Organization', 'Repository', [
'title' => sprintf('お問い合わせ: %s 様', $this->contact->name),
'body' => $this->contact->content,
'assignee' => 'nunulk',
'labels' => ['お問い合わせ'],
])
;
}
public function failed(Exception $e)
{
event(new JobFailed($this, $e));
}
}
ジョブの名前は責務が明確になるようにしましょう。
イベント同様、failed メソッドをオーバーライドすればジョブの失敗を色んな経路で通知できるようになります。
注意すべきなのは、イベントリスナーの failed メソッドは第一引数に Event を受け取っていたのに対し、ジョブの failed メソッドは例外オブジェクトのみ来るので、ここでは JobFaied の生成時に $this
を渡しています。
まとめ
- 時間のかかる処理をする場合はキューを使いましょう
- イベントの名前は、処理の完了であれば過去形、処理の途中であれば現在進行形にしましょう
- イベントリスナーの名前は、そのリスナーが担当する処理の名前にしましょう
- ジョブの名前は、そのジョブが担当する処理の名前にしましょう
- 通知の名前はイベントの名前に合わせましょう
- イベントもジョブも、キューに送る場合は failed メソッドを実装してエラーを記録もしくは通知しましょう
- ひとつのイベントに対して複数の処理が必要であれば、複数のイベントリスナーを登録しましょう
ややごちゃごちゃしてしまいましたが、イベント・イベントリスナー・通知は密接に関連していますし、イベントとジョブはよく似た仕組みになっているので、仕方ありませんでした。
あと、BroadCasting についてはどこかで取り上げたいですね。5.3 の目玉機能のひとつである Echo と組み合わせることで、イベントの使い途が間違いなく増えると思っています。
Event Broadcasting - Laravel - The PHP Framework For Web Artisans
他にもこんなベストプラクティスがあるよ、などコメントや編集リクエストいただけると助かります