基于 Redis 实现 Laravel 广播功能(下):在私有频道和存在频道发布和接收消息


在私有频道广播事件消息

在上面的示例广播事件 UserSignedUp 中,我们通过 Channel 定义了一个公共频道广播,即所有客户端都可以接收到这个事件消息:

public function broadcastOn()
{
    return new Channel('test-channel');
}

定义私有频道广播事件类

不过很多时候,我们的业务需要实现的都是在私有频道发布消息,比如一个微信群或者 QQ 群内的某个用户发布了消息,只有这个群内的用户才能接收到消息,不可能其他群能收到消息,否则就乱套了,要实现这样的功能,需要借助 Laravel 提供的私有频道类 PrivateChannel

我们新建一个广播事件类 UserSendMessage

php artisan make:event UserSendMessage

然后基于 PrivateChannel 编写一个在私有频道(指定微信群)分发的广播事件消息:

<?php

namespace App\Events;

use App\Models\User;
use Illuminate\Broadcasting\Channel;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Broadcasting\PresenceChannel;
use Illuminate\Broadcasting\PrivateChannel;
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;

class UserSendMessage implements ShouldBroadcast
{
    use Dispatchable, InteractsWithSockets, SerializesModels;

    public User $user;
    public string $message;
    public int $groupId;
    
    public string $broadcastQueue = 'broadcast';

    /**
     * Create a new event instance.
     *
     * @param User $user
     * @param $message
     * @param $groupId
     */
    public function __construct(User $user, $message, $groupId)
    {
        $this->user = $user;
        $this->message = $message;
        $this->groupId = $groupId;
    }

    /**
     * Get the channels the event should broadcast on.
     *
     * @return Channel|array
     */
    public function broadcastOn()
    {
        return new PrivateChannel('wechat.group.' . $this->groupId);
    }
}

客户端接收私有频道广播消息

这样一来,这个事件消息只会广播给监听 'wechat.group.' . $this->groupId 频道的客户端,在客户端(在 resources/views/websocket.blade.php 中模拟),我们可以通过 Echo.private 方法接收上面这个私有频道广播的消息:

...
<script type="text/javascript">
    let groupId = 1;
    window.Echo.private('wechat.group.' + groupId).listen('UserSendMessage', event => {
        console.log(event.user.name + ' Says ' + event.message);
    })
</script>
</html>

刷新这个视图页面让代码修改生效,但是会看到建立 Websocket 连接失败,错误码是 403:

-w980

注:此外,为了让上述前端代码私有频道订阅成功,需要注释掉 config/database.phpredis.options.prefix 里面定义的 laravel_database_ 前缀,因为 Laravel Echo 目前没有提供这个前缀设置,而 private 方法又会在频道名称前面加上 private- 前缀,这会导致后端和前端的频道名称不一致(后端是 laravel_database_private-wechat.group.1,前端是 private-laravel_database_wechat.group.1),除了取消 Redis 前缀设置,目前这个问题无解。

私有频道认证与授权

这是因为私有频道需要用户已认证并且对用户进行授权后才能订阅并接收广播消息,这个时候广播路由就派上用场了,我们可以在 routes/channels.php 中注册这个私有频道的广播路由来定义授权策略:

Broadcast::channel('wechat.group.{id}', function ($user, $id) {
    // 模拟微信群与用户映射关系列表,正式项目可以读取数据库获取
    $group_users = [
        [
            'group_id' => 1,
            'user_id' => 1,
        ],
        [
            'group_id' => 1,
            'user_id' => 2,
        ],
    ];
    // 判断微信群 ID 是否有效以及用户是否在给定群里,并以此作为授权通过条件
    $result = collect($group_users)->groupBy('group_id')
        ->first(function ($group, $groupId) use ($user, $id) {
            return $id == $groupId && $group->contains('user_id', $user->id);
        });
    return $result == null ? false : true;
});

先模拟一个微信群与用户表的映射关系,然后根据传入的用户 ID 和群 ID 判断群 ID 是否有效,以及用户是否在这个群里作为授权是否通过的依据。

你可以参考入门套件中的 Laravel Breeze 文档快速实现用户认证功能(breeze:install 会清空 routes/web.php 中的路由,请注意备份):

composer require laravel/breeze --dev
php artisan breeze:install
npm install && npm run dev

访问 http://redis.test/login 即可通过登录表单完成用户认证:

-w828

-w1268

然后再次刷新 http://redis.test/broadcast 页面,就没有报错信息了:

-w1308

laravel-echo-server 日志中,也可以看到对应的认证请求细节:

-w1199

分发私有频道事件消息

RedisPublish 命令类中编写分发 UserSendMessage 这个私有频道广播事件的代码:

public function handle()
{
    $user = \App\Models\User::find(1);
    //event(new UserSignedUp($user));
    $message = '你好, 学院君!';
    $groupId = 1;
    event(new UserSendMessage($user, $message, $groupId));
}

运行 sail artisan redis:publish 分发事件,然后重启 sail artisan queue:work --queue=broadcast 进程处理这个事件(队列处理进程是常驻内存的,通过单进程应用处理所有队列任务,一旦启动,只会将启动时的代码载入内存,如果后续代码有调整,需要重启才能让修改生效):

-w976

查看 laravel-echo-server 日志确认消息已经转发到 Websocket 客户端:

-w975

然后在 /broadcast 视图,就可以在开发者工具 Console 标签中看到输出的问候信息了,这个信息来自私有频道的广播信息:

-w903

如果用户未认证、或者未通过授权(不再这个群里面),是无法接收到这个私有频道的广播事件消息的。

在存在频道广播事件消息

存在频道是建立私有频道基础之上的,因此需要也需要认证和授权,所谓存在频道其实指的是订阅了特定私有频道的所有在线连接,还是以微信/QQ群为例,通过存在频道我们可以统计某个群(私有频道)当前在线用户数,或者给当前在线用户发送提醒信息,这样类比下,是不是更好理解一些?

定义存在频道广播事件类

我们以统计当前微信群在线用户数为例进行演示,每当有新用户进入时,更新在线用户数并广播这个事件消息,为此我们需要创建一个标识用户进入微信群的广播事件类:

php artisan make:event UserEnterGroup

编写 UserEnterGroup 类的实现代码如下,在 broadcastOn 方法中,我们通过 PresenceChannel 类定义了这个广播事件的存在频道:

<?php

namespace App\Events;

use App\Models\User;
use Illuminate\Broadcasting\Channel;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Broadcasting\PresenceChannel;
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;

class UserEnterGroup implements ShouldBroadcast
{
    use Dispatchable, InteractsWithSockets, SerializesModels;

    public User $user;
    public int $groupId;
    public string $broadcastQueue = 'broadcast';

    /**
     * Create a new event instance.
     *
     * @param User $user
     * @param $groupId
     */
    public function __construct(User $user, $groupId)
    {
        $this->user = $user;
        $this->groupId = $groupId;
    }

    /**
     * Get the channels the event should broadcast on.
     *
     * @return Channel|array
     */
    public function broadcastOn()
    {
        return new PresenceChannel('wechat.group.' . $this->groupId);
    }
}

存在频道是基于私有频道的,可以看到这个广播事件的频道名称也和 UserSendMessage 完全一样,加入存在频道的授权校验逻辑也不需要调整,所以它们可以共用同一个授权路由,无需重复定义。

在客户端接收存在频道消息

在 Laravel Echo 客户端,我们可以通过 Echo.join 加入某个私有频道返回 PresenceChannel 实例,然后在其基础上通过 listen 接收 Websocket 服务端广播消息,以及处理加入、离开、在线事件,还是在 websocket.blade.php 视图文件中新增相应的广播消息接收代码:

...
<script src="{{ asset('js/app.js') }}" type="text/javascript"></script>
<script type="text/javascript">
    let groupId = 1;

    ...

    window.Echo.join('wechat.group.' + groupId)
        .listen('UserEnterGroup', event => {
            // 监听&接收服务端广播的消息
            console.log(event.user.name + '加入了群聊');
        });
</script>
</html>

刷新 http://redis.test/broadcast 页面,让客户端新增的存在频道广播消息接收代码生效。

发布存在频道广播消息

为了简化演示流程,我们还是在 RedisPublish 命令基础上调整广播事件分发代码:

public function handle()
{
    $user = \App\Models\User::find(1);
    //event(new UserSignedUp($user));
    //$message = '你好, 学院君!';
    $groupId = 1;
    //event(new UserSendMessage($user, $message, $groupId));
    event(new UserEnterGroup($user, $groupId));
}

重新启动队列处理进程,之后通过如下命令模拟发布存在频道广播消息:

redis-demo sail artisan redis:publish
sail artisan queue:work --queue=broadcast

/broadcast 页面可以看到已经成功接收到广播消息:

-w857

但是这条广播消息推送给自己显得有点奇怪,我当然知道自己加入了群聊,应该通知其他人才对。

推送广播消息给其他用户

Laravel 广播组件提供了类似这种功能的语法支持,我们只需要稍微调整下广播事件的分发代码即可,不过为了让 Laravel 识别是哪个客户端发布的广播消息,就不能通过命令行分发广播事件了,在 routes/web.php 中注册一个新的测试路由:

Route::post('/groups/{id}/enter', function ($id) {
    broadcast(new \App\Events\UserEnterGroup(request()->user(), $id))->toOthers();
    return true;
});

在这里,我们将事件分发函数从 event 调整为了 broadcast,这是一个专门用于分发广播事件的辅助函数,可以在分发事件返回实例上调用 toOthers 方法告知系统将这个事件消息广播给排除当前用户的所有其他在线用户。

当然使用 event 函数也可以,需要像这样调用:

event((new \App\Events\UserEnterGroup($user, $groupId))->dontBroadcastToCurrentUser());

或者,你还可以在事件类的构造函数中直接设置,以免在分发事件时额外指定:

public function __construct(User $user, $groupId)
{
    $this->user = $user;
    $this->groupId = $groupId;
    $this->dontBroadcastToCurrentUser();
}

不管哪种方式最终都依赖于广播事件类使用了 InteractsWithSockets Trait。

另外,这个功能还依赖于客户端请求头包含 X-Socket-ID(Laravel Echo 初始化时会为每个连接分配一个唯一的 Socket ID,用于标识不同的 Websocket 客户端),如果你在 Laravel 应用中使用 Axios 库发送请求,这个请求头会自动设置,如果使用的是其他的 JavaScript 库,则需要手动设置,你可以这样获取这个 Socket ID:

var socketId = window.Echo.socketId();

具体示例这里就不再演示了,你可以自行去体验下,有什么问题,欢迎通过评论与我讨论,更多关于 Laravel 广播的功能特性,请参考官方文档

另外,你还可以使用 Swoole 实现 Websocket 服务端,学院君之前发布了一个基于 Redis + Swoole + Socket.io 实现的 Laravel 在线聊天室项目,可以作为进一步学习的参考教程。

关于 Laravel 广播组件的实现和使用,学院君就简单介绍到这里,下篇教程,我们来探讨如何通过 Redis 实现分布式锁以及该功能在 Laravel 任务调度中的应用。


Vote Vote Cancel Collect Collect Cancel

<< 上一篇: 基于 Redis 实现 Laravel 广播功能(中):引入 Laravel Echo 接收广播消息

>> 下一篇: 基于 Redis 实现分布式锁及其在 Laravel 底层的实现源码