/首页
/开源
/关于
再聊一道面试题:Websocket
发表@2020-04-25 20:38:09
更新@2023-04-27 18:33:35
大家好,我是往那儿一站背影吸引泥腿子无数的谢顶道人 --- 老李。 事情是这样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 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(); ``` ```php
websocket
``` 上面代码你们复制粘贴走,跑一下,注意让Wireshark先开启监听状态,如果不出意外的话,TA已经抓到数据了:  注意啊上图这个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-Accept和Sec-WebSocket-Key具体是怎么个联系呢?这两个玩意实际上是WS进行握手的关键数据,如果这两个数据没有办法根据WS协议要求的算法对上号,WS握手就会失败,那么具体是怎么个算法呢?这里依然用Swoole进行一下演示,Swoole里有一个事件叫做handshake(注意:这个事件如果你写了,就一定要自己实现WS握手过程;如果不写这个回调,那么默认完成),具体是这样的: ```php 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-Key与Sec-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 ... | +---------------------------------------------------------------+