运行环境:Nginx+PHP8.2+Swoole5.x拓展
程序环境:ThinkPHP8 + think-swoole4.0
首先吐槽ThinkPHP的think-swoole的文档,我直说两个字:破旧! 再加多两个字:垃圾文档!
因为文档太过于垃圾,所以走了很多坑,终于在测试机器里面跑起来了,测试单发消息也没问题,但是我想测试群发消息的时候,就发现貌似没有一个可以获取在线所有客户端的方法来进行遍历群发的。
于是花了两天开启了寻求在ThinkPHP8+think-swoole4.0下的群发之路。进行了各种尝试后,终于有点眉目。
网上有资料说可以使用 websocket 的 broadcast() 方法就是发送广播消息。TM要看版本啊,这是 think-swoole3.0 的内置方法,think-swoole4.0 下面可没有发送广播消息的方法!所以这个方法行不通。
看了think-swoole4.0的源码,没看到有在think-swoole4.0中可以获取所有在线用户的上下文,只有单发,没有群发!也没有看到有获取到在线所有用户的上下文。
努力看了一下Swoole文档,发现Swoole\Server 类有一个getClientList()方法,可以遍历当前 Server 所有的客户端连接,而且Server::getClientList 方法是基于共享内存的,不存在 IOWait,遍历的速度很快。另外 getClientList 会返回所有 TCP 连接,而不仅仅是当前 Worker 进程的 TCP 连接。
所以如果是用Swoole\Server 类创建的服务,用getClientList()方法。值得注意的是:
getClientList
仅可用于TCP
客户端,UDP
服务器需要自行保存客户端信息- SWOOLE_BASE 模式下只能获取当前进程的连接
此外,Swoole推荐使用 Server::$connections 迭代器来遍历连接。文档是这么说的:
示例:
foreach ($server->connections as $fd) {
var_dump($fd);
}
echo "当前服务器共有 " . count($server->connections) . " 个连接\n";
下面是文档中群发的代码示例:
$server->on('request', function (Swoole\Http\Request $request, Swoole\Http\Response $response) {
global $server;//调用外部的server
// $server->connections 遍历所有websocket连接用户的fd,给所有用户推送
foreach ($server->connections as $fd) {
// 需要先判断是否是正确的websocket连接,否则有可能会push失败
if ($server->isEstablished($fd)) {
$server->push($fd, $request->get['message']);
}
}
});
但是,我要说但是了……TM
Swoole\Server
此节包含 Swoole\Server 类的全部方法、属性、配置项以及所有的事件。Swoole\Server 类是所有异步风格服务器的基类,后面章节的 Swoole\Http\Server、Swoole\WebSocket\Server、Swoole\Redis\Server 都是它的子类。
重点在上面,Swoole\Server是异步风格服务器,是异步风格的服务端
而在think-swoole中,用的是 Swoole\Coroutine\Http\Server创建的服务,这个是协程风格的, Swoole\Coroutine\Http\Server 没有getClientList() 方法。
别不信,我试过了,Swoole\Coroutine\Http\Server下使用getClientList() 报错:
PHP Fatal error: Uncaught Error: Call to undefined method Swoole\Coroutine\Http\Server::getClientList()
Swoole\Coroutine\Http\Server下用connections迭代器报错如下:
Undefined property: Swoole\Coroutine\Http\Server::$connections
在协程风格下,swoole文档关于群发的是这么说的,红色就是关键实现群发的代码:
也就是说,只要能获取到上下文的Response 对象,就可以有办法使用这个方法群发?这个可能是一个思路,上代码是了一下,的确是可以发多端。但想到think-swoole中,用的是协程风格 Swoole\Coroutine\Http\Server编写的服务,那么可能会面临着多线程带来的问题。也及时说,可能遍历后只能得到那个程序执行的线程的客户端。
由于 PHP
语言不支持多线程,因此 Swoole
使用多进程模式,在多进程模式下存在进程内存隔离,在工作进程内修改 global
全局变量和超全局变量时,在其他进程是无效的。
Swoole
服务器底层会创建多个 Worker
进程,在 var_dump($fds)
打印出来的值,只有部分连接的 fd
。
所以,上面这个 使用global
全局变量的方式,也只能在当前进程下有效。
难道是说,如果是协程风格多进程模式下,是没办法遍历到所有的在线客户端的?
对此的解决方案,貌似就只能是使用外部存储服务:
- 数据库,如:
MySQL
、MongoDB
- 缓存服务器,如:
Redis
、Memcache
- 磁盘文件,多进程并发读写时需要加锁
在Swoole的文档中看到,Swoole推荐使用共享内存来保存数据,Swoole\Table
是一个基于共享内存和锁实现的超高性能,并发数据结构。用于解决多进程 / 多线程数据共享和同步加锁问题。Table
的内存容量不受 PHP
的 memory_limit
控制
Table看起来挺不错的,不过有一点得注意:
-
由于
Table
底层是建立在共享内存之上,所以无法动态扩容。所以$size
必须在创建前自己计算设置好,Table
能存储的最大行数与$size
正相关,但不完全一致,如$size
为1024
实际可存储的行数小于1024
,如果$size
过大,机器内存不足Table
会创建失败。
所以我们这次用的是redis。
所幸,think-swoole 驱动Swoole时,也内置了Table
和Redis两种方式来记录fd和room的信息。我把think-swoole的配置config/swoole.php的配置中的 websocket.room.types 的配置,改成redis
用到的时候,就读取这个表里面的记录,就可以知道所有在线用户,遍历单发,就是群发了。
redis遍历的方式如下:
$redis = new \Redis();
$result = $redis->connect('127.0.0.1', 6379);
var_dump($result); //结果:bool(true)
// 要搜索的前缀 {$this->prefix}{$table}:{$key}
$prefix = 'swoole:room:';
// 使用keys命令查找所有以$prefix开头的key
$keys = $redis->keys($prefix . '*');
// 如果需要获取这些key的值
foreach ($keys as $key) {
$value = $redis->smembers($key);
print_r("Key: $key :" . PHP_EOL);
var_dump($value);
}
这里拿到的就是所有的信息,输出后类似下面大家结果:
bool(true)
Key: swoole:room:fds:2.43 :
array(1) {
[0]=>
string(4) "2.43"
}
Key: swoole:room:rooms:2.43 :
array(1) {
[0]=>
string(4) "2.43"
}
Key: swoole:room:fds:2.36 :
array(1) {
[0]=>
string(4) "2.36"
}
Key: swoole:room:rooms:2.36 :
array(1) {
[0]=>
string(4) "2.36"
}
不过还得后续处理一下,就能拿到所有fd和room了。至于怎么样处理,请自行处理吧。
拿到fd和room之后,就可以用think-swool中自带的
think\swoole\Websocket的 ->to(fd和room的ID数据数组)->push(数据)
就可以进行群发了。
否则的话,大家还有方法共享一下?
总结:
在ThinkPHP8 + think-swoole4.0 使用 swoole 搭建websocket服务器进行群发所有客户端,目前我能想到的方法就是使用外部存储服务, think-swoole4.0 可考虑采用遍历redis记录来获取所有客户端的fd,在调用websocket的push方法来进行发送。
要么,就不要使用 think-swoole4.0 ,直接用Swoole的原生代码来实现,就用Swoole的文档的示例都能跑起来,看起来更直观,且没那么复杂。
最后补充一下更加简便的实现方式:
一个群友看到这篇文章后,说其实没有那么复杂,说只要用户加入room,同一个room批量发送即可。
虽然我走了一些坑,但是测试后发现的确可以:
1、在open事件中,先join到一个群发的房间,例如:
$this->websocket->join('broadcast');
2、在close事件中,离开这个房间,例如:
$this->websocket->leave('broadcast'); //离开广播房间
3、在你需要响应的事件中,使用:
$this->websocket->to('broadcast')->emit("事件",数据);
或者
$this->websocket->to('broadcast')->push(数据);
就可以把消息群发到这个房间里,这样,加入到这个房间里面的所有用户,就能收到群发的消息了
其实……就是这么简单……真的是吃了没有文档的苦啊!!!!
最后再吐槽一下,看来ThinkPHP口口声声喊的“大道至简”看来也只是一句口号。