为何 Hyperf 能够在两个端口上监听 WebSocket 连接?
源码角度来看,在配置了多个 Servers 时,实际上,只启动了一个 Server
注:我之前接触的代码都是启动一个服务绑定一个端口,之前也看过 swoole 扩展的文档,但是没留意服务和监听端口也是分离的,这启发了我一种思维,代码凡是能继续拆分的,就继续拆分,这样代码就会有更多的灵活,每个功能都能进行扩展,将服务和端口进行拆分之后,就可以在一个 Server 绑定多个 Port,每个 Port 又能有独立的事件。
/*** @param Port[] $servers* @return Port[]*/
protected function sortServers(array $servers): array
{$sortServers = [];foreach ($servers as $server) {switch ($server->getType()) {case ServerInterface::SERVER_HTTP:$this->enableHttpServer = true;if (! $this->enableWebsocketServer) {array_unshift($sortServers, $server);} else {$sortServers[] = $server;}break;case ServerInterface::SERVER_WEBSOCKET:$this->enableWebsocketServer = true;array_unshift($sortServers, $server);break;default:$sortServers[] = $server;break;}}return $sortServers;
}
从源码看,排在第一个的服务配置,会被创建服务,之后都是增加监听
protected function initServers(ServerConfig $config)
{$servers = $this->sortServers($config->getServers());foreach ($servers as $server) {$name = $server->getName();$type = $server->getType();$host = $server->getHost();$port = $server->getPort();$sockType = $server->getSockType();$callbacks = $server->getCallbacks();if (! $this->server instanceof SwooleServer) {$this->server = $this->makeServer($type, $host, $port, $config->getMode(), $sockType);$callbacks = array_replace($this->defaultCallbacks(), $config->getCallbacks(), $callbacks);$this->registerSwooleEvents($this->server, $callbacks, $name);$this->server->set(array_replace($config->getSettings(), $server->getSettings()));ServerManager::add($name, [$type, current($this->server->ports)]);if (class_exists(BeforeMainServerStart::class)) {// Trigger BeforeMainServerStart event, this event only trigger once before main server start.$this->eventDispatcher->dispatch(new BeforeMainServerStart($this->server, $config->toArray()));}} else {/** @var bool|\Swoole\Server\Port $slaveServer */$slaveServer = $this->server->addlistener($host, $port, $sockType);if (! $slaveServer) {throw new \RuntimeException("Failed to listen server port [{$host}:{$port}]");}$server->getSettings() && $slaveServer->set(array_replace($config->getSettings(), $server->getSettings()));$this->registerSwooleEvents($slaveServer, $callbacks, $name);ServerManager::add($name, [$type, $slaveServer]);}// Trigger beforeStart event.if (isset($callbacks[Event::ON_BEFORE_START])) {[$class, $method] = $callbacks[Event::ON_BEFORE_START];if ($this->container->has($class)) {$this->container->get($class)->{$method}();}}if (class_exists(BeforeServerStart::class)) {// Trigger BeforeServerStart event.$this->eventDispatcher->dispatch(new BeforeServerStart($name));}}
}
从makeServer函数来看,如果服务中有SERVER_WEBSOCKET,则这个会被作为主服务启动,new SwooleWebSocketServer
protected function makeServer(int $type, string $host, int $port, int $mode, int $sockType): SwooleServer
{switch ($type) {case ServerInterface::SERVER_HTTP:return new SwooleHttpServer($host, $port, $mode, $sockType);case ServerInterface::SERVER_WEBSOCKET:return new SwooleWebSocketServer($host, $port, $mode, $sockType);case ServerInterface::SERVER_BASE:return new SwooleServer($host, $port, $mode, $sockType);}throw new RuntimeException('Server type is invalid.');
}
$this->registerSwooleEvents($this->server, $callbacks, $name); 这句代码会将 Websocket 的各种事件都注册进去,于是主服务器拥有 websocket 的各种事件,而后 http 服务器挂载 9501 端口上,绑定了 onrequest 事件,但是如果有 websocket 连接9501 端口上时,默认该服务器是自动开启 websocket 自动升级的,又因为监听 9501 端口绑定的主服务器是 WebSocketServer,因此,WebSocketServer 默认的 onmessage,onopen事件就会被拿来用。
推测,如果开启 9503 WebSocket服务器,那么理论上用 WebSocket连接 9501 端口,应该就是连接的 9503 的回调事件。如果不想让 http 监听端口自动开启 websocket 协议,则将open_websocket_protocol=false
<?php
return [
// 这里省略了该文件的其它配置
'servers' => [['name' => 'http','type' => Server::SERVER_HTTP,'host' => '0.0.0.0','port' => 9501,'sock_type' => SWOOLE_SOCK_TCP,'callbacks' => [Event::ON_REQUEST => [Hyperf\HttpServer\Server::class,'onRequest'],],'settings' => ['open_websocket_protocol' => false,]],]];
关于很多SWOOLE中出现的常量来看,这些东西在执行脚本时,会被自动设置好,通过实际代码运行发现
define('SWOOLE_HTTP2_ERROR_COMPRESSION_ERROR', 9);
define('SWOOLE_HTTP2_ERROR_CONNECT_ERROR', 10);
define('SWOOLE_HTTP2_ERROR_ENHANCE_YOUR_CALM', 11);
define('SWOOLE_HTTP2_ERROR_INADEQUATE_SECURITY', 12);
define('SWOOLE_BASE', 1);
define('SWOOLE_PROCESS', 2);
define('SWOOLE_IPC_UNSOCK', 1);
define('SWOOLE_IPC_MSGQUEUE', 2);
define('SWOOLE_IPC_PREEMPTIVE', 3);
以下,随便设置的一个test.php中输出SWOOLE_BASE,都能输出1,我之前都以为这些常量是运行时设置的呢,看来这种理解是错误的。
<?php
echo SWOOLE_BASE."\n";
echo "hello\n";// 输出
1
hello
Hyperf-skeleton 给的默认配置就是进程模式,这个竟然没有发现,这样就比较明确了,使用的都是PROCESS模式,那么在websocket连接时,所有的连接都是由Manager来控制
SWOOLE_PRECESS 和 SWOOLE_BASE 两种模式
Server 的两种运行模式介绍
在 Swoole\Server 构造函数的第三个参数,可以填 2 个常量值 -- SWOOLE_BASE或 SWOOLE_PROCESS,下面将分别介绍这两个模式的区别以及优缺点
SWOOLE_PROCESS
SWOOLE_PROCESS 模式的 Server 所有客户端的 TCP 连接都是和主进程建立的,内部实现比较复杂,用了大量的进程间通信、进程管理机制。适合业务逻辑非常复杂的场景。Swoole 提供了完善的进程管理、内存保护机制。 在业务逻辑非常复杂的情况下,也可以长期稳定运行。
Swoole 在 Reactor线程中提供了 Buffer 的功能,可以应对大量慢速连接和逐字节的恶意客户端。
进程模式的优点:
-
连接与数据请求发送是分离的,不会因为某些连接数据量大某些连接数据量小导致 Worker 进程不均衡
-
Worker 进程发生致命错误时,连接并不会被切断
-
可实现单连接并发,仅保持少量 TCP 连接,请求可以并发地在多个 Worker 进程中处理
进程模式的缺点:
-
存在 2 次 IPC 的开销,master 进程与 worker 进程需要使用 unixSocket进行通信
-
SWOOLE_PROCESS 不支持 PHP ZTS,在这种情况下只能使用 SWOOLE_BASE 或者设置 single_thread为 true
SWOOLE_BASE
SWOOLE_BASE 这种模式就是传统的异步非阻塞 Server。与 Nginx 和 Node.js 等程序是完全一致的。
worker_num参数对于 BASE 模式仍然有效,会启动多个 Worker 进程。
当有 TCP 连接请求进来的时候,所有的 Worker 进程去争抢这一个连接,并最终会有一个 worker 进程成功直接和客户端建立 TCP 连接,之后这个连接的所有数据收发直接和这个 worker 通讯,不经过主进程的 Reactor 线程转发。
-
BASE 模式下没有 Master 进程的角色,只有 Manager进程的角色。
-
每个 Worker 进程同时承担了 SWOOLE_PROCESS模式下 Reactor线程和 Worker 进程两部分职责。
-
BASE 模式下 Manager 进程是可选的,当设置了 worker_num=1,并且没有使用 Task 和 MaxRequest 特性时,底层将直接创建一个单独的 Worker 进程,不创建 Manager 进程
BASE 模式的优点:
-
BASE 模式没有 IPC 开销,性能更好
-
BASE 模式代码更简单,不容易出错
BASE 模式的缺点:
-
TCP 连接是在 Worker 进程中维持的,所以当某个 Worker 进程挂掉时,此 Worker 内的所有连接都将被关闭
-
少量 TCP 长连接无法利用到所有 Worker 进程
-
TCP 连接与 Worker 是绑定的,长连接应用中某些连接的数据量大,这些连接所在的 Worker 进程负载会非常高。但某些连接数据量小,所以在 Worker 进程的负载会非常低,不同的 Worker 进程无法实现均衡。
-
如果回调函数中有阻塞操作会导致 Server 退化为同步模式,此时容易导致 TCP 的 backlog队列塞满问题。
BASE 模式的适用场景:
如果客户端连接之间不需要交互,可以使用 BASE 模式。如 Memcache、HTTP 服务器等。
BASE 模式的限制:
在 BASE 模式下,Server 方法除了 send和 close以外,其他的方法都不支持跨进程执行。
Reactor 线程和 Worker 进程
Reactor 线程
-
Reactor 线程是在 Master 进程中创建的线程
-
负责维护客户端 TCP 连接、处理网络 IO、处理协议、收发数据
-
不执行任何 PHP 代码
-
将 TCP 客户端发来的数据缓冲、拼接、拆分成完整的一个请求数据包
Worker 进程
-
接受由 Reactor 线程投递的请求数据包,并执行 PHP 回调函数处理数据
-
生成响应数据并发给 Reactor 线程,由 Reactor 线程发送给 TCP 客户端
-
可以是异步非阻塞模式,也可以是同步阻塞模式
-
Worker 以多进程的方式运行
他们之间的关系可以理解为 Reactor 就是 nginx,Worker 就是 PHP-FPM。Reactor 线程异步并行地处理网络请求,然后再转发给 Worker 进程中去处理。Reactor 和 Worker 间通过 unixSocket进行通信。
在 PHP-FPM 的应用中,经常会将一个任务异步投递到 Redis 等队列中,并在后台启动一些 PHP 进程异步地处理这些任务。Swoole 提供的 TaskWorker 是一套更完整的方案,将任务的投递、队列、PHP 任务处理进程管理合为一体。通过底层提供的 API 可以非常简单地实现异步任务的处理。另外 TaskWorker 还可以在任务执行完成后,再返回一个结果反馈到 Worker。
Swoole 的 Reactor、Worker、TaskWorker 之间可以紧密的结合起来,提供更高级的使用方式。
一个更通俗的比喻,假设 Server 就是一个工厂,那 Reactor 就是销售,接受客户订单。而 Worker 就是工人,当销售接到订单后,Worker 去工作生产出客户要的东西。而 TaskWorker 可以理解为行政人员,可以帮助 Worker 干些杂事,让 Worker 专心工作。
结论
-
SWOOLE_PRECESS 模式下,Websocket 的连接对象都是由 Server 来控制,创建一个 Reactor 后,将该连接交付给 Reactor 来管理,Reactor 会将请求分配给 Worker 处理,Worker 处理完后,再把消息发给Server,Server 将消息压入队列等具体的 Reactor 发送出去
-
hyperf 中有一处进程间通信,目前还不清楚,这里为何要向其他进程求助,以及其他进程监听到消息后会不会多发,等问了 hyperf 开发者们再补充
-
也就是说分布式 websocket 必须借助进程间通信这种方式来实现,否则分布式服务器无法给聊天室内的所有人发消息