はじめに
まずはマニュアルの一読を。
基本的に Laravel のコンポーネントは完成度が高く,あまり自分で拡張する必要は無いが,「通知」に限っては機能拡張しないと実用性に乏しい面が多い。そのため,拡張すべきところとその実装例も一部示していくことにする。
なお通知機能は Laravel の他の基礎機能の上に構築されているため,それらに対する理解も必要だ。以下に関連する記事を示しておく。
- 【Laravel】サービスコンテナ・サービスプロバイダ・ファサード・契約に関する補足資料 - Qiita
- 【Laravel】 キュー・イベント・ブロードキャストに関する補足とフロントエンドへの導入 - Qiita
通知のアーキテクチャ
全体の概要図を示す。通知の送信手段としては
- メール送信
- ブロードキャスト送信
- データベースレコード作成
などがあるが,ここでは例としてデータベースレコード作成を行う DatabaseChannel
を Channel の1つとして使用している。
概念 | 説明 |
---|---|
Notifiable | 通知の送信先ユーザ。 基本的には App\User を表す。 |
Channel | 通知の送信経路。 Notification 定義をもとに Message を生成し, Notifiable に対して具体的な送信処理を行う。 |
Notification | 通知の種類ごとの定義。 基本的に ShouldQueue 契約 を実装する。
|
Message | 生成された通知の内容。 |
クラス名 | 説明 |
---|---|
AnonymousNotifiable |
Slack 通知など,送信先がユーザではない特殊な場合に使う。 |
ChannelManager |
通知サービスのルートオブジェクト。Notification ファサードに対応する。 |
NotificationSender |
ChannelManager から処理の委譲を受けるクラス。 |
SendQueuedNotifications |
キュー経由で送信を行うためのジョブ。ShouldQueue 契約 を実装した Notification に対して使用される。 |
DatabaseChannel |
データベース通知作成のために使用される Channel。 |
DatabaseMessage |
Notification の toDatabase() メソッドが返す Message。 |
DatabaseNotification |
通知の内容を格納する Eloquent Model。 デフォルトでは以下のカラムを持つ。
|
Channel に投入されるまでの流れ (バックグラウンド)
通知は,キューに入る段階で1単位ごとにバラけられる。
- Notifiable・Notification を引数にして
ChannelManager::send()
を実行する。 - Notification の
via()
メソッドで使用する経路を特定し,
Notifiable 1人・Channel 1経路ごとに
SendQueuedNotifications
ジョブがディスパッチされる。 -
SendQueuedNotifications::handle()
メソッドの中で,
Notifiable・Notification・Channel を引数としてChannelManager::sendNow()
が実行される。 - Notifiable・Notification を引数として Channel の
send()
メソッドが実行される。
Channel に投入されるまでの流れ (フォアグラウンド)
通知は,送信直前に1単位ごとにバラけられる。
- Notifiable・Notification を引数にして
ChannelManager::sendNow()
を実行する。 - Notification の
via()
メソッドで使用する経路を特定し,
Notifiable 1人・Channel 1経路ごとに
Notifiable・Notification を引数として Channel のsend()
メソッドが実行される。
Channel に投入された後の流れ (ここでは DatabaseChannel
について説明)
- Notification の
toDatabase()
メソッドでDatabaseMessage
が取得される。 -
DatabaseMessage
を使ってdata
フィールドを埋め,DatabaseNotification
が作成される。
動作の流れ
ここから,ステップ別にソースコードリーディングを行っていく。
Notifiable の準備
実は,Notifiable
契約というものは存在せず, Notifiable
トレイトが存在するだけになっている。PHPDocの型には mixed
が濫用されている形だ。
(型安全性が…)
<?php
namespace Illuminate\Notifications;
trait Notifiable
{
use HasDatabaseNotifications, RoutesNotifications;
}
ロジックは HasDatabaseNotifications
と RoutesNotifications
に委譲されている。冗長なコメントを省いてこれらを合成したものを以下に示す。
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重ループになっている。
- Notifiable ごとに UUID を作成して,それぞれ別の Notification オブジェクトとして扱う
- さらに, Channel ごとに通知送信のためのジョブを発行している
注意すべきは,主キーとして 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');
$this->manager->driver(DatabaseChannel::class);
これによって生成された Channel の send()
メソッドが呼ばれる。
Channel::send()
の実行
ここでは一例として, 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,
];
}
}
DatabaseNotification
の resource
に関する Eager Loading 対応は割愛する。
(それなりに大変)
UUID を Ordered UUID に変更する
これを実施するには,ChannelManager
および NotificationSender
の置き換えが必要だ。
<?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);
}
}
<?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 にもマージしてってお願いしたけど完全にスルーされた)
- Fix Notification service resolution in provider by mpyw · Pull Request #10 · laravel/slack-notification-channel
- Fix Notification service resolution in provider by mpyw · Pull Request #5 · laravel/nexmo-notification-channel
あとがき
本音を言うと,Laravel の Notification サービスは,全サービス中最も実装が汚いと言ってもいいだろう。尤も,非常に抽象化が難しい内容でもあるのだが…
(今回の記事も「どこまで改善例を出せばいいだろう…」というので非常に悩みが大きかった)
Laravel 6 以降のリファクタリングに期待しよう。