返回

解决Laravel Reverb后端WebSocket断开追踪难题

php

好的,这是你要求的博客文章内容:

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.phpUserStatusListener 中已经正确使用了 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::setexRedis::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 中的逻辑,改为 fetchaxios 调用 /api/heartbeat

方案二:利用 Presence Channel 的 here, joining, leaving 事件 (后端间接感知)

Presence Channel 本身设计就是为了让频道内的其他成员 知道谁在线、谁加入、谁离开。我们可以利用这一点,让后端应用某种程度上“扮演”一个监听者。

  • 原理和作用:
    虽然无法直接捕获某个连接的 close 事件,但当一个用户从 Presence Channel 离开时(无论是正常离开还是因为连接断开被服务器清理),WebSocket 服务器(Reverb)通常会向该频道内所有其他 在线的客户端广播一个 leaving 事件,告知是哪个用户离开了。
    如果你的后端能 somehow “监听”这个 leaving 事件,就能知道用户下线了。但这并不直接,因为这个事件是广播给客户端的。

  • 实现思路 (复杂,可能有局限):

    1. 后端也“加入”频道? 让你的 Laravel 应用本身(比如通过一个后台进程或队列任务)像一个客户端一样连接到 WebSocket 服务器,并加入到相关的 Presence Channel。然后监听 leaving 事件。
      • 缺点: 非常规,增加了系统复杂度,需要处理后端连接自身的维护和认证,可能消耗服务器资源。并不推荐。
    2. 利用 Webhooks (如果 Reverb 支持): 更标准的方式是看 Reverb 是否支持(或计划支持)Webhook 功能。像 Pusher 这样的商业服务,可以配置当 Presence Channel 成员变化(加入/离开)时,向你指定的 HTTP(S) 地址发送一个 POST 请求。你的 Laravel 应用只需创建一个路由和控制器来接收这个请求,解析内容(哪个用户离开了哪个频道),然后执行 Redis 删除操作。
      • 检查 Reverb 文档: 需要确认当前版本的 Reverb 是否提供类似 Pusher Webhooks 的功能。截至目前,Reverb 主要关注作为自托管的 Pusher 协议兼容服务器,Webhook 可能不是其核心功能。
  • 代码示例 (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;
          // }
      }
      
  • 安全建议:

    • Webhook 端点必须 做签名验证,防止恶意请求。
    • 确保 Webhook URL 是 HTTPS 的。

方案三:定时任务检查和清理 (不太推荐,但可行)

如果上述方法都不可行或不满足要求,可以考虑创建一个计划任务。

  • 原理和作用:
    写一个 Laravel Console Command,比如 php artisan presence:cleanup。使用 Laravel 的任务调度功能,让这个命令每分钟左右执行一次。
    这个命令的任务是:

    1. 获取 Redis 中所有 user:online:* 的键。
    2. 关键挑战: 如何验证这些用户对应的 WebSocket 连接是否还真的存在?这需要 Reverb 提供一个 API 或方式来查询当前的活动连接及其关联的用户信息。这通常比较困难,或者 Reverb 没有直接提供这样的功能。
    3. 替代验证: 如果无法直接验证连接,这个方法就退化成了另一种形式的 TTL 管理。比如,任务检查每个键的 last_seen 时间戳,如果超过一定时间(例如 5 分钟)没有被更新(说明心跳机制可能失效了),就删除它。但这又回到了类似 TTL 的逻辑。
  • 代码示例 (仅概念,依赖于验证方式):

    <?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 那样直接明了的官方标准方案。

  1. 最实用和推荐的方案: 依然是利用 Redis 的 TTL (setex) 。这是处理网络异常、浏览器崩溃等“硬断开”情况的可靠基石。它能保证最终数据的一致性,代价是存在一定的延迟。
  2. 提升实时性: 如果 5 分钟或更长的延迟不可接受,可以考虑引入前端心跳机制 (方案一的进阶) 。前端定时通过 WebSocket 或 API “打卡”,后端收到后刷新 Redis 的 TTL。这样可以将“假在线”时间缩短到心跳间隔 + 短暂缓冲时间。虽然牺牲了“纯后端”,但效果最好。
  3. 关注 Reverb 发展: 持续关注 Reverb 的官方文档和更新。未来版本可能会增加对服务器端事件钩子或 Webhooks 的支持(方案二),这会是更优雅的后端解决方案。
  4. 定时清理作为补充: 定时任务(方案三)可以作为最后的补充手段,主要用于清理那些由于某些极端情况(例如 Redis 服务短暂中断后恢复)导致可能未能正常过期的 key,但不要把它作为主要的断开检测机制,除非你能找到可靠的方式验证连接活跃度。

结合你目前的代码,你已经有了方案一的基础。根据你对实时性的要求,决定是否要引入前端心跳来增强它。检查 Reverb 的文档和社区,看看是否有未被广泛宣传的底层事件或 Webhook 功能可以利用。