Open16

Laravel Notificationの通知履歴機能を作りたい

ryomaryoma

方針
通知履歴をデータベースに保存したい
通知機能を実装したが、通知履歴機能を実装するために以下の課題が発生

  • notify() メソッドの返り値がnullなので notify() から先の処理で通知履歴機能を実装することはできなさそう
  • 通知履歴機能を実装するためには対象の通知クラス(app/Notifications)内でなんとかする必要ありそう

なので、通知クラスのデータベース通知機能を利用して通知履歴機能を実装することにする。
参考に、データベース通知の方法と、データベース通知のデフォルトをカスタマイズする方法を調べた。

参考
https://readouble.com/laravel/10.x/ja/notifications.html
https://qiita.com/SallyCinnamon/items/433f6aff5d97b42b06af

ryomaryoma

データベース通知ではLaravelの用意したコマンドを実行することでnotificationsマイグレーションが生成され、notificationsテーブルにデータベース通知を行うことができる。
今回はこの機能を利用して通知履歴機能を実装するので

  • notificationsを任意のテーブル名に変更(notification_histories)
  • notification_historiesに紐づくモデルを作成
  • デフォルトチャネルの mail database につづくカスタムチャネルを作成
  • notifiableなモデルにmorphManyを返すメソッドを追加

を行っていく

ryomaryoma

マイグレーションファイル作成

php artisan notifications:table

生成したマイグレーションファイルを任意のテーブル名とカラムに変更

マイグレーションファイル
<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
    /**
     * Run the migrations.
     */
    public function up(): void
    {
        Schema::create('notification_histories', function (Blueprint $table) {
        // ここに必要なカラムを設定する
        });
    }

    /**
     * Reverse the migrations.
     */
    public function down(): void
    {
        Schema::dropIfExists('notification_histories');
    }
};
ryomaryoma

notification_historiesにひもづくモデルを作成
ただのモデルの作成なのでコマンドは割愛

Model
<?php

namespace App\Models\***;

use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Notifications\DatabaseNotification;

class NotificationHistory extends DatabaseNotification
{
    use HasFactory;

    protected $table = 'notification_histories';
}
ryomaryoma

カスタムチャネルを作成

参考にしたQiitaの記事だとコマンドを実行してカスタムチャネルを作っていたが
カスタムチャネルはDatabaseChannelを継承しており、
そのDatabaseChannelは Illuminate\Notifications\Channels をnamespaseとしていたので
それにならってこちらでは通知クラスが作成されたディレクトリ App\Notifications に Channelsディレクトリを作成し、そこにカスタムチャネルを置くことにした。

データベースチャネル
<?php

namespace App\Notifications\Channels;

use Illuminate\Notifications\Channels\DatabaseChannel;
use Illuminate\Notifications\Notification;

class CustomDatabaseChannel extends DatabaseChannel
{
}

通知クラスとカスタムチャネルを紐づける

通知クラスのvia()メソッド
/**
     * Get the notification's delivery channels.
     *
     * @return array<int, string>
     */
    public function via(object $notifiable): array
    {
        return [
            CustomDatabaseChannel::class,
            'mail'
        ];
    }
ryomaryoma

notifiableなモデルにメソッドを追加

notifiableをuseしたモデルに notifications() メソッドを追加してNotificationHistoryモデルをmorphManyで紐づける感じの処理を書く

notifiableをuseしたモデル
<?php

namespace App\Models\***;

use App\Models\***\NotificationHistory;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Notifications\Notifiable;

class Hoge extends Model
{
    use HasFactory;
    use Notifiable;

    public function notifications()
    {
        return $this->morphMany(NotificationHistory::class, 'notifiable')->latest();
    }
}
ryomaryoma

以上、記事を参考に実装したところ

そして以下の疑問がわいたので調査した。

  • notifiableをuseしたモデルに、さらに紐づけたいデータベース通知機能があったときどうするのか
  • notifications() 以外の名前でメソッドを定義できないか

後々、モデルを改修するとなったときにnotifications()メソッドでしか定義できないとなると社内的に問題になりそうなのでもう少し深く調査した。

ryomaryoma

notifications() が呼ばれるまで

通知クラス::via()

DatabaseChannel::send()

RoutesNotifications::routeNotificationFor()

notifiableをuseしたモデル::notifications()


DatabaseChannelはカスタムデータベースの親クラス
RoutesNotificationsはモデルでuseしたNotifiableがさらにuseしているトレイト

RoutesNotifications::routeNotificationFor()

notifications()はRoutesNotificationsトレイトのrouteNotificationFor()で呼ばれていることがわかったので、routeNotificationFor()を見てみる

RouteNotificationsTrait routeNotificationFor()
/**
 * Get the notification routing information for the given driver.
 *
 * @param  string  $driver
 * @param  \Illuminate\Notifications\Notification|null  $notification
 * @return mixed
 */
public function routeNotificationFor($driver, $notification = null)
{
    if (method_exists($this, $method = 'routeNotificationFor'.Str::studly($driver))) {
        return $this->{$method}($notification);
    }

    return match ($driver) {
        'database' => $this->notifications(),
        'mail' => $this->email,
        default => null,
    };
}

このメソッドは通知クラスのvia()で定義するチャネルごとにどのような処理を行うかが書かれている。
さらに、 routeNotificationFor() メソッドを呼び出している DatabaseChannel::send() を見てみる

DatabaseChannel::send()

DatabaseChannelClass send()
/**
 * 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)
    );
}

routeNotificationFor() の第一引数がdatabaseとなっているため、RoutesNotifications::routeNotificationFor()$this->notifications() を呼び出している。
つまり、Notifiableをuseしたモデルに定義した notifications() を呼び出している。

つまり、notifications()メソッド以外のメソッド名を定義するためには

  • notifications()以外のメソッド名を定義することは可能
  • カスタムチャネルにsend()メソッドを作成してDatabaseChannel::send()をオーバーライドする
  • モデルに routeNotificationFor***() というメソッド名を定義

すればよいことがわかった

ryomaryoma

データベース通知以外でも、もしカスタムチャネルを実装することがある場合
カスタムチャネルの作成と同時にrouteNotification***()メソッドも必要になると思われる。。。

ryomaryoma

メール送信していないのに履歴に登録するのがナンセンスな気がするのと、
送信したメール本文をDatabaseChannelでは取得できないのに気付いたので
この方法では実装できないことがわかった。

  • メール送信しているような処理でデータを取得できないか見てみる
  • イベントの発火からobserverで処理ができないか見てみる
ryomaryoma

notificationはモデルに紐づくのでイベントの取得にはobserverクラスが必要なのではと思っていたが
あれはDB操作時に発火されるイベント専門なので
今回実装する機能についてobserverクラスは多分作らない

Laravelのイベントとリスナーの機能を使って、通知イベントをキャッチできるらしいのでそこで送信履歴登録機能を実装してみる。
今までのLaravelの機能開発でイベントとリスナーに遭遇することがなかったのでこの機能があることを現時点で初めて知った・・・

LaravelのNotificationの仕様書にもちゃんとイベントのこと書いてあるね
ちゃんと仕様書読まなきゃね

ryomaryoma

イベント&リスナーを使って履歴登録機能を実装する

通知を送るときに発火するイベント

  • NotificationSender
  • NotificationSending
  • NotificationSent

のうち、NotificationSentを利用する。

リスナーを新規作成

php artisan make:Listener SaveNotificationHistory --event=NotificationSent

EventServiceProviderにイベントとリスナーを登録

EventServiceProvider
<?php

namespace App\Providers;

use App\Listeners\SaveNotificationHistory;
use Illuminate\Foundation\Support\Providers\EventServiceProvider as ServiceProvider;
use Illuminate\Notifications\Events\NotificationSent;
use Illuminate\Support\Facades\Event;

class EventServiceProvider extends ServiceProvider
{
	/**
	 * The event to listener mappings for the application.
	 *
	 * @var array<class-string, array<int, class-string>>
	 */
	protected $listen = [
        // 通知イベント
        NotificationSent::class => [
            SaveNotificationHistory::class,
        ],
	];
}

作成したリスナーを編集
handle()メソッド内に書かれた処理が、イベント発火時に呼び出される
引数として受け取った$eventは
$event->channel
$event->notifiable
$event->notification
でNotificationの詳細にアクセスできる。

SaveNotificationHistory
<?php

namespace App\Listeners;

use App\Events\NotificationSent;
use App\Notifications\SendTemplateNotification;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Notifications\Events\NotificationSent as EventsNotificationSent;
use Illuminate\Queue\InteractsWithQueue;

class SaveNotificationHistory
{
    /**
     * Create the event listener.
     */
    public function __construct()
    {
        //
    }

    /**
     * Handle the event.
     */
    public function handle(EventsNotificationSent $event): void
    {
        // Notificationの種類によって通知後の処理をわけたほうがよいかも
        match (get_class($event->notification)) {
            SendTemplateNotification::class => $this->saveNotificationHistory($event),
        };
    }

    protected function saveNotificationHistory(EventsNotificationSent $event)
    {
        // ここに保存する処理書く
    }
}
ryomaryoma

NotificationSentのリスナーについて
通知したメッセージ自体を取得する場合は
$event->response->getOriginalMessage()
から取得できた

ただ、こんなに遠回りじゃないと取得できないの?と腑に落ちないのでいる

メッセージの取得
public function handle(EventsNotificationSent $event): void
{
    $message = $event->response->getOriginalMessage();

    // 件名
    $message->getSubject();

    // html形式本文
    $message->getHtmlBody();

    // text形式本文
    $message->getTextBody();

    // to
    $message->getTo();

    // bcc
    $message->getBcc();

    // cc
    $message->getCc();
}
ryomaryoma

新たな課題

  • notification×mailpitで意図的に失敗できるのか
  • メールステータスあるいは失敗したメールを抽出できるのか
ryomaryoma
  • notification×mailpitで意図的に失敗できるのか

意図的な失敗はおそらくできない

  • メールステータスあるいは失敗したメールを抽出できるのか

これもできない
ただ、NotificationSentイベントの $event->response からdebugを出力することが可能

getDebug()
Log::channel('mail')->info($event->response->getDebug());

以下、ログ出力例

[2024-06-19 16:57:24] local.INFO: < 220 cc2fc2140ec5 Mailpit ESMTP Service ready
> EHLO [127.0.0.1]
< 250-cc2fc2140ec5 greets [127.0.0.1]
< 250-SIZE 0
< 250 ENHANCEDSTATUSCODES
> MAIL FROM:<hoge@example.com>
< 250 2.1.0 Ok
> RCPT TO:<***********@gmail.com>
< 250 2.1.5 Ok
> DATA
< 354 Start mail input; end with <CR><LF>.<CR><LF>
> .
< 250 2.0.0 Ok: queued

この文字列を解析して成功、失敗を見ることにする

ryomaryoma

通知対象のコレクションに対して
Notificationファサードから通知するのと
Notification::send($notifiables, $notification)

コレクションを繰り返し処理で各モデルに逐次通知する
$notifiable->notify($notification)

どちらのスピードがはやいのか検証
コレクションには、通知対象が59件分入ってる

速度計測
// ファサード使用時の速度計測
$time_start = microtime(true);
try {
    Notification::send($collection, $notification);
} catch (Exception $e) {
    Log::channel('mail')->error($e->getMessage());
}
$time_end = microtime(true);
$time = $time_end - $time_start;
var_dump($time);
// 3.0664739608765

// 繰り返し処理での速度計測
$time_start = microtime(true);
$collection->each(function ($notifiable) use ($notification) {
    try{
        $notifiable->notify($notification);
    } catch (Exception $e) {
        Log::channel('mail')->error($e->getMessage());
    }
});
$time_end = microtime(true);
$time = $time_end - $time_start;
var_dump($time);
// 2.4966559410095

結果、何回かやってみたけどnotify繰り返したほうがはやそう・・・?