4

使用 PHP 的 Ratchet 框架搭建 WebSocket 服务端

 2 years ago
source link: https://akarin.dev/2020/01/10/php-websocket-server/
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
使用 PHP 的 Ratchet 框架搭建 WebSocket 服务端
✨小透明・宸✨
存在感消失的地方|ω•`)
brightness_4 深色模式 keyboard_arrow_down
根据系统主题切换
insert_chart 访问统计 keyboard_arrow_down
站点访问量 48792
站点访客数 25323
页面访问量 148
使用 PHP 的 Ratchet 框架搭建 WebSocket 服务端
✨小透明・宸✨
2020-01-10 22:27:32

封面图:Pixiv ID: 78461776 「お団子」 by tama2396

几个月前,小透明尝试过使用短轮询、长轮询、Server-Sent Events 三种不同的方式实现了一个简单的留言板。

在查找相关资料时,小透明也已经知道了 HTML5 新增的 WebSocket 协议是在客户端、服务端之间实时发送数据的最高效的方式。遗憾的是由于当时姿势水平还不够,小透明当时并没有对 WebSocket 进行哪怕是再深入一点点的研究,最后还是在某个小项目中选用了短轮询 (っ ̯ -。)

U20c8fd4f70b04f308fb272150165904cZ.png

虽然没有人在意屏幕上的消息为什么每隔一秒才会刷新一次,每秒数百个的 AJAX 请求也没有对服务器造成严重的压力,但是总觉得还是有点不对劲……

大量的网络流量被用于请求头和响应头,而真正有价值的消息主体只占了极小一部分,这简直是对网络资源的极大浪费!(╯‵□′)╯︵┻━┻
——沃兹基・硕德

WebSocket 是建立在 TCP 连接上进行全双工通讯的协议,然鹅小透明当时才刚开始学计算机网络,不知道数据帧一般是什么结构、不知道 TCP 协议是什么、不知道什么是套接字、不知道什么是 bind/listen/accept/……然后查资料的时候又看到这种东西,就直接被劝退了:

H3b466a98774143e496b9cc0594714d9au.png

自己查找资料造一个 WebSocket 服务端的轮子需要涉及到套接字编程、数据帧解析之类的知识。即使是现在学完了计算机网络,完全实现仍然是非常困难的

后来在期末复习周摸鱼的时候,小透明找到了 Ratchet 这个可以在 PHP 中搭建 WebSocket 服务端的框架,于是开始尝试了解它的使用方法~

这篇的重点是如何用 PHP 搭建 WebSocket 的服务端,至于客户端就是浏览器运行 JS 代码创建的 WebSocket 对象。所有的主流浏览器(甚至还包括 IE 10!)都支持使用 WebSocket 协议。

这次仍然是使用一个十分简单、没有样式的留言板页面进行演示~

  • 发送消息:在文本框 message 中输入内容,点击按钮 send 发送。
  • 接收消息:服务端将收到的消息纯文本发送给所有客户端,显示在 timeline 中。
H781ccef3ab864e00b0630aae8681298aD.png
<!DOCTYPE html>
<html>
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <meta http-equiv="X-UA-Compatible" content="ie=edge">
</head>
<body>
    <input id="message" type="text">
    <button id="send">Send</button>
    <hr>
    <div id="timeline"></div>

    <script>
        // 设定URL建立WebSocket连接
        var conn = new WebSocket('ws://localhost:25252/chat');
        // 成功建立连接
        conn.onopen = function (e) {
            conn.send('Hello!');
        };
        // 收到消息
        conn.onmessage = function (e) {
            document.getElementById('timeline').innerText += e.data;
            document.getElementById('timeline').innerHTML += '<br>';
        };
        // 连接出错
        conn.onerror = function (e) {
            alert('WebSocket connection error.');
        }
        // 断开连接
        conn.onclose = function (e) {
            alert('WebSocket connection closed.');
        }
        // 发送消息
        document.getElementById('send').onclick = function () {
            if (!document.getElementById('message').value) return;
            conn.send(document.getElementById('message').value);
            document.getElementById('message').value = '';
        }
    </script>
</body>
</html>

编写服务端代码

开始编写服务端之前,需要使用 Composer 安装 Ratchet 框架:

composer require cboden/ratchet

官方网站上提供了一个简单的例子可以作为参考。首先对框架进行导入:

use Ratchet\MessageComponentInterface;
use Ratchet\ConnectionInterface;

require_once __DIR__ . '/vendor/autoload.php';

整个服务端“应用”可以当成是一个基于 Ratchet\MessageComponentInterface 的类,和前端代码类似,只要对建立连接 onOpen、收到消息 onMessage、连接出错 onError、断开连接 onClose 这四个事件编写函数即可。

与服务端建立连接的每个客户端都被视为一个对象,这些客户端将保存在 $clients 中。通过每个客户端对象的 send($msg) 方法可以实现服务端向某一个客户端发送消息,还可以遍历 $clients 实现消息广播。

class MyChat implements MessageComponentInterface {
    // 用于保存客户端信息
    protected $clients;

    public function __construct() {
        // 新建SplObjectStorage用于保存对象
        $this->clients = new \SplObjectStorage;
    }

    // $conn是要与服务端建立连接的客户端对象
    public function onOpen(ConnectionInterface $conn) {
        // 向$clients添加建立连接的客户端
        $this->clients->attach($conn);
        // 输出提示,resourceId可以用于标识客户端
        echo "New connection! Id: {$conn->resourceId}\n";
    }

    public function onClose(ConnectionInterface $conn) {
        // 将关闭连接的客户端从$clients中删除
        $this->clients->detach($conn);
        echo "Connection {$conn->resourceId} has disconnected\n";
    }

    public function onError(ConnectionInterface $conn, \Exception $e) {
        $conn->close();
    }

    // $msg是客户端发送的数据
    public function onMessage(ConnectionInterface $from, $msg) {
        // 遍历所有客户端,转发消息
        foreach ($this->clients as $client) {
            $client->send("Client {$from->resourceId}: {$msg}");
            // 也可以选择不转发给发送者自己
            // if ($from !== $client) {
            //     $client->send("Client {$from->resourceId}: {$msg}");
            // }
        }
        echo "Client {$from->resourceId}: {$msg}\n";
    }
}

最后新建一个类 Ratchet\App,设定端口,将上面的应用实例化并指定地址:

echo "Server is running...\n";

// 在本机的25252端口启动Ratchet应用
$app = new Ratchet\App('localhost', 25252);
// Ratchet自带的测试应用,原样返回客户端发送的消息
// ws://localhost:25252/echo
$app->route('/echo', new Ratchet\Server\EchoServer, array('*'));
// 上面的留言板应用
// ws://localhost:25252/chat
$app->route('/chat', new MyChat, array('*'));
$app->run();

将以上代码保存为 chat.php,然后使用命令行 php chat.php 启动服务端程序。打开网页,浏览器就可以和服务端建立 WebSocket 连接,允许在留言板上发送消息,与此同时命令行也会输出建立/中断连接的提示。按下 Ctrl+C 就可以终止服务端程序的运行。

客户端和服务端通过 WebSocket 协议互相发送消息,可以看出所有客户端几乎同时收到了来自服务端的消息,客户端无须像传统的轮询一样频繁发送 AJAX 请求,效率较高。

使用 WSS 协议建立加密连接

就像 HTTP 协议和 HTTPS 协议一样,WebSocket 的 WS 协议也有对应的安全加密传输协议 WSS,而且在使用了 HTTPS 的页面上是无法使用未加密的 WebSocket 连接的。一个简单的解决方法是使用 Nginx 的反向代理,客户端在公网上发送建立 WebSocket 加密连接的请求,Nginx 在内网将请求转发给相应的 WebSocket 应用。

server {
    listen 443 ssl http2;
    server_name ...;
    ssl_certificate ...;
    ssl_certificate_key ...;

    ...

    location = /chat {
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection "upgrade";
        proxy_pass http://localhost:25252/chat;

        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    }
}

使用了反向代理以后,客户端就可以使用类似于 wss://***.com/chat 的 URL 建立加密连接了,不需要添加端口号。

由于使用了反向代理,如果需要获取客户端的 IP 地址,就得将 IP 地址写在自定义请求头(上面的例子使用的是 X-Real-IPX-Forwarded-For)上。在 Ratchet 应用中也可以获取客户端建立连接时的请求头消息,例如获取 IP 就可以使用 $conn->httpRequest->getHeader('X-Real-IP')[0]

另外,Ratchet 应用默认只允许来自本机的客户端(localhost)连接,官方网站上的解释是为了安全性,使用反向代理后也可以跳过这个限制了。

Q: I can connect locally but not remotely or when I run on my server.
A: This is also another security feature! By default Ratchet binds to 127.0.0.1 which only allows connections from itself. The recommended approach is to put Ratchet behind a proxy and only that proxy (locally) will connect.
If you want to open Ratchet up (not behind a proxy) set the third parameter of App to ‘0.0.0.0’.

定时执行垃圾回收

Ratchet 使用 SplObjectStorage 保存与服务端建立连接的客户端对象,通过 attach()detach() 方法添加和删除,但这种方法存在内存泄漏问题,需要定期执行 gc_collect_cycles() 进行垃圾回收。

Ratchet 允许在创建应用时使用自定义的事件循环,在循环中自行加入定期执行的函数。

// 新建事件循环
$loop = React\EventLoop\Factory::create();
// 每分钟进行一次垃圾回收,并在控制台输出内存占用
$loop->addPeriodicTimer(60, function () {
    $memory_before = memory_get_usage();
    gc_collect_cycles();
    $memory_now = memory_get_usage();
    if ($memory_before !== $memory_now) echo "GC Collected: {$memory_before} bytes -> {$memory_now} bytes\n";
});
// 使用自定义的事件循环创建应用
$app = new Ratchet\App('localhost', 25252, '127.0.0.1', $loop);

用 AJAX 发送请求,用 WebSocket 推送消息

WebSocket 本身支持发送/接收纯文本或二进制数据,因此直接发送图片之类的东西也是没关系的~

但是有的时候又要发送文本又要发送二进制数据(比如做留言板的时候,有同时发送文本和图片的需求),这个时候就需要多折腾一下了……

常规的使用 POST 上传文件使用的 multipart/form-data 格式,实际发送的数据类似于这样:

-----------------------------18467633426500
Content-Disposition: form-data; name="smfile"; filename="untitled.png"
Content-Type: image/png

(二进制数据)
-----------------------------18467633426500
Content-Disposition: form-data; name="file_id"

0
-----------------------------18467633426500--

发送的数据被“分割线”分割为多个部分,每个部分可以是文本或二进制数据,互不影响,服务端也很容易从中解析出不同部分的数据。如果造轮子在前端用 WebSocket 发送这种格式的数据,在服务端再自己造轮子进行解析,似乎也不是不可以……那为什么不直接使用 POST 方式发送数据呢?而且还可以继续使用 PHP 的 Session 机制保存用户状态。

原来的结构是客户端直接使用 WebSocket 向服务端发送数据,现在的结构就变成了客户端先使用 POST 向服务端的 post.php 发送数据,然后 post.php 在内网对消息进行处理后再向 WebSocket 应用发送用于推送的消息(这时已经可以使用纯文本了,例如包含了图片 URL 的 JSON 格式数据),客户端与服务端建立的 WebSocket 连接只用于接收推送消息

由于客户端与服务端建立 WebSocket 连接必须经过 Nginx 的反向代理并添加上 X-Real-IP 之类的请求头,而从内网(例如 post.php)建立的连接无须经过反向代理,因此可以通过请求头判断发送给服务端的数据是否来自内网,防止客户端直接控制 WebSocket 应用推送任意消息。

Pawl 是 Ratchet 框架的一个子项目,可以在 PHP 代码中使用 WebSocket 客户端的功能。使用 Pawl 就可以实现“从内网向 WebSocket 应用发送数据”,例如在 post.php 中可以写入以下代码(未包括处理图片部分,仅演示处理使用 POST 发送的数据):

require_once __DIR__ . '/vendor/autoload.php';

session_start();
// 发送的内容不能为空
if (empty($_POST['content'])) die();
// 根据Session为新用户指定随机的ID
if (empty($_SESSION['userid'])) $_SESSION['userid'] = bin2hex(random_bytes(4));

// 要发送给服务端的数据
$send = [
    'userid' => $_SESSION['userid'],
    'content' => $_POST['content'],
    'timestamp' => time(),
];

// 向本机的 WebSocket 推送应用发送数据,这个写法有点类似于JS的Promise
// 两个函数分别是与服务端连接成功或失败后执行的函数
Ratchet\Client\connect('ws://localhost:25252/push_server')->then(
    function ($conn) {
        global $send;
        $conn->send(json_encode($send, JSON_UNESCAPED_UNICODE));
        $conn->close();
    },
    function ($e) {
        echo "Could not connect: {$e->getMessage()}\n";
        http_response_code(503);
    }
);

对应的 WebSocket 服务端代码:

public function onMessage(ConnectionInterface $from, $msg) {
    // 只接收未经过反向代理的,来自内网的客户端发送的数据
    if (!empty($from->httpRequest->getHeader('X-Real-IP'))) return;

    foreach ($this->clients as $client) {
        $client->send($msg);
    }
}

最终的效果演示:

本作品采用知识共享署名-非商业性使用-相同方式共享 4.0 国际许可协议进行许可。不允许内容农场类网站、CSDN 用户和微信公众号转载。
本文作者:✨小透明・宸✨
本文链接:https://akarin.dev/2020/01/10/php-websocket-server/


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK