Laravel 5.3 新特性系列 —— 深入探讨 Laravel Echo 使用:实时聊天室

目录
  1. 1. 什么是 Laravel Echo
  2. 2. 什么时候使用Echo
  3. 3. 实现一个简单的广播事件
  4. 4. 通过Echo实现广播事件
    1. 4.1. 安装Echo JS库
    2. 4.2. 通过Echo订阅公共频道
  5. 5. 通过Echo订阅私有频道
    1. 5.1. Echo 基本认证和授权
    2. 5.2. 编写私有频道认证权限
  6. 6. 通过Echo订阅存在频道
  7. 7. 排除当前用户

什么是 Laravel Echo

Echo是一个让我们在Laravel应用中轻松实现WebSockets(关于WebSockets工作原理和机制可参考这篇文章:WebSocket 实战)功能的工具,同时简化了构建复杂WebSockets交互中更加通用、复杂的部分。

注:Echo 还处于开发阶段,本教程代码和最终发布版本可能会有出入,望知悉。

Echo 由两部分组成:针对Laravel事件广播系统的一系列优化,以及一个新的JavaScript包。

在 Laravel 5.3 中,Echo 后端组件已经集成到Laravel核心库,不需要额外引入(不同于Cashier扩展包),你需要和前端JavaScript配合使用这些组件,而不仅仅是使用Echo JavaScript库,还会看到 Laravel 在处理 WebSockets时在易用性上的显著优化。

Echo JavaScript库可以通过NPM引入,这个库基于Pusher JS(JavaScript Pusher SDK)或者Socket.io(JavaScript Redis WebSockets SDK)。

什么时候使用Echo

当你需要发送异步实时消息给用户时WebSockets很有用 —— 不管这些消息是通知还是页面更新数据,同时保持用户在同一页面无需刷新。当然,你可以使用长轮询,或者某些定期的JavaScript ping来实现这样的功能,但是这样做在服务端没有更新的情况下对带宽造成浪费,造成一些不必要的请求。相比之下,Websockets功能强大,不会对服务器造成额外负载,可伸缩,速度极快。

如果你想要在Laravel应用中使用WebSockets,Echo提供了干净、简洁的语法来实现各种功能,简单如公共频道,复杂如认证、授权、私有和存在频道。

注:WebSockets实现提供了三种频道:public,意味着所有人可以订阅;private,认证且经过授权的用户才能订阅;presense,不允许发送消息,只通知用户在频道中是否已存在。

实现一个简单的广播事件

假设我们想要实现一个有多个房间的聊天室系统,这样我们就需要在每次接收到新的聊天室消息时触发一个事件。

注:你需要熟悉Laravel的事件广播机制以便能更好的理解这篇教程。

因此,首先我们创建这个事件:

php artisan make:event ChatMessageWasReceived

打开这个新生成的类app/Events/ChatMessageWasReceived.php并确保其实现了 ShouldBroadcast,接下来我们让其广播到一个名为chat-room.1的公共(public)频道。

然后我们为聊天室消息创建一个模型和对应的迁移,该模型包含user_id和message字段:

php artisan make:model ChatMessage --migration

最终,事件类ChatMessageWasReceived 的代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
...
class ChatMessageWasReceived extends Event implements ShouldBroadcast
{
use InteractsWithSockets, SerializesModels;

public $chatMessage;
public $user;

public function __construct($chatMessage, $user)
{
$this->chatMessage = $chatMessage;
$this->user = $user;
}

public function broadcastOn()
{
return [
"chat-room.1"
];
}

}

编辑生成的迁移类代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
...
class CreateChatMessagesTable extends Migration
{
public function up()
{
Schema::create('chat_messages', function (Blueprint $table) {
$table->increments('id');
$table->string('message');
$table->integer('user_id')->unsigned();
$table->timestamps();
});
}

public function down()
{
Schema::drop('chat_messages');
}
}

还要确保模型中的新增字段在白名单中:

1
2
3
4
5
...
class ChatMessage extends Model
{
public $fillable = ['user_id', 'message'];
}

再然后,在具体场景中触发该事件。为了测试方便,我通常会创建一个Artisan命令来创建事件,让我们来试试。

php artisan make:command SendChatMessage

打开新创建的命令类app/Console/Commands/SendChatMessage.php,编辑该文件内容如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
...
class SendChatMessage extends Command
{
protected $signature = 'chat:message {message}';

protected $description = 'Send chat message.';

public function handle()
{
// Fire off an event, just randomly grabbing the first user for now
$user = \App\User::first();
$message = \App\ChatMessage::create([
'user_id' => $user->id,
'message' => $this->argument('message')
]);

event(new \App\Events\ChatMessageWasReceived($message, $user));
}
}

打开app/Console/Kernel.php,将刚创建的命令添加$commands属性以将其注册为有效的Artisan命令:

1
2
3
4
5
6
7
...
class Kernel extends ConsoleKernel
{
protected $commands = [
Commands\SendChatMessage::class,
];
...

至此,这个事件代码基本完成,你需要注册一个Pusher帐号(Echo也可以处理Redis和Socket.io,但是本例中我们使用Pusher),在该Pusher帐号中创建一个新的应用并获取key、secret以及App ID,然后将这些值设置到.env文件对应的 PUSHER_KEY, PUSHER_SECRET以及 PUSHER_APP_ID。

最后,引入Pusher库:
composer require pusher/pusher-php-server:~2.0

现在你可以通过运行如下命令发送事件到Pusher账户:
php artisan chat:message "Howdy everyone"

如果一切顺利,你应该可以进入到Pusher调试控制台,触发这个事件,看到如下效果:

通过Echo实现广播事件

刚刚我们实现了一个简单的推送事件到Pusher的系统,下面我们来看看Echo为我们提供了些什么。

安装Echo JS库

将Echo JavaScript库引入项目最简单的方式就是通过NPM和Elixir。首先,我们引入Pusher JS:

1
2
3
4
5
# Install the basic Elixir requirements
npm install
# Install Pusher JS and Echo, and add to package.json
npm install pusher-js --save
npm install laravel-echo --save

接下来,修改resouces/assets/js/app.js来导入相应文件:

1
2
3
4
5
6
window.Pusher = require('pusher-js');

import Echo from "laravel-echo"

window.echo = new Echo('your pusher key here');
// @todo: Set up Echo bindings here

然后,设置Elixir的gulpfile.js文件让其生效:

1
2
3
4
5
var elixir = require('laravel-elixir');

elixir(function (mix) {
mix.browserify('app.js');
});

最后运行gulpgulp watch命令将结果文件导入HTML模板,此外还需要添加CSRF令牌输入:

1
2
3
4
5
6
7
8
9
10
11
12
<html>
<head>
...
<meta name="csrf-token" content="{{ csrf_token() }}">
...
</head>
<body>
...

<script src="js/app.js"></script>
</body>
</html>

注:如果是新安装的Laravel应用,需要在编写所有HTML之前运行php artisan make:auth,因为后续的功能需要用到Laravel的认证。

通过Echo订阅公共频道

回到 resources/assets/js/app.js,让我们来监听Echo广播到的公共频道chat-room.1,并将所有收到的信息记录到用户控制台:

1
2
3
4
5
6
7
8
9
10
window.Pusher = require('pusher-js');

import Echo from "laravel-echo"

window.echo = new Echo('your pusher key here');

echo.channel('chat-room.1')
.listen('ChatMessageWasReceived', function (data) {
console.log(data.user, data.chatMessage);
});

我们告诉Echo:订阅的公共频道名字叫做chat-room.1,监听的事件是 ChatMessageWasReceived,当事件发生时,将其传递给这个匿名函数并执行其中的代码。具体显示如下:

这样通过短短几行代码,我们就可以访问到JSON格式的聊天信息以及相应用户,这些数据不仅可以用来通知用户,还可以用于更新内存数据,从而让每个WebSockets消息实现当前页面数据的更新。

通过Echo订阅私有频道

接下来我们让chat-room.1变成私有的。要实现这一目的首先我们需要在频道名称前加上private-前缀,然后编辑事件类ChatMessageWasReceived上的broadcastsOn()方法,设置频道名称为private-chat-room.1

接下来,使用app.js中的echo.private()替代之前的echo.channel()

其他保持不变,但是现在运行脚本会报错:

这就是Echo为我们提供的又一个强大功能:认证和授权。

Echo 基本认证和授权

认证系统由两部分组成,首先,当你第一次打开应用的时候,Echo会发送POST请求到/broadcasting/socket路由,当我们在Laravel端设置好Echo工具后,这个路由会通过你的Laravel session ID关联到相应到Pusher socket ID,这样Laravel和Pusher都知道如何标识给定的Pusher socket连接是否连接到特定的Laravel session。

注:每个JavaScript发起的请求,不管是Vue还是jQuery,都会包含一个对应到socket ID的X-Socket-Id头,但是没有它应用也能正常工作——可以通过更早与session关联的socket ID获取。

其次,Echo的认证和授权功能指的是,当你想要访问一个受保护的资源时,Echo会ping /broadcasting/auth来检查你是否可以访问这个频道,由于你的socket ID会被关联到对应的Laravel session,我们可以为这个路由编写一个简单清晰的ACL规则。

首先,打开config/app.php取消这一行的注释:
// App\Providers\BroadcastServiceProvider::class,

打开这个服务提供者文件app/Providers/BroadcastServiceProvider.php,内容如下:

1
2
3
4
5
6
7
8
9
10
11
...
class BroadcastServiceProvider extends ServiceProvider
{
public function boot()
{
Broadcast::route(['middleware' => ['web']]);

Broadcast::auth('channel-name.*', function ($user, $id) {
return true;
});
}

其中有两个地方需要注意,首先,Broadcast::route()允许你定义要应用到/broadcasting/socket/broadcasting/auth的中间件,你可以将其保持为web不变。其次,Broadcast::auth()让我们可以定义指定频道或频道组的权限。

编写私有频道认证权限

现在我们有一个名为private-chat-room.1的频道,以后可能还有多个频道,如private-chat-room.2等,所以我们这里为所有频道定义权限:

1
2
3
Broadcast::auth('chat-room.*', function ($user, $chatroomId) {
// return whether or not this current user is authorized to visit this chat room
});

正如你所看到的,传递到闭包的第一个值是当前用户,如果有任何被匹配到,就会作为第二个参数传进来。

注:尽管我们重命名了private-chat-room.1,你可以看到在定义访问权限的时候没必要加上private-前缀。

在这篇博客教程中,我们只是简单演示授权代码,你还需要为聊天室创建一个模型和迁移,以及与用户之间的多对多关联,然后在闭包中检查当前用户是否连接到这个聊天室,现在我们只是简单返回true:

1
2
3
4
5
Broadcast::auth('chat-room.*', function ($user, $chatroomId) {
if (true) { // Replace with real ACL
return true;
}
});

测试一下看你会看到什么。

你应该能看到一个空的控制台日志,然后你可以触发Artisan命令,这样会看到用户和聊天室消息,和之前一样,只不过现在需要是经过授权的认证用户。

如果你看到如下消息,也是没有问题的,意思是一切工作正常,只不过你的系统判定你无权访问该聊天室:

通过Echo订阅存在频道

现在,我们可以在后台判断哪些用户可以访问聊天室,当用户发送消息到聊天室(类似于通过AJAX发送请求到服务器,只不过在我们的案例中通过Artisan命令取代用户请求),会触发ChatMessageWasReceived事件然后进行广播,将消息通过WebSockets发送给所有认证且授权的用户,下一步,我们要做什么?

假设,我们想要在聊天室中显示哪些用户在线,或者在用户进入或离开时做下提示,这可以通过存在频道来实现。

我们需要做两件事:一个新的Broadcast::auth()权限定义以及一个新的以presence-前缀开头的频道。有趣的是,由于认证定义不需要private-presence-前缀,所以private-chat-room.1presence-chat-room.1Broadcast::auth()中可以共用同一份代码:chat-room.*,这没有什么问题,只要两者认证规则一致。但是这会给大家带来困惑,所以我准备添加一个新的命名,使用presence-chat-room-presence.1

由于我们只是讨论是否存在,没必要将这个频道绑定到事件,取而代之,只需要在app.js中将我们直接加入到这个频道即可:

1
2
3
4
5
echo.join('chat-room-presence.1')
.here(function (members) {
// runs when you join, and when anyone else leaves or joins
console.table(members);
});

我们加入一个存在频道,然后提供一个回调在用户加载页面或者当有其他用户加入或离开时触发。here会在这三个事件时都触发,此外,还可以进行更加细粒度的控制,可以监听then(当前用户加入),joining(其他用户加入)以及leaving(其他用户离开):

1
2
3
4
5
6
7
8
9
10
11
12
13
echo.join('chat-room-presence.1')
.then(function (members) {
// runs when you join
console.table(members);
})
.joining(function (joiningMember, members) {
// runs when another member joins
console.table(joiningMember);
})
.leaving(function (leavingMember, members) {
// runs when another member leaves
console.table(leavingMember);
});

再次提醒你可以不在频道名称前加presence-前缀,据我所知,Echo中唯一必须加上presence-前缀的场景,是事件类的broadcastOn()方法中定义事件在私有频道广播。其他所有地方都可以去掉这些前缀,Echo 会自动处理(比如BroadcastServiceProvider中的认证定义),或者通过方法名(JavaScript包中的echo.channel()echo.private()方法)。

接下来,在BroadcastServiceProvider中为这个频道设置权限:

1
2
3
4
5
6
7
8
Broadcast::auth('chat-room-presence.*', function ($user, $roomId) {
if (true) { // Replace with real authorization
return [
'id' => $user->id,
'name' => $user->name
];
}
});

正如你所看到的,当用户认证后存在频道并不仅仅返回true,而是返回一个包含用户信息的数组,这些用户信息可用于在线用户之类的侧边栏。

如果一切正常,现在你可以在不同浏览器中打开应用,在控制台查看更新的会员列表:

排除当前用户

Echo还提供了一个功能:如果你不想让当前用户获取通知怎么做?

也许你所在的聊天室每次都会弹出各种各样的新消息,而你只想在屏幕顶部弹出少量消息,你也不想让发送消息的人收到消息,对不对?

要从接收消息列表中排除当前用户,需要在事件类的构造函数中调用$this->dontBroadcastToCurrentUser()方法:

1
2
3
4
5
6
7
8
9
10
11
...
class ChatMessageWasReceived extends Event implements ShouldBroadcast
{
...
public function __construct($chatMessage, $user)
{
$this->chatMessage = $chatMessage;
$this->user = $user;

$this->dontBroadcastToCurrentUser();
}