解决Laravel Reverb后端WebSocket断开追踪难题
2025-04-10 07:34:21
好的,这是你要求的博客文章内容:
Laravel Reverb 后端追踪 WebSocket 连接断开:难题与方案
用 Laravel Reverb 和 ReactJS 开发聊天应用时,追踪用户的在线状态(在线或离线)是个常见需求。一般我们会用 Presence Channel 来处理。
当用户连接到 Presence Channel 时,在 Redis 里记录一下用户的在线状态,这事儿挺简单的。麻烦的是,怎么在用户断开连接(比如关了浏览器标签页)时,也能在后端 及时知道,然后把 Redis 里的记录删掉?
很多人可能会建议在前端加代码,在用户离开页面(beforeunload
事件)或者断开 WebSocket 连接时,主动调一个 API 通知后端。但这种方式不太靠谱,尤其是用户直接关闭浏览器或者网络突然中断的情况,前端的代码根本没机会执行。
既然 Reverb 的调试控制台能看到 "connection established" 和 "connection closed" 的消息,这说明服务器本身是知道连接状态变化的。那我们能不能直接在后端捕捉这个 "connection closed" 事件呢?
答案是:有点复杂,但有几种思路可以尝试。
问题分析:为什么后端直接捕捉断开不容易?
标准的 Laravel Broadcasting,特别是 Presence Channel 的授权回调 (routes/channels.php
里的闭包),主要负责的是验证用户是否有权限加入频道 。这个回调函数只在用户尝试连接时执行一次。它没有内建一个对应的 leaving
或者 disconnected
回调,让你在用户断开时执行后端逻辑。
// routes/channels.php (现有逻辑)
Broadcast::channel('presence-user-status-{user_id}', function ($user, $user_id) {
// ... 认证逻辑 ...
if (!empty($userData)) {
// 用户连接成功,记录 Redis,触发 'connected' 事件
Redis::setex("user:online:{$user_id}", 300, json_encode([
'user_id' => $userData['user_id'],
'type' => $userData['type'],
'last_seen' => now()->toDateTimeString(),
]));
// 注意:这里只处理了连接成功的情况
event(new UserStatus($userData['user_id'], 'connected'));
return $userData; // 允许用户加入频道
}
return false; // 拒绝用户加入频道
});
你提供的 UserStatusListener
也只是监听了你自己触发的 UserStatus
事件。目前这个事件只在用户成功连接时,由 channels.php
文件中的授权回调触发了一次,并且状态是 'connected'
。
<?php
// app/Listeners/UserStatusListener.php (现有逻辑)
namespace App\Listeners;
use App\Events\UserStatus;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Redis;
class UserStatusListener
{
// ...构造函数...
public function handle(UserStatus $event): void
{
$user_id = $event->user_id;
$status = $event->status; // 这个 status 目前只可能是 'connected'
Log::info("User Status Changed: User ID {$user_id} is now {$status}");
if ($status === "connected") {
// 存储用户在线状态
Redis::setex("user:online:{$user_id}", 300, json_encode([
'user_id' => $user_id,
'status' => 'online',
'last_seen' => now()->toDateTimeString(),
]));
} elseif ($status === "disconnected") {
// 理想情况:如果能收到 'disconnected' 状态的事件,就在这里处理
Log::info("Processing disconnection for User ID {$user_id}"); // 增加日志确认
Redis::del("user:online:{$user_id}");
}
}
}
所以,核心问题在于,Laravel Reverb(或者说它依赖的底层 WebSocket 服务器,以及它遵循的 Pusher 协议)的标准工作方式,并没有提供一个简单的、开箱即用的服务器端 钩子,让你能在某个 特定用户 的 WebSocket 连接 物理断开 时,立即执行一段你的 Laravel 应用代码。Reverb Debug 面板看到的 "connection closed" 是服务器底层的日志,还没有直接桥接到 Laravel 的事件系统里,让你能方便地关联到具体的用户并执行清理操作。
解决方案探讨
既然直接捕捉断开事件比较困难,我们看看有哪些可行的、主要依赖后端的解决方案或替代方案。
方案一:利用 Redis 的 TTL (Time-To-Live) 机制 (基本可靠)
这是目前最常用,也是相对可靠的一种处理“硬断开”(如直接关浏览器、网络中断)的后端策略。你其实已经在用了!
-
原理和作用:
当你使用Redis::setex("user:online:{$user_id}", 300, ...)
时,你不仅设置了一个键值对,还给这个键设置了一个 300 秒(5 分钟)的过期时间 (TTL)。如果在这 5 分钟内,没有新的setex
操作来“续期”(覆盖原来的键并重置 TTL),Redis 会自动删除这个键。
这意味着,即使用户非正常掉线,没有机会通知后端,他/她的在线状态最多也只会在 Redis 里残留 5 分钟。之后查询时,查不到这个键,就可以认为用户已离线。 -
代码示例:
你的channels.php
和UserStatusListener
中已经正确使用了setex
:// 在 channels.php 或 UserStatusListener 的 connected 分支 Redis::setex("user:online:{$user_id}", 300, json_encode([ 'user_id' => $userData['user_id'], 'type' => $userData['type'], // 'status' => 'online', // 这个字段可以省略,存在即表示在线 'last_seen' => now()->toDateTimeString(), // 保留 last_seen 挺好 ]));
-
如何检查状态:
在需要获取用户在线状态的地方,直接检查 Redis 中对应的 key 是否存在即可。public function isUserOnline($userId) { return Redis::exists("user:online:{$userId}"); // 或者获取数据判断 // $userData = Redis::get("user:online:{$userId}"); // return !is_null($userData); }
-
安全建议/注意事项:
- TTL 的选择: 300 秒是一个折衷。设置太短,可能用户只是网络波动一下就被判定下线;设置太长,状态更新就不够及时。根据你的应用场景调整。
- “假在线”窗口期: 缺点是存在一个最长为 TTL 时间的“假在线”窗口。用户可能已经掉线 4 分钟了,但在系统里看起来还是在线的。
-
进阶使用技巧:
-
心跳续期 (Heartbeat): 如果想让 TTL 机制更精确一点,可以结合前端心跳。让前端通过 WebSocket 连接,每隔比如 1 分钟,给后端发一个“我还活着”的信号 (可以是一个空的 WebSocket 消息,或者一个特定的事件)。后端收到这个信号后,就重新执行
Redis::setex
或Redis::expire
,给这个用户的 key 续期。这样,只有当超过 1 分钟没收到心跳时,TTL 才会开始倒计时。这大大缩短了“假在线”的时间窗口,但牺牲了纯后端方案的简洁性,需要前端配合。-
前端 (React with Laravel Echo):
import Echo from 'laravel-echo'; // ... 初始化 Echo ... let heartbeatInterval; const presenceChannel = Echo.join(`presence-user-status.${userId}`) .here((users) => { console.log('当前在线用户:', users); // 设置心跳定时器 if (heartbeatInterval) clearInterval(heartbeatInterval); heartbeatInterval = setInterval(() => { // 发送心跳事件到服务器,不需要携带数据 // 使用 whisper 发送瞬时事件,避免广播给他人 presenceChannel.whisper('heartbeat', {}); console.log('Sent heartbeat'); }, 60000); // 每 60 秒发送一次 }) .joining((user) => { console.log(user.name, '加入了频道'); }) .leaving((user) => { console.log(user.name, '离开了频道'); // 用户离开频道(可能是正常退出) // 注意:这个 leaving 事件在硬断开时,前端可能无法触发 }) .error((error) => { console.error('Presence channel error:', error); if (heartbeatInterval) clearInterval(heartbeatInterval); // 出错时停止心跳 }); // 页面卸载时尝试清理 window.addEventListener('beforeunload', () => { if (heartbeatInterval) clearInterval(heartbeatInterval); // Echo.leaveChannel(...) // Echo 会自动处理离开 }); // (重要) 处理连接断开事件 // Echo 实例本身可以监听连接状态 Echo.connector.pusher.connection.bind('disconnected', () => { console.log('Pusher connection disconnected.'); if (heartbeatInterval) clearInterval(heartbeatInterval); }); Echo.connector.pusher.connection.bind('connected', () => { console.log('Pusher connection reconnected.'); // 可能需要重新加入频道和启动心跳,根据 Echo 的实现可能自动处理 });
-
后端 (监听 Whisper 事件): 你需要在
routes/channels.php
对应的频道授权下方添加对whisper
事件的监听,或者在全局的 WebSocket 路由里处理。由于whisper
事件不会经过标准的 Laravel 事件系统,处理方式略有不同,通常在 Reverb (或底层驱动如 Swoole/FrankenPHP) 的事件循环中直接处理。Reverb 目前对whisper
的原生后端处理支持可能还不完善,需要查看具体版本和配置。
一个简化的概念(注意:实际实现可能依赖 Reverb 版本和底层驱动细节 ):你需要找到一个地方能接收到客户端通过whisper
发送的heartbeat
事件,然后执行 Redis 续期。
如果直接监听whisper
不方便,可以改为前端定时调用一个简单的 API 端点来续期。// 示例:如果用 API 端点进行心跳续期 // routes/api.php Route::post('/heartbeat', function (Request $request) { $user = $request->user(); // 确保路由有认证中间件 if ($user) { $userId = $user->getAuthIdentifier(); // 根据你的用户模型调整 // 找到对应的用户类型(如果需要) $type = $user instanceof \App\Models\User ? 'user' : ($user instanceof \App\Models\BusinessUser ? 'business' : 'staff'); // 示例逻辑 Redis::setex("user:online:{$userId}", 120, json_encode([ // TTL 可以设短一点,比如 2 分钟 'user_id' => $userId, 'type' => $type, 'last_seen' => now()->toDateTimeString(), ])); return response()->json(['status' => 'ok']); } return response()->json(['status' => 'unauthenticated'], 401); })->middleware('auth:api'); // 使用 Passport 或 Sanctum 的认证中间件
前端相应地修改
setInterval
中的逻辑,改为fetch
或axios
调用/api/heartbeat
。
-
-
方案二:利用 Presence Channel 的 here
, joining
, leaving
事件 (后端间接感知)
Presence Channel 本身设计就是为了让频道内的其他成员 知道谁在线、谁加入、谁离开。我们可以利用这一点,让后端应用某种程度上“扮演”一个监听者。
-
原理和作用:
虽然无法直接捕获某个连接的close
事件,但当一个用户从 Presence Channel 离开时(无论是正常离开还是因为连接断开被服务器清理),WebSocket 服务器(Reverb)通常会向该频道内所有其他 在线的客户端广播一个leaving
事件,告知是哪个用户离开了。
如果你的后端能 somehow “监听”这个leaving
事件,就能知道用户下线了。但这并不直接,因为这个事件是广播给客户端的。 -
实现思路 (复杂,可能有局限):
- 后端也“加入”频道? 让你的 Laravel 应用本身(比如通过一个后台进程或队列任务)像一个客户端一样连接到 WebSocket 服务器,并加入到相关的 Presence Channel。然后监听
leaving
事件。- 缺点: 非常规,增加了系统复杂度,需要处理后端连接自身的维护和认证,可能消耗服务器资源。并不推荐。
- 利用 Webhooks (如果 Reverb 支持): 更标准的方式是看 Reverb 是否支持(或计划支持)Webhook 功能。像 Pusher 这样的商业服务,可以配置当 Presence Channel 成员变化(加入/离开)时,向你指定的 HTTP(S) 地址发送一个 POST 请求。你的 Laravel 应用只需创建一个路由和控制器来接收这个请求,解析内容(哪个用户离开了哪个频道),然后执行 Redis 删除操作。
- 检查 Reverb 文档: 需要确认当前版本的 Reverb 是否提供类似 Pusher Webhooks 的功能。截至目前,Reverb 主要关注作为自托管的 Pusher 协议兼容服务器,Webhook 可能不是其核心功能。
- 后端也“加入”频道? 让你的 Laravel 应用本身(比如通过一个后台进程或队列任务)像一个客户端一样连接到 WebSocket 服务器,并加入到相关的 Presence Channel。然后监听
-
代码示例 (Webhook 方式,假设 Reverb 支持):
- Reverb 配置 (假设):
# .env ( hypothetical Reverb webhook config ) REVERB_APP_WEBHOOK_URL=https://yourdomain.com/webhooks/reverb REVERB_APP_WEBHOOK_EVENTS=channel_occupied,channel_vacated,member_added,member_removed # 指定事件
- Laravel 路由:
// routes/web.php or routes/api.php use App\Http\Controllers\ReverbWebhookController; Route::post('/webhooks/reverb', [ReverbWebhookController::class, 'handle']);
- Laravel 控制器:
<?php namespace App\Http\Controllers; use Illuminate\Http\Request; use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Redis; class ReverbWebhookController extends Controller { public function handle(Request $request) { // 1. 验证 Webhook 请求的签名 (非常重要!) // Reverb (或 Pusher) 会提供一种签名机制,你需要用共享密钥验证请求来源。 // $signature = $request->header('X-Pusher-Signature'); // $body = $request->getContent(); // if (!$this->isValidSignature($signature, $body)) { // Log::warning('Invalid Reverb webhook signature received.'); // abort(403, 'Invalid signature'); // } $payload = $request->input('events'); // Pusher webhook 格式通常是这样 if (!$payload || !is_array($payload)) { Log::warning('Invalid Reverb webhook payload received.', ['payload' => $request->all()]); return response('Invalid payload', 400); } foreach ($payload as $event) { Log::info('Received Reverb webhook event:', $event); if ($event['name'] === 'member_removed') { $channelName = $event['channel']; // e.g., "presence-user-status-123" $userId = $event['user_id']; // The user ID that left // 从频道名解析出用户 ID 可能需要更稳健的逻辑 if (strpos($channelName, 'presence-user-status-') === 0 && $userId) { // 确认 user_id 与 频道中的 id 匹配 (可选,增加一层校验) $channelUserId = substr($channelName, strlen('presence-user-status-')); if ((string)$userId === (string)$channelUserId) { Log::info("Processing member_removed via webhook: User ID {$userId} left channel {$channelName}"); Redis::del("user:online:{$userId}"); } else { Log::warning("Webhook User ID mismatch", ['event' => $event]); } } } // 可能还需要处理 channel_vacated 事件,表示频道变空了 } return response('Webhook processed', 200); } // private function isValidSignature($signature, $body) { // 实现 Pusher/Reverb 的签名验证逻辑 // $appSecret = config('broadcasting.connections.reverb.apps.0.secret'); // 获取你的 app secret // return hash_hmac('sha256', $body, $appSecret) === $signature; // } }
- Reverb 配置 (假设):
-
安全建议:
- Webhook 端点必须 做签名验证,防止恶意请求。
- 确保 Webhook URL 是 HTTPS 的。
方案三:定时任务检查和清理 (不太推荐,但可行)
如果上述方法都不可行或不满足要求,可以考虑创建一个计划任务。
-
原理和作用:
写一个 Laravel Console Command,比如php artisan presence:cleanup
。使用 Laravel 的任务调度功能,让这个命令每分钟左右执行一次。
这个命令的任务是:- 获取 Redis 中所有
user:online:*
的键。 - 关键挑战: 如何验证这些用户对应的 WebSocket 连接是否还真的存在?这需要 Reverb 提供一个 API 或方式来查询当前的活动连接及其关联的用户信息。这通常比较困难,或者 Reverb 没有直接提供这样的功能。
- 替代验证: 如果无法直接验证连接,这个方法就退化成了另一种形式的 TTL 管理。比如,任务检查每个键的
last_seen
时间戳,如果超过一定时间(例如 5 分钟)没有被更新(说明心跳机制可能失效了),就删除它。但这又回到了类似 TTL 的逻辑。
- 获取 Redis 中所有
-
代码示例 (仅概念,依赖于验证方式):
<?php namespace App\Console\Commands; use Illuminate\Console\Command; use Illuminate\Support\Facades\Redis; use Illuminate\Support\Facades\Log; use Carbon\Carbon; class CleanupPresence extends Command { protected $signature = 'presence:cleanup'; protected $description = 'Clean up stale user presence records from Redis'; public function handle() { $keys = Redis::keys('user:online:*'); $count = 0; foreach ($keys as $key) { $data = Redis::get($key); if (!$data) continue; $userData = json_decode($data, true); $lastSeen = Carbon::parse($userData['last_seen'] ?? '1970-01-01'); // Handle missing timestamp // 策略 1: 基于 last_seen 的简单超时 (如果不用心跳,就是固定 TTL) // 如果有了心跳,这里就检查最后心跳时间 $threshold = 5; // Minutes if (Carbon::now()->diffInMinutes($lastSeen) > $threshold) { $userId = str_replace('user:online:', '', $key); Log::info("Cleaning up stale presence for User ID {$userId} (last seen: {$lastSeen->toDateTimeString()})"); Redis::del($key); $count++; } // 策略 2: (理想但困难) 查询 Reverb/WebSocket 服务器确认连接状态 // $userId = str_replace('user:online:', '', $key); // if (!ReverbApiService::isConnectionActiveForUser($userId)) { // Log::info("Cleaning up presence for disconnected User ID {$userId} (verified via Reverb)"); // Redis::del($key); // $count++; // } } $this->info("Presence cleanup finished. Removed {$count} stale records."); return 0; } } // app/Console/Kernel.php protected function schedule(Schedule $schedule) { $schedule->command('presence:cleanup')->everyMinute(); }
-
缺点:
- 有延迟,不是实时清理。
- 验证连接状态是最大的难点。
- 可能对 Redis 造成周期性压力(如果在线用户很多)。
总结建议
目前来看,对于 Laravel Reverb 环境下,想纯粹在后端、实时地捕捉 WebSocket 的 disconnect
事件并执行与特定用户关联的清理逻辑,还没有一个像处理 connect
那样直接明了的官方标准方案。
- 最实用和推荐的方案: 依然是利用 Redis 的 TTL (
setex
) 。这是处理网络异常、浏览器崩溃等“硬断开”情况的可靠基石。它能保证最终数据的一致性,代价是存在一定的延迟。 - 提升实时性: 如果 5 分钟或更长的延迟不可接受,可以考虑引入前端心跳机制 (方案一的进阶) 。前端定时通过 WebSocket 或 API “打卡”,后端收到后刷新 Redis 的 TTL。这样可以将“假在线”时间缩短到心跳间隔 + 短暂缓冲时间。虽然牺牲了“纯后端”,但效果最好。
- 关注 Reverb 发展: 持续关注 Reverb 的官方文档和更新。未来版本可能会增加对服务器端事件钩子或 Webhooks 的支持(方案二),这会是更优雅的后端解决方案。
- 定时清理作为补充: 定时任务(方案三)可以作为最后的补充手段,主要用于清理那些由于某些极端情况(例如 Redis 服务短暂中断后恢复)导致可能未能正常过期的 key,但不要把它作为主要的断开检测机制,除非你能找到可靠的方式验证连接活跃度。
结合你目前的代码,你已经有了方案一的基础。根据你对实时性的要求,决定是否要引入前端心跳来增强它。检查 Reverb 的文档和社区,看看是否有未被广泛宣传的底层事件或 Webhook 功能可以利用。