老司机带你用php实现websocket

1,455 阅读17分钟

我为什么会写这篇文章?

当初作为编程小白的我,刚刚从事后台工作,觉得http是个很牛逼的东西,然而后面随着自己深入学习并实践之后,觉得原来和我所想的天壤之别,没大家想象的那么复杂,仅仅是个协议嘛!。后面学习的东西多了,慢慢的就淡定了。今天这里之所以要讲websocket,而不是其它的协议,从某种意义上来说(请允许我装个逼),更能说明问题,如果你把websocket都搞懂了,那么http对于你来说,简直就是雕虫小技啊,关于websocket的代码,以前我使用C和C++写的,但是为了PHP的coder(PHP是世界上最好的语言)能明白,我用PHP重新写了一遍,但是个精简版,对于我们彻底搞懂websocket,理解它的精华所在,已经足够了。代码我已经上传到了码云(php-websocket-base-implemention),请大家一定一定要下载下来,并亲自运行实践才是检验真理的唯一标准啊,代码是完全可以运行的,如果运行的时候有障碍,请联系我。该博文差不多修修改改了3天(幸亏公司里面事不多),尽可能的给大家讲清楚。突然感觉,写文章好累啊,这都不重要,希望大家能够看懂,不然我写的就没啥用了。更希望大家遇到不懂的,提出疑问。写完之后,我再次审查了当前博文的内容,修改了一些拼写错误,可能还会有一些漏网之鱼,希望大家多多指正。

准备工作

在阅读这篇博文之前,需要大家有一定的基础知识储备,下面我会给大家列出来,先装一下逼

)

socket基础

基本的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的内存地址依次增大。

  • 小端序,低字节存储在低位地址,高字节存储在高位地址,什么意思呢?此时0x78存储在a,0x56存储b,0x34存储c,0x12存储d。
  • 大端序,高位字节存储在低位,低位字节存储在高位,此时0x78存储在d,0x56存储c,0x34存储b,0x12存储a。
  • 网络字节序,网络字节序是大端字节序,这已经成为标准。

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

协议的诞生

Websocket协议如今应用非常广泛,,造成这一现象的很大原因,在于http协议的短暂性,客户端和服务器之间每一次的请求应答都需要建立TCP三次握手,这对于流量很大的服务器来说是非常恐怖的(系统级资源),所以这个时候websocket诞生了,具体的诞生日期是哪一年已经不得而知了,但是真正的标准化时间是在2011年,由IETF正式完成,具体请参考RFC6455

协议工作流程

下面有一张图,可以说明这一点,该图片来自Google,

websocket协议和http协议都属于应用层协议(在TCP/IP之上),但是websocket协议相对于http协议多了一个握手(这个握手不是平时所说的tcp三次握手啊,注意了)的过程,从上面的图可以很清晰的看出来,http是是一个文本协议,但是websocket有所不同,它有自己严格的字节格式,稍后会讲到。

数据包格式

协议流程概览

该协议由2部分组成,握手数据传输,握手部分并不复杂,并且握手是建立在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所特有的一些字段:

Upgrade字段

这个字段表示需要升级到的协议,这个字段是必须的,并且它的值必须是websocket。

Connection

这个字段表示需要升级协议,也是必须的,它的值必须是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-Version

websocket协议的版本号,根据RFC6455的文档,我们知道,这个值必须是13,其它的任何值都不行,下面是它的描述:

The request MUST include a header field with the name |Sec-WebSocket-Version|. The value of this header field MUST be 13. NOTE: Although draft versions of this document (-09, -10, -11,and -12) were posted (they were mostly comprised of editorial changes and clarifications and not changes to the wire protocol), values 9, 10, 11, and 12 were not used as valid values for Sec-WebSocket-Version. These values were reserved in the IANA registry but were not and will not be used.

Sec-WebSocket-Protocol

选择websocket所使用的子协议,这个字段不是必须的,取决于具体的实现,如果你使用的是Google浏览器的话,那么这个值是不会传递的。

握手阶段

在讲解完了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");
    }

首先我们从客户端socket中读取1000字节的内容,这1000的字节足以读出所有的头部了(但是在企业级代码中,我们不能这么写,我们永远不能假设整个http头部有多大,在这片博文中,我们为了突出问题的重点,简化了很多代码,但是你放心,对我们来说,丝毫没有影响,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

FIN位,也是整个片段的第一个字节的最高位,他只能是0或者是1,这个位的作用只有一个,如果它为1,表示这个片段是整个消息的最后一个片段,如果是0,表示这个片段之后,还有其它的片段。是不是听着直接懵逼了,啥是 片段?啥是 消息?非常好,看来我装逼的时候已经来临了,废话不多说。为了搞清楚这几个概念,代码为敬

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

这是一段JAVASCRIPT代码,send函数的参数就是一条消息,非常短,但是注意了,我们不能假设任何时间,任何地点,都这么短,当它变得很长的时候,客户端就有可能对它进行切割,比如,我有一个字符串,大小为4M,我把它分为4个1M的字符串,那么每一个1M的字符串,就只能成为一个片段,每个片段独立发送,四个片段组合在一起形成了一条消息,每一个片段的格式都是固定的,格式和上面的贴图是一样的,按照刚才说的,前面的三个片段,FIN都是0,第四个才是1,清楚了么?So easy!!

RSV1,RSV2,RSV3

这三位是保留给扩展使用的,基本不会用到,反正我没用到,所以我们可以把它们当做空气就行,永远设置为0,就是这么果断。

opcode

opcode顾名思义就是操作码,占用第一个字节的低四位,所以opcode可以代表16种不同的值。你是不是想问,opcode是用来干嘛的? opcode是用 来解析当前片段的载荷(携带的数据)的,具体的后面会再次说明。

  • 0x00,表示当前片段是连续片段,这是啥意思呢?还记得上面讨论FIN的时候,一条消息被分割成多条片段?如果当前片段不是第一个,那么opcode必须设置为0。
  • 0x01,表示当前片段所携带的数据是文本数据(记得最开始说的文本数据和二进制数据的区别??),如果有多个片段的话,只需要在第一个片段设置该值,属于同一条消息中后面的片段,只需要设置为0即可。
  • 0x02,表示当前片段所携带的数据是二进制数据,如果有多个片段的话,只需要在第一个片段设置该值,属于同一条消息中后面的片段,只需要设置为0即可。
  • 0x03-0x07,保留给将来使用,也就是说暂时还没用到。
  • 0x08,表示关闭websocket连接,这个后面我会再一次讲到,先放着
  • 0x09,发送Ping片段,说白了,它主要是用来检测远程端点是否还存活,我想检查我的对象是不是已经死了,但是这个片段可以携带数据,如果端点的一方发送了Ping,那么接受方,必须返回Pong片段,用中国人的话来说,就是礼尚往来嘛。
  • 0xA,发送Pong,用以回复Ping,是不是很简单?
  • 0xB-F,保留给将来使用,也就是说暂时还没用到。

MASK

表示当前片段所携带的数据是否经过加密,位置为第二个字节的最高位,总共1位,它的值不是你想设置就设置的啊,RFC6455 明确规定,所有从客户端发送给服务器的数据必须加密,所以mask的值必须是1。还有,所有从服务器发往客户端的数据,一定不能加密,所以呢,mask必须为0,就是这么简单粗暴。

Payload Length

这部分是用来定义负载数据的长度的,总共7位,所以最大值为127,就这么简单?哼哼,不会的。

  • payload_length<=125,此时数据的长度就是payload_length的大小。
  • payload_length=126,那么紧接着payload_length的2个字节,就用来表示数据的大小,所以当数据大小大于125,小于65535的时候,payload_length设置为126,后面分析代码的时候,我会再次讲到。
  • payload_length=127,也就是payload_length取最大值,那么紧接着payload_length的8个字节,就用来表示数据的大小,此可以表示的数据可就相当大了,后面分析代码的时候,我会再次讲到。

Mask key

它的位置紧接着数据长度的后面,大小为0或者是4个字节。前面分析了mask的作用,如果mask为1的话,数据需要加密,此时mask key占用4个字节,否则长度为0,至于mask key如何用来解密数据的,后面会再次讲到。

payload data

这里就是我们从客户端接收到的数据,不过它是经过加密的,“我是奥巴马”,之前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数值,两个进行与运算,就可以得到第二个字节的低7位对应的数值(与运算不熟悉的朋友,请先查看我在这篇博文前面给大家指定的链接),

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的时候,就有些麻烦了,此时第3和第4个字节组合为一个无符号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位保持不动。

解析mask key

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

要找到maskey,首先必须找到它在当前片段的偏移,如果payload_length<=125,那么偏移就是2,如果payload_length==126,那么偏移就是(2+2)=4,如果payload_length>126,那么偏移就是(2+8)=10,同时mask key的大小为4个字节,所以找到了偏移和长度,mask key就可以获取到了。

解密数据

//获取加密的内容
$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,opcode设置为1,接下来检查数据的长度,这部分内容和解析数据长度的步骤刚好相反,就不再分析了,如果你把之前的都看懂了,这里也应该没有问题,但是特别注意了,之前我们就已经提到过,服务器返回给客户端的数据,不能加密,所以mask必须设置为0,mask key的长度为0。

运行实例

就和本篇博文开篇所提到的,我写了一个简单的websocket实现,请一定要下载自己运行起来,光看是没有用的:php-websocket-base-implemention

如何运行websocket服务器

为了你可以看到实际运行的结果,请打开websocket.html文件,页面上出现这个就表示运行成功了。

运行之前,请检查端口8080是否被占用,当然你可以修改websocket.html,改为其他的都可以,确保不被占用就可以了,如果你仍然无法运行,请联系我,如果你想看到其他的内容,也请修改websocket.html文件,然后重启服务器。

提示

本篇博文的目的仅仅是为了向大家简要的介绍websocket最为核心的内容,还有一些内容没有讲到(剩下的不难,感兴趣的自己可以去实现),出于让大家更为直观的看清楚websocket的目的,代码中去掉了错误检查等内容,因此并不严谨,祝你学习愉快。

联系方式

如果你有什么问题,请联系我,欢迎大家加入QQ群:971572229