vlambda博客
学习文章列表

再聊一道面试题:Websocket

大家好,我是往那儿一站背影吸引泥腿子无数的谢顶道人 --- 老李。


事情是这样shai儿的,早在很多年前老李曾经到一家公司去面试,面试官和老李之间产生了这样一段对话:



面试官似乎感觉智商受到了侮辱,他没有再多问老李任何一个问题,然后把他们技术总监给叫过来了,总监来了就开始问老李脑筋急转弯,诸如类似于「你来面试路上堵车时长半个小时,求北京路面大概有多少辆行驶的车」或「小明今年十五岁,求太阳的质量」这样的问题,老李似乎也感觉智商似乎受到了侮辱。


时隔多年后,main去面试再次被问到了类似的问题,至于他有没有灰头土面,老李也不得而知了,讲道理main那么流弊不应该灰头土面的,灰头土面的应该是面试官。比如main会反问他:你怎么也不问问我Linux操作系统的内存管理中的主要点?你问不问?你不问的话,我给你讲讲。


老李尘封的记忆就是这样被打开的,老李脑海里又浮现出技术总监的音容笑貌以及他送老李出门时候边走边说老师经常说的那句话「作为普通人,你想爬到金字塔顶,但是你总得先从金字塔底部开始吧」。


时至今日,任何一个人面对「浏览器如何和服务器保持实时通信」这个问题,都考虑如下几个方案:


  • AJAX。这个是最粗暴的,我就不多解释了。

  • 长轮训。其实本质上也是HTTP协议,只是说AJAX每隔一定时间发送一次请求,而长轮训是在建立HTTP链接后,如果服务器此时还没有数据要返回的话,那么这个HTTP链接就会在此处halt住,一直halt到有数据了然后发送给浏览器,然后再次发起第二次HTTP链接...重复上述过程。

  • Websocket。


前两种方式飞数据的方式,现在看起来都是属于客户端主动发起,服务器是被动应答的一方,其实主要的原因是因为性能太差劲了,单反性能TA性能凑合差不多,都能挑一波儿大梁。


而Websocket,而且至今还有很多泥腿子搞不明白Websocket和HTML5到底是咋回事,你说神不神?这个问题就留作常规小常识,不清楚的自己去查一下,老李这边儿偷懒就不占额外篇幅了。让我们步入正题,了解下WS协议。




WS与HTTP和TCP


先从高层次去概括一下Websocket与HTTP和TCP到底是咋回事。Websocket协议和HTTP协议都位于网络中的应用层,都是应用层协议,而TCP则是位于传输层,属于传输层协议,并且WS和HTTP都是基于TCP实现的上层协议,与HTTP不同的是,WS可以使得客户端(广义客户端,包括浏览器)与服务器建立一个长链接全双工的通信信道,不仅使得客户端可以主动向服务器发送消息,也可以让服务器主动向客户端发送消息,由于是长链接通道所以每次消息的发送并不会反复创建、销毁链接。


其实我感觉Websocket最大的意义就是使得传统的Web网页具备了长链接双向通信的能力,这在以前只能通过Native客户端或在网页中搞一个Flash才能搞定的。


然而这里有一个关键步骤,就是WS链接的建立第一步是借助于HTTP协议实现的,后面才是真正的WS链接。




是时候表演真正的技术了!


那么让我们开始借助Wireshark来分析一波儿这玩意。最近Wireshark出镜率有点儿高,上两篇分析HTTPS的文章里就出镜了,和青花瓷相比的话,青花瓷能干的Wireshark都能干,Wireshark能干的青花瓷不一定能干。如果你要研究TCP/IP,Wireshark是像老李这种泥腿子的首选工具,而大佬都用Tcpdump,毕竟屏幕黑乎乎的一大坨看着感觉确实不一样。


借助Swoole力量来创建一个贼简单的WS服务器,然后再从搜索到的CSDN博客里复制粘贴一个JS客户端代码,你们感受一下:


<?php$o_server = new Swoole\WebSocket\Server( "0.0.0.0", 8000 );// 握手完毕,创建了一个ws链接$o_server->on( 'open', function ( Swoole\WebSocket\Server $o_server, $o_request ) { echo "server: handshake success with fd{ $o_request->fd }\n";} );// 收到消息时候$o_server->on( 'message', function ( Swoole\WebSocket\Server $o_server, $o_frame ) { echo "receive from {$o_frame->fd}:{$o_frame->data},opcode:{$o_frame->opcode},fin:{$o_frame->finish}\n"; $o_server->push( $o_frame->fd, "this is server");});// 链接被关闭时候$o_server->on( 'close', function ($o_server, $i_fd) { echo "client {$i_fd} closed\n";});// 开始启动$o_server->start();


<!DOCTYPE html><html> <meta charset="utf-8"> <head> <title>websocket</title> </head> <body> </body> <script>    var ws = new WebSocket("ws://119.3.76.237:8000/"); ws.onopen = function(evt) {  console.log("Connection open ...");  ws.send("Hello WebSockets!"); }; ws.onmessage = function(evt) { console.log( "Received Message: " + evt.data); //ws.close(); /* setTimeout(function(){ ws.send("ping"); }, 1000 ); */ }; ws.onclose = function(evt) { console.log("Connection closed."); }; </script></html>


上面代码你们复制粘贴走,跑一下,注意让Wireshark先开启监听状态,如果不出意外的话,TA已经抓到数据了:


再聊一道面试题:Websocket


注意啊上图这个Wireshark中抓包数据的顺序是按照时间保证一定是有序的。看前三条,这三条就是传说中的TCP三次握手,客户端(192.168.199.225)率先向服务器(119.3.76.237)发起握手请求,简单说就是一个SYN包;然后服务器(119.3.76.237)第二步向客户端(192.168.199.225)也发送一个SYN包;最后客户端(192.168.199.225)服务器(119.3.76.237)回复以ACK表示:我已经好了。简单总结下就是:两个SYN再加一个最终ACK。


由于我们并不打算深入研究TCP三次握手这个事儿,所以就适可而止,适可而止,有兴趣同学自己深入去调研TCP三次握手


注意第四个请求,也就是蓝色背景那条,从Protocol列可以看出这是一个HTTP请求,那么我们说「借助HTTP协议完成第一步」就是指这个咯,我把具体报文信息贴出来你们认真感受一下:



这是一个典型的HTTP协议数据内容,你们应该看过《PHP网络编程》里老李曾经专门解析过HTTP协议的构成,所以我假设你们完全能看明白这里,然后我们注意几个特殊的HTTP Header:


  • Connection: Upgrade   // 告诉服务器,这个链接要进行协议升级。实际上这个header平时用的更多时候,TA的值是赫赫有名的keep-alive

  • Upgrade: websocket    // 告诉服务器,具体想升级成websocket协议

  • Sec-WebSocket-Extensions: permessage-deflate; client_max_window_bits    // 协议扩展类型

  • Sec-WebSocket-Key: IhTmM/PyVb55uCkAU5Iw1Q==    // 传输给服务器的key,这个key的算是这样来的 客户端随机一坨字符,然后base64一下

  • Sec-WebSocket-Version: 13   // 客户端支持WebSocket的版本


然后注意第六行的服务器返回的HTTP Response,我依旧截图你们仔细感受一下:



注意,此时服务器返回HTTP状态码为101,这表示服务器OK接受协议升级为Websocket,本质上就是代表着WS链接已经好了,后面就是直接进行数据交互就可以了。


然后除此之外,还需要注意Sec-Websocket-Accept,TA的值看起来也是一坨base64,那么Sec-Websocket-AcceptSec-WebSocket-Key具体是怎么个联系呢?这两个玩意实际上是WS进行握手的关键数据,如果这两个数据没有办法根据WS协议要求的算法对上号,WS握手就会失败,那么具体是怎么个算法呢?这里依然用Swoole进行一下演示,Swoole里有一个事件叫做handshake(注意:这个事件如果你写了,就一定要自己实现WS握手过程;如果不写这个回调,那么默认完成),具体是这样的:


<?php$o_server = new Swoole\WebSocket\Server( "0.0.0.0", 8000 );// 握手事件$o_server->on( 'handshake', function ( \Swoole\Http\Request $o_request, \Swoole\Http\Response $o_response ) { // websocket握手连接算法验证 $s_secwebsocketkey = $o_request->header['sec-websocket-key']; $s_patten = '#^[+/0-9A-Za-z]{21}[AQgw]==$#'; if ( 0 === preg_match( $s_patten, $s_secwebsocketkey ) || 16 !== strlen( base64_decode( $s_secwebsocketkey ) ) ) { $o_response->end(); return false;        } $s_key = base64_encode( sha1( $o_request->header['sec-websocket-key'] . '258EAFA5-E914-47DA-95CA-C5AB0DC85B11', true ) ); $a_headers = [ 'Upgrade' => 'websocket', 'Connection' => 'Upgrade', 'Sec-WebSocket-Accept' => $s_key, 'Sec-WebSocket-Version' => '13',        ]; if ( isset( $o_request->header['sec-websocket-protocol'] ) ) { $a_headers['Sec-WebSocket-Protocol'] = $o_request->header['sec-websocket-protocol']; } foreach ( $a_headers as $s_key => $s_val ) { $o_response->header( $s_key, $s_val ); } $o_response->status( 101 ); $o_response->end();} );// 握手完毕,创建了一个ws链接$o_server->on( 'open', function ( Swoole\WebSocket\Server $o_server, $o_request ) { echo "server: handshake success with fd{ $o_request->fd }\n";} );// 收到消息时候$o_server->on( 'message', function ( Swoole\WebSocket\Server $o_server, $o_frame ) { echo "receive from {$o_frame->fd}:{$o_frame->data},opcode:{$o_frame->opcode},fin:{$o_frame->finish}\n"; $o_server->push( $o_frame->fd, "this is server");});// 链接被关闭时候$o_server->on( 'close', function ($o_server, $i_fd) { echo "client {$i_fd} closed\n";});// 开始启动$o_server->start();


可以看到服务器获取到Sec-WebSocket-Key后,在后面加上"258EAFA5-E914-47DA-95CA-C5AB0DC85B11"后然后使用sha1算法生成一个结果,然后再base64一下,当作Sec-Websocket-Accept的值返回给客户端,客户端收到HTTP Response后按照相同的算法也计算出最终base64,和服务器返回的Sec-Websocket-Accept进行对比,如果相同表示验证通过。


至于"258EAFA5-E914-47DA-95CA-C5AB0DC85B11"这个值别问,问就是人家这么规定的(实际上一定有原因,但我没细究),具体资料在这个RFC里:


https://tools.ietf.org/html/rfc6455


这里要考虑的一个问题就是:比如你做了一个Websocket服务器,如何保护自己的服务器呢?你总不能没有任何保护吧?做在message回调合适吗?如果说做在message回调里,就意味着很多非法客户端可能会链接到你服务器,你只能在每次收到消息后在message里进行鉴定。所以最好直接做在handshake事件里,也就是说handshake时候你就要直接通过与客户端商议好的口令token进行权限鉴定。不要指望利用Sec-Websocket-KeySec-Websocket-Accept来完成鉴定。


一旦握手事件完成,服务器返回101,那么再往后就是「全双工的数据传输」了,那么一个WS协议封装的数据包到底长什么样子呢?我从上面的Websocket RFC文档里复制出来一个,RFC里称这种数据包为frame,这就是Websocket客户端和服务器进行数据交互的最小数据单元,翻译过来差不多可以叫「数据帧」:


      0               1                   2                   3
0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
+-+-+-+-+-------+-+-------------+-------------------------------+
|F|R|R|R| opcode|M| Payload len | Extended payload length |
|I|S|S|S| (4) |A| (7) | (16/64) |
|N|V|V|V| |S| | (if payload len==126/127) |
| |1|2|3| |K| | |
+-+-+-+-+-------+-+-------------+ - - - - - - - - - - - - - - - +
| Extended payload length continued, if payload len == 127 |
+ - - - - - - - - - - - - - - - +-------------------------------+
| |Masking-key, if MASK set to 1 |
+-------------------------------+-------------------------------+
| Masking-key (continued) | Payload Data |
+-------------------------------- - - - - - - - - - - - - - - - +
: Payload Data continued ... :
+ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - +
| Payload Data continued ... |
+---------------------------------------------------------------+


吐了么?别吐,其实和TCP/IP里数据包构成原理都是相似的。但是有一点一定注意的是:这东西千万别去背诵,没必要的,你顶多记住一两个关键的数据bit所代表的含义即可。


说下这个几个字段的含义,如果你要写一个Websocket协议解析器(比如Workerman里的WS协议解析器你可以了解下),就必须要明白协议规定的数据帧的数据位都是什么含义:


  • FIN,占据了1bit。有时候数据太大会被分片发送,FIN为0表示后面还有消息需要接续接受,FIN为1表示这是当前最后一个数据段了,总之把所有数据分片都收全乎了才能正确解析出来。

  • RSV1与RSV2与RSV3。分别各占据1bit,这里涉及到一个WebSocket扩展的概念。还记得在WS协商第一步里的Sec-WebSocket-Extensions吗?我对这个header理解一直不是特别深刻,目前我的理解是这个header的value可以设置为一些自定义的value满足自定义需求,对这个header熟悉的大佬还望指点一波儿。

  • Opcode,占据4bit。这个和PHP中的opcode可不是一回事,这里的opcode表示操作代码,就是说根据这个代码的值来决定对当前数据做什么操作,TA的值是有限固定的值。%x0表示延续帧,表示数据分片了,当前是其中一个分片;%x1表示这是文本帧;%x2表示这是一个二进制帧;%x8表示链接断开;%x9表示心跳中的ping;%xA表示心跳中的pong;而%x3-7和%xB-F目前作为保留的冗余帧,不知道将来会被什么用上。

  • Mask,占据1bit。表示是否要对data-payload载荷数据进行掩码操作。一般说客户端->服务器的数据载荷进行了掩码操作,而服务器->客户端的数据不会进行掩码操作,所以服务器发现客户端收到的数据进行了掩码操作,当前WS链接就会被直接断开。而当进行了掩码操作时候,Mask值为1,这会儿👇这个Masking-key中就有产生一个数值,服务器用这个数值对收到的数据进行“ 反 ”掩码操作即可。

  • Masking-key,32bit或者0bit。与👆的Mask息息相关,所以我放到了这里。

  • Payload length,占据7bit,描述载荷数据长度的。

  • PayLoad data,就是载荷数据,两部分组成:扩展数据+应用数据。扩展数据有时候是没有的,其实就是我前面说的「我理解不深刻」的那段。但是应用数据一定是有的,啥叫应用数据呢?比如客户端给服务器发送了一个“ shadiaomianshiguan ”,那么这个“ shadiaomianshiguan ”就是应用数据。


上面这就是WS基本上最常会被问到的一些内容了,当然了除此之外还有一些扩展问题,比如:


  • websocket的安全验证,这个我在文章中提到过了

  • websocket集群大概怎么做,这是一个常见问题