Laravel 訊息通知原始碼閱讀筆記

hustnzj發表於2018-11-22

為理解訊息通知功能,特別是toDatabasetoMail方法,為什麼要那樣寫?通讀了相關原始碼。

L02_6.3 《訊息通知》

在建立了一個通知類\App\Notifications\TopicReplied後,程式碼填充如下:

<?php

namespace App\Notifications;

use App\Models\Reply;
use Illuminate\Bus\Queueable;
use Illuminate\Notifications\Notification;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Support\Facades\Log;

//class TopicReplied extends Notification implements ShouldQueue
class TopicReplied extends Notification
{
    use Queueable;
    public $reply;

    public function __construct(Reply $reply)
    {
        $this->reply = $reply;
    }

    public function via($notifiable)
    {
        // 開啟通知的頻道
        return ['database', 'mail'];
    }

    public function toDatabase($notifiable)
    {
        $topic = $this->reply->topic;
        $link =  $topic->link(['#reply' . $this->reply->id]);

        // 存入資料庫裡的資料
        return [
            'reply_id' => $this->reply->id,
            'reply_content' => $this->reply->content,
            'user_id' => $this->reply->user->id,
            'user_name' => $this->reply->user->name,
            'user_avatar' => $this->reply->user->avatar,
            'topic_link' => $link,
            'topic_id' => $topic->id,
            'topic_title' => $topic->title,
        ];
    }

    public function toMail($notifiable)
    {
        $url = $this->reply->topic->link(['#reply' . $this->reply->id]);
        return (new MailMessage)
                    ->line('尊敬的'.$notifiable->name.',您的話題有新回覆!')
                    ->action('檢視回復', $url);
    }

}

現在的問題是:為什麼這樣寫了之後,就可以通過$user->notify(new TopicReplied($reply))這樣的程式碼傳送通知了?

下面將簡單的分析一下原始碼。

背景介紹

首先,是在建立話題的回覆後,就傳送通知。這個使用的是ReplyObserver的created事件來監控的。程式碼如下:

    public function created(Reply $reply)
    {
        $reply->topic->increment('reply_count', 1);
        // 通知作者話題被回覆了
        $reply->topic->user->topicNotify(new TopicReplied($reply));
    }

user模型中的topicNotify程式碼:

    public function topicNotify($instance)
    {
        // 如果要通知的人是當前使用者,就不必通知了!
        if ($this->id == Auth::id()) {
            return;
        }
        $this->increment('notification_count');
        $this->notify($instance);
    }

解析出ChannelManager

$this->notify($instance)開始,由於user模型使用了Notifiable這個trait,而Notifiable又使用了RoutesNotifications這個trait,因此,呼叫的是其中的notify:

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

這裡的app(Dispatcher::class)解析出ChannelManager。

\Illuminate\Notifications\RoutesNotifications::notify中,有這麼一句:

app(Dispatcher::class)->send($this, $instance);
//Illuminate\Contracts\Notifications\Dispatcher

因為在:\Illuminate\Notifications\NotificationServiceProvider::register中有如下程式碼:

    public function register()
    {
        //註冊`\Illuminate\Notifications\ChannelManager`
        $this->app->singleton(ChannelManager::class, function ($app) {
            return new ChannelManager($app);
        });

        //對`\Illuminate\Notifications\ChannelManager`起別名為`Illuminate\Contracts\Notifications\Dispatcher`
        $this->app->alias(
            ChannelManager::class, DispatcherContract::class
        );
        ...
    }

所以,這裡的app(Dispatcher::class)解析出來是一個\Illuminate\Notifications\ChannelManager物件。

在解析這個ChannelManager物件時,有朋友指出,可以使用繫結介面到實現的功能來解析。也就是說為什麼不直接使用這個功能去解析,反而要繞個圈子去起別名,然後再去繫結單例?

的確,在bootstrap/app.php中,我們就可以看到如下繫結介面到實現的例子:

$app->singleton(
    Illuminate\Contracts\Http\Kernel::class,
    App\Http\Kernel::class
);

$app->singleton(
    Illuminate\Contracts\Console\Kernel::class,
    App\Console\Kernel::class
);

$app->singleton(
    Illuminate\Contracts\Debug\ExceptionHandler::class,
    App\Exceptions\Handler::class
);

於是,我修改了框架原始碼,\Illuminate\Notifications\NotificationServiceProvider::register

    public function register()
    {
//        $this->app->singleton(ChannelManager::class, function ($app) {
//            return new ChannelManager($app);
//        });
        //TODO: just for testing!
        $this->app->singleton(DispatcherContract::class, ChannelManager::class);

        $this->app->alias(
            ChannelManager::class, DispatcherContract::class
        );
        ...
    }

然後執行,回覆&傳送通知,此時,會提示:"Unresolvable dependency resolving [Parameter #0 [ <required> $app ]] in class Illuminate\\Support\\Manager"

進入Illuminate\\Support\\Manager

    public function __construct($app)
    {
        $this->app = $app;
    }

發現其建構函式並沒有宣告引數的型別,因此,在使用反射解析ChannelManager物件時,無法根據引數型別使用依賴注入,所以就無法解析依賴關係了。

而在原始碼自己的單例繫結中,是將此實現類繫結到了一個回撥函式上,

    $this->app->singleton(ChannelManager::class, function ($app) {
        return new ChannelManager($app);
    });

在解析ChannelManager::class字串時,會去執行這個回撥函式,並自動傳入app物件。

\Illuminate\Container\Container::build:

    public function build($concrete)
    {
        如果繫結的是閉包,那麼這裡預設都會傳入`app`物件
        if ($concrete instanceof Closure) {
            return $concrete($this, $this->getLastParameterOverride());
        }
        ...

這樣,ChannelManager的建構函式中就可以傳入app物件了,也就用不著使用反射再去推敲建構函式中的引數型別了。

回到bootstrap/app.php中:

App\Http\KernelApp\Console\Kernel都是\Illuminate\Foundation\Http\Kernel的子類,都看\Illuminate\Foundation\Http\Kernel的建構函式:

    public function __construct(Application $app, Router $router)
    {
        ...
    }

可以看到,都是宣告瞭引數型別的。

App\Exceptions\Handler的基類為\Illuminate\Foundation\Exceptions\Handler,其建構函式:

    public function __construct(Container $container)
    {
        $this->container = $container;
    }

也是宣告瞭引數型別的。

所以,這二者差別不大,區別在於實現類的建構函式是否需要引數以及是否使用了型別依賴注入。

ChannelManner來傳送訊息

ChannelManner來傳送訊息實際上是使用了\Illuminate\Notifications\NotificationSender物件的send方法。

    public function send($notifiables, $notification)
    {
        return (new NotificationSender(
            $this, $this->app->make(Bus::class), $this->app->make(Dispatcher::class), $this->locale)
        )->send($notifiables, $notification);
    }

例項化IlluminateNotificationSender物件

傳入幾個引數:

  • \Illuminate\Bus\Dispatcher物件
  • \Illuminate\Events\Dispatcher物件
  • \Illuminate\Notifications\ChannelManager物件

呼叫IlluminateNotificationSender物件的send方法

    public function send($notifiables, $notification)
    {
        //Format the notifiables into a Collection / array if necessary.
        $notifiables = $this->formatNotifiables($notifiables);

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

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

$notifiables = $this->formatNotifiables($notifiables); 將待通知的實體轉為集合。

由於$notification沒有實現ShouldQueue介面,就直接到了$this->sendNow($notifiables, $notification)

呼叫IlluminateNotificationSender物件的sendNow方法

    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);
                }
            });
        }
    }

$notification->via($notifiable)
這裡的$notification就是我們要傳送的通知物件\App\Notifications\TopicReplied,因此呼叫的程式碼如下。

    public function via($notifiable)
    {
        // 開啟通知的頻道
        return ['database', 'mail'];
    }

很明顯,返回一個頻道陣列供後面來遍歷處理。

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

$this->sendToNotifiable(...)這個就是傳送通知到目的地了。

    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)
        );
    }

$this->manager->driver($channel)->send($notifiable, $notification);
$this->manager就是channelManager,呼叫其driver方法,在其中獲取或者建立一個driver:

    public function driver($driver = null)
    {
        $driver = $driver ?: $this->getDefaultDriver();

        if (is_null($driver)) {
            throw new InvalidArgumentException(sprintf(
                'Unable to resolve NULL driver for [%s].', static::class
            ));
        }

        if (! isset($this->drivers[$driver])) {
            $this->drivers[$driver] = $this->createDriver($driver);
        }

        return $this->drivers[$driver];
    }

DatabaseChannel

該driver方法返回的是一個channel,比如DatabaseChannel

    public function send($notifiable, Notification $notification)
    {
        return $notifiable->routeNotificationFor('database', $notification)->create(
            $this->buildPayload($notifiable, $notification)
        );
    }

這裡的$notifiable->routeNotificationFor('database', $notification)返回的是一個MorphMany(多型一對多)的關係。

$this->buildPayload($notifiable, $notification)返回的是一個陣列:

    protected function buildPayload($notifiable, Notification $notification)
    {
        return [
            'id' => $notification->id,
            'type' => get_class($notification),
            'data' => $this->getData($notifiable, $notification),
            'read_at' => null,
        ];
    }

這裡的$this->getData程式碼如下:

    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.');
    }

可以看到,這裡就是去呼叫$notification->toDatabase($notifiable)方法!也就是:

    public function toDatabase($notifiable)
    {
        //Log::info($notifiable);
        $topic = $this->reply->topic;
        $link =  $topic->link(['#reply' . $this->reply->id]);

        // 存入資料庫裡的資料
        return [
            'reply_id' => $this->reply->id,
            'reply_content' => $this->reply->content,
            'user_id' => $this->reply->user->id,
            'user_name' => $this->reply->user->name,
            'user_avatar' => $this->reply->user->avatar,
            'topic_link' => $link,
            'topic_id' => $topic->id,
            'topic_title' => $topic->title,
        ];
    }

由於MorphMany物件沒有create方法,因此會去呼叫其父類的方法,在\Illuminate\Database\Eloquent\Relations\HasOneOrMany::create:

    public function create(array $attributes = [])
    {
        return tap($this->related->newInstance($attributes), function ($instance) {
            $this->setForeignAttributesForCreate($instance);

            $instance->save();
        });
    }

這裡的$this->related\Illuminate\Notifications\DatabaseNotification,因此,$this->related->newInstance($attributes):

    public function newInstance($attributes = [], $exists = false)
    {
        // This method just provides a convenient way for us to generate fresh model
        // instances of this current model. It is particularly useful during the
        // hydration of new objects via the Eloquent query builder instances.
        $model = new static((array) $attributes);

        $model->exists = $exists;

        $model->setConnection(
            $this->getConnectionName()
        );

        return $model;
    }

這裡的$attributes就是我們前面toDatabase方法返回的資料:

‌array (
  'id' => 'b61035ff-339c-4017-bf10-315bfe302f10',
  'type' => 'App\\Notifications\\TopicReplied',
  'data' => 
  array (
    'reply_id' => 1039,
    'reply_content' => '<p>有看看</p>',
    'user_id' => 8,
    'user_name' => '馬娟',
    'user_avatar' => 'https://fsdhubcdn.phphub.org/uploads/images/201710/14/1/LOnMrqbHJn.png?imageView2/1/w/200/h/200',
    'topic_link' => 'http://olarabbs.test/topics/68?#reply1039',
    'topic_id' => 68,
    'topic_title' => 'Nisi blanditiis et ut delectus distinctio.',
  ),
  'read_at' => NULL,
)

new static((array) $attributes)就是建立一個新的model,返回後,在\Illuminate\Database\Eloquent\Relations\HasOneOrMany::create中,

$this->setForeignAttributesForCreate($instance);這個是設定外來鍵屬性。

$instance->save()這樣,就把資料寫到預設的notifications表中了!

最後資料庫notifications表如下圖:

file

MailChannel

如果channel是mail,那麼程式碼稍有不同:
$response = $this->manager->driver($channel)->send($notifiable, $notification);這一句,使用的driver就是mail關鍵字去建立的MailChannel了。
\Illuminate\Notifications\Channels\MailChannel::send

    public function send($notifiable, Notification $notification)
    {
        $message = $notification->toMail($notifiable);

        if (! $notifiable->routeNotificationFor('mail', $notification) &&
            ! $message instanceof Mailable) {
            return;
        }

        if ($message instanceof Mailable) {
            return $message->send($this->mailer);
        }

        $this->mailer->send(
            $this->buildView($message),
            array_merge($message->data(), $this->additionalMessageData($notification)),
            $this->messageBuilder($notifiable, $notification, $message)
        );
    }

$message = $notification->toMail($notifiable); 返回一個message物件:

    public function toMail($notifiable)
    {
        $url = $this->reply->topic->link(['#reply' . $this->reply->id]);
        return (new MailMessage)
                    ->line('尊敬的'.$notifiable->name.',您的話題有新回覆!')
                    ->action('檢視回復', $url);
    }

$this->mailer->send進行郵件的傳送!

  • 在建立的訊息類中,如果實現了ShouldQueue介面,那麼將會把此訊息放入佇列中,不在本文考慮範圍內。
  • 如果要研究佇列,則.env檔案中的QUEUE_CONNECTION不要選擇redis,而選擇sync,否則非同步起來執行也無法打斷點,我就是在這裡鬱悶了好久。。。

相關文章