深入解析WebSocket协议:从编程小白到PHP开发者的学习之路

我为什么要写这篇文章?

深入解析WebSocket协议:从编程小白到PHP开发者的学习之路

作为一个编程新手,我刚刚开始在后台工作,并认为http是一个非常酷的东西。然而,当我进一步学习和实践后,我发现它和我想象的完全不一样,并没有大家想象的那么复杂。 ,只是一个约定而已! 。后来我明白了很多,也渐渐平静了。今天我们之所以要在这里谈论websocket,而不是其他协议,是因为从某种意义上(请允许我装酷),它更具有说明性。如果你了解 websocket,那么对于你来说, http 只是一个小技巧,关于 websocket 代码,我以前是用 C 和 C++ 编写的,但是为了让 PHP 编码员(PHP 是世界上最好的语言)理解,我用PHP重新编写了它,但它是一个精简版本,这对于我们充分理解websocket非常重要。 ,了解其本质就足够了。我已将代码上传到码云(php-websocket-base-implementation)。请务必自行下载并运行。实践是检验真理的唯一标准。该代码是完全可执行的。如果您运行有任何问题,请联系我。这篇博文已经修改修改了快3天了(还好公司没有太多事情),所以我会尽量解释清楚。突然觉得写文章好累。这些都不重要。希望大家能够理解,不然我写的东西就没用了。也希望大家遇到不懂的地方可以提问。写完之后,我再次审阅了当前博文的内容,并纠正了一些拼写错误。可能有些东西漏网了。我希望你能纠正我。

文章链接

PHP实现Base64编码

准备

在阅读这篇博文之前,您需要具备一定的基础知识。下面我就为大家一一列举。我们先假装很酷吧。

套接字基础知识

基本的socket编程技巧,如果你不会,不要慌,以防万一,我给大家准备好了,请参考PHP编写基本的Socket程序

位运算

因为在一般的PHP编程中,很少会遇到进行位运算的情况,所以很自然的就会忘记和陌生。我们可以参考PHP官方文档,但是我还是想讲一下异或(^)操作,请看下面。这个结论非常重要。请记住它。重要的事情记得说三遍。

a ^ b = c  可以推导出 c ^ b = a

二进制数据和文本数据

您是否有时打开文件显示乱码,如下所示?

因为你打开的是二进制数据,所以二进制数据和文本数据最根本的区别就是数字的存储方式。例如,假设数字int a=100,我们假设它会占用4个字节的空间,但是要注意,如果将它存储为字符串,结果只需要三个字节(每一位占用一个字节)。文本软件无论如何都会将其视为文本,显示的内容就会变成乱码。因此,如果二进制文件不是由您编写的,则解析其内容是不切实际的。

大端和小端、网络字节顺序

之所以存在这种说法,是因为在不同的CPU架构下,内容中多字节数据的存储格式是不同的。这里我们用int(假设为4字节)数据m(数据为16进制格式)为例,m=0x12345678,来说明,请仔细理解,a、b、c、d的内存地址依次递增。

从上面的分析我们可以知道,当我们从网络数据中解析多字节数据时,必须要考虑字节的顺序。这就是我在这里强调的原因。

协议的诞生

Websocket协议现在被广泛使用。造成这种现象的很大原因是http协议的短期性。客户端和服务器之间的每次请求响应都需要建立TCP三向握手。这对于流量大的服务器来说是非常困难的。据说非常可怕(系统级资源),所以websocket就在这个时候诞生了。具体诞生日期不详,但真正的标准化时间是2011年,由IETF正式完成。请参阅RFC6455。

协议工作流程

这是一张图片说明了这一点,这张图片来自谷歌,

websocket协议和http协议都是应用层协议(TCP/IP之上),但是websocket协议比http协议多了一次握手(这个握手不是通常的TCP三向握手,请注意)。流程从上图可以清楚地看出,http是一种文本协议,但websocket不同。它有自己严格的字节格式,稍后将讨论。

数据包格式

看到这张图,你是否想到了TCP和IP协议?不过,这张图比较简单。稍后我会详细解释每个部分的含义。慢慢来,不要惊慌。恐慌有什么意义呢?

协议流程概述

该协议由两部分组成,握手和数据传输。握手部分并不复杂,握手是基于HTTP协议的。我们首先看一下协议的握手过程。

    GET /chat HTTP/1.1
    Host: server.example.com
    Upgrade: websocket
    Connection: Upgrade
    Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
    Origin: http://example.com
    Sec-WebSocket-Protocol: chat, superchat
    Sec-WebSocket-Version: 13

服务器响应如下:

    HTTP/1.1 101 Switching Protocols
    Upgrade: websocket
    Connection: Upgrade
    Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=
    Sec-WebSocket-Protocol: chat

无论是请求还是响应数据包,头域的顺序都没有要求。相信大家对其中一些领域都非常熟悉。即使不熟悉,在百度上也很容易搞清楚。让我们详细讨论一下Websocket。一些独特的领域:

升级领域

该字段表示需要升级到的协议。该字段为必填字段,其值必须为 websocket。

联系

该字段表示协议需要升级,也是必须的。它的值必须是Upgrade。

Sec-WebSocket-Key 和 Sec-WebSocket-Accept

该值用于客户端和服务器之间的握手,必须传递,因为服务器将使用该值执行某些转换,然后将其传回客户端。然后,客户端检查该值以查看它是否与计算出的值相同。如果不是的话,那么客户端就会认为服务器有问题,结果只能是连接失败。在介绍具体操作之前,我们还需要引入一个常量GUID,其值为258EAFA5-E914-47DA-95CA-C5AB0DC85B11。该值是固定的。任何Websocket服务器和客户端(包括浏览器)都必须定义该值。现在让我们重点关注这个领域。如果客户端传递的值为dGhlIHNhbXBsZSBub25jZQ==,那么如果用PHP代码表达的话,会是这样:

$GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11";
$sec_websocket_key = "dGhlIHNhbXBsZSBub25jZQ==";
$result = base64_encode(sha1($sec_websocket_key . $GUID));

这个计算出来的$result值最终会被发送回客户端的http响应头Sec-WebSocket-Accept。客户端将验证该值。这是客户的事。

Sec-WebSocket-版本

Websocket 协议的版本号。根据RFC6455文档,我们知道这个值必须是13,任何其他值都不起作用。这是它的描述:

请求必须包含名称为 |Sec-WebSocket-Version| 的标头字段。此标头字段的值必须为 13。 注意:尽管已发布本文档的草稿版本(-09、-10、-11 和 -12)(它们主要由编辑更改和澄清组成,而不是对线路的更改)协议),值 9、10、11 和 12 未用作 Sec-WebSocket-Version 的有效值。这些值已在 IANA 注册表中保留,但并未也不会被使用。

Sec-WebSocket-协议

选择websocket使用的子协议。该字段不是必需的,取决于具体的实现。如果您使用的是 Google Chrome,则不会传递该值。

握手阶段

解释完Websocket的主要http头字段后,我们来看一下服务器端的检查代码。这里我把示例程序中的代码贴出来供大家分析。

/**
     * @param $client_socket_handle
     * @throws Exception
     */
    private function shakehand($client_socket_handle)
    {
        if (socket_recv($client_socket_handle, $buffer, 1000, 0) < 0) {
            throw new Exception(socket_strerror(socket_last_error($this->socket_handle)));
        }
        while (1) {
            if (preg_match("/([^\r]+)\r\n/", $buffer, $match) > 0) {
                $content = $match[1];
                if (strncmp($content, "Sec-WebSocket-Key", strlen("Sec-WebSocket-Key")) == 0) {
                    $this->websocket_key = trim(substr($content, strlen("Sec-WebSocket-Key:")), " \r\n");
                }
                $buffer = substr($buffer, strlen($content) + 2);
            } else {
                break;
            }
        }
        //响应客户端
        $this->writeToSocket($client_socket_handle, "HTTP/1.1 101 Switching Protocol\r\n");
        $this->writeToSocket($client_socket_handle, "Upgrade: websocket\r\n");
        $this->writeToSocket($client_socket_handle, "Connection: upgrade\r\n");
        $this->writeToSocket($client_socket_handle, "Sec-WebSocket-Accept:" . $this->calculateResponseKey() . "\r\n");
        $this->writeToSocket($client_socket_handle, "Sec-WebSocket-Version: 13\r\n\r\n");
    }

首先,我们从客户端套接字读取 1000 个字节。这1000字节足够读取所有的header了(但是在企业级代码中,我们不能这样写,而且我们永远不能假设整个http header有多大,在这篇博文中,为了突出重点这个问题,简化了很多代码,不过不用担心,对我们来说完全没有影响socket_recv,请参考我上面说的)。下面的while循环遍历我们读取到的内容。要理解循环中的代码,我们有必要提到http协议的格式,见下图

我想上图足以描述http协议的格式了。如果你还不明白,也没关系。推荐简书的一篇博文(HTTP协议格式详细讲解)。现在对于我们来说,最关心的就是当前请求的 Sec-WebSocket-Key 头,因为这个值需要返回给客户端,获得这个值后,我们将其存储在当前对象中。接下来我们需要回复客户。如果你不知道它的格式,让我解释一下:

对于websocket握手来说,如果服务器同意客户端连接,返回的状态码一定是101。至于后面的文本,不一定非得是Switching Protocol。别人这样传,那就这样传。其次,Upgrade:websocket、Connection:upgrade 和 Sec-WebSocket-Version:13 必须传递给客户端。这是固定的,应该不难。另外,我们之前已经提到过Sec-WebSocket-Accept。我在上面贴出了它的计算代码。这个计算方法也是固定的。不要忘记每行后面必须有\r\n,最后一行后面必须有两个\r\n。

分析数据协议

看完上面的握手代码,是不是感觉自己已经到了天堂呢?感觉这么简单? ?这是美好的一年,醒醒吧,醒醒吧。哈哈,你还这么年轻,青春就是好

你看到我上面贴的websocket数据包格式了吗?是时候解开谜底了。这部分可能有点困难。别害怕,我在这儿。下面我做一个原子级别的分析。

鳍

FIN 位也是整个片段第一个字节的最高位。只能是0或1。该位只有一个作用。如果为1,则表示该分片是整个消息的最后一个分片。如果为0则表示在这个分片之后还有其他分片。听完之后你是否一头雾水?什么是剪辑?什么是新闻?很好,看来我大显身手的时候到了,不再废话了。为了理解这些概念,代码尊重

(new WebSocket()).send("我是奥巴马");

这是一段 JAVASCRIPT 代码。 send函数的参数是一条消息,消息很短。但请注意,我们不能假设它在任何时间、任何地点都会这么短。当它变得很长时,客户端可以修改它。对于切割来说,比如说我有一个4M大小的字符串,我把它分成4段1M的字符串,那么每一个1M的字符串只能成为一个片段。每个片段都是独立发送的。这四个片段组合起来形成一条消息。每个片段的格式是固定的。格式与上面相同。纹理是一样的。刚才提到了,前三个片段的FIN都是0,第四个是1,清楚了吗?太简单了!

RSV1,RSV2,RSV3

这三位是保留用于扩展的,根本不会被使用。反正我不用它们,所以我们可以把它们当作空气,一直设置为0。这就是它的决定性。

操作码

顾名思义,opcode是一种操作码,占据第一个字节的低四位,因此opcode可以表示16个不同的值。想问一下,opcode是用来做什么的?操作码用于解析当前片段的有效负载(携带的数据)。详细内容稍后会再次说明。

面具

指示当前分片携带的数据是否已加密。该位置为第二个字节的最高位,共1位。它的值不是按照你想要的设置的。 RFC6455明确规定所有从客户端发送到服务器的数据都必须加密,因此mask的值必须为1。同时,所有从服务器发送到客户端的数据都必须不加密,因此mask必须为0就是这么简单粗暴。

有效负载长度

该部分用于定义有效负载数据的长度。一共7位,那么最大值就是127,是不是就这么简单呢?嗯,不。

面膜键

它的位置紧跟在数据长度后面,其大小为0或4字节。 mask的作用前面已经分析过了。如果mask为1,则需要对数据进行加密。此时掩码密钥占用4个字节,否则长度为0。至于如何使用掩码密钥来解密数据,我们稍后再讲。

有效负载数据

这是我们从客户端收到的数据,但它是加密的,“我是奥巴马”。前面的payload_length的长度是加密数据的长度,而不是原始数据的长度。

解释完上面的内容,我们就可以开始分析如何使用php来解析Websocket消息片段了。

解析数据包

我在这篇博文一开始就说过,目前的websocket实现会集中在websocket最本质、最难的部分,所以有些内容会被忽略。如果你理解了下面的内容,那么剩下的细节就不成问题了。 。

计算数据长度

//等待客户端新传输的数据
    if (!socket_recv($client_socket_handle, $buffer, 1000, 0)) {
        throw new Exception(socket_strerror(socket_last_error($client_socket_handle)));
    }
    //解析消息的长度
    $payload_length = ord($buffer[1]) & 0x7f;//第二个字符的低7位
    if ($payload_length >= 0 && $payload_length < 125) {
        $this->current_message_length = $payload_length;
        $payload_type = 1;
        echo $payload_length . "\n";
    } else if ($payload_length == 126) {
        $payload_type = 2;
        $this->current_message_length = ((ord($buffer[2]) & 0xff) << 8) | (ord($buffer[3]) & 0xff);
        echo $this->current_message_length;
    } else {
        $payload_type = 3;
        $this->current_message_length =
            (ord($buffer[2]) << 56)
            | (ord($buffer[3]) << 48)
            | (ord($buffer[4]) << 40)
            | (ord($buffer[5]) << 32)
            | (ord($buffer[6]) << 24)
            | (ord($buffer[7]) << 16)
            | (ord($buffer[8]) << 8)
            | (ord($buffer[7]) << 0);
    }

对于上面的代码,下面逐行分析

$payload_length = ord($buffer[1]) & 0x7f;//第二个字符的低7位

读取第二个字节的低7位,即之前讨论的payload_length。 0x7f转成二进制就是01111111。ord($buffer[1])是将第二个字符转成对应的ASCII值。两个过程通过AND运算,就可以得到第二个字节的低7位对应的值(对于AND运算不熟悉的同学,请先查看我在本博文开头为你指定的链接) ,

if ($payload_length >= 0 && $payload_length < 125) {
        $this->current_message_length = $payload_length;
        $payload_type = 1;
        echo $payload_length . "\n";
 }

当payload_length的长度小于125时,数据长度等于分片长度。

if ($payload_length == 126) {
        $payload_type = 2;
        $this->current_message_length = ((ord($buffer[2]) & 0xff) << 8) | (ord($buffer[3]) & 0xff);
        echo $this->current_message_length;
  }

当payload_length的长度等于126时,就会出现一些麻烦。此时,第三个和第四个字节组合成一个无符号的16位整数。还记得我们之前说过的网络字节顺序吗?高位字节在前,低位字节在后,所以我们读取的时候,第3个字节是高8位,第4个字节是低8位,所以我们先移高8位位左移 8 位,然后与低 8 位进行或运算。

$payload_type = 3;
$this->current_message_length =
    (ord($buffer[2]) << 56)
    | (ord($buffer[3]) << 48)
    | (ord($buffer[4]) << 40)
    | (ord($buffer[5]) << 32)
    | (ord($buffer[6]) << 24)
    | (ord($buffer[7]) << 16)
    | (ord($buffer[8]) << 8)
    | (ord($buffer[9]) << 0);

当payload_length的长度等于127时,第3到第10位组合成无符号64位整数,因此最高8位需要左移56位,以此类推,低8位保持不变。

解析掩码键

//解析掩码,这个必须有的,掩码总共4个字节
$mask_key_offset = ($payload_type == 1 ? 0 : ($payload_type == 2 ? 2 : 8)) + 2;
$this->mask_key = substr($buffer, $mask_key_offset, 4);

要找到掩码键,必须首先找到它在当前片段中的偏移量。如果payloadlength为126,那么偏移量为(2+8) = 10。同时掩码密钥的大小为4字节,因此找到了偏移量和长度。 mask 即可获取密钥。

解密数据

//获取加密的内容
$real_message = substr($buffer, $mask_key_offset + 4);
$i = 0;
$parsed_ret = '';
//解析加密的数据
while ($i < strlen($real_message)) {
    $parsed_ret .= chr((ord($real_message[$i]) ^ ord(($this->mask_key[$i % 4]))));
    $i++;
}

解密数据的第一步是找到加密数据在当前分片中的偏移量。这很简单。这个值等于maskkey的偏移量(上面已经计算出来的)+maskkey本身的长度,4。那么如何解密数据呢?看上面的代码可以看到,解密过程其实就是遍历加密数据每个字符的ASCII值和数据(当前遍历到的位置模4,得到的数据一定是 0,1,2,3 ,找到获取到的数据中maskkey对应位置的ASCII值)并进行异或运算得到。该算法由RFC6455规定,全世界通用。

返回数据给客户端

客户端发送到服务器和服务器发送到客户端的数据格式都遵循相同的数据包格式,所以在我的实现中,代码如下:

function echoContentToClient($client_socket, $content)
{
    $len = strlen($content);
    //第一个字节
    $char_seq = chr(0x80 | 1);
    $b_2 = 0;
    //fill length
    if ($len > 0 && $len <= 125) {
        $char_seq .= chr(($b_2 | $len));
    } else if ($len <= 65535) {
        $char_seq .= chr(($b_2 | 126));
        $char_seq .= (chr($len >> 8) . chr($len & 0xff));
    } else {
        $char_seq .= chr(($b_2 | 127));
        $char_seq .=
            (chr($len >> 56)
                . chr($len >> 48)
                . chr($len >> 40)
                . chr($len >> 32)
                . chr($len >> 24)
                . chr($len >> 16)
                . chr($len >> 8)
                . chr($len >> 0));
    }
    $char_seq .= $content;
    $this->writeToSocket($client_socket, $char_seq);
}

为了简单起见,第一个字节的FIN=1,操作码设置为1。接下来检查数据的长度。这部分与解析数据长度的步骤正好相反,所以不再分析。如果把前面的都我理解的话,这里应该没有问题,但是请特别注意。前面我们提到过,服务器返回给客户端的数据是无法加密的,所以掩码必须设置为0,并且掩码密钥的长度为0。

运行实例

正如本文开头提到的,我编写了一个简单的 websocket 实现。请务必自行下载并运行。光看没啥用:php-websocket-base-implemention

如何运行 websocket 服务器

为了让您看到实际的操作结果,请打开websocket.html文件。如果页面出现此信息,则说明操作成功。

运行前请检查8080端口是否被占用。当然,你可以修改websocket.html并将其更改为其他内容。只要确保它未被占用即可。如果还是跑不了,请联系我。如果您想查看其他内容,还请修改websocket.html文件,然后重新启动服务器。

你可能想看:
« 上一篇
下一篇 »

相关推荐

php实时推送,php实地培训

2026年10月05日

53阅读

php乱码出现问号的原因,php乱码出现问号的原因有哪些

2026年10月05日

45阅读

php一句话木马,一句话木马ctf

2026年10月05日

48阅读

php获取数组长度,php中如何获取数组的长度

2026年10月05日

50阅读

php面试题库及答案,php程序员面试真题库

2026年10月05日

47阅读

phpcontent-type修改,php源码二次修改需要多少钱

2026年10月05日

42阅读