1. 背景
过来,在创立须要在客户端和服务端之间进行双向通信的 Web 应用程序(比方,即时通讯和游戏应用程序)时,须要滥用 HTTP,轮询服务端以获取更新,并且通过独自的 HTTP 调用发送上行告诉。
这导致许多问题:
服务器被迫为每个客户端应用多个不同的底层 TCP 连贯:一个用于向客户端发送信息,每个传入的音讯都须要建设新连贯。
协定开销较高,每个客户端到服务端的音讯都带有 HTTP 头。客户端脚本被迫保护从出站连贯到入站连贯的映射,以跟踪回复。更简略的解决方案是在两个方向上应用单个 TCP 连贯进行通信。
这就是 WebSocket 协定所提供的。
它为网页与近程服务器之间的双向通信提供一种代替 HTTP 轮询的抉择。该技术能够用于各种 Web 应用程序,比方游戏、股票行情、反对并发编辑的多用户应用程序、实时公开服务器端服务的用户界面等。WebSocket 协定旨在取代应用 HTTP 作为传输层的双向通信技术,以便利用现有基础设施(代理、过滤、身份验证)。
因为 HTTP 最后并非为双向通信而设计,因而这些技术是在效率和可靠性之间进行衡量的状况下施行的。WebSocket 协定的指标是在现有的 HTTP 基础设施环境中,实现双向 HTTP 技术。因而,WebSocket 协定被设计为能够在 HTTP 端口 80 和 443 上运行,并且反对 HTTP 代理和中间设备,即便可能引入一些特定于以后环境的复杂性。
然而,WebSocket 的设计不局限于 HTTP,将来的实现能够在专用端口上应用更简略的握手形式,而无需从新设计整个协定。最初一点很重要,因为交互式音讯的流量模式与规范 HTTP 流量不齐全匹配,某些组件可能产生异样负载。
2. WebSocket 握手
WebSocket 服务端应用规范 TCP 套接字监听进入的连贯。下文假设服务端监听 example.com 的 8000 端口,响应 example.com/chat 上的 GET 申请。握手是 WebSocket 中 “Web”。它是从 HTTP 到 WebSocket 的桥梁。
在握手过程中,协商连贯的细节,并且如果行为不非法,那么任何一方都能够在实现前退出。服务端必须认真了解客户端的所有要求,否则可能呈现平安问题。
2.1 客户端握手申请
客户端通过分割服务端,申请 WebSocket 连贯的形式,发动 WebSocket 握手流程。客户端发送带有如下申请头的规范 HTTP 申请(HTTP 版本必须是 1.1 或更高,并且申请办法必须是 GET):`
GET /chat HTTP/1.1
Host: example.com:8000
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Sec-WebSocket-Version: 13
在这里,客户端能够申请扩大和/或子协定。此外,也能够应用常见的申请头,比方 User-Agent、Referer、Cookie 或者身份验证申请头。这些申请头与 WebSocket 没有间接关联。如果存在不非法的申请头,那么服务端应该发送 400 响应(“Bad Request”),并且立刻敞开套接字。通常状况下,服务端能够在 HTTP 响应体中提供握手失败的起因 。如果服务端不反对该版本的 WebSocket,那么它应该发送蕴含它反对的版本的 Sec-WebSocket-Version 头。在下面的示例中,它批示 WebSocket 协定的版本为 13。在申请头中,最值得关注的是 Sec-WebSocket-Key。接下来,将讲述它。2.2 服务端握手响该当服务端收到握手申请时,将发送一个非凡响应,该响应表明协定将从 HTTP 变更为 WebSocket。该响应头大抵如下(记住,每个响应头行以 \r\n 结尾,在最初一行的前面增加额定的 \r\n,以阐明响应头完结):
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=
此外,服务端能够在这里对扩大/子协定申请做出抉择。Sec-WebSocket-Accept响应头很重要,服务端必须通过客户端发送的Sec-WebSocket-Key申请头生成它。具体的形式是,将客户端的Sec-WebSocket-Key与字符串"258EAFA5-E914-47DA-95CA-C5AB0DC85B11"(“魔法字符串”)连贯在一起,而后对后果进行 SHA-1 哈希运算,最初返回哈希值的 Base64 编码。因而,如果 Key 为"dGhlIHNhbXBsZSBub25jZQ==",那么Sec-WebSocket-Accept响应头的值是"s3pPLMBiTxaQ9kYGzzhZRbK+xOo="。服务端发送这些响应头后,握手实现,能够开始替换数据。上面的 Python 代码依据Sec-WebSocket-Key申请头生成Sec-WebSocket-Accept响应头的值:
import typing
from hashlib import sha1
import base64
SEC_WS_MAGIC_STRING: bytes = b"258EAFA5-E914-47DA-95CA-C5AB0DC85B11"
def get_sec_ws_accept(sec_ws_key: typing.Union[bytes, str]) -> bytes:
if isinstance(sec_ws_key, str): sec_ws_key = sec_ws_key.encode()return base64.b64encode(sha1(sec_ws_key + SEC_WS_MAGIC_STRING).digest())
if name == "__main__":
assert get_sec_ws_accept(b"dGhlIHNhbXBsZSBub25jZQ==") == b"s3pPLMBiTxaQ9kYGzzhZRbK+
## 3. 数据帧(Data Framing)### 3.1 概览在 WebSocket 协定中,应用一系列帧传输数据。为防止混同网络中间人(比方拦挡代理),以及出于平安思考,客户端必须对发送给服务端的所有帧进行掩码(Mask)解决。(留神,无论 WebSocket 协定是否运行在 TLS 上,都须要进行掩码解决。)服务端在收到未进行掩码解决的帧时,必须敞开连贯。在这种状况下,服务端能够发送状态码为 1002(协定谬误)的敞开帧。服务端不得对发送给客户端的任何帧进行掩码解决。如果客户端检测到掩码帧,那么必须敞开连贯。在这种状况下,能够应用状态码 1002(协定谬误)。根底帧协定定义了一种帧类型,包含操作码(Opcode)、有效载荷长度,以及“扩大数据”和“利用数据”的指定地位,它们一起定义“有效载荷数据”。一些位和操作码被保留,以供将来扩大协定。在握手实现后,端点被发送敞开帧前,客户端和服务端能够随时传输数据帧。### 3.2 根底帧协定帧的格局如下图所示:
FIN:1 比特示意该帧是音讯中的最初一个分片。第一个分片也可能是最初一个分片。RSV1、RSV2、RSV3:每个 1 比特除非协商了定义非零值含意的扩大,否则必须为 0。如果收到非零值,并且没有协商的扩大定义该非零值的含意,那么接收端点必须使该 WebSocket 连贯失败。
操作码:4 比特定义对“有效载荷数据”的解释。如果收到未知操作码,那么接收端点必须使该 WebSocket 连贯失败。定义的值如下:%x0 示意连续帧%x1 示意文本帧%x2 示意二进制帧%x3-7 为未来的非管制帧预留%x8 示意连贯敞开%x9 示意 PING%xA 示意 PONG%xB-F 为未来的管制帧保留掩码:1 比特定义“有效载荷数据”是否被掩码解决。如果设置为 1,那么掩码键呈现在 Masking-key 中,它用于解除“有效载荷数据”的掩码。从客户端发送到服务器的所有帧都将此位设置为 1。
有效载荷长度:7 比特,7+16 比特,或 7+64 比特“有效载荷数据”的长度,单位是字节:如果设置为 0-125,那么它是有效载荷长度。如果设置为 126,那么接下来的 2 个字节(被解释为 16 位无符号整数)是有效载荷长度。如果设置为 127,那么接下来的 8 个字节(被解释为 64 位无符号整数,最高无效位必须为 0)是有效载荷长度。
多字节长度量应用网络字节序示意。留神,在所有状况下,必须应用最小字节数编码长度,比方,124 字节长的字符串的长度不能编码为序列 126, 0, 124。有效载荷的长度是“扩大数据”的长度 + “利用数据”的长度。“扩大数据”的长度可能为 0,在这种状况下,有效载荷长度是“利用数据”的长度。
掩码键:0 或 4 字节从客户端发送到服务端的所有帧必须通过蕴含在帧里的 32 位数值进行掩码解决。如果掩码位为 1,那么该字段存在,如果掩码位为 0,那么该字段不存在。有效载荷数据:(x+y) 字节“有效载荷数据”被定义为将 “扩大数据” 与 “利用数据” 连贯在一起。扩大数据:x 字节除非曾经协商了扩大,否则“扩大数据”为 0 字节。
所有扩大必须指定"扩大数据"的长度,或者如何计算该长度,并且在开始握手期间,必须协商扩大的应用形式。如果存在,那么“扩大数据”蕴含在总有效载荷长度中。利用数据:y 字节任意“利用数据”,占用帧中“扩大数据”前面的残余局部。“利用数据”的长度等于有效载荷长度减去“扩大数据”的长度。3.3 音讯分片(Message Fragmentation)FIN 和 Opcode 字段独特合作,发送被拆分成独自帧的音讯。这被称为音讯分片。分片仅实用于 Opcode 0x0 到 0x2 的状况。Opcode 阐明帧的用处。
如果为 0x1,那么有效载荷是文本。如果为 0x2,那么有效载荷是二进制数据。如果为 0x0,那么该帧是连续帧;这意味着服务端应该将该帧的有效载荷连贯到其从该客户端收到的最初一个帧。在上面的草图中,服务端对发送文本音讯的客户端做出响应。第一条音讯以单个帧发送,而第二条音讯用三个帧发送。
下图仅显示客户端的 FIN 和 Opcode 细节:
Client: FIN=1, opcode=0x1, msg="hello"Server: (process complete message immediately) Hi.Client: FIN=0, opcode=0x1, msg="and a"Server: (listening, new message containing text started)Client: FIN=0, opcode=0x0, msg="happy new"Server: (listening, payload concatenated to previous message)Client: FIN=1, opcode=0x0, msg="year!"Server: (process complete message) Happy new year to you too!留神,第一个帧蕴含整个音讯(FIN=1,并且opcode!=0x0),因而服务端能够按需解决或响应。客户端发送的第二个帧的有效载荷是文本(opcode=0x1),但整个音讯尚未达到(FIN=0)。该音讯的所有残余局部应用连续帧(opcode=0x0)发送,并且音讯的最初一帧用FIN=1标记。### 4. 搭建测试环境操作系统:
- macOS 12.6Wireshark 4.0.7Python 3.10.64.1 初始化虚拟环境python3 -m venv wsenv
source ./wsenv/bin/activate
pip3 install 'uvicorn[standard]' fastapi4.2 服务端代码ws_server.py:from typing import List
from fastapi import FastAPI, WebSocket, WebSocketDisconnect, Request
from fastapi.responses import HTMLResponse
import uvicorn
app = FastAPI()
html = """
<!DOCTYPE html>
<html>
<head> <title>聊天室</title></head><body> <h1>聊天室</h1> <h2>你的 ID:<span id="ws-id"></span></h2> <form action="" onsubmit="sendMessage(event)"> <input type="text" id="messageText" autocomplete="off" /> <button>发送</button> </form> <ul id="messages"> </ul> <script> const characters = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!@#$%^&*-+" let client_id = "" for (let i = 0; i < 10; i++) { const randomIndex = Math.floor(Math.random() * characters.length) client_id += characters.charAt(randomIndex) } document.querySelector("#ws-id").textContent = client_id var ws = new WebSocket(`ws://localhost:8080/ws/${client_id}`) ws.onmessage = function(event) { var messages = document.getElementById("messages") var message = document.createElement("li") var content = document.createTextNode(event.data) message.appendChild(content) messages.appendChild(message) } function sendMessage(event) { var input = document.getElementById("messageText") ws.send(input.value) input.value = "" event.preventDefault() } </script></body>
</html>
"""
# 将 HTML 返回给前端@app.get("/")async def get(): return HTMLResponse(html)class ConnectionManager: def __init__(self): self.active_connections: List[WebSocket] = [] async def connect(self, websocket: WebSocket): await websocket.accept() self.active_connections.append(websocket) def disconnect(self, websocket: WebSocket): self.active_connections.remove(websocket) async def send_personal_message(self, message: str, websocket: WebSocket): await websocket.send_text(message) async def broadcast(self, message: str): for connection in self.active_connections: await connection.send_text(message)manager = ConnectionManager()@app.websocket("/ws/{client_id}")async def websocket_endpoint(client_id: str, websocket: WebSocket): await manager.connect(websocket) await manager.broadcast(f"{client_id} 进入聊天室") try: while True: data = await websocket.receive_text() await manager.broadcast(f"{client_id} 发送音讯:{data}") await manager.send_personal_message(f"服务端回复 {client_id}:你发送的信息是:{data}", websocket) except WebSocketDisconnect: manager.disconnect(websocket) await manager.broadcast(f"{client_id} 来到聊天室")if __name__ == '__main__': uvicorn.run(app=app, host="0.04.3 启动 WebSocket 服务python3 ws_server.py4.4 启动 Wireshark启动 Wireshark 后,输出过滤条件tcp.port==8080。4.5 拜访 WebSocket 服务在浏览器地址栏中输出 http://127.0.0.1:8080/。4.6 通过 Wireshark 查看报文由上图可见,在 TCP 三次握手后,客户端向服务端发动 HTTP GET 申请,服务端的响应码是 101(Switching Protocol),至此 WebSocket 握手实现。下图是握手过程中的服务端响应报文:可见,客户端和服务端协商应用扩大“permessage-deflate”,也就是对每条音讯应用 deflate 压缩。下图是序号为 1371 的 WebSocket 文本帧:因为FIN = 1,所以该音讯只蕴含一个帧。压缩后的 Payload 长度是 30 字节。能够应用相似上面的 Python 脚本对 Payload 进行解压缩,失去压缩前的 Payload:import zlibdef decode_payload(compressed_payload_hex: str) -> bytes: return zlib.decompressobj(wbits=-zlib.MAX_WBITS).decompress(bytearray.fromhex(compressed_payload_hex))if __name__ == "__main__": assert decode_payload("0a310ff332a92a7673485278b17ff6d3d6a52f1abb9e2e59f974dd120000").decode() == \ "T7VJ4zsF@b 进入聊下图是 Payload 被掩码解决的示例:能够应用 Masking-Key 和如下 Python 脚本,获取被掩码解决前的
Payload:def unmask_payload(masking_key, masked_payload): payload = bytearray() for i in range(len(masked_payload)): payload.append(masked_payload[i] ^ masking_key[i % 4]) return payloadif __name__ == "__main__assert unmask_payload(bytes.fromhex("957824e8"), bytes.fro
## 参考文档RFC 6455: The WebSocket ProtocolWriting WebSocket servers - Web APIs | MDNhttps://blog.csdn.net/qq_33801641/article/details/120620816