Laravel 消息通知源码阅读笔记

前言

为理解消息通知功能,特别是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,否则异步起来执行也无法打断点,我就是在这里郁闷了好久。。。
本作品采用《CC 协议》,转载必须注明作者和本文链接
日拱一卒
本帖由系统于 4年前 自动加精
《L02 从零构建论坛系统》
以构建论坛项目 LaraBBS 为线索,展开对 Laravel 框架的全面学习。应用程序架构思路贴近 Laravel 框架的设计哲学。
《G01 Go 实战入门》
从零开始带你一步步开发一个 Go 博客项目,让你在最短的时间内学会使用 Go 进行编码。项目结构很大程度上参考了 Laravel。
讨论数量: 17

写得很仔细,我看完明白个大概也大约要1.5小时。
Dispatcher::class 解析到 ChannelManager::class ,我想可能会用到laravel的绑定接口到实现,因为Dispatcher::class是一个接口。从实际上来看,是用类的别名来映射。
对于异步调试,我一般是加return $data;返回数据,在谷歌浏览器审查元素里面看NetWork对应请求的返回值,不知道这里队列的异步跟AJAX是不是同一个概念。

5年前 评论

laravel的实现过程太复杂了,看哪天能不能根据思路写个简单版的。

5年前 评论

@tsin 原文里面的异步和AJAX不是一个东西。

5年前 评论

@tsin 直接使用绑定接口到实现可能不行,因为Manager的构造函数中已经写死了传入参数为$app,而不是Appliction $app。我来文章中已经详细分析了一下,你可以看一下。

Ajax异步调试我也是可以打断点的。队列的异步可能不太一样,因为队列的流程一般都是:有一个地方存放队列信息,一个PHP进程在运行时将任务写入,另外一个PHP守护进程轮询队列信息,将达到执行要求的任务执行并删除

如果使用redis队列来debug时,经过一番debug,最后到了\Predis\Connection\StreamConnection::write

    protected function write($buffer)
    {
        $socket = $this->getResource();

        while (($length = strlen($buffer)) > 0) {
            $written = @fwrite($socket, $buffer);

            if ($length === $written) {
                return;
            }

            if ($written === false || $written === 0) {
                $this->onConnectionError('Error while writing bytes to the server.');
            }

            $buffer = substr($buffer, $written);
        }
    }

$written = @fwrite($socket, $buffer); 执行完这句后,数据就已经写到数据库的notifications表了,而我关心的toDataBase方法根本就没有执行到。这就奇怪了。
这里的$socketresource,而$buffer是一个字符串:

*3
$5
RPUSH
$14
queues:default
$1268
{"displayName":"App\\Notifications\\TopicReplied","job":"Illuminate\\Queue\\CallQueuedHandler@call","maxTries":null,"timeout":null,"timeoutAt":null,"data":{"commandName":"Illuminate\\Notifications\\SendQueuedNotifications","command":"O:48:\"Illuminate\\Notifications\\SendQueuedNotifications\":9:{s:11:\"notifiables\";O:45:\"Illuminate\\Contracts\\Database\\ModelIdentifier\":4:{s:5:\"class\";s:15:\"App\\Models\\User\";s:2:\"id\";i:1;s:9:\"relations\";a:0:{}s:10:\"connection\";s:5:\"mysql\";}s:12:\"notification\";O:30:\"App\\Notifications\\TopicReplied\":9:{s:5:\"reply\";O:45:\"Illuminate\\Contracts\\Database\\ModelIdentifier\":4:{s:5:\"class\";s:16:\"App\\Models\\Reply\";s:2:\"id\";i:1046;s:9:\"relations\";a:2:{i:0;s:5:\"topic\";i:1;s:10:\"topic.user\";}s:10:\"connection\";N;}s:2:\"id\";s:36:\"1aaf5a7b-9444-4c35-9293-62890d438072\";s:6:\"locale\";N;s:10:\"connection\";N;s:5:\"queue\";N;s:15:\"chainConnection\";N;s:10:\"chainQueue\";N;s:5:\"delay\";N;s:7:\"chained\";a:0:{}}s:8:\"channels\";a:1:{i:0;s:8:\"database\";}s:10:\"connection\";N;s:5:\"queue\";N;s:15:\"chainConnection\";N;s:10:\"chainQueue\";N;s:5:\"delay\";N;s:7:\"chained\";a:0:{}}"},"id":"107","attempts":0,"type":"notification","tags":["App\\Models\\Reply:1046"],"pushedAt":1542938490.868963}

具体这个$buffer字符串在上面这个$socket资源中进行了什么处理,就是看不到了。。所以我只能暂时理解为异步执行了。。

5年前 评论
yema

问一下。你看源码有没有使用什么助手工具。

5年前 评论

@yema 就是要打断点然后去步进

5年前 评论

@yema sublime text中,鼠标放到函数或者类名上,会出现一个列表,可以选择跳转到可能是其所在定义的地方。这样找文件比较方便。

5年前 评论

@hustnzj 感谢你的辛苦研究!

5年前 评论
yema

关键有时候看到一半 phpstorm 跟踪不下去了。比如源码里的事件对应的监听器,我找老长时间都没找到。还有那些从 容器里直接取出的,也不能跟踪,脑袋疼。不过还好有手册查查。

5年前 评论

@yema 都可以找到的,listeners在\Illuminate\Events\Dispatcher对象中有

5年前 评论
yema

file
你知道这几个监听器在哪吗?

5年前 评论

@yema 监听器要注册了才有,比如register

file

5年前 评论
yema

你的意思是这是框架留给我们自己写监听器的?

5年前 评论

@yema 对啊,不过框架自己默认也有。一般以Illuminate开头的就是。你可以参考文档:https://learnku.com/docs/laravel/5.7/events#regist...

5年前 评论

(:з」∠)

1年前 评论

讨论应以学习和精进为目的。请勿发布不友善或者负能量的内容,与人为善,比聪明更重要!
未填写
文章
93
粉丝
85
喜欢
153
收藏
121
排名:71
访问:11.4 万
私信
所有博文
社区赞助商