「番外篇之一」初探网络协议

上一篇文章中,我利用 Node.js 搭建了简单的聊天服务器,并支持了 HTTP 以及 WebSocket 两个传输协议。网络数据传输是互联网时代最常见的一种数据传输形式,从 Web 到客户端,每个平台都有许许多多优秀的库,将网络请求封装起来。上文中,不论是服务端的 nodejs-websocket 库,还是客户端使用的 AlamofireSwiftWebSocket,都提供了极其便利的接口,让我们能快速搭建起完整的应用。本篇文章中,我们先从抓取网络请求说起,分析一个个 TCP/HTTP/WebSocket 请求,再看看这几个库的关键源码,了解别人是如何处理这些请求的。

工欲善其事

在漫长的学习生涯中,我看到过许多抓包的方式,但鄙人悟性较低,还是喜欢高度集成的可视化抓包工具,日常开发与后台联调,抓取 HTTP/HTTPS 的 request 和 response 时,我强力推荐 Charles Proxy,个人开发,30 刀终身享用,物超所值。而由于我想把整个客户端与服务端交互的流程都捕捉到,所以 TCP 的包我也想看,这时候,鲨鱼哥 Wireshark 便是一个很好的选择。

Quick Review:
上文中的聊天室,主要流程如下:客户端发起昵称验证(HTTP),验证通过后,进入聊天室(WebSocket),发送、接受消息均使用 WebSocket 协议。

分析请求

抓取请求

本地运行 nodejs 服务器,Wireshark 设置好以太网监听后,就可以打开手机,连接服务器开始聊天了。下图是单次连接-发送消息-断开连接的流程(172.25.247.148是我手机的 ip,172.26.41.2是我mac的 ip):

接下来让我们逐一分析,解密这背后的数据传输。

HTTP 校验昵称

客户端首次向服务器提交数据,是用户尝试进入聊天室时,对昵称的校验,此处设计为用 HTTP GET 请求将用户昵称发送至服务器。我们知道,HTTP、WebSocket 协议都是应用层协议,其建立在 TCP 传输协议之上,因此,想要向服务器发送一个 HTTP 请求,我们首先要建立起 TCP 连接,图中红框部分即为 TCP 的三次握手:

在列表序号为 21 的行,我们看到一条服务器发送的 [TCP Window Update] 数据,该数据是告知客户端其缓冲区 Window 大小的,主要作用是数据传输的拥塞控制,这方面的知识如果你不太熟悉,我推荐你看看 这篇文章

序号 22 - 25 的数据,代表了校验昵称的数据交换过程,需要注意,尽管 22,24 两条数据的 Protocol 只写了 HTTP,但记住,HTTP 传输是建立在 TCP 之上的,因此 22,24 的本质,依然是 TCP 之上的数据包交换,而 23,25 两条“回包”,正是 TCP 协议为实现可靠传输而传输的确认包。

同时,我们也可以看到,GET 方法所带的数据一般是在 URL 中的,当传输诸如密码等机密信息的时候,这样传参显然是不合适的,而 HTTP 请求头携带了许多连接信息,如 ConnectionUser-Agent 等等信息,而由于 HTTP 是无状态的,所以每次数据交换,都要携带完整的信息,这也是 HTTP 在传输小数据时被人诟病的一点。序号 24 的 HTTP response 返回成功后,我们就可以拿着这个用户名去连接 WebSocket 服务器了。

WebSocket 聊天室

协议切换

由于并非所有服务器都支持 WebSocket 协议,因此通常,当客户端希望以 WebSocket 协议进行数据通信时,需要先“请求”服务器切换协议,若服务器能支持 WebSocket 协议,则回复客户端相应确认信息。本例中的协议切换过程,发生在 26 - 33 之间:

之前我们做 HTTP 请求,是与服务器的 8080 端口建立的连接,而我的 WebSocket 服务器监听的是 8081 端口,所以此时,我们需要重新和 8081 进行 TCP 的握手。与前面情况类似,26 - 29 即为 TCP 连接的建立过程。

30 即为客户端请求切换协议的 HTTP 请求,在 Swift 代码中,我们并没有显示的做任何 HTTP 请求,而是直接调用了以下逻辑:

1
2
3
4
5
if ws == nil {
ws = WebSocket()
}
ws.close()
ws.open("ws://\(self.ip):\(self.chatPort)")

可见,是 SwiftWebSocket 库帮我们完成了协议的切换,先不看库的源码,让我们通过分析这个协议切换的 HTTP 请求,来看看这个过程都发生了哪些事情。

上图中红框的部分,是 WebSocket 协议切换请求与普通 HTTP 请求主要的不同之处,这些字段的意义,可以参考 维基百科

Connection必须设置Upgrade,表示客户端希望连接升级。

Upgrade字段必须设置Websocket,表示希望升级到Websocket协议。

Sec-WebSocket-Key是随机的字符串,服务器端会用这些数据来构造出一个SHA-1的信息摘要。把“Sec-WebSocket-Key”加上一个特殊字符串“258EAFA5-E914-47DA-95CA-C5AB0DC85B11”,然后计算SHA-1摘要,之后进行BASE-64编码,将结果做为“Sec-WebSocket-Accept”头的值,返回给客户端。如此操作,可以尽量避免普通HTTP请求被误认为Websocket协议。

Sec-WebSocket-Version 表示支持的Websocket版本。RFC6455要求使用的版本是13,之前草案的版本均应当被弃用。

Origin字段是可选的,通常用来表示在浏览器中发起此Websocket连接所在的页面,类似于Referer。但是,于Referer不同的是,Origin只包含了协议和主机名称。

可以看出,大部分字段及其中的值均是“硬性”要求,是为了让目标服务器知道这个 HTTP 请求是一个切换 WebSocket 协议的请求,而其中比较特殊的 Sec-WebSocket-Key,经过我的查阅,是一个客户端用于校验服务器是否支持 WebSocket 协议的一个校验字段。根据的 MDN 文档 说法,对于客户端发送的 Sec-WebSocket-Key,支持 WebSocket 协议的服务器将会进行一些字符串操作,返回一串与之唯一对应的字符串,客户端校验通过后,则证明服务器的确支持 WebSocket 协议,那么接下来的数据包也就可以以 WebSocket 的格式传输了。相对的,如果将一个 WebSocket 协议切换请求发送给不支持 WebSocket 的服务器,那么服务器所响应的 HTTP response 是不会包含正确的返回值的,因此客户端也就不会以 WebSocket 数据格式向服务端发送数据,服务端在这个过程中也仅仅是接到了一个无法处理的 HTTP 请求,两端均不会产生什么负面作用。

接下来看看,服务器在收到该请求之后的回包:

HTTP 返回 101,代表服务器了解并同意切换协议(尽管 header 和各路文档里都用的是 upgrade,但 ws 和 http 真没有谁是谁的升级版的关系),同时返回的 Sec-WebSocket-Accpet,就是上面说到的与 Sec-WebSocket-Key 所对应的字符串,客户端校验该字符串通过后,后续的数据交换就会以 WebSocket 格式来收发数据包了。同样地,服务端代码也完全不涉及协议切换的过程,所使用的 node 库已经替我们完成了这些工作了。

发送与接收

50 - 53 是这么样的一个过程:客户端发送消息到服务端,服务端再广播到客户端(本例中只有一个活跃连接,所以这个广播看着像单播)。

先来看看客户端发送消息到服务端的这个数据包:

可以看到,一个 WebSocket 数据包明显比 HTTP 要轻量,各个字段的含义依然可以在 MDN 文档 中找到,此处 Fin = 1 代表该数据包为该次请求的最后一个数据包,Reserved 是保留字段,Opcode 代表 Payload 中的数据格式,和 HTTP 中头部 Content-Type 作用类似,Payload length 指明数据包长度(实际应用中应该过滤数据中的多余空格,节省空间),而 Mask 说明 Payload 经过了编码操作,编码 Key 则是 Masking-Key。由于 WebSocket 是全双工的一种协议,也不是基于 Request-Response 的工作模式,因此和 HTTP 请求会带有成功与否的响应不同,我们是无法在 WebSocket 协议的层面,了解该条消息是否发送成功的(当然,底层 TCP 会保证数据送达)。

服务端的广播内容几乎与客户端发送一模一样,唯一的区别在于,服务端未对发送的数据内容加密:

此处的加密,并非确保数据机密的那种加密,就像 http 对应 https 一样,ws 所对应的加密协议是 wss,而这里加密的意义是为了防御“attacks on proxies that led to the poisoning
of caching proxies deployed in the wild”,其中的具体含义我看得也是一知半解,Google 所查到的资料也相当有限,这里仅提供 RFC6455,Section-10.3 以及 Github 上面的这个话题 作为参考,如果看到这篇文章的你对这块了解深入,还请留言指导 :)。

就是这样!在 WebSocket 连接建立完成后,数据的收发就是这么简单!

连接的关闭

退出界面之后,自然要断开这个 WebSocket 连接,释放两端的网络资源。

关闭连接的 WebSocket 请求和一个普通的 WebSocket 请求十分相似,客户端请求依然需要 mask 加密。

接下来的逻辑本应是双方准备断开 TCP 连接,走 TCP 四次挥手的逻辑,但经过我这边反复尝试,发现 TCP 连接始终无法正常关闭,服务端尝试发送 FIN 的时候,客户端已经把连接关闭了,同时,服务端业务层也会收到一个 ECONNRESET 的错误,导致服务端 crash。目前的规避逻辑是对该错误进行特殊处理,不做抛出。

总结

站在传输层的角度,HTTP 也好,WebSocket 也罢,TCP 仅仅是将数据打包,加上头部,再将数据交给网络层。所以,HTTP 和 WebSocket 是同层级的两个不同的协议,协议是什么呢?协议不过是规定了数据传输的格式与方式而已,HTTP 要求每个请求携带所有信息,并且一个请求对应一个响应;WebSocket 出于兼容性考虑,在建立连接之前先用 HTTP 请求确定服务端是否支持 WebSocket,其请求不需要重复携带验证信息,也不要求一一对应的响应,但本质上,两者均建立在 TCP 协议搭起的全双工通道上,尽管有依赖关系,但可以互相独立。