什么是 WebSocket
我们知道 socket 是基于 TCP/IP 协议栈所封装的一组功能接口,这些接口可以让我们很方便地在传输层收发数据,而无需直接面对 TCP/IP 协议栈。那么问题来了, WebSocket 是什么呢?
很简单,WebSocket 是一种和 HTTP 类似的网络传输协议,并提供了和 TCP Socket 类似的功能,使用它可以轻松地调用下层协议栈,收发数据。也就是说,WebSocket 同样是一种基于 TCP 的轻量级网络传输协议,在地位上和 HTTP 是平级的。
不过 HTTP 已经纵横江湖 N 多年了,已经被广泛使用,为啥还要搞出一个 WebSocket 呢?其实 WebSocket 和 HTTP/2 一样,都是为了解决 HTTP 的缺陷而诞生的。HTTP/2 针对的是队头阻塞,WebSocket 针对的是请求-响应这种通信模式。
请求-响应是一种半双工的通信模式,虽然可以双向收发数据,但同一时刻只能在一个方向上有动作,传输效率低。举个例子就是,客户端可以向服务端发数据,服务端也可以向客户端发数据,但两者不能同时发。
更关键的一点,它还是一种被动通信模式,服务端只能被动响应客户端的请求,无法主动向客户端发送数据。服务端表示:"敌不动我不动",客户端发一个请求,自己就给一个响应,你若不来找我,我也不去找你。
虽然后来的 HTTP/2、HTTP/3 新增了 Stream, Server Push 等特性,但请求-响应依然是主要的工作方式,这就导致 HTTP 难以应用在动态页面、即时消息、网络游戏等要求实时通信的领域。
因此在 WebSocket 出现之前,在浏览器环境里用 JavaScript 开发实时 Web 应用很麻烦。因为浏览器是一个受限制的沙箱,不能用 TCP,只有 HTTP 协议可用,所以就出现了很多变通的技术,轮询(polling)就是比较常用的一种。简单地说,轮询就是不停地向服务端发送 HTTP 请求,问有没有数据,有数据的话服务端就用响应报文回应。如果轮询的频率比较高,那么就可以近似地实现实时通信的效果;但轮询的缺点也很明显,反复发送无效查询请求耗费了大量的带宽和 CPU 资源,非常不经济,对服务端而言也是一种压力。
所以为了克服 HTTP 的请求-响应模式的缺点,WebSocket 就应运而生了。它原来是 HTML5 的一部分,后来自立门户,形成了一个单独的标准,RFC 文档编号是 6455。
WebSocket 的特点
WebSocket 是一个真正的全双工通信协议,与 TCP 一样,客户端和服务端都可以随时向对方发送数据,而不用像 HTTP 那么客套。于是,服务端就可以变得更加主动了,一旦后台有新的数据,就能立即推送给客户端,而不需要客户端轮询,实时通信的效率也就提高了。
然后是报文格式,这一点 WebSocket 与 HTTP 完全不兼容。HTTP 的报文格式采用的是纯文本,而 WebSocket 的报文格式则采用了二进制帧结构,所以它的传输性能要优于 HTTP。因为二进制虽然对人不友好,但却大大方便了计算机的解析。因为使用纯文本的话,很容易出现多义性,比如大小写、空白字符、回车换行、多字少字等等,程序在处理时必须用复杂的状态机,效率低,还麻烦。
但毕竟 WebSocket 的主要应用环境是浏览器,所以为了便于推广和应用,就不得不搭便车,在使用习惯上尽量向 HTTP 靠拢,这就是它名字里 Web 的含义。服务发现方面,WebSocket 没有使用 TCP 的 IP 地址 + 端口号,而是延用了 HTTP 的 URI 格式。但开头的协议名不是 http,引入的是两个新的名字:ws 和 wss,分别表示明文和加密的 WebSocket 协议。
而 WebSocket 的默认端口也选择了 80 和 443,因为现在互联网上的防火墙屏蔽了绝大多数的端口,只对 HTTP 的 80、443 端口放行,所以 WebSocket 就可以伪装成 HTTP 协议,比较容易地穿透防火墙,与服务器建立连接。下面举几个 WebSocket URI 的例子,看看是不是和 HTTP 高度相似呢?
- ws://www.xxx.com
- ws://www.xxx.com:8080/chat
- wss://www.xxx.com:445/chat?user_id=xxx
除了协议名不一样,其它部分基本一致。
要注意的一点是,WebSocket 的名字容易让人产生误解,虽然大多数情况下我们会在浏览器里调用 API 来使用 WebSocket,但它不是一个调用接口的集合,而是一个通信协议,或者说网络传输协议,可以简单把它理解成 TCP over Web。
WebSocket 的报文结构
重点来了,WebSocket 的报文长什么样子呢?不过在此之前,先来看看 HTTP 的报文结构。
首先是请求报文:
接下来是响应报文:
所以无论是请求报文还是响应报文,都由 起始行 + 请求头/响应头 + 请求体/响应体 组成。而我们在拿到原始的报文之后,也可以很方便地进行解析,从图中可以看出最后一个 Header 字段和响应体之间有两个换行,而换行用 \r\n 表示。因此我们只要按照 "\r\n\r\n" 进行 split 即可,会得到一个数组,数组的第二个元素就是请求体/响应体,第一个元素就是起始行 + 请求头/响应头。
然后对数组的第一个元素按照 "\r\n" 再进行 split,又可以得到一个数组,该数组的第一个元素就是起始行,剩余的元素就是请求头/响应头。我们举个例子,这里我用 Tornado 写了一个服务,监听 8080 端口,通过 POST 请求向根路径传递一个 JSON,即可返回一个字符串。下面看看如何使用 socket 发请求,并进行解析:
from pprint import pprint import socket client = socket.socket() client.connect(("localhost", 8080)) # 构造请求报文,别忘了请求体的前面要有一个换行 message = b"""POST / HTTP/1.1 Host: localhost:8080 Connection: close Content-Length: 26 {"name":"satori","age":17}""" # 发送请求 client.send(message) # 获取响应 content = client.recv(4096) # 按照 \r\n\r\n 进行分隔 data = content.split(b"\r\n\r\n") # 第二个元素就是响应体 print(data[1].decode("utf-8")) # name: satori, age: 17 # 对第一个元素使用 \r\n 进行分隔 headers = data[0].split(b"\r\n") # 起始行 print(headers[0].decode("utf-8")) # HTTP/1.1 200 OK # 响应头 for header in headers[1:]: print(header) """ b'Server: TornadoServer/6.1' b'Content-Type: text/html; charset=UTF-8' b'Date: Sun, 22 May 2022 17:54:11 GMT' b'Content-Length: 21' b'Connection: close' """ # 当然啦,我们还可以将每个响应头按照冒号进行 split # 然后将它们组成键值对放在一个字典里面 headers_dict = {} for header in headers[1:]: key, val = header.decode("utf-8").split(":", 1) # 由于 val 前面多一个空格,所以要 strip 一下 headers_dict[key] = val.strip() pprint(headers_dict) """ {'Connection': 'close', 'Content-Length': '21', 'Content-Type': 'text/html; charset=UTF-8', 'Date': 'Sun, 22 May 2022 17:59:08 GMT', 'Server': 'TornadoServer/6.1'} """
以上就是 HTTP 报文的解析和处理,这里我们是客户端,所以解析的是响应报文。当然作为服务端,解析请求报文也是类似的,这里 Tornado 帮我们做了。总之报文的解析非常简单,因此你可以不断地完善,然后实现一个属于自己的网络请求库。比如 Python 的 requests 模块,你完全有能力自己封装一个。
这里稍微啰嗦了一下 HTTP 相关的内容,但完全是值得的,因为 WebSocket 要用到 HTTP。
和 TCP、TLS 一样,WebSocket 也要有一个握手过程,然后才能正式收发数据。在 WebSocket API 中,浏览器和服务器只需要完成一次握手,两者之间就可以创建持久性的连接, 并进行双向数据传输。
而握手这一过程是基于 HTTP 实现的,利用 HTTP 本身的协议升级特性,伪装成 HTTP,这样就能绕过浏览器沙箱、网络防火墙等限制,这也是 WebSocket 与 HTTP 的另一个重要关联点。所以 WebSocket 的握手没有使用 TCP,而是使用一个标准的 HTTP GET 请求,但要带上两个表示协议升级的专用头字段。
- Connection: Upgrade,表示要求协议升级;
- Upgrade: websocket,表示要升级成 WebSocket 协议;
另外,为了防止普通的 HTTP 消息被意外识别成 WebSocket,握手消息还增加了两个额外的用于认证的头字段。
- Sec-WebSocket-Key:一个 Base64 编码的 16 字节随机数,作为简单的认证密钥;
- Sec-WebSocket-Version:协议的版本号,当前必须是 13
服务端收到 HTTP 请求报文,看到上面的四个字段,就知道这不是一个普通的 GET 请求了,而是 WebSocket 的升级请求,也就是握手。于是就不走普通的 HTTP 处理流程,而是构造一个特殊的 101 Switching Protocols 响应报文,通知客户端,接下来就不用 HTTP 了,全改用 WebSocket 协议进行通信,有点像 TLS 的 Change Cipher Spec。
当然 WebSocket 的握手响应报文也是有特殊格式的,要包含以下字段:
- Connection: Upgrade,表示同意协议升级;
- Upgrade: websocket,表示同意升级成 WebSocket 协议;
- Sec-WebSocket-Accept
返回的响应头中还要额外添加一个字段 Sec-WebSocket-Accept,用于验证客户端请求报文,同样也是为了防止误连接。具体的做法是把请求头里 Sec-WebSocket-Key 的值,加上一个专用的 UUID:258EAFA5-E914-47DA-95CA-C5AB0DC85B11(也被称为魔法字符串),计算 SHA-1 摘要,再进行 base64 编码。然后将编码的结果,作为 Sec-WebSocket-Accept 字段的值。
b64encode( sha1(Sec-WebSocket-Key + b'258EAFA5-E914-47DA-95CA-C5AB0DC85B11') )
客户端收到响应报文,就可以用同样的算法进行计算,然后对比值是否相等。如果相等,就说明认证成功,握手完成,后续传输的数据就不再是 HTTP 报文,而是 WebSocket 格式的二进制帧了。
并且握手在建立完成之后,后续就直接走 TCP 协议了,只有在握手的时候才会使用 HTTP 协议。所以 WebSocket 和 HTTP 是平级的,都是应用层传输协议。
然后就是重点了,WebSocket 传输的数据长什么样子呢?首先 WebSocket 和 HTTP/2 一样,用的都是二进制帧,不过两者的关注点不同。WebSocket 侧重于实时通信,而 HTTP/2 更侧重于提高传输效率,因此 WebSocket 没有像 HTTP/2 那样定义流,也就不存在多路复用、优先级等复杂的特性。所以 WebSocket 的帧结构要比 HTTP/2 简单很多,下面看一下它的结构。
首先是 data[0],也就是第一个字节:
它的第一位是 FIN,也就是消息结束的标志位,相当于 HTTP/2 里的 END_STREAM,表示数据发送完毕。因为一个消息可以拆成多个帧,接收方看到 FIN 后,就可以把前面的帧拼起来,组成完整的消息。FIN 后面的三个位是保留位,目前没有任何意义,但必须是 0。
data[0] 的后四位叫 OPCODE(操作码),说白了就是帧类型,比如 1 表示帧内容是纯文本,2 表示帧内容是二进制数据,8 是关闭连接,9 和 10 分别是连接保活的 PING 和 PONG。
然后是 data[1],也就是第二个字节:
它的第一个位是掩码标志位 MASK,表示帧内容是否使用异或操作(xor)做简单的加密,当该位被设置为 1 时表示加密,设置为 0 时表示不加密。如果加密了,那么必须解密才能得到正确内容,而解密规则也很简单,一会儿代码中有体现。目前的 WebSocket 标准规定,客户端发送数据必须使用掩码加密,而服务器发送则不使用掩码加密。
data[1] 的后 7 位表示 Payload Length,也就是有效负载、或者说有效业务消息的长度,并且采用的是大端存储。所以在获取到字节流的时候,只需要让第二个字节和 0b0111_1111、即 127 做按位与,即可得到有效负载的长度。但这里就产生一个问题,因为一个字节的后 7 位最大能表示的数字就是 127,如果有效负载的长度大于 127 该怎么办?
所以 WebSocket 这里给出了一个规则,让 data[1]和 127 进行按位与,计算得到结果:
- 如果结果等于 127,那么由 data[2: 10] 表示有效负载的长度;
- 如果结果等于 126,那么由 data[2: 4] 表示有效负载的长度;
- 如果结果小于 126,那么由 data[1] & 127 表示有效负载的长度;
所以一个 WebSocket 帧的最大长度可以用 8 字节表示。
然后长度字段后面是 Masking-key(掩码密钥),它是由上面的标志位 MASK 决定的。如果 MASK 为 1,那么 Masking-key 是一个 4 字节的随机数;如果 MASK 为 0,那么 Masking-key 不存在。所以对于客户端发送的数据而言:
- 当 data[1] & 127 等于 127 时,那么由 data[10: 14] 表示 Masking-key;
- 当 data[1] & 127 等于 126 时,那么由 data[4: 8] 表示 Masking-key;
- 当 data[1] & 127 小于 126 时,那么由 data[2: 6] 表示 Masking-key;
Masking-key 后面就是有效负载了,也就是 Payload,比如客户端发送了一个字符串 "hello world",那么这个字符串就是有效负载,或者说有效的业务消息。
- 当 data[1] & 127 等于 127 时,那么由 data[14:] 表示 Payload;
- 当 data[1] & 127 等于 126 时,那么由 data[8:] 表示 Payload;
- 当 data[1] & 127 小于 126 时,那么由 data[6:] 表示 Payload;
所以在不同情况下, WebSocket 的二进制帧格式如下:
手动模拟 WebSocket 服务端
那么下面,我们就可以通过 TCP 手动模拟 WebSocket 服务端接收请求了。
import asyncio from asyncio import StreamReader, StreamWriter from base64 import b64encode from hashlib import sha1 from struct import pack, unpack class WebSocket: def __init__(self, host="0.0.0.0", port=9999): self.host = host self.port = port @staticmethod def parse_request_headers(data: bytes) -> dict: """ WebSocket 建立握手时,是一个标准的 HTTP GET 请求 所以发送的数据格式就是一个普通的 HTTP 请求报文 此函数负责从原始字节流中解析出请求头 :param data: :return: """ headers = data.split(b"\r\n\r\n")[0].split(b"\r\n") header_dict = {} for header in headers[1:]: key, val = header.decode("utf-8").split(":", 1) header_dict[key] = val.strip() return header_dict @staticmethod def make_response(request_headers: dict) -> bytes: """ 拿到请求头之后,还要给客户端一个响应 该响应的起始行是 HTTP/1.1 101 Switching Protocols 此函数负责基于客户端的请求头构造响应 :param request_headers: :return: """ response = (b"HTTP/1.1 101 Switching Protocols\r\n" b"Connection: Upgrade\r\n" b"Upgrade: websocket\r\n" b"Sec-WebSocket-Accept: %s\r\n\r\n") # 魔法字符串,写死的 magic_string = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11" val = (request_headers["Sec-WebSocket-Key"] + magic_string).encode("utf-8") # 进行 sha1 加密、计算摘要,然后进行 base64 编码 response = response % b64encode(sha1(val).digest()) return response @staticmethod def parse_recv_message(data: bytes) -> tuple: """ 如果握手完成,那么后续就直接走 TCP 协议了 客户端发送的数据也不再是 HTTP 报文 而是 WebSocket 的二进制帧 此函数负责对帧进行解析,拿到有效的实体数据、即有效负载 :param data: 客户端发送的字节流 :return: """ # 计算有效负载的长度 payload_len = data[1] & 127 if payload_len == 127: # data[2: 10] 真正表示有效负载的长度 # 但它是一个字节串,要用 struct.unpack 进行解析 # 由于是 8 字节,所以解析的结果是一个 uint64_t extended_payload_len = unpack(">Q", data[2: 10]) # data[10: 14] 表示 masking_key masking_key = data[10: 14] # data[14:] 表示有效负载 payload = data[14:] elif payload_len == 126: # data[2: 4] 真正表示有效负载的长度 # 由于是 2 字节,所以解析的结果是一个 uint16_t extended_payload_len = unpack(">H", data[2: 4]) # data[4: 8] 表示 masking_key masking_key = data[4: 8] # data[8:] 表示有效负载 payload = data[8:] else: # 显然此时有效负载的长度就是 payload_len extended_payload_len = payload_len # data[2: 6] 表示 masking_key masking_key = data[2: 6] # data[6:] 表示有效负载 payload = data[6:] # 但我们说 payload 是经过加密的,使用之前要进行解密 # 做法也很简单:将每个字节 和 masking_key[i % 4] 做异或操作即可 # 其中 i 是该字节所在的索引 recv_message = bytes( [char ^ masking_key[i % 4] for i, char in enumerate(payload)] ) return extended_payload_len, recv_message @staticmethod def make_send_message(extended_payload_len: int, recv_message: bytes) -> bytes: """ 收到消息之后,我们还要客户端返回消息 此函数负责构造返回给客户端的消息 :param extended_payload_len: 客户端发来的消息的长度 :param recv_message: 客户端发来的消息的实体 :return: """ # 对客户端发来的消息进行简单的封装,然后返回回去 send_message = (f"消息长度: {extended_payload_len}, " f"消息内容: ").encode("utf-8") + recv_message # 但是需要注意,这里的 send_message 不能直接发 # 否则我们就没有必要单独再写一个方法了 # 虽然服务端返回的数据不要求加密,但也是有格式的 length = len(send_message) # 第一个字节必须是 b"\x81" token = b"\x81" if length < 126: # 如果长度小于 126,那么直接加上 length 即可 # 但是要将 length 打包成字节流,并且是大端 token += pack(">B", length) elif length < 0xFFFF: # 如果长度小于 65535,length 占两字节 # 那么将 126 和 length 以大端方式打包成字节流 token += pack(">BH", 126, length) else: # 否则长度按照 8 字节算 # 那么将 126 和 length 以大端方式打包成字节流 token += pack(">BQ", 126, length) # 然后再加上要发送的消息 send_message = token + send_message return send_message async def handler_requests(self, reader: StreamReader, writer: StreamWriter): """ 负责处理来自客户端的请求 每来一个客户端连接,就会基于此函数创建一个协程 并且自动传递两个参数:reader 和 writer reader.read 负责读取数据,等价于 socket.recv writer.write 负责发送数据,等价于 socket.send :param reader: :param writer: :return: """ # 第一次读取数据显然是在握手阶段 # 显然读到的数据是一个 HTTP 报文 data = await reader.readuntil(b"\r\n\r\n") # 解析出请求头 request_headers = self.parse_request_headers(data) # 基于请求头构造出响应 response = self.make_response(request_headers) # 写入数据,返回给客户端 writer.write(response) await writer.drain() # 连接一旦建立,后续客户端发送的数据就不再是 HTTP 报文格式 # 而是 WebSocket 定义的二进制帧 while data := await reader.read(8096): # 当客户端关闭连接时,会再次发送一个 6 字节的数据 # 并且 OPCODE 为 8,表示已断开连接,那么服务端也应断开连接 if len(data) == 6 and data[0] & 0b1111 == 8: writer.close() break # 拿到客户端发送的数据,并进行解析 extended_payload_len, recv_message = \ self.parse_recv_message(data) # 生成要发送给客户端的数据 send_message = self.make_send_message(extended_payload_len, recv_message) writer.write(send_message) await writer.drain() async def __create_server(self): # 创建服务,第一个参数是一个回调函数 # 当连接过来的时候就会根据此函数创建一个协程 # 后面是绑定的 ip 和 端口 server = await asyncio.start_server(self.handler_requests, self.host, self.port) # 然后开启无限循环 async with server: await server.serve_forever() def run_server(self): loop = asyncio.get_event_loop() loop.run_until_complete(self.__create_server()) if __name__ == '__main__': websocket = WebSocket() websocket.run_server()
以上就是 WebSocket 服务端,下面我们来编写客户端:
<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <title>Title</title> </head> <body> <script> ws = new WebSocket("ws://localhost:9999"); //如果连接成功, 会打印下面这句话, 否则不会打印 ws.onopen = function () { console.log('连接成功') }; //接收数据, 服务端有数据过来, 会执行 ws.onmessage = function (event) { console.log(event) }; //服务端主动断开连接, 会执行 //客户端主动断开的话, 不执行 ws.onclose = function () { } </script> </body> </html>
下面我们来测试一下,用浏览器打开 HTML,然后启动控制台:
可以看到输出结果一切正常,以上我们就手动实现了 WebSocket 服务端。当然了,很多 Web 框架都内置了对 WebSocket 的支持。而且 Python 有一个第三方库,就叫 websocket,它即可以充当服务端,也可以充当客户端。我们用这个库继续访问一下刚才的服务:
from websocket import WebSocket ws = WebSocket() ws.connect("ws://localhost:9999") ws.send("你好呀") print(ws.recv()) """ 消息长度: 9, 消息内容: 你好呀 """ ws.send("古明地觉") print(ws.recv()) """ 消息长度: 12, 消息内容: 古明地觉 """
结果也是正常的。
小结
浏览器是一个沙箱环境,有很多的限制,不允许建立 TCP 连接收发数据,但 HTTP 的请求-响应通信模式又无法满足实时通信的要求。而有了 WebSocket,我们就可以在浏览器里与服务端直接建立 TCP 连接(但握手走的是 HTTP),获得更多的自由。只不过自由也是有代价的,WebSocket 虽然是在应用层,但使用方式却与 TCP Socket 差不多,过于原始,用户必须自己管理连接、缓存、状态,开发上比 HTTP 复杂的多,所以是否要在项目中引入 WebSocket 必须慎重考虑。
本文章参考自:
- 极客时间,罗剑锋《透视 HTTP 协议》