打开APP
userphoto
未登录

开通VIP,畅享免费电子书等14项超值服

开通VIP
详解 WebSocket

什么是 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 协议》

本站仅提供存储服务,所有内容均由用户发布,如发现有害或侵权内容,请点击举报
打开APP,阅读全文并永久保存 查看更多类似文章
猜你喜欢
类似文章
【热】打开小程序,算一算2024你的财运
细说WebSocket - Node篇
WebSocket 浅析
跨平台长连接组件设计及可插拔改造
【Swoole系列4.1】Swoole协程系统
Python3+WebSockets实现WebSocket通信
全双工通信的 WebSocket
更多类似文章 >>
生活服务
热点新闻
分享 收藏 导长图 关注 下载文章
绑定账号成功
后续可登录账号畅享VIP特权!
如果VIP功能使用有故障,
可点击这里联系客服!

联系客服