Qiita Teams that are logged in
You are not logged in to any team

Log in to Qiita Team
Community
OrganizationAdvent CalendarQiitadon (β)
Service
Qiita JobsQiita ZineQiita Blog
Help us understand the problem. What is going on with this article?

【Laravel】 通知に関する補足と拡張の手引き

More than 1 year has passed since last update.

はじめに

まずはマニュアルの一読を。

基本的に Laravel のコンポーネントは完成度が高く,あまり自分で拡張する必要は無いが,「通知」に限っては機能拡張しないと実用性に乏しい面が多い。そのため,拡張すべきところとその実装例も一部示していくことにする。

なお通知機能は Laravel の他の基礎機能の上に構築されているため,それらに対する理解も必要だ。以下に関連する記事を示しておく。

通知のアーキテクチャ

全体の概要図を示す。通知の送信手段としては

  • メール送信
  • ブロードキャスト送信
  • データベースレコード作成

などがあるが,ここでは例としてデータベースレコード作成を行う DatabaseChannel を Channel の1つとして使用している。

notification

概念 説明
Notifiable 通知の送信先ユーザ。
基本的には App\User を表す。
Channel 通知の送信経路。
Notification 定義をもとに Message を生成し,
Notifiable に対して具体的な送信処理を行う。
Notification 通知の種類ごとの定義。
基本的に ShouldQueue 契約 を実装する。
  • via($notifiable)
    $notifiable への送信に用いる
    Channel のクラス名の配列を返す。
  • to{ドライバ名}($notifiable)
    Channel および $notifiable
    対応する Message を生成して返す。
Message 生成された通知の内容。
クラス名 説明
AnonymousNotifiable Slack 通知など,送信先がユーザではない特殊な場合に使う。
ChannelManager 通知サービスのルートオブジェクト。
Notification ファサードに対応する。
NotificationSender ChannelManager から処理の委譲を受けるクラス。
SendQueuedNotifications キュー経由で送信を行うためのジョブ。
ShouldQueue 契約 を実装した Notification に対して使用される。
DatabaseChannel データベース通知作成のために使用される Channel。
DatabaseMessage Notification の toDatabase() メソッドが返す Message。
DatabaseNotification 通知の内容を格納する Eloquent Model。
デフォルトでは以下のカラムを持つ。
  • id
  • type
  • notifiable_type
  • notifiable_id
  • data
  • read_at
  • created_at
  • updated_at

Channel に投入されるまでの流れ (バックグラウンド)

通知は,キューに入る段階で1単位ごとにバラけられる。

  1. Notifiable・Notification を引数にして ChannelManager::send() を実行する。
  2. Notification の via() メソッドで使用する経路を特定し,
    Notifiable 1人・Channel 1経路ごとに
    SendQueuedNotifications ジョブがディスパッチされる。
  3. SendQueuedNotifications::handle() メソッドの中で,
    Notifiable・Notification・Channel を引数として ChannelManager::sendNow() が実行される。
  4. Notifiable・Notification を引数として Channel の send() メソッドが実行される。

Channel に投入されるまでの流れ (フォアグラウンド)

通知は,送信直前に1単位ごとにバラけられる。

  1. Notifiable・Notification を引数にして ChannelManager::sendNow() を実行する。
  2. Notification の via() メソッドで使用する経路を特定し,
    Notifiable 1人・Channel 1経路ごとに
    Notifiable・Notification を引数として Channel の send() メソッドが実行される。

Channel に投入された後の流れ (ここでは DatabaseChannel について説明)

  1. Notification の toDatabase() メソッドで DatabaseMessage が取得される。
  2. DatabaseMessage を使って data フィールドを埋め, DatabaseNotification が作成される。

動作の流れ

ここから,ステップ別にソースコードリーディングを行っていく。

Notifiable の準備

実は,Notifiable 契約というものは存在せずNotifiable トレイトが存在するだけになっている。PHPDocの型には mixed が濫用されている形だ。

(型安全性が…)

<?php

namespace Illuminate\Notifications;

trait Notifiable
{
    use HasDatabaseNotifications, RoutesNotifications;
}

ロジックは HasDatabaseNotificationsRoutesNotifications に委譲されている。冗長なコメントを省いてこれらを合成したものを以下に示す。

trait *
{
    public function notifications()
    {
        return $this->morphMany(DatabaseNotification::class, 'notifiable')->orderBy('created_at', 'desc');
    }

    public function readNotifications()
    {
        return $this->notifications()->whereNotNull('read_at');
    }

    public function unreadNotifications()
    {
        return $this->notifications()->whereNull('read_at');
    }

    public function notify($instance)
    {
        app(Dispatcher::class)->send($this, $instance);
    }

    public function notifyNow($instance, array $channels = null)
    {
        app(Dispatcher::class)->sendNow($this, $instance, $channels);
    }

    public function routeNotificationFor($driver, $notification = null)
    {
        if (method_exists($this, $method = 'routeNotificationFor'.Str::studly($driver))) {
            return $this->{$method}($notification);
        }

        switch ($driver) {
            case 'database':
                return $this->notifications();
            case 'mail':
                return $this->email;
            case 'nexmo':
                return $this->phone_number;
        }
    }
}

最初の notifications() readNotifications() unreadNotifications()DatabaseNotification に関するリレーションの定義である。

(…おい待て,ここで DatabaseNotification 決め打ちの実装書いちゃうってどうなんだ…まだ誰もこの機能を使うとは言ってないぞ)

notify() notifyNow() は自分自身を第1引数として ChannelManager::send() に処理を委譲している。これがあるため,例えば以下の2つの処理は等価になる。どちらを使ってもよい。

$user->notify(new PostCreated($post));
Notification::send($user, new PostCreated($post));

その下の routeNotificationFor()Channel が Notifiable から必要な情報をもらうために存在する。が,見ての通り返されるものが文字列だったりリレーションだったりぐちゃぐちゃ…

(型安全性…)

Notification の準備

Notification はゼロから自分で作るわけではなく,基底 Notification クラスを継承して使用する形となっている。

<?php

namespace Illuminate\Notifications;

use Illuminate\Queue\SerializesModels;

class Notification
{
    use SerializesModels;

    /**
     * The unique identifier for the notification.
     *
     * @var string
     */
    public $id;

    /**
     * The locale to be used when sending the notification.
     *
     * @var string|null
     */
    public $locale;

    /**
     * Get the channels the event should broadcast on.
     *
     * @return array
     */
    public function broadcastOn()
    {
        return [];
    }

    /**
     * Set the locale to send this notification in.
     *
     * @param  string  $locale
     * @return $this
     */
    public function locale($locale)
    {
        $this->locale = $locale;

        return $this;
    }
}

$id$locale は既定で存在する public フィールドだ。これらは後で登場するので存在だけ覚えておこう。そのほか, BroadcastChannel を使うためにブロードキャスト用のメソッドも存在している。

(…基底クラスで全部入りにしちゃうのやりすぎでは?)

さて,下に継承した定義例を示す。

  • via() で通知する Channel クラス名の配列を返す。
  • ここでは DatabaseChannel を利用することから, DatabaseMessage を返す toDatabase() メソッドを実装している。

いずれも Notifiable を引数で受け取るため, Notifiable の内容によって配信する Channel,生成される Message を切り替えることができる。逆に言えば,Notifiable 自身を Notification インスタンスで保持するのはアーキテクチャ的にあまり正しくないようだ。送信相手を特定しない状態で Notification インスタンスを生成する必要がある。

<?php

namespace App\Notifications;

use Illuminate\Bus\Queueable;
use Illuminate\Notifications\Channels\DatabaseChannel;
use Illuminate\Notifications\Messages\DatabaseMessage;
use Illuminate\Notifications\Notification;
use Illuminate\Contracts\Queue\ShouldQueue;

class PostCreated extends Notification implements ShouldQueue
{
    use Queueable;

    /**
     * 通知チャンネルの取得
     *
     * @param  mixed  $notifiable
     * @return array|string
     */
    public function via($notifiable)
    {
        return [DatabaseChannel::class];
    }

    /**
     * 通知のデータベースプレゼンテーションを取得
     *
     * @param  mixed  $notifiable
     * @return DatabaseMessage
     */
    public function toDatabase($notifiable)
    {
        // ...
    }
}

ChannelManager::send() の実行

Notification::send($user, new PostCreated($post));

を実行したときの流れを追ってみよう。まず, ChannelManager の中身を見る。

<?php

namespace Illuminate\Notifications;

/* ... */
use Illuminate\Support\Manager;
use Illuminate\Contracts\Events\Dispatcher;
use Illuminate\Contracts\Bus\Dispatcher as Bus;
use Illuminate\Contracts\Notifications\Factory as FactoryContract;
use Illuminate\Contracts\Notifications\Dispatcher as DispatcherContract;

class ChannelManager extends Manager implements DispatcherContract, FactoryContract
{
    /* ... */
    protected $locale;

    /**
     * Send the given notification to the given notifiable entities.
     *
     * @param  \Illuminate\Support\Collection|array|mixed  $notifiables
     * @param  mixed  $notification
     * @return void
     */
    public function send($notifiables, $notification)
    {
        return (new NotificationSender(
            $this, $this->app->make(Bus::class), $this->app->make(Dispatcher::class), $this->locale)
        )->send($notifiables, $notification);
    }

    /* ... */
}

このクラスは処理のほとんどを NotificationSender に委譲しているようだ。

  • ChannelManager
  • Bus Dispatcher
  • Event Dispatcher
  • ロケール(既定値は null

これらの値をコンテナも使って揃えた上で,丸投げしている。続いて NotificationSender を見ると

<?php

namespace Illuminate\Notifications;

use Illuminate\Support\Str;
use Illuminate\Support\Collection;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Traits\Localizable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Contracts\Translation\HasLocalePreference;
use Illuminate\Database\Eloquent\Collection as ModelCollection;

class NotificationSender
{
    /* ... */

    /**
     * The notification manager instance.
     *
     * @var \Illuminate\Notifications\ChannelManager
     */
    protected $manager;

    /**
     * The Bus dispatcher instance.
     *
     * @var \Illuminate\Contracts\Bus\Dispatcher
     */
    protected $bus;

    /**
     * The event dispatcher.
     *
     * @var \Illuminate\Contracts\Events\Dispatcher
     */
    protected $events;

    /**
     * The locale to be used when sending notifications.
     *
     * @var string|null
     */
    protected $locale;

    /**
     * Create a new notification sender instance.
     *
     * @param  \Illuminate\Notifications\ChannelManager  $manager
     * @param  \Illuminate\Contracts\Bus\Dispatcher  $bus
     * @param  \Illuminate\Contracts\Events\Dispatcher  $events
     * @param  string|null  $locale
     * @return void
     */
    public function __construct($manager, $bus, $events, $locale = null)
    {
        $this->bus = $bus;
        $this->events = $events;
        $this->manager = $manager;
        $this->locale = $locale;
    }

    /**
     * Send the given notification to the given notifiable entities.
     *
     * @param  \Illuminate\Support\Collection|array|mixed  $notifiables
     * @param  mixed  $notification
     * @return void
     */
    public function send($notifiables, $notification)
    {
        $notifiables = $this->formatNotifiables($notifiables);

        if ($notification instanceof ShouldQueue) {
            return $this->queueNotification($notifiables, $notification);
        }

        return $this->sendNow($notifiables, $notification);
    }

    /**
     * Send the given notification immediately.
     *
     * @param  \Illuminate\Support\Collection|array|mixed  $notifiables
     * @param  mixed  $notification
     * @param  array  $channels
     * @return void
     */
    public function sendNow($notifiables, $notification, array $channels = null)
    {
        /* ... */
    }

    /* ... */

    /**
     * Queue the given notification instances.
     *
     * @param  mixed  $notifiables
     * @param  array[\Illuminate\Notifications\Channels\Notification]  $notification
     * @return void
     */
    protected function queueNotification($notifiables, $notification)
    {
        /* ... */
    }

    /**
     * Format the notifiables into a Collection / array if necessary.
     *
     * @param  mixed  $notifiables
     * @return \Illuminate\Database\Eloquent\Collection|array
     */
    protected function formatNotifiables($notifiables)
    {
        if (! $notifiables instanceof Collection && ! is_array($notifiables)) {
            return $notifiables instanceof Model
                            ? new ModelCollection([$notifiables]) : [$notifiables];
        }

        return $notifiables;
    }
}

formatNotifiables() で単一の通知対象をコレクションでラップした上で,処理を queueNotification() または sendNow() で続けている。ShouldQueue 契約を実装している場合,キューに投入される。

キューへの投入

ここではまず,キューに投入される前者を見てみよう。

    /**
     * Queue the given notification instances.
     *
     * @param  mixed  $notifiables
     * @param  array[\Illuminate\Notifications\Channels\Notification]  $notification
     * @return void
     */
    protected function queueNotification($notifiables, $notification)
    {
        $notifiables = $this->formatNotifiables($notifiables);

        $original = clone $notification;

        foreach ($notifiables as $notifiable) {
            $notificationId = Str::uuid()->toString();

            foreach ($original->via($notifiable) as $channel) {
                $notification = clone $original;

                $notification->id = $notificationId;

                if (! is_null($this->locale)) {
                    $notification->locale = $this->locale;
                }

                $this->bus->dispatch(
                    (new SendQueuedNotifications($notifiable, $notification, [$channel]))
                            ->onConnection($notification->connection)
                            ->onQueue($notification->queue)
                            ->delay($notification->delay)
                );
            }
        }
    }

ここで大雑把に流れを示すと,以下のような2重ループになっている。

  1. Notifiable ごとに UUID を作成して,それぞれ別の Notification オブジェクトとして扱う
  2. さらに, Channel ごとに通知送信のためのジョブを発行している

download.png

注意すべきは,主キーとして UUID が使われていることだ。ここでもしパフォーマンス上の理由やソートにおける利便性での理由で Ordered UUID を代わりに使う場合,このクラスの拡張が必須となる。

キューからの取り出し

次に,キューから取り出されたときに実行される SendQueuedNotifications::handle() を見てみよう。

<?php

namespace Illuminate\Notifications;

use Illuminate\Bus\Queueable;
use Illuminate\Queue\SerializesModels;
use Illuminate\Contracts\Queue\ShouldQueue;

class SendQueuedNotifications implements ShouldQueue
{
    use Queueable, SerializesModels;

    /* ... */

    /**
     * Create a new job instance.
     *
     * @param  \Illuminate\Support\Collection  $notifiables
     * @param  \Illuminate\Notifications\Notification  $notification
     * @param  array  $channels
     * @return void
     */
    public function __construct($notifiables, $notification, array $channels = null)
    {
        $this->channels = $channels;
        $this->notifiables = $notifiables;
        $this->notification = $notification;
        $this->tries = property_exists($notification, 'tries') ? $notification->tries : null;
        $this->timeout = property_exists($notification, 'timeout') ? $notification->timeout : null;
    }

    /**
     * Send the notifications.
     *
     * @param  \Illuminate\Notifications\ChannelManager  $manager
     * @return void
     */
    public function handle(ChannelManager $manager)
    {
        $manager->sendNow($this->notifiables, $this->notification, $this->channels);
    }

    /* ... */

    /**
     * Call the failed method on the notification instance.
     *
     * @param  \Exception  $e
     * @return void
     */
    public function failed($e)
    {
        if (method_exists($this->notification, 'failed')) {
            $this->notification->failed($e);
        }
    }

    /* ... */
}

もう一度 ChannelManager::sendNow() を実行し直しているに過ぎないが,この時点ですでに Notifiable 1人・Channel 1経路ごとに ジョブ単位で分割されていることに注意されたい。

ChannelManager::sendNow() の実行

更に ChannelManager::sendNow() を実行したときの流れを追ってみよう。もし SendQueuedNotifications から自動的に投げられる場合は

Notification::sendNow([$user], new PostCreated($post), [$channel]);

のような呼び出しとなる。 ChannelManager の中身を見ると,

<?php

namespace Illuminate\Notifications;

/* ... */
use Illuminate\Support\Manager;
use Illuminate\Contracts\Events\Dispatcher;
use Illuminate\Contracts\Bus\Dispatcher as Bus;
use Illuminate\Contracts\Notifications\Factory as FactoryContract;
use Illuminate\Contracts\Notifications\Dispatcher as DispatcherContract;

class ChannelManager extends Manager implements DispatcherContract, FactoryContract
{
    /* ... */
    protected $locale;

    /* ... */

    /**
     * Send the given notification immediately.
     *
     * @param  \Illuminate\Support\Collection|array|mixed  $notifiables
     * @param  mixed  $notification
     * @param  array|null  $channels
     * @return void
     */
    public function sendNow($notifiables, $notification, array $channels = null)
    {
        return (new NotificationSender(
            $this, $this->app->make(Bus::class), $this->app->make(Dispatcher::class), $this->locale)
        )->sendNow($notifiables, $notification, $channels);
    }
}

同じように処理のほとんどが NotificationSender に委譲され,

/**
 * Send the given notification immediately.
 *
 * @param  \Illuminate\Support\Collection|array|mixed  $notifiables
 * @param  mixed  $notification
 * @param  array  $channels
 * @return void
 */
public function sendNow($notifiables, $notification, array $channels = null)
{
    $notifiables = $this->formatNotifiables($notifiables);

    $original = clone $notification;

    foreach ($notifiables as $notifiable) {
        if (empty($viaChannels = $channels ?: $notification->via($notifiable))) {
            continue;
        }

        $this->withLocale($this->preferredLocale($notifiable, $notification), function () use ($viaChannels, $notifiable, $original) {
            $notificationId = Str::uuid()->toString();

            foreach ((array) $viaChannels as $channel) {
                $this->sendToNotifiable($notifiable, $notificationId, clone $original, $channel);
            }
        });
    }
}

先ほどとほぼ同じような2重ループ処理が行われる。

/**
 * Send the given notification to the given notifiable via a channel.
 *
 * @param  mixed  $notifiable
 * @param  string  $id
 * @param  mixed  $notification
 * @param  string  $channel
 * @return void
 */
protected function sendToNotifiable($notifiable, $id, $notification, $channel)
{
    if (! $notification->id) {
        $notification->id = $id;
    }

    if (! $this->shouldSendNotification($notifiable, $notification, $channel)) {
        return;
    }

    $response = $this->manager->driver($channel)->send($notifiable, $notification);

    $this->events->dispatch(
        new Events\NotificationSent($notifiable, $notification, $channel, $response)
    );
}

/**
 * Determines if the notification can be sent.
 *
 * @param  mixed  $notifiable
 * @param  mixed  $notification
 * @param  string  $channel
 * @return bool
 */
protected function shouldSendNotification($notifiable, $notification, $channel)
{
    return $this->events->until(
        new Events\NotificationSending($notifiable, $notification, $channel)
    ) !== false;
}

これはイベントハンドラでキャンセルされなければ,

$this->manager->driver($channel)->send($notifiable, $notification);

が実行されることになる。ChannelManager の処理を追うと,Laravel おなじみのマジックメソッドを使ったドライバ実装パターンが見える。ここでいうドライバとは Channel のことだ。

<?php

namespace Illuminate\Notifications;

use InvalidArgumentException;
use Illuminate\Support\Manager;
use Illuminate\Contracts\Events\Dispatcher;
use Illuminate\Contracts\Bus\Dispatcher as Bus;
use Illuminate\Contracts\Notifications\Factory as FactoryContract;
use Illuminate\Contracts\Notifications\Dispatcher as DispatcherContract;

class ChannelManager extends Manager implements DispatcherContract, FactoryContract
{
    /* ... */

    /**
     * Create an instance of the database driver.
     *
     * @return \Illuminate\Notifications\Channels\DatabaseChannel
     */
    protected function createDatabaseDriver()
    {
        return $this->app->make(Channels\DatabaseChannel::class);
    }

    /**
     * Create an instance of the broadcast driver.
     *
     * @return \Illuminate\Notifications\Channels\BroadcastChannel
     */
    protected function createBroadcastDriver()
    {
        return $this->app->make(Channels\BroadcastChannel::class);
    }

    /**
     * Create an instance of the mail driver.
     *
     * @return \Illuminate\Notifications\Channels\MailChannel
     */
    protected function createMailDriver()
    {
        return $this->app->make(Channels\MailChannel::class);
    }

    /**
     * Create a new driver instance.
     *
     * @param  string  $driver
     * @return mixed
     *
     * @throws \InvalidArgumentException
     */
    protected function createDriver($driver)
    {
        try {
            return parent::createDriver($driver);
        } catch (InvalidArgumentException $e) {
            if (class_exists($driver)) {
                return $this->app->make($driver);
            }

            throw $e;
        }
    }
}

createDriver() の実装を見ると,以下の2つのパターンに対応していることが分かる。

メソッド名の一部を指定
$this->manager->driver('database');
Channel クラス名を完全修飾で指定
$this->manager->driver(DatabaseChannel::class);

これによって生成された Channel の send() メソッドが呼ばれる。

Channel::send() の実行

ここでは一例として, DatabaseChannel の実装を見てみよう。

DatabaseChannel

<?php

namespace Illuminate\Notifications\Channels;

use RuntimeException;
use Illuminate\Notifications\Notification;

class DatabaseChannel
{
    /**
     * Send the given notification.
     *
     * @param  mixed  $notifiable
     * @param  \Illuminate\Notifications\Notification  $notification
     * @return \Illuminate\Database\Eloquent\Model
     */
    public function send($notifiable, Notification $notification)
    {
        return $notifiable->routeNotificationFor('database', $notification)->create(
            $this->buildPayload($notifiable, $notification)
        );
    }

    /**
     * Get the data for the notification.
     *
     * @param  mixed  $notifiable
     * @param  \Illuminate\Notifications\Notification  $notification
     * @return array
     *
     * @throws \RuntimeException
     */
    protected function getData($notifiable, Notification $notification)
    {
        if (method_exists($notification, 'toDatabase')) {
            return is_array($data = $notification->toDatabase($notifiable))
                                ? $data : $data->data;
        }

        if (method_exists($notification, 'toArray')) {
            return $notification->toArray($notifiable);
        }

        throw new RuntimeException('Notification is missing toDatabase / toArray method.');
    }

    /**
     * Build an array payload for the DatabaseNotification Model.
     *
     * @param  mixed  $notifiable
     * @param  \Illuminate\Notifications\Notification  $notification
     * @return array
     */
    protected function buildPayload($notifiable, Notification $notification)
    {
        return [
            'id' => $notification->id,
            'type' => get_class($notification),
            'data' => $this->getData($notifiable, $notification),
            'read_at' => null,
        ];
    }
}

routeNotificationFor() は, ここでは notifiable_type notifiable_id が埋まったリレーションを返す。これを踏まえた上で,メソッド分割を外して最小限の処理を書き下すと以下のようになる。

<?php

namespace Illuminate\Notifications\Channels;

use RuntimeException;
use Illuminate\Notifications\Notification;

class DatabaseChannel
{
    /**
     * Send the given notification.
     *
     * @param  mixed  $notifiable
     * @param  \Illuminate\Notifications\Notification  $notification
     * @return \Illuminate\Database\Eloquent\Model
     */
    public function send($notifiable, Notification $notification)
    {
        return $notifiable->morphMany(DatabaseNotification::class, 'notifiable')->create([
            'id' => $notification->id,
            'type' => get_class($notification),
            'data' => $notification->toDatabase($notifiable)->data,
            'read_at' => null,
        ]);
    }
}

これで,データベース通知が生成されて処理が終了する。が, data カラムの型は notification.stub を見ると

/**
 * Run the migrations.
 *
 * @return void
 */
public function up()
{
    Schema::create('notifications', function (Blueprint $table) {
        $table->uuid('id')->primary();
        $table->string('type');
        $table->morphs('notifiable');
        $table->text('data');
        $table->timestamp('read_at')->nullable();
        $table->timestamps();
    });
}

となっている通り,ただの TEXT 型となっている。

(…検索性悪すぎない?)

さて,数々の本家による「微妙な実装」が見えたところで,問題点と改善策についての提案をしていこう。

拡張すべきポイントとその手引き

問題点を箇条書きで挙げてみる。

  • Notifiable 契約が存在しない
  • Notifiable トレイトDatabaseNotification の存在を前提としている
  • Notifiable の routeNotificationFor() メソッドが型安全を破壊する行き過ぎた抽象化をしている
    ⇔ Channel が具象性を放棄しすぎている
  • 基底 Notification クラスBroadcastChannel の存在を前提としている
  • Notification::$id に Ordered UUID ではなく UUID が使われている
  • DatabaseNotification に使われる notifications テーブルの検索性が悪い
  • DatabaseNotification を作成し,その結果レコードを別 Channel から送信するという手段が選択できない
  • DatabaseChannel 経由で通知を更新する手段がない

挙げだすとキリが無いが,このうちの一部を改善できる実装例を以下に示す。

「リソース」通知という概念の導入

ある通知に対し,何かしらリソースを1つ関連付けたいケースは多い。例えば

  • 新規投稿があります
  • 動画のエンコードが終わりました

こういう通知であれば,

  • resource_type=post, resource_id=xxx
  • resource_type=video, resource_id=yyy

のような情報を持たせたくなるだろう。これを DatabaseNotification と合わせて採用する場合は, notifications テーブルの定義自体を拡張しておこう。こうすることで, data に雑に突っ込むよりは検索性が大幅に向上する。

$table->string('resource_type')->nullable();
$table->unsignedBigInteger('resource_id')->nullable();

その上で,例えば以下のように実装するとよい。クラス名のインポートなどは省略する。

class PostCreated extends Notification
{
    use Queueable, InteractsWithSockets;

    public $resource;

    public function __construct(Post $post)
    {
        $this->resource = $post;
    }

    public function via($notifiable): array
    {
        return [DatabaseChannel::class];
    }

    public function toDatabase(): DatabaseMessage
    {
        return new DatabaseMessage('...');
    }
}
use Illuminate\Notifications\Channels\DatabaseChannel as BaseDatabaseChannel;

class DatabaseChannel extends BaseDatabaseChannel
{
    protected function buildPayload($notifiable, Notification $notification)
    {
        return [
            'id' => $notification->id,
            'type' => get_class($notification),
            'data' => $this->getData($notifiable, $notification),
            'resource_type' => $notification->resource->getMorphClass() ?? null,
            'resource_id' => $notification->resource->getKey() ?? null,
            'read_at' => null,
        ];
    }
}

DatabaseNotificationresource に関する Eager Loading 対応は割愛する。
(それなりに大変)

UUID を Ordered UUID に変更する

これを実施するには,ChannelManager および NotificationSender の置き換えが必要だ。

app/Providers/NotificationServiceProvider.php
<?php

declare(strict_types=1);

namespace App\Providers;

use App\Notifications\ChannelManager;
use Illuminate\Notifications\ChannelManager as BaseChannelManager;
use Illuminate\Notifications\NotificationServiceProvider as BaseNotificationServiceProvider;

class NotificationServiceProvider extends BaseNotificationServiceProvider
{
    public function register(): void
    {
        parent::register();

        $this->app->singleton(BaseChannelManager::class, function ($app) {
            return new ChannelManager($app);
        });
        $this->app->alias(BaseChannelManager::class, ChannelManager::class);
    }
}
app/Notifications/NotificationSender.php
<?php

declare(strict_types=1);

namespace App\Notifications;

use Illuminate\Notifications\NotificationSender as BaseNotificationSender;
use Illuminate\Notifications\SendQueuedNotifications;
use Illuminate\Support\Str;

class NotificationSender extends BaseNotificationSender
{
    public function sendNow($notifiables, $notification, array $channels = null): void
    {
        $notifiables = $this->formatNotifiables($notifiables);
        $original = clone $notification;

        foreach ($notifiables as $notifiable) {
            if (empty($viaChannels = $channels ?: $notification->via($notifiable))) {
                continue;
            }

            $notificationId = (string)Str::orderedUuid(); // ←ここを変更
            foreach ((array)$viaChannels as $channel) {
                $this->sendToNotifiable($notifiable, $notificationId, clone $original, $channel);
            }
        }
    }

    protected function queueNotification($users, $notification): void
    {
        $notifiables = $this->formatNotifiables($notifiables);
        $original = clone $notification;

        foreach ($notifiables as $notifiable) {
            $notificationId = (string)Str::orderedUuid(); // ←ここを変更

            foreach ($original->via($user) as $channel) {
                $notification = clone $original;
                $notification->id = $notificationId;

                $this->bus->dispatch(
                    (new SendQueuedNotifications($notifiable, $notification, [$channel]))
                        ->onConnection($notification->connection)
                        ->onQueue($notification->queue)
                        ->delay($notification->delay)
                );
            }
        }
    }
}

但し,バージョン 5.7 時点では Laravel 本家のサービスプロバイダの実装にバグがあるため, composer.json の書き換えが必須になる。詳しくは以下の Issue を参照。(5.7 にもマージしてってお願いしたけど完全にスルーされた)

あとがき

本音を言うと,Laravel の Notification サービスは,全サービス中最も実装が汚いと言ってもいいだろう。尤も,非常に抽象化が難しい内容でもあるのだが…
(今回の記事も「どこまで改善例を出せばいいだろう…」というので非常に悩みが大きかった)

Laravel 6 以降のリファクタリングに期待しよう。

mpyw
古い記事はそのまま参考にしないようにご注意ください
synapse
Synapseは、オンラインサロンサービスにおけるパイオニアとして、かつて存在していたスタートアップです。
https://synapseam.github.io/
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