第6章:现代协议:WebSocket 与 gRPC
元数据卡
| 属性 | 值 |
|---|---|
| 难度 | |
| 前置 | HTTP/1.1(第4章)、TLS 基础(第5章) |
| 核心概念 | WebSocket 升级握手、全双工通信、gRPC + Protocol Buffers、HTTP/2 多路复用、Streaming |
| 实战能力 | 手写 WebSocket 客户端、搭建 gRPC 服务、理解流式 RPC 调用 |
你在哪
"HTTP 默认是'一问一答'——你发一道法术请求,信标塔回复,然后结束。但如果信标塔想主动给你推送法术消息呢?或者你需要跨法师塔调用远程服务?WebSocket 和 gRPC 改写了对话规则。"
HTTP/1.1 是请求-响应模型:法师客户端发请求、信标塔回响应、传送咒连接也许复用但模式始终是一问一答。
一问一答: → 请求 → → 请求 →
← 响应 ← ← 响应 ←
全双工: →→→→→ 同时收发 ←←←←←你需要推送通知、即时消息、股票行情、实时游戏——HTTP/1.1 能行吗?轮询?太长太蠢。
本章两个协议都突破了 HTTP 的枷锁,但走了完全不同的路:
- WebSocket:在 HTTP 头部打一个洞,然后完全抛弃 HTTP 协议
- gRPC:用 HTTP/2 的筋骨包装 RPC,让 HTTP 重新活过来
本章分层
- 必读:理解 HTTP 请求-响应模型不适用的场景(推送、实时消息、流式数据),WebSocket 升级握手的基本流程,gRPC 的核心概念(.proto 文件、四种调用模式)
- 选读:WebSocket 帧格式详解、gRPC 服务端/客户端代码编写
- 深水区:Protocol Buffers 编码效率分析、HTTP/2 帧级多路复用细节
本章不会要求你掌握
- WebSocket 掩码(Masking)的格式细节
- gRPC 的 Deadlines / Timeouts 传播机制
- gRPC-Web 的代理转码原理
你的任务
- 理解 HTTP 请求-响应模型不适合什么场景(推送、实时消息、流式数据),从而引出 WebSocket 和 gRPC
- 理解 WebSocket 升级握手如何从 HTTP 切换到 WebSocket 协议
- 用 Python 实现一个能读取 WebSocket 帧的最小客户端
- 掌握 gRPC 的核心概念:Protocol Buffers、Service Definition、Stub
- 区分四种 gRPC 调用模式(Unary / Server Streaming / Client Streaming / Bidirectional)
- 了解 HTTP/2 多路复用如何让 gRPC 高效(不要求帧级细节)
遭遇战 → 获得技能
技能 1:WebSocket 升级握手
WebSocket 出生在 2011 年(RFC 6455),目标是在以文档为中心的 HTTP 上开一个实时通信的隧道。
握手是一场协议换挡:
客户端(HTTP 请求,带 Upgrade)
─────────────────────────────→
GET /chat HTTP/1.1
Host: server.example.com
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Sec-WebSocket-Version: 13
服务器(101 Switching Protocols)
←─────────────────────────────
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=三步换挡:
- 客户端发标准的 HTTP GET——但带
Upgrade: websocket Sec-WebSocket-Key是 16 字节 base64 编码的随机数——防缓存代理搞混- 服务器 SHA-1(
Sec-WebSocket-Key+258EAFA5-E914-47DA-95CA-C5AB0DC85B11) → base64 →Sec-WebSocket-Accept
为什么要有那个魔法 GUID? 如果服务器不认识 WebSocket,它返回 200 而不是 101——客户端发现不是正确的 Accept 值就断开连接。那个 GUID 是 WebSocket 协议规范里的固定字符串,确保只有实现该规范的服务器能完成握手。
握手后:
之前: HTTP 协议(文本头部 + 分隔符)
之后: WebSocket 帧协议(二进制帧头 + payload)
← 双向、实时的低延迟通道Python 极简 WebSocket 客户端(只做握手 + 读一帧):
import socket
import base64
import hashlib
import struct
def websocket_connect(host: str, port: int, path: str = "/"):
# 1. 生成随机的 Sec-WebSocket-Key
import os
key_raw = os.urandom(16)
key_b64 = base64.b64encode(key_raw).decode()
# 2. TCP 连接 + 发送 HTTP 升级请求
sock = socket.create_connection((host, port), timeout=5)
request = (
f"GET {path} HTTP/1.1\r\n"
f"Host: {host}:{port}\r\n"
f"Upgrade: websocket\r\n"
f"Connection: Upgrade\r\n"
f"Sec-WebSocket-Key: {key_b64}\r\n"
f"Sec-WebSocket-Version: 13\r\n"
f"\r\n"
)
sock.sendall(request.encode())
# 3. 验证响应
response = sock.recv(4096)
expected_accept = base64.b64encode(
hashlib.sha1(
(key_b64 + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11").encode()
).digest()
).decode()
if expected_accept.encode() not in response:
raise Exception("WebSocket 握手失败")
print("✅ WebSocket 连接建立")
return sock
# 读一帧(只看第一个字节的 opcode)
def read_frame(sock: socket.socket):
header = sock.recv(2)
b0, b1 = header[0], header[1]
opcode = b0 & 0x0F
masked = bool(b1 & 0x80)
length = b1 & 0x7F
if length == 126:
length = struct.unpack("!H", sock.recv(2))[0]
elif length == 127:
length = struct.unpack("!Q", sock.recv(8))[0]
mask_key = sock.recv(4) if masked else None
payload = sock.recv(length)
if masked:
payload = bytes(b ^ mask_key[i % 4] for i, b in enumerate(payload))
return opcode, payload
sock = websocket_connect("echo.websocket.org", 80)
opcode, payload = read_frame(sock)
# Opcode 0x1 = text, 0x9 = ping
print(f"Opcode: {hex(opcode)}, payload: {payload}")帧结构一览:
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) |
+-------------------------------+-------------------------------+
| Masking-key (continued) | Payload Data |
+-------------------------------- - - - - - - - - - - - - - - -+
: Payload Data continued ... :
+---------------------------------------------------------------+现在不需要理解 掩码(Masking)的细节。记住「客户端发服务器必须加掩码,服务器发客户端不加」就足够了。
技能 2:全双工 vs 半双工 vs 多路复用
HTTP/1.1(半双工——即使 keep-alive,一次也只有一个请求在处理)
Time ─────────────────────────────────────────────→
客户端: REQ1 ·············· REQ2
服务器: ······· RES1 ··············· RES2
WebSocket(全双工——双方可以同时发)
客户端: ──────── 推 ──── 推 ──────────
服务器: ─── 推 ──── 推 ────────────────
↑ 同时进行 ↑
HTTP/2(多路复用——一个连接上的多个流可以交错)
客户端: ──[REQ1]──[REQ2]──[REQ1帧续]───────
服务器: ──[RES1头]──[RES2头]──[RES1体]─────
↑ 多个 HTTP 请求交错了,但互不阻塞高密度对比:
| WebSocket | gRPC | HTTP/2 SSE | |
|---|---|---|---|
| 传输 | 自定义帧协议 | HTTP/2 帧 | HTTP 文本流 |
| 方向 | 全双工 | 全双工 | 服务器→客户端单工 |
| 消息格式 | 任意(常为 JSON) | Protocol Buffers | 纯文本 |
| 浏览器支持 | 原生 | 需 grpc-web | 原生 EventSource |
| 连接数 | 1 连接 | 1 连接(多路复用) | 1 连接 |
🔼 以上为 WebSocket 入门(必读/选读)
以下为 gRPC 部分(选读/深水区)
技能 3:gRPC 与 Protocol Buffers
gRPC 是 Google 在 2016 年开源的高性能 RPC 框架。口号:无论你在哪,调用远程方法就像调用本地函数。
┌───────────┐ ┌───────────┐
│ gRPC │ HTTP/2 │ gRPC │
│ Client │──────────│ Server │
│ │ 帧协议 │ │
│ Stub <─── Proto ───> Service │
└───────────┘ └───────────┘核心约定:IDL 文件(.proto)
.proto 文件是 gRPC 的合同——定义服务接口和数据结构的单一真相来源。
// proto/weather.proto
syntax = "proto3";
package weather;
// --- 数据结构 ---
message GetWeatherRequest {
string city_code = 1; // 字段编号 = 1
bool extended = 2;
}
message WeatherResponse {
double temperature = 1;
double humidity = 2;
string description = 3;
int64 timestamp = 4; // Unix 时间戳
}
// --- 服务定义 ---
service WeatherService {
rpc GetWeather(GetWeatherRequest) returns (WeatherResponse);
// 服务端流式:订阅实时天气
rpc SubscribeWeather(GetWeatherRequest) returns (stream WeatherResponse);
// 客户端流式:批量上报传感器数据
rpc ReportBatch(stream WeatherResponse) returns (WeatherResponse);
// 双向流式:视频会议叫号
rpc WeatherChat(stream GetWeatherRequest) returns (stream WeatherResponse);
}深水区:以下 Protobuf 编码效率对比和 HTTP/2 多路复用的帧级细节属于加深理解的内容。主线只需知道「Protobuf 比 JSON 小且快」「HTTP/2 多路复用让 gRPC 高效」即可。
Protocol Buffers 的编码效率:
| JSON | Protobuf | |
|---|---|---|
| 体积 | 32 字节({"temp":23.5}) | 5 字节 |
| 编码 | 文本,键名重复 | 二进制,只传字段编号 |
| 模式 | 无强类型 | 编译时类型检查 |
| 解析速度 | 慢(运行时反射) | 快(直接内存映射) |
# 从 .proto 生成 Python stub
pip install grpcio grpcio-tools
python -m grpc_tools.protoc \
-I proto \
--python_out=gen \
--grpc_python_out=gen \
proto/weather.proto结果文件:
| 文件 | 内容 |
|---|---|
weather_pb2.py | 消息类(GetWeatherRequest、WeatherResponse) |
weather_pb2_grpc.py | 服务 Stub 和服务端基类 |
技能 4:四种 gRPC 调用模式
Unary RPC(单一调用)
最简单的模式。一次请求,一次响应。行为上和传统的 HTTP POST + JSON 一模一样——但底层走 Protobuf + HTTP/2。
# 服务端
import grpc
from concurrent import futures
class WeatherServiceServicer(weather_pb2_grpc.WeatherServiceServicer):
def GetWeather(self, request, context):
# request 是 GetWeatherRequest 对象
# request.city_code → string
# request.extended → bool
return weather_pb2.WeatherResponse(
temperature=23.5,
humidity=65.0,
description="多云转晴",
timestamp=int(time.time())
)
def serve():
server = grpc.server(futures.ThreadPoolExecutor(max_workers=10))
weather_pb2_grpc.add_WeatherServiceServicer_to_server(
WeatherServiceServicer(), server
)
server.add_insecure_port("[::]:50051")
server.start()
server.wait_for_termination()
# 客户端
def run_client():
with grpc.insecure_channel("localhost:50051") as channel:
stub = weather_pb2_grpc.WeatherServiceStub(channel)
resp = stub.GetWeather(
weather_pb2.GetWeatherRequest(city_code="BJ", extended=True)
)
print(f"温度: {resp.temperature}°C, {resp.description}")注意
grpc.insecure_channel()——生产环境必须换成grpc.secure_channel()加 TLS 证书。
Server Streaming RPC(服务端流)
服务器发 N 条消息,客户端收完为止。
# 客户端 — 订阅实时天气更新
def subscribe():
with grpc.insecure_channel("localhost:50051") as channel:
stub = weather_pb2_grpc.WeatherServiceStub(channel)
# SubscribeWeather 返回一个 iterator
for update in stub.SubscribeWeather(
weather_pb2.GetWeatherRequest(city_code="BJ", extended=True)
):
print(f"[{datetime.fromtimestamp(update.timestamp)}] "
f"{update.temperature}°C {update.description}")
# 这会在循环中逐条消费——HTTP/2 流保证了持续推送Client Streaming RPC(客户端流)
客户端发 N 条,服务器回 1 条汇总。
# 客户端 — 批量上报传感器
def report_batch():
with grpc.insecure_channel("localhost:50051") as channel:
stub = weather_pb2_grpc.WeatherServiceStub(channel)
def sensor_generator():
"""生成无限传感器数据的模拟器"""
for i in range(100):
yield weather_pb2.WeatherResponse(
temperature=20.0 + i * 0.1,
timestamp=int(time.time())
)
time.sleep(0.01) # 100 条 / 秒
summary = stub.ReportBatch(sensor_generator())
print(f"汇总报告: {summary.temperature}°C, {summary.description}")Bidirectional Streaming RPC(双向流)
双方各自独立流式发送——通过 HTTP/2 多路复用在同一连接上交错传输。
# 客户端 — 双向天气聊天
def weather_chat():
with grpc.insecure_channel("localhost:50051") as channel:
stub = weather_pb2_grpc.WeatherServiceStub(channel)
def input_generator():
cities = ["BJ", "SH", "GZ", "SZ", "CD"]
for city in cities:
yield weather_pb2.GetWeatherRequest(city_code=city)
time.sleep(0.5)
# 双向流:等待响应的同时继续发送
responses = stub.WeatherChat(input_generator())
for resp in responses:
print(f"收到: {resp.city_code} → {resp.temperature}°C")
# 注意:for resp in responses 不会阻塞发送——HTTP/2 双工特性技能 5:HTTP/2 多路复用——gRPC 的隐形引擎
为什么 gRPC 选择 HTTP/2 而不是 WebSocket?因为 HTTP/2 原生支持:
HTTP/2 连接
┌─────────────────────────────────────────────────────┐
│ Stream 1: gRPC Unary Call #1 │
│ HEADERS ──→ DATA ──→ ... ──→ HEADERS ←─ DATA ←─ │
│ │
│ Stream 2: gRPC Server Streaming │
│ HEADERS ──→ DATA ──→ DATA ──→ DATA ──→ ... │
│ │
│ Stream 3: gRPC Client Streaming │
│ HEADERS ──→ DATA ──→ DATA ──→ ... ──→ HEADERS ←─│
│ │
│ Stream 4: gRPC Bidirectional Streaming │
│ HEADERS ──→ DATA ──→ DATA ←─ DATA ──→ DATA ←─ │
└─────────────────────────────────────────────────────┘每个 gRPC 调用对应一个 HTTP/2 Stream——多个 Stream 复用同一个 TCP 连接。一次 TLS 握手,无数次 RPC 调用。头部压缩(HPACK)进一步降低开销。
这也是为什么 gRPC 在高吞吐场景下比 REST + JSON 快 5-10 倍的原因——二进制 + 多路复用 + 无序列化开销。
常见陷阱:完整 gRPC 项目
# 项目结构
grpc_weather/
├── proto/
│ └── weather.proto # 上面的定义
├── server.py # 服务端
├── client.py # 客户端
├── gen/ # 生成的代码
│ ├── weather_pb2.py
│ └── weather_pb2_grpc.py
└── requirements.txtserver.py(完整服务端):
# grpc_weather/server.py
import time
import grpc
from concurrent import futures
from gen import weather_pb2, weather_pb2_grpc
class WeatherServicer(weather_pb2_grpc.WeatherServiceServicer):
def GetWeather(self, request, context):
# 简单的城市→温度映射
data = {"BJ": 23.5, "SH": 25.0, "GZ": 30.0}
temp = data.get(request.city_code, 20.0)
return weather_pb2.WeatherResponse(
temperature=temp,
description="晴",
timestamp=int(time.time())
)
def SubscribeWeather(self, request, context):
"""服务端流:每秒推送一次"""
for _ in range(10): # 推 10 次
yield weather_pb2.WeatherResponse(
temperature=23.0 + (time.time() % 5),
timestamp=int(time.time())
)
time.sleep(1)
def WeatherChat(self, request_iterator, context):
"""双向流:对每个请求立即回复"""
for req in request_iterator:
yield weather_pb2.WeatherResponse(
city_code=req.city_code,
temperature=25.0,
timestamp=int(time.time())
)
def serve():
server = grpc.server(
futures.ThreadPoolExecutor(max_workers=10),
options=[
("grpc.max_send_message_length", 50 * 1024 * 1024),
("grpc.max_receive_message_length", 50 * 1024 * 1024),
]
)
weather_pb2_grpc.add_WeatherServiceServicer_to_server(
WeatherServicer(), server
)
server.add_insecure_port("[::]:50051")
print("gRPC server on :50051")
server.start()
server.wait_for_termination()
if __name__ == "__main__":
serve()1.5 秒多客户端:
# 在另一个终端
python -c "
import grpc
from gen import weather_pb2, weather_pb2_grpc
with grpc.insecure_channel('localhost:50051') as ch:
stub = weather_pb2_grpc.WeatherServiceStub(ch)
resp = stub.GetWeather(weather_pb2.GetWeatherRequest(city_code='BJ'))
print(f'BJ: {resp.temperature}°C')
"通关挑战
基础级 🥉
- 用工具 websocat 或 wscat 连接
ws://echo.websocket.org,手动发送一条消息并验证回显 - 从 Java 的
java.net.http.WebSocket或 C++ 的 Beast 库中找到 WebSocket 的握手代码,标注哪个字段对应Sec-WebSocket-Accept - 编写一个
.proto定义用户登录服务(LoginRequest / LoginResponse / AuthService)
进阶级 🥈
- 用 Python
socket模块实现 WebSocket 二进制帧的解码函数,能正确解析 opcode、payload length 和 payload - 部署一个本地 gRPC 服务(Unary + Server Streaming),用
grpcurl工具不写代码调用它 - 对比 gRPC 和传统 HTTP POST 的吞吐量——为什么 gRPC 快?
专家级 🥇
- 在 WebSocket 之上实现简单的 STOMP 协议(消息广播 + 订阅模式)
- 用 gRPC 的拦截器(Interceptor)实现请求日志和 token 验证
- 搭建 gRPC 双向流实现一个迷你聊天室(登录 → 进入房间 → 实时收发消息)
验收标准
- [ ] 我能解释 WebSocket 101 升级握手每一步做了什么
- [ ] 我说得出 WebSocket 帧的基本结构(FIN/Opcode/Mask/Payload)
- [ ] 我会写
.proto文件并生成 Python stub - [ ] 我能区分四种 gRPC 模式并给出适用场景
- [ ] 我知道 gRPC 为什么是基于 HTTP/2 而不是 WebSocket
- [ ] 我知道全双工和多路复用是两个不同概念
- [ ] 我能从网络排查的角度判断 WebSocket 握手是否成功
常见卡点
卡点:WebSocket 的跨域问题 握手的 HTTP 头部也有 Origin。服务器可以检查 Origin 来拒绝跨域连接——CORS-like 机制。但 WebSocket 不受浏览器同源策略严格限制,所以服务器端必须自己校验。
卡点:gRPC 的负载均衡 gRPC 默认是长连接。普通的七层轮询 LB 会把所有请求散到不同后端——gRPC 复用连接后永远落在一个后端上。解法:gRPC 客户端侧负载均衡(name resolver + pick_first / round_robin 策略)或 L7 代理支持 gRPC。
卡点:gRPC 什么时候不用
- 浏览器客户端(grpc-web 可用但功能受限)
- HTTP 简单接口/REST 公开 API
- 调试友好优先的场景(Protobuf 二进制流难 curl)
卡点:WebSocket 的心跳(Ping/Pong) WebSocket 协议内置 Ping/Pong 帧(opcode 0x9 / 0xA)——不需要业务层发心跳消息。服务器也可以主动 Pong。标准实现必须响应 Ping。
现在不需要理解
- WebSocket 的关闭帧(Close Frame)握手细节——知道有 opcode 0x8 就行
- gRPC 的 Deadlines / Timeouts 传播机制——看 client_interceptor 文档即可
- gRPC-Web 的代理转码原理——需要 Envoy 或其他 gRPC-Web 代理
- HTTP/2 的流控(Flow Control)窗口算法——gRPC 内部处理了,你一般不需要调
- WebSocket over HTTP/2(RFC 8441)——实验性扩展,等着就好
旅人笔记
- WebSocket 库推荐
websockets(Python 异步)或ws(Node.js)——别手搓帧解析,除非你要面试 - gRPC 服务最少代码路径:写
.proto→protoc→ 实现 Servicer → 启动 server → Stub 调用 grpcurl类比curl——grpcurl -plaintext localhost:50051 list列出所有服务- gRPC 强烈建议生产环境启用 TLS——
grpc.secure_channel()和server.add_secure_port() - 双向流不是全双工同时读写——在 Python 里它是两个独立的
async循环,但底层确实是双工 - 浏览器端的 WebSocket 已经很强大了——原生
new WebSocket(),无需第三方库
→ 下一站预告: 第7章从域名解析法阵到内容分发驿道到负载均衡——你把法术域名变成驿道坐标、把法术请求指向最近的信标塔、把魔力流量均匀分摊到后端法阵集群。这些构成现代驿道的入口骨架,下一章带你走进这个看不见的导航系统。