Help us understand the problem. What is going on with this article?

Laravelでウェブアプリケーションをつくるときのベストプラクティスを探る (6) イベント&ジョブ編

More than 3 years have passed since last update.

はじめに

このエントリーについて

この記事は「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 に圧縮する、みたいな処理を一つずつ非同期に処理する、とかそういうので使うのかな…ちょっといい実例が思い浮かばないので「こんなかんじで使ってるよ」というのがあったら教えてください :bow:

まぁ、とにかく、時間のかかる処理を同期的に行わず、キューに投げておいて、レスポンスを早く返しましょう、ということですね。

キューを処理するサーバーは同じである必要はないので、別のサーバーを用意しておいて、キューリスナーをそちらで動かしていれば、処理を分散させることもできます。

イベントのサンプルコード

コントローラー編で使用した、お問い合わせのサンプルを流用します。

ContactController.php
    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 プロパティに登録することで行います。

EventServiceProvider.php
<?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 コマンドを実行すると、スケルトンを生成してくれます。

イベントリスナーはその責務が分かる名前がいいですね、ひとつのイベントに対して処理が複数あるのであれば、複数のイベントリスナーを登録しましょう。

生成されたスケルトンに手を加えたものがこちらです。

ContactReceived.php
<?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 にすればイベントはキューに入ります)。

ContactReceivedNotifier.php
<?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 クラスは下記のようにシンプルなものです。

Admin.php
<?php

namespace App;

use Illuminate\Notifications\Notifiable;

class Admin
{
    use Notifiable;

    public function routeNotificationForMail()
    {
        return config('mail.admin_email');
    }
}

routeNotificationForMail メソッドをオーバーライドすれば、送信先のメールアドレスを指定できます (Notifiable なクラスが Eloquent なクラスか、あるいは public なフィールドに email を持っていればオーバーライドする必要はありません)。

通知クラスも載せておきます。

イベント失敗時にメールを送る処理です。

Events/JobFailed.php
<?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 のイシューとして登録するシナリオを考えてみます (本来であれば別のイベントリスナーをつくってそちらでディスパッチを行う方がいいとは思いますが、サンプルなのでご容赦を)。

app/Listeners/ContactReceivedNotifier.php
    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

AddContactAsGitHubIssue.php
<?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

他にもこんなベストプラクティスがあるよ、などコメントや編集リクエストいただけると助かります :bow:

nunulk
PHP, Laravel, オブジェクト指向プログラミング, デザインパターン, リファクタリング, 関数プログラミング, etc.
http://nunulk.hatenablog.com
phper-oop
ペチオブはオブジェクト指向ワーキンググループです。様々なエンジニアの方に参加頂いております。
https://phper-oop.connpass.com/
Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away