Laravel 广播系统工作原理

1,835 阅读12分钟
原文链接: blog.phpzendo.com

今天,让我们深入研究下 Laravel 的广播系统。广播系统的目的是用于实现当服务端完成某种特定功能后向客户端推送消息的功能。本文我们将学习如何使用第三方 Pusher 工具向客户端推送消息的功能。

如果您遇到在 Laravel 中需要实现当服务器处理完成某项工作后向客户端发送消息这类的功能,那么您需要使用到 Laravel 的广播系统。

比如在一个支持用户互相发送消息的即时通信应用,当用户 A 给用户 B 发送一条消息时,系统需要实时的将消息推送给用户 B,并且信息以弹出框或提示消息框形式展现给用户 B。

这种使用场景可以完美诠释 Laravel 广播系统的工作原理。另外,本教程将使用 Laravel 广播系统实现这样一个即时通信应用。

或许您会对服务器是如何将消息及时的推送给客户端的技术原理感兴趣,这是因为在服务端实现这类功能时使用了套接字编程技术。在开始实现即时通信系统前,先让我们了解下套接字编程的大致流程:

  • 首先,服务器需要支持 WebSocket 协议,并且允许客户端建立 WebSocket 连接;
  • 您可以实现自己的 WebSocket 服务,或者使用第三方服务如 Pusher,后文会用到 Pusher 库;
  • 客户端创建一个服务器的 Web Socket 连接,连接成功后客户端会获取唯一标识符;
  • 一旦客户端连接成功,表示该客户端订阅了指定频道,将接收这个频道的消息;
  • 最后,客户端还会注册其所订阅的频道的监听事件;
  • 当服务端完成指定功能后,我们以指定频道名称和事件名称的信息通知到 WebSocket 服务器;
  • 最终,WebSocket 服务器将这个指定事件已广播的形式推送到所有注册这个频道监听的客户端。

以上所涉及的内容看似很多,但通过本文学习您将掌握个中的诀窍。

接下来,让我们打开 Laravel 默认广播系统配置文件 config/broadcasting.php 看看里面的配置选项:

<?php

return [

    /*
    |--------------------------------------------------------------------------
    | Default Broadcaster 默认广播驱动
    |--------------------------------------------------------------------------
    |
    | This option controls the default broadcaster that will be used by the
    | framework when an event needs to be broadcast. You may set this to
    | any of the connections defined in the "connections" array below.
    |
    | 该配置选项用于配置项目需要提供广播服务时的默认驱动器。配置连接器可以使任意
    | 在 "connections" 节点配置的驱动名称。
    |
    | Supported: "pusher", "redis", "log", "null"
    | 
    | 支持:"pusher", "redis", "log", "null"
    |
    */

    'default' => env('BROADCAST_DRIVER', 'null'),

    /*
    |--------------------------------------------------------------------------
    | Broadcast Connections
    |--------------------------------------------------------------------------
    |
    | Here you may define all of the broadcast connections that will be used
    | to broadcast events to other systems or over websockets. Samples of
    | each available type of connection are provided inside this array.
    |
    */

    'connections' => [

        'pusher' => [
            'driver' => 'pusher',
            'key' => env('PUSHER_APP_KEY'),
            'secret' => env('PUSHER_APP_SECRET'),
            'app_id' => env('PUSHER_APP_ID'),
            'options' => [
                'cluster' => env('PUSHER_APP_CLUSTER'),
                'encrypted' => true,
            ],
        ],

        'redis' => [
            'driver' => 'redis',
            'connection' => 'default',
        ],

        'log' => [
            'driver' => 'log',
        ],

        'null' => [
            'driver' => 'null',
        ],

    ],

];

默认情况下 Laravel 框架提供诸多开箱即用的广播驱动器程序。

本文将使用 Pusher 作为广播驱动器。但在调试阶段,我们可以选择使用 log 作为广播驱动。同时如果选用 log 驱动,也就表示客户端将不会接收任何消息,而只是将需要广播的消息写入到 laravel.log 日志文件内。

在下一节,我们将进一步讲解如何实现一个即时通信应用。

前期准备

Laravel 广播系统支持 3 中不同频道类型 - public(公共), private(私有) 和 presence(存在)。当系统需要向所用用户推送信息时,可以使用 「public(公共)」 类型的频道。相反,如果仅需要将消息推送给指定的频道,则需要使用 「 private(私有)」 类型的频道。

我们的示例项目将实现一个仅支持登录用户才能收到即时信息的消息系统,所以将使用 「 private(私有)」 类型的频道。

开箱即用的认证服务

首先对于新创建的 Laravel 项目,我们需要安装 Laravel 提供的开箱即用的认证服务组件,默认认证服务功能包括:注册、登录等功能。如果您不知道如何使用默认认证服务,可以查看 Laravel 的用户认证系统 文档快速入门。

服务端 Pusher SDK 安装配置

这边我们将使用 Pusher 这个第三方服务作为 WebSocket 服务器,所以还需要创建一个 帐号 并确保已获取 API 证书。安装配置遇到任何问题,请在评论区说明。

之后需要使用 Composer 包管理工具安装 Pusher 的 PHP 版本 SDK,这样才能在 Laravel 项目中使用 Pusher 发送广播信息。

现在进入 Laravel 项目的根目录,执行下面这条命令进行安装:

composer require pusher/pusher-php-server "~3.0"

安装完成后修改广播配置文件,启用 Pusher 驱动作为广播系统的驱动器。

<?php

return [

    /*
    |--------------------------------------------------------------------------
    | Default Broadcaster
    |--------------------------------------------------------------------------
    |
    | This option controls the default broadcaster that will be used by the
    | framework when an event needs to be broadcast. You may set this to
    | any of the connections defined in the "connections" array below.
    |
    | Supported: "pusher", "redis", "log", "null"
    |
    */

    'default' => env('BROADCAST_DRIVER', 'pusher'),

    /*
    |--------------------------------------------------------------------------
    | Broadcast Connections
    |--------------------------------------------------------------------------
    |
    | Here you may define all of the broadcast connections that will be used
    | to broadcast events to other systems or over websockets. Samples of
    | each available type of connection are provided inside this array.
    |
    */

    'connections' => [

        'pusher' => [
            'driver' => 'pusher',
            'key' => env('PUSHER_APP_KEY'),
            'secret' => env('PUSHER_APP_SECRET'),
            'app_id' => env('PUSHER_APP_ID'),
            'options' => [
                        'cluster' => 'ap2',
                        'encrypted' => true
            ],
        ],

        'redis' => [
            'driver' => 'redis',
            'connection' => 'default',
        ],

        'log' => [
            'driver' => 'log',
        ],

        'null' => [
            'driver' => 'null',
        ],

    ],

];

如你所见,我们修改了默认驱动器。并且将 connections 节点的 Pusher 配置的 cluster 修改成 ap2

同时还有需要从 .env 配置文件获取的配置选项,所以我们需要更新 .env 文件,加入如下配置信息:

BROADCAST_DRIVER=pusher

PUSHER_APP_ID={YOUR_APP_ID}
PUSHER_APP_KEY={YOUR_APP_KEY}
PUSHER_APP_SECRET={YOUR_APP_SECRET}

接下来,还需要对 Laravel 核心文件稍作修改才能使用最新的 Pusher SDK。不过,我并不提倡修改 Laravel 核心文件,这边由于演示方便所以我修改了其中的代码。

让我们打开 vendor/laravel/framework/src/Illuminate/Broadcasting/Broadcasters/PusherBroadcaster.php 文件。将 use Pusher; 替换为 use Pusher\Pusher;

之后打开 vendor/laravel/framework/src/Illuminate/Broadcasting/BroadcastManager.php 文件,在类似下面的代码中做相同修改:

return new PusherBroadcaster(
  new \Pusher\Pusher($config['key'], $config['secret'],
  $config['app_id'], Arr::get($config, 'options', []))
);

最后,在 config/app.php 配置中开启广播服务提供者配置:

App\Providers\BroadcastServiceProvider::class,

这样 Pusher 库的安装工作就完成了。下一节,我们将讲解客户端类库的安装。

客户端 Pusher 和 Laravel Echo 类库的安装配置

在广播系统中,客户端接口负责连接 WebSocket 服务器、订阅指定频道和监听事件等功能。

幸运的是 Laravel 已经给我们提供了一个叫 Laravel Echo 的插件,它实现一个复杂的 JavaScript 客户端程,。并且这个插件内置支持 Pusher 的服务器连接。

可以通过 NPM 包管理器安装 Laravel Echo 模块。如果您还没有安装 Node.js 及 NPM 包管理程序,还是要先安装 Node.js 才行。

这里我认为您已经安装好了 Node.js,所以安装 Laravel Echo 扩展的命令如下:

npm install laravel-echo

安装完成后我们直接将 node_modules/laravel-echo/dist/echo.js 文件复制到 public/echo.js 就行了。

仅适用一个 echo.js 文件有点杀鸡用了牛刀的感觉,所以您还可以到 Github 直接下载 echo.js 文件。

至此,我们就完成了客户端组件的安装。

服务端文件设置

回想一下前文提到的内容:首先我们需要实现一个允许用户互相发送消息的应用;另外,应用会通过广播系统向已登录系统并且有收到消息的用户推送消息。

这一节我们将编写服务端代码实现广播系统相关功能。

创建 message 迁移文件

首先,我们需要创建一个 Message 模型用于存储用户发送的消息,执行如下命令创建一个迁移文件:

php make:model Message --migration

但在执行 migrate 命令前,我们需要在迁移文件中加入表字段 tofrommessage

<?php

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

class CreateMessagesTable extends Migration
{
    /**
     * Run the migrations.
     *
     * @return void
     */
    public function up()
    {
        Schema::create('messages', function (Blueprint $table) {
            $table->increments('id');
            $table->integer('from', false, true);
            $table->integer('to', false, true);
            $table->text('message');
            $table->timestamps();
        });
    }

    /**
     * Reverse the migrations.
     *
     * @return void
     */
    public function down()
    {
        Schema::dropIfExists('messages');
    }
}

然后运行 migrate 命令运行数据库迁移文件:

php artisan migrate

当需要在 Laravel 执行事件时,我们首先需要做的是创建一个事件类,Laravel 将基于不同的事件类型执行不同的操作。

如果事件为一个普通事件,Laravel 会调用对应的监听类。如果事件类型为广播事件,Laravel 会使用 config/broadcasting.php 配置的驱动器将事件推送到 WebSocket 服务器。

本文使用的是 Pusher 服务,所以 Laravel 将事件推送到 Pusher 服务器。

先使用下面的 artisan 命令创建一个事件类:

php artisan make:event NewMessageNotification

这个命令会创建 app/Events/NewMessageNotification.php 文件,让我们修改文件内的代码:

<?php

namespace App\Events;

use Illuminate\Broadcasting\Channel;
use Illuminate\Queue\SerializesModels;
use Illuminate\Broadcasting\PrivateChannel;
use Illuminate\Broadcasting\PresenceChannel;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow;
use App\Message;

class NewMessageNotification implements ShouldBroadcastNow
{
    use SerializesModels;

    public $message;

    /**
     * Create a new event instance.
     *
     * @return void
     */
    public function __construct(Message $message)
    {
        $this->message = $message;
    }

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

需要重点指出的是 NewMessageNotification 类实现了 ShouldBroadcastNow 接口,所以当我们触发一个事件时,Laravel 就能够立即知道有事件需要广播给其他用户了。

实际上,我们还可以去实现 ShouldBroadcast 接口,这个接口会将事件加入到消息队列中。然后由队列的 Worker 进程依据入队顺序依次执行。由于我们项目需要立即将消息推送给用户,所以我们实现 ShouldBroadcastNow 接口更为合适。

还有就是我们需要显示用户接收的消息信息,所以我们将 Message 模型作为构造函数的参数,这样消息信息就会同事件一起传入到指定频道。

接下来还在 NewMessageNotification 类中创建了一个 broadcastOn 方法,在该方法中定义了广播事件的频道名称,因为只有登录的用户才能接收消息,所以这里创建了 PrivateChannel 实例作为一个私有频道。

定义频道名称格式类似于 user.{USER_ID} ,其中包含了指向接收信息的用户 ID,用户ID 从 $this->message->to 中获取。

对于客户端程序需要先进行用户身份校验,然后才能惊醒连接 WebSocket 服务器处理;这样才能保证私有频道的消息仅会广播给登录用户。同样在客户端也仅允许登录用户才能够订阅 user.{USER_ID} 私有频道。

如果您在客户端程序使用了 Laravel Echo 组件处理订阅服务。那在客户端代码中仅需设置频道路由即可,而无需关心用户认证处理细节。

打开 routes/channels.php 文件,然后定义一个广播路由:

<?php

/*
|--------------------------------------------------------------------------
| Broadcast Channels
|--------------------------------------------------------------------------
|
| Here you may register all of the event broadcasting channels that your
| application supports. The given channel authorization callbacks are
| used to check if an authenticated user can listen to the channel.
|
*/

Broadcast::channel('App.User.{id}', function ($user, $id) {
    return (int) $user->id === (int) $id;
});

Broadcast::channel('user.{toUserId}', function ($user, $toUserId) {
    return $user->id == $toUserId;
});

以上,我们设置了名为 user.{toUserId} 路由,Broadcast::channel 方法的第二个参数接收一个闭包,Laravel 会将登录用户信息自动注入到闭包的第一个参数,第二个参数会从渠道中解析并获取。

当客户端尝试订阅 user.{USER_ID} 这个私有频道时 Laravel Echo 组件会使用 XMLHttpRequest 以异步请求方式进行用户身份校验处理。

到这里即时通信所有编码工作就完成了。

创建测试用例

首先,创建一个控制器 app/Http/Controllers/MessageController.php

<?php
namespace App\Http\Controllers;

use App\Http\Controllers\Controller;
use App\Message;
use App\Events\NewMessageNotification;
use Illuminate\Support\Facades\Auth;

class MessageController extends Controller
{
    public function __construct() {
        $this->middleware('auth');
    }

    public function index()
    {
        $user_id = Auth::user()->id;
        $data = array('user_id' => $user_id);

        return view('broadcast', $data);
    }

    public function send()
    {
        // ...

        // 创建消息
        $message = new Message;
        $message->setAttribute('from', 1);
        $message->setAttribute('to', 2);
        $message->setAttribute('message', 'Demo message from user 1 to user 2');
        $message->save();

        // 将 NewMessageNotification 加入到事件
        event(new NewMessageNotification($message));

        // ...
    }
}

接下来创建 index 路由所需的 broadcast 视图文件 resources/views/broadcast.blade.php

<!DOCTYPE html>
<html lang="{{ app()->getLocale() }}">
<head>
    <meta charset="utf-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1">

    <!-- CSRF Token -->
    <meta name="csrf-token" content="{{ csrf_token() }}">

    <title>Test</title>

    <!-- Styles -->
    <link href="{{ asset('css/app.css') }}" rel="stylesheet">
</head>
<body>
    <div id="app">
        <nav class="navbar navbar-default navbar-static-top">
            <div class="container">
                <div class="navbar-header">

                    <!-- Collapsed Hamburger -->
                    <button type="button" class="navbar-toggle collapsed" data-toggle="collapse" data-target="#app-navbar-collapse">
                        <span class="sr-only">Toggle Navigation</span>
                        <span class="icon-bar"></span>
                        <span class="icon-bar"></span>
                        <span class="icon-bar"></span>
                    </button>

                    <!-- Branding Image -->
                    <a class="navbar-brand123" href="{{ url('/') }}">
                        Test
                    </a>
                </div>

                <div class="collapse navbar-collapse" id="app-navbar-collapse">
                    <!-- Left Side Of Navbar -->
                    <ul class="nav navbar-nav">
                         
                    </ul>

                    <!-- Right Side Of Navbar -->
                    <ul class="nav navbar-nav navbar-right">
                        <!-- Authentication Links -->
                        @if (Auth::guest())
                            <li><a href="{{ route('login') }}">Login</a></li>
                            <li><a href="{{ route('register') }}">Register</a></li>
                        @else
                            <li class="dropdown">
                                <a href="#" class="dropdown-toggle" data-toggle="dropdown" role="button" aria-expanded="false">
                                    {{ Auth::user()->name }} <span class="caret"></span>
                                </a>

                                <ul class="dropdown-menu" role="menu">
                                    <li>
                                        <a href="{{ route('logout') }}"
                                            onclick="event.preventDefault();
                                                     document.getElementById('logout-form').submit();">
                                            Logout
                                        </a>

                                        <form id="logout-form" action="{{ route('logout') }}" method="POST" style="display: none;">
                                            {{ csrf_field() }}
                                        </form>
                                    </li>
                                </ul>
                            </li>
                        @endif
                    </ul>
                </div>
            </div>
        </nav>

        <div class="content">
                <div class="m-b-md">
                    New notification will be alerted realtime!
                </div>
        </div>
    </div>

    <!-- receive notifications -->
    <script src="{{ asset('js/echo.js') }}"></script>

    <script src="https://js.pusher.com/4.1/pusher.min.js"></script>

        <script>
          Pusher.logToConsole = true;

          window.Echo = new Echo({
            broadcaster: 'pusher',
            key: 'c91c1b7e8c6ece46053b',
            cluster: 'ap2',
            encrypted: true,
            logToConsole: true
          });

          Echo.private('user.{{ $user_id }}')
          .listen('NewMessageNotification', (e) => {
              alert(e.message.message);
          });
        </script>
    <!-- receive notifications -->
</body>
</html>

之后,打开 routes/web.php 路由配置文件定义 HTTP 路由:

Route::get('message/index', 'MessageController@index');
Route::get('message/send', 'MessageController@send');

由于 MessageController 构造函数中使用了 auth 中间件,所以确保了仅有登录用户才能访问以上路由。

接下来,让我们分析下 broadcast 视图文件的核心代码:

<!-- receive notifications -->
<script src="{{ asset('js/echo.js') }}"></script>

<script src="https://js.pusher.com/4.1/pusher.min.js"></script>

<script>
    Pusher.logToConsole = true;

    window.Echo = new Echo({
        broadcaster: 'pusher',
        key: 'c91c1b7e8c6ece46053b',
        cluster: 'ap2',
        encrypted: true,
        logToConsole: true
    });

    Echo.private('user.{{ $user_id }}')
    .listen('NewMessageNotification', (e) => {
        alert(e.message.message);
    });
</script>
<!-- receive notifications -->

视图文件里首先,引入了 echo.jspusher.min.js这两个必要的模块,这样我们才能够使用 Laravel Echo 去连接 Pusher 的服务器。

接着,创建 Laravel Echo 实例。

之后,通过 Echo 实例的 private 方法订阅 user.{USER_ID} 这个私有频道。之前我们说过只有登录用户才能订阅私有频道,所以 Echo 实例会使用 XHR 异步校验用户。然后,Laravel 会尝试查找 user.{USER_ID} 路由,并匹配到已在 routes/channels.php 文件中定义的广播路由。

一切顺利的话,我们的项目此时即完成了 Pusher 服务器连接,之后就会监听 user.{USER_ID} 频道。这样客户端才可以正常接收指定频道的所有消息。

完成客户端接收 WebSocket 服务器消息接收编码工作后,在服务端需要通过 Message::send 方法发送一个广播消息。

发送的代码如下:

    public function send()
    {
        // ...

        // 创建消息
        $message = new Message;
        $message->setAttribute('from', 1);
        $message->setAttribute('to', 2);
        $message->setAttribute('message', 'Demo message from user 1 to user 2');
        $message->save();

        // 将 NewMessageNotification 加入到事件
        event(new NewMessageNotification($message));

        // ...
    }

这段代码先是模拟了登录用户发送消息的操作。

然后通过 event 辅助函数将 NewMessageNotification 事件类实例加入广播频道。由于 NewMessageNotificationShouldBroadcastNow 类的实例,Laravel 会从 config/broadcasting.php 配置文件中读取广播配置数据,然后将 NewMessageNotification 事件分发到配置文件所配置的 WebSocket 服务器的 user.{USER_ID} 频道。

对于本文示例会将消息广播到 Pusher 服务器的 user.{USER_ID} 频道里。如果订阅者的 ID 是 1,事件所处的广播频道则为 user.1

之前我们已经在前端代码中完成频道的订阅和监听处理,这里当用户收到消息时会在页面弹出一个消息框提示给用户。

现在如何对以上功能进行测试呢?

在浏览器访问地址 http://your-laravel-site-domain/message/index 。如果您未登录系统,请先进行登录处理,登录后就可以看到广播页面信息了。

虽然现在的 Web 页面看起来什么也没有做,但是 Laravel 已经在后台进行了一系列处理。通过 Pusher 组件的 Pusher.logToConsole 我们可以开启 Pusher 的调试功能。下面是登录后的调试信息内容:

Pusher : State changed : initialized -> connecting

Pusher : Connecting : {"transport":"ws","url":"wss://ws-ap2.pusher.com:443/app/c91c1b7e8c6ece46053b?protocol=7&client=js&version=4.1.0&flash=false"}

Pusher : Connecting : {"transport":"xhr_streaming","url":"https://sockjs-ap2.pusher.com:443/pusher/app/c91c1b7e8c6ece46053b?protocol=7&client=js&version=4.1.0"}

Pusher : State changed : connecting -> connected with new socket ID 1386.68660

Pusher : Event sent : {"event":"pusher:subscribe","data":{"auth":"c91c1b7e8c6ece46053b:cd8b924580e2cbbd2977fd4ef0d41f1846eb358e9b7c327d89ff6bdc2de9082d","channel":"private-user.2"}}

Pusher : Event recd : {"event":"pusher_internal:subscription_succeeded","data":{},"channel":"private-user.2"}

Pusher : No callbacks on private-user.2 for pusher:subscription_succeeded

可以看到我们完成了 WebSocket 服务器连接和私有频道监听。当然您看到的频道名称获取和我的不一样,但内容大致相同。接下来不要关闭这个 Web 页面,然后去访问 send 方法发送消息。

新开一个页面窗口在浏览器访问 http://your-laravel-site-domain/message/send 页面,顺利的话会在 http://your-laravel-site-domain/message/index 页面收到一个提示消息。

同时在 index 的控制台您还将看到到如下调试信息:

Pusher : Event recd : {"event":"App\\Events\\NewMessageNotification","data":{"message":{"id":57,"from":1,"to":2,"message":"Demo message from user 1 to user 2","created_at":"2018-01-13 07:10:10","updated_at":"2018-01-13 07:10:10"}},"channel":"private-user.2"}

如你所见,调试信息告诉我们我们接收来自 Pusher 服务器的 private-user.2 频道的 App\Events\NewMessageNotification 消息。

当然,我们还可以通过 Pusher 管理后台的仪表盘看到这个消息内容,它在 Debug Console 标签页,我们可以看到如下日志信息。

调试日志

这就是今天的全部内容,希望能给大家带来帮助。

结论

今天,我们研究了 Laravel 的 广播 这个较少使用的特性。广播可以让我们使用 Web Sockets 发送实时消息。此外我们还使用广播功能实现了一个简单的实时消息推送项目。本文内容较多,需要一些时间消化,有任何问题可以随时联系我。

原文