使用WebSocket与PHP通信的思路梳理(一)

作者: 乘风御上者 分类: JavaScript,PHP 发布时间: 2020-05-11 14:55

使用PHP的socket扩展与WebSocket通信,在讲解之前先大致的过一遍一些比较重要的socket扩展函数。

这些函数除错误相关函数(socket_last_error/socket_strerror)之外,在执行错误时都将返回FALSE,可作为错误判断。

(该实验在Linux环境下完成)

部分函数参数的使用,请看下相关文档即可:

socket_create(int $domain, int $type, int $protocol): resource
该函数创建并返回一个套接字,也称作一个通讯节点。一个典型的网络连接由2个套接字构成,一个运行在客户端,另一个运行在服务器端。

socket_bind(resource $socket, string $address [, int $port = 0]): bool
该函数绑定一个地址(和端口)到Socket套接字上。

socket_listen(resource $socket [, int $backlog = 0 ]): bool
该函数监听Socket套接字上的连接。

socket_accept(resource $socket): resource
该函数阻塞程序的执行,等待客户端的连接,当有客户端连接时,该函数返回客户端Socket套接字。

socket_close(resource $socket): void
该函数关闭Socket套接字资源。

socket_set_option(resource $socket, int $level, int $optname, mixed $optval): bool
在指定的协议级别上,将optname参数指定的选项设置为套接字的optval参数所指向的值。

socket_last_error([resource $socket]): int
如果将Socket套接字资源传递给此函数,则返回在此套接字上发生的最后一个错误。如果省略套接字资源,则返回最后一个失败的套接字函数的错误代码。后者对于socket_create()和socket_select()这样的函数尤其有用,因为它们在出现故障时不会返回套接字,而socket_select()可能由于没有直接绑定到特定的套接字而失败。错误代码适合提供给socket_strerror(),后者返回一个描述给定错误代码的字符串。

socket_strerror(int $errno): string
将socket_last_error()返回的套接字错误代码作为其errno参数,并返回相应的解释文本。

socket_read(resource $socket, int $length [, int $type = PHP_BINARY_READ]): string
读取由socket_create()或socket_accept()函数创建的套接字资源内容

socket_recv(resource $socket, string &$buf, int $len, int $flags): int
该函数用于从已连接的socket中接收数据。该函数从Socket套接字中接受长度为 len 字节的数据,并保存在 buf 中 。除此之外,可以设定一个或多个 flags 来控制函数的具体行为。

socket_write(resource $socket, string $buffer [, int $length = 0]): int
该函数从给定的缓冲区写入Socket套接字。

socket_set_nonblock(resource $socket): bool
该函数设置Socket套接字为非阻塞模式。

socket_set_block(resource $socket): bool
该函数用于将Socket套接字设置为阻塞模式,只有一个必选参数,返回布尔值。它可以将非阻塞模式的Socket套接字转换为阻塞模式。

socket_select()

通信开始

先说下思路,想要实现客户端与服务器通信,首先要有个开启的Socket服务器,随时等待客户端的通信连接请求。两端连接通过三次握手完成,而断开连接则是四次握手完成(也叫四次分手)。

至于怎么三次握手、四次分手,我怕误人子弟,也觉得phper不一定想听。真想弄明白请先移步到百度。

在PHP的技术范畴内,我们主要讲解Socket服务器端的内容,至于客户端则很简单,几行JS代码就能搞定(保存到HTML页面中):

let ws = new WebSocket("ws://ip:端口")
if (ws.readyState <= 0) {
    console.log('连接服务器失败!', ws)
}

// 当成功连接到服务器后执行
ws.onopen = function (event) {
    console.log('连接服务器成功!', event)
}

// 当该连接关闭后执行
ws.onclose = function (event) {
    console.log('连接关闭!')
}

// 当连接出错
ws.onerror = function (event) {
    console.log('连接出错!')
}

// 接收到服务器返回的信息
ws.onmessage = function (event) {
    console.log('服务器返回信息:' + event.data)
}

// 什么时候发送消息,自己决定
function toSend () {
    let txt = '发送内容自己搞定';
    ws.send(txt);
}

// 配置正确的IP与端口后,没打开一个该页面就是一次客户端请求

客户端已准备好,怎么开启Socket服务端呢?其实也简单:

// 创建TCP类型的Socket服务
// 该扩展不能直接创建ws协议的Socket服务器,所以后面真正通信前需要升级协议
$server = socket_create(AF_INET, SOCK_STREAM, SOL_TCP);

// 配置一下(接收所有的数据包)
$res = socket_set_option($server, SOL_SOCKET, SO_REUSEADDR, 1);

// 绑定IP与端口号
// IP地址若是0.0.0.0则可以是任意客户端连接,若是127.0.0.1则只能是内网客户端连接
$res = socket_bind($server, $address, $port);

// 监听该通道
$res = socket_listen($server);

// 此时Socket服务器已经创建完成
// 需要注意: 以上每一步都有可能失败,所以严谨写法每一步都需要对结果做判断
// stream_socket_server()可以一行搞定,但不在讲解范围,感兴趣的可以自行百度

此时Socket服务器已经创建,产生的$server就是服务器端的Socket资源(套接字),每当一个客户端连接都会额外产生一个Socket资源,但是与服务器端的Socket要有区分(服务端Socket始终就一个,客户端有任意多个且能自由增加和销毁)。

Socket服务创建后开始处理客户端请求,思路:

  1. 要保持服务器端运行,随时处理客户端请求,则需要一个循环操作while(true)。
  2. 设置一个接待程序(socket_accept()),随时为客户端服务。该接待是阻塞形式,没有客户请求时,会阻塞程序的继续执行(所以第一步的while死循环不会出问题)。当有客户端请求时,接待会产生一个客户端的Socket资源,这也是与不同客户的唯一识别。
  3. 服务端协议是TCP,客户端协议是WS。语言不通,服务端升级到WS协议(升级方式看代码)。
  4. 协议一致后,使用阻塞函数随时监听客户端请求内容并做出回应。

写代码之前先说几个问题:

  1. 阻塞:进程或是线程执行到这些函数时必须等待某个事件发生,如果事件没有发生,进程或线程就被阻塞,函数不能立即返回。
  2. 非阻塞:进程或线程执行此函数时不必非要等待事件的发生,一旦执行肯定返回,以返回值的不同来反映函数的执行情况。如果事件发生则与阻塞方式相同,若事件没有发生则返回一个代码来告知事件未发生,而进程或线程继续执行,所以效率高。
  3. socket_accept()、socket_read()、socket_recv()默认都会阻塞,当然可以用socket_set_nonblock()、socket_set_block()设置。
  4. socket_accept()会一直阻塞等待客户端请求,必须是第一次请求连接的新客户端才会触发该函数。
  5. socket_read()socket_recv()都是阻塞方式读取缓冲数据,可以指定每次读取的数据长度。需要注意,若执行该函数读取缓冲区数据,并没有全部读完,此时客户端接收不到socket_write()发出的内容。

// 设置死循环防止服务器关闭
while (true) {

    // 阻塞循环等待客户端的请求
    $client = socket_accept($server);

    // 只有新的客户端请求连接时,才会继续执行
    // 而且新客户端第一次请求带协议头信息
    // 读取客户端信息,此时客户端信息只有请求头信息,设置1024防止一次读取不完
    $buffer = socket_read($client, 1024);

    // 协议不对等,无法交流,升级协议
    $fer = substr($buffer, strpos($buffer, 'Sec-WebSocket-Key:') + 18);
    $key = trim(substr($fer, 0, strpos($fer, "\r\n")));
    $enKey = base64_encode(sha1($key . "258EAFA5-E914-47DA-95CA-C5AB0DC85B11", true));
    // 按照协议组合返回头信息
    $header = "HTTP/1.1 101 Switching Protocols\r\n";
    $header .= "Upgrade: websocket\r\n";
    $header .= "Sec-WebSocket-Version: 13\r\n";
    $header .= "Connection: Upgrade\r\n";
    $header .= "Sec-WebSocket-Accept: " . $enKey . "\r\n\r\n";
    $res = socket_write($client, $header, strlen($header));

    // 此时协议对等,阻塞等待接收客户端发送的信息
    // 此处1024可能不够,可以循环读取。
    $buffer = socket_read($client, 1024);
    // 要使用客户端信息,需要先解码, 下方附带该函数
    $buffer = deCode($buffer);

    // 若上面读取缓冲区数据完成,此处向客户端做出的回应才会送到
    $msg = '直接返回数据是不行的,需要对该数据先编码,客户端才可识别';
    $msg = enCode($msg);
    $res = socket_write($client, $msg, strlen($msg));

    // 关闭该客户端资源(断开连接)
    socket_close($client);
}

// 关闭服务端资源(停止服务)
// 思维上应该这么写,但是因为此处永远执行不到,所以写了也没用
// 只能使用Linux命令杀死服务端进程
socket_close($server);

缓冲区数据编码与解码的函数:

// 别问我编码解码的原理,读一下代码,自己琢磨(百度)。
// 编码
function enCode($msg){
    $frame = [];
    $frame[0] = '81';
    $len = strlen($msg);
    if ($len < 126) {
        $frame[1] = $len < 16 ? '0' . dechex($len) : dechex($len);
    } else if ($len < 65025) {
        $s = dechex($len);
        $frame[1] = '7e' . str_repeat('0', 4 - strlen($s)) . $s;
    } else {
        $s = dechex($len);
        $frame[1] = '7f' . str_repeat('0', 16 - strlen($s)) . $s;
    }
    $frame[2] = ordHex($msg);
    $data = implode('', $frame);
    return pack("H*", $data);
}

// 解码
function deCode($buffer){
    $decoded = '';
    $len = !empty($buffer[1]) ? (ord($buffer[1]) & 127) : 0;
    if ($len === 126) {
        $masks = substr($buffer, 4, 4);
        $data = substr($buffer, 8);
    } else if ($len === 127) {
        $masks = substr($buffer, 10, 4);
        $data = substr($buffer, 14);
    } else {
        $masks = substr($buffer, 2, 4);
        $data = substr($buffer, 6);
    }
    for ($index = 0; $index < strlen($data); $index++) {
        $decoded .= $data[$index] ^ $masks[$index % 4];
    }
    return $decoded;
}

// 进制转换
function ordHex($data){
    $msg = '';
    $l = strlen($data);
    for ($i = 0; $i < $l; $i++) {
        $msg .= dechex(ord($data[$i]));
    }
    return $msg;
}

到此将PHP代码在Linux的命令行执行:

// 执行PHP代码开启服务器
// Ctrl + Z 即可停止该服务
php 文件.php

// 停止Socket服务后需要手动关闭该进程
// 查看进程
lsof -i tcp:12345

// 杀死进程
kill -9 进程ID

打开一个客户端,正常的话,应该显示连接服务器成功。此刻服务器程序执行完第一个socket_write(),也就是升级到一致的ws协议。在第二个socket_read()阻塞等待用户发送消息。

此时可以直接F12打开谷歌调试的Console, 在此处发送一条消息:ws.send(‘消息内容’)。服务端返回消息,并释放客户端Socket资源,前端显示已断开连接。

该服务器功能非常简单,而且每个客户端只能发送一次消息,服务器回应之后该连接被断开。功能非常简陋,对于想了解Socket通信的道友算是个起步练习。

文学功底太烂,描述问题不全面,我随意一写,你随便一看!再写一篇继续完善。

如果觉得我的文章对您有用,请随意赞赏。您的支持将鼓励我继续创作!

发表回复