nanisore oishisou

Webエンジニアあるまさんのゆるふわ奮闘記。

Laravel5.4でイベント&キューを使ってメールをキュー送信する手順(3)

Laravel5.4でイベント&キューを使ってメールをキュー送信する手順もとうとう最終回を迎えます!!

上級Laravelerの人はこの回から読んでも大丈夫です。
初級Laravelerの人は過去記事から読んでみてください。

arm4.hatenablog.com

arm4.hatenablog.com

というわけで恒例の今北さん用3行を

  • Laravel新規プロジェクトを作ってダミーデータを挿入した
  • 投稿一覧ページと詳細ページを作っていいねボタンを設置した
  • いいね押したら投稿者にメールでお知らせが行くようにした

今回やること

  • ボタンの種類を増やして種類ごとにメール送信の挙動を変える
  • イベント&リスナーでメール送信を実装
  • リスナーをキュー化して非同期でメールを送信

イベントリスナーって何?

いわゆるオブザーバーパターンと呼ばれるコードの実装方法で、JavaScriptでDOM操作するときにはおなじみの方法です。

ボタンがクリックされたら"click"というイベントが発火して、そのイベントが発火したら特定の処理が走るようにするという実装方法です。

イベント&リスナーとか使ったことない機能で怖いと思うサーバサイドエンジニア諸君もいるかもしれませんが、JavaScriptで普段やってることを、PHPでもやる。ただそれだけのことです。

フロントエンドから来たエンジニアにとっては、むしろ分かりやすくてコードも綺麗になるので、とてもオススメです。

↓これより下は手順だから、ですますやめます。

実装方法を考える

まずはどんなイベントが必要か考える。

今回は「お知らせの種類」ごとにイベントを作成することにした。

  • ユーザー全員に知らせるお知らせイベント
  • 本人だけに知らせるお知らせイベント

今回は処理にフォーカスしたイベントを考えてみたが、イベントには複数のリスナークラス(処理)を紐付けられるので、もっと大きく捉えて「何をした」というイベントを発火させて、その後の処理をリスナーとして登録するという実装方法もあるだろう。

例えば、「いいね」ボタンを押したら、「全員にお知らせメールを送り、アプリの通知機能でお知らせする」のように複数の処理が紐付くような場合は、「○○ボタンが押された」のようなイベントを作成するといいかもしれない。

ルートを追加する

まずは「やだねする」「どうでもいい」「君の名は?」の3種類を押したとき用のルートを追加しよう。

routes/web.php

<?php
.
.
.

Route::post('/send-dislike/{post}', 'HomeController@sendDislike')->name('send.dislike');
Route::post('/send-whatever/{post}', 'HomeController@sendWhatever')->name('send.whatever');
Route::post('/send-yourname/{post}', 'HomeController@sendYourname')->name('send.yourname');

コントローラーを修正

まずはイベント&リスナーを使わず、「いいねする」と同じように本人にだけメールが送信されるところまで作っていこう。

<?php
.
.
.

    public function sendDislike(Request $request, Post $post)
    {
        $triggered_user = Auth::user();

        $data = [
            'type' => 'やだね',
        ];

        // やだねのときは本人だけに知らせる
        Mail::to($post->user)
                ->send(new SendMail($post, $triggered_user, $data));

        return response()->json(['status' => 0]);
    }
    public function sendWhatever(Request $request, Post $post)
    {
        $triggered_user = Auth::user();

        $data = [
            'type' => 'どうでもいい',
        ];

        // どうでもいいときは誰にも知らせない
        
        return response()->json(['status' => 0]);
    }
    public function sendYourname(Request $request, Post $post)
    {
        $triggered_user = Auth::user();

        $data = [
            'type' => '君の名は?',
        ];

        // 君の名は?のときは本人だけに知らせる
        Mail::to($post->user)
                ->send(new SendMail($post, $triggered_user, $data));

        return response()->json(['status' => 0]);
    }

同じことをしてる部分をまとめる

ボタンを押した時に何のボタンかdataに入れてpostしているので、それを使ってデータセット部分を1つのメソッドで済ませるようにリファクタリングしてみる。

<?php
.
.
.
class HomeController extends Controller
{
    private $types = [
        'like'     => 'いいね',
        'dislike'  => 'やだね',
        'whatever' => 'どうでもいい',
        'yourname' => '君の名は?',
    ];

    private $triggered_user = null;
    private $data = null;
.
.
.
    private function setAlertData($request) {
        $this->triggered_user = Auth::user();

        $this->data = [
            'type' => $this->types[$request->type],
        ];
    }

    public function sendLike(Request $request, Post $post)
    {
        $this->setAlertData($request);

        // いいねのときはユーザー全員に知らせる
        Mail::to($post->user)
                ->send(new SendMail($post, $this->triggered_user, $this->data));

        return response()->json(['status' => 0]);
    }

    public function sendDislike(Request $request, Post $post)
    {
        $this->setAlertData($request);

        // やだねのときは本人だけに知らせる
        Mail::to($post->user)
                ->send(new SendMail($post, $this->triggered_user, $this->data));

        return response()->json(['status' => 0]);
    }
    public function sendWhatever(Request $request, Post $post)
    {
        $this->setAlertData($request);

        // どうでもいいときは誰にも知らせない
        
        return response()->json(['status' => 0]);
    }
    public function sendYourname(Request $request, Post $post)
    {
        $this->setAlertData($request);

        // 君の名は?のときは本人だけに知らせる
        Mail::to($post->user)
                ->send(new SendMail($post, $this->triggered_user, $this->data));

        return response()->json(['status' => 0]);
    }

EventServiceProviderにイベントを記載

直書きしていたメール送信部分をイベントにしていく。

プロジェクト作成時から用意されているEventServiceProvider.phpの$listen部分に連想配列の形式で、イベント(key) => リスナー(value)のように記載してイベントとリスナーを登録する。

まだapp/Eventsやapp/Listenersというディレクトリが無くても構わない。ここにクラス名を記載してgenerateコマンドを実行すると、ディレクトリとファイルが作成される。

イベント、リスナーの命名規則はないが、イベント名は「何が起こった」、リスナー名は「何をする」のように付けると分かりやすい。

つなげて読むと、「何が起きたら、何をする」と読めるからだ。

app/Providers/EventServiceProvider.php

<?php
.
.
.
    protected $listen = [
        'App\Events\PersonalAlertCreated' => [
            'App\Listeners\SendPersonalAlert',
        ],
        'App\Events\PublicAlertCreated' => [
            'App\Listeners\SendPublicAlert',
        ],
    ];

イベント&リスナーファイルを作成

generateコマンドを実行する

php artisan event:generate

イベントを発火させる

コントローラーを修正し、メール送信している箇所にイベントを仕込んでいく。

  • 全員に知らせる ( PublicAlertCreated )
  • 本人だけに知らせる ( PersonalAlertCreated )

app/Http/Controllers/HomeController.php

<?php
.
.
.
use App\Events\PublicAlertCreated;
use App\Events\PersonalAlertCreated;
.
.
.
    public function sendLike(Request $request, Post $post)
    {
        $this->setAlertData($request);

        // いいねのときはユーザー全員に知らせる
        event(new PublicAlertCreated($post, $this->triggered_user, $this->data));

        return response()->json(['status' => 0]);
    }

    public function sendDislike(Request $request, Post $post)
    {
        $this->setAlertData($request);

        // やだねのときは本人だけに知らせる
        event(new PersonalAlertCreated($post, $this->triggered_user, $this->data));

        return response()->json(['status' => 0]);
    }

    public function sendWhatever(Request $request, Post $post)
    {
        $this->setAlertData($request);

        // どうでもいいときは誰にも知らせない
        
        return response()->json(['status' => 0]);
    }

    public function sendYourname(Request $request, Post $post)
    {
        $this->setAlertData($request);

        // 君の名は?のときは本人だけに知らせる
        event(new PersonalAlertCreated($post, $this->triggered_user, $this->data));

        return response()->json(['status' => 0]);
    }

イベントファイルを設定

イベントファイルはデータを受け取ってセットするだけのクラスなのでロジックを書くことはない。

コンストラクタの中でイベントで使用するデータをセットする。

PersonalAlertCreatedの記述もまったく同じなので、ここでは省略するが、ファイルは2つともちゃんと設定すること。

app/Events/PublicAlertCreated.php

<?php
.
.
.
use App\Post;
use App\User;
.
.
.
class PublicAlertCreated
{
    use Dispatchable, InteractsWithSockets, SerializesModels;

     public $post;
     public $triggered_user;
     public $data;

    /**
     * Create a new message instance.
     *
     * @return void
     */
    public function __construct(Post $post, User $triggered_user, $data)
    {
        $this->post = $post;
        $this->triggered_user = $triggered_user;
        $this->data = $data;
    }

リスナーファイルを設定

handleの部分に処理を書いていく。

app/Listeners/SendPublicAlert.php

<?php
.
.
.
use App\User;
use App\Mail\SendMail;
use Illuminate\Support\Facades\Mail;
use App\Events\PublicAlertCreated;
.
.
.
    public function handle(PublicAlertCreated $event)
    {
        $post = $event->post;
        $triggered_user = $event->triggered_user;
        $data = $event->data;

        // クリックした人以外の全員に送る
        $users = User::whereNotIn('id', [$triggered_user->id])->get();

        foreach ($users as $user) {
            Mail::to($user)
                    ->send(new SendMail($post, $triggered_user, $data));
        }
    }

app/Listeners/SendPersonalAlert.php

<?php
.
.
.
use App\User;
use App\Mail\SendMail;
use Illuminate\Support\Facades\Mail;
use App\Events\PersonalAlertCreated;
.
.
.
    public function handle(PersonalAlertCreated $event)
    {
        $post = $event->post;
        $triggered_user = $event->triggered_user;
        $data = $event->data;

        Mail::to($post->user)
                ->send(new SendMail($post, $triggered_user, $data));
    }

ここまで設定したら、「いいねする」ボタンを押してみよう。

自分以外の全員あてのメール(およそ40通)が送られてきていたら、イベントリスナーでメール送信の実装はできている。

大量の処理を追加してみよう

ここで、publicAlertのほうに大量にデータを突っ込む処理を入れないといけないことになったとする。

app/Listeners/SendPublicAlert.php

<?php
.
.
.
use App\User;
use App\Mail\SendMail;
use Illuminate\Support\Facades\Mail;
use App\Events\PublicAlertCreated;
.
.
.
    public function handle(PublicAlertCreated $event)
    {
        $post = $event->post;
        $triggered_user = $event->triggered_user;
        $data = $event->data;

        // クリックした人以外の全員に送る
        $users = User::whereNotIn('id', [$triggered_user->id])->get();

        // 大量の処理を追加!!
        for ($i=0;$i<5000;$i++) {
            $users = User::whereNotIn('id', [$triggered_user->id])->get();
        }

        // みんなに知られてしまうアラート
        foreach ($users as $user) {
            Mail::to($user)
                    ->send(new SendMail($post, $triggered_user, $data));
        }
    }

これで「いいねする」ボタンを押してみた人は気づいたと思うが、40通のメールの送信処理&5000回のクエリ取得が終わってからレスポンスが返ってくるので、「メール完了 メールを送りました!」の表示が表示されるのはボタンを押してからしばらく経ってからになる。

本番のアプリケーションでは二重投稿を防止する処理などが入っていて、レスポンスが返ってくるまでユーザーがボタンを押せないこともあるだろう。

送信完了のお知らせ表示が表示される時間が、送信ユーザー数に依存するというのはユーザーにとって分かりづらいため、送信できていないと勘違いして連投してしまう人もいるかもしれない。

多くのユーザーにメールを送信したり、多くの通知用データを挿入したりする処理を挟んでしまうと、その処理が完了してレスポンスが返ってくるまでに時間がかかる。

こんなときは、処理の受付だけ済ませて、あとの処理はバックグラウンドでやってもらいたいと思う。

誰も32個の小包の発送手続きが完了する様を20分立ったまま眺めていたくはない。
32個の小包を誰かに渡して、「住所は貼っといたから、あとはよろしく」と言いたいのだ。

そこでキューの登場

この「あとは、そっちでよろしく」方式のことを、キューと呼ぶ。待ち行列という意味だ。

ユーザーは自分の代わりに、小人たちを列に並ばせて小包を32個発送させられる。この小人のことをワークやジョブと呼ぶ。

キューを使うには、小人を並ばせるためのテーブルが必要だ。

Laravelではジョブを管理するDriverは以下の中から選べる。

  • sync
  • database
  • Amazon SQS
  • Beanstalkd
  • Redis

今回はアプリで使ってるDBにキュー用テーブルを作成して実装するdatabaseを選択することにする。

キューの設定をする

.env

QUEUE_DRIVER=database

config/queue.php

ひとまずqueue.phpはデフォルトのままでOK。

ジョブを発行するためのテーブルを作成

以下のコマンドを実行するとジョブテーブルのmigrationが出来る。

failed-tableのほうはその名のとおり、エラーを吐いたジョブを記録しておくテーブルだ。こちらは、作らなくてもキュー化はできる。

php artisan queue:table
php artisan queue:failed-table

マイグレーションファイルが出来たのでmigrateを実行しよう。

php artisan migrate

jobsとfailed_jobsというテーブルがDBに作成された。

メール送信部分をキュー化

テーブルができたら、SendPersonalAlert.phpとSendPublicAlert.phpのMailのsendをqueueとするだけ。

SendPublicAlert

            Mail::to($user)
                    ->queue(new SendMail($post, $triggered_user, $data));

SendPersonalAlert

        Mail::to($post->user)
                ->queue(new SendMail($post, $triggered_user, $data));

これでメール送信処理のジョブがテーブルの中に並ぶようになる。

あとはジョブが受付に並んだらそれを検知して実際に処理をしてくれる人が必要だ。

ワーカーを立ち上げる

ジョブを処理するプロセスを起動させる。

php artisan queue:work --tries=3

このワーカーは手動でストップするか、ターミナルを閉じるまで起動している。
バックグラウンドで永続的に起動させておきたい場合は、Supervisorなどのプロセス永続化ツールを導入しよう。

これでメール送信のほうはキュー化できた。

いったん話をメール送信だけにしたいので、大量処理の部分はコメントアウトしてからいいねボタンを押してみて欲しい。

app/Listeners/SendPublicAlert.php

<?php
.
.
.
        // 大量の処理を追加!!
        // for ($i=0;$i<5000;$i++) {
        //     $users = User::whereNotIn('id', [$triggered_user->id])->get();
        // }

Chromeのデブツールなどで観察すれば、すぐにレスポンスが返ってきていることが分かると思う。

ターミナルに処理ログが表示されていればキューで処理が実行されている。

でもまだ大量インサートの問題が残っている。
もちろん、それもキューにしたい!

リスナーをキュー化する

Laravelコマンドでgenerateすると、リスナーファイルは最初からこのShouldQueueというインターフェースを読み込んでいる。

このIlluminate\Contracts\Queue\ShouldQueueをリスナークラスに実装してあげると、キューにして処理させるべきクラスだということをLaravelに知らせることができる。

app/Listeners/SendPublicAlert.php

<?php
.
.
.
class SendPublicAlert implements ShouldQueue
{

では、さっそく大量処理部分のコメントアウトを外して、いいねボタンを押してみよう。

f:id:arm4:20170911180222p:plain

コードの変更が反映されてないと思ったら、いったんワーカーを立ち上げ直してみよう。

デーモンで立ち上げている場合は、起動時のコードがメモリにロードされていてそれで実行されてしまうためだ。

まとめ

いやーまとめるのは時間かかったけど、こうやって読み返すと、Laravelで重たい処理をキュー化するのが、いかに簡単かということが分かっていただけたことと思います!!

バッチだけじゃなくて、キューで処理というのも、いろんなことに活用できるような気がしますね。

キューにしなくても、イベント&リスナーの実装はコードが整理されて気持ちよくコーディングできるのでいいなと思いましたYO★

参考になった人は是非★をください!!

▼サンプルコードをgithubにアップしたよーー。

github.com