Skip to content

第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 的代理转码原理

你的任务

  1. 理解 HTTP 请求-响应模型不适合什么场景(推送、实时消息、流式数据),从而引出 WebSocket 和 gRPC
  2. 理解 WebSocket 升级握手如何从 HTTP 切换到 WebSocket 协议
  3. 用 Python 实现一个能读取 WebSocket 帧的最小客户端
  4. 掌握 gRPC 的核心概念:Protocol Buffers、Service Definition、Stub
  5. 区分四种 gRPC 调用模式(Unary / Server Streaming / Client Streaming / Bidirectional)
  6. 了解 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=

三步换挡:

  1. 客户端发标准的 HTTP GET——但带 Upgrade: websocket
  2. Sec-WebSocket-Key 是 16 字节 base64 编码的随机数——防缓存代理搞混
  3. 服务器 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 客户端(只做握手 + 读一帧):

python
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 请求交错了,但互不阻塞

高密度对比:

WebSocketgRPCHTTP/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 的合同——定义服务接口和数据结构的单一真相来源。

protobuf
// 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 的编码效率:

JSONProtobuf
体积32 字节({"temp":23.5}5 字节
编码文本,键名重复二进制,只传字段编号
模式无强类型编译时类型检查
解析速度慢(运行时反射)快(直接内存映射)
bash
# 从 .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消息类(GetWeatherRequestWeatherResponse
weather_pb2_grpc.py服务 Stub 和服务端基类

技能 4:四种 gRPC 调用模式

Unary RPC(单一调用)

最简单的模式。一次请求,一次响应。行为上和传统的 HTTP POST + JSON 一模一样——但底层走 Protobuf + HTTP/2。

python
# 服务端
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 条消息,客户端收完为止。

python
# 客户端 — 订阅实时天气更新
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 条汇总。

python
# 客户端 — 批量上报传感器
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 多路复用在同一连接上交错传输。

python
# 客户端 — 双向天气聊天
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 项目

bash
# 项目结构
grpc_weather/
├── proto/
   └── weather.proto       # 上面的定义
├── server.py               # 服务端
├── client.py               # 客户端
├── gen/                    # 生成的代码
   ├── weather_pb2.py
   └── weather_pb2_grpc.py
└── requirements.txt

server.py(完整服务端):

python
# 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 秒多客户端:

bash
# 在另一个终端
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')
"

通关挑战

基础级 🥉

  1. 用工具 websocat 或 wscat 连接 ws://echo.websocket.org,手动发送一条消息并验证回显
  2. 从 Java 的 java.net.http.WebSocket 或 C++ 的 Beast 库中找到 WebSocket 的握手代码,标注哪个字段对应 Sec-WebSocket-Accept
  3. 编写一个 .proto 定义用户登录服务(LoginRequest / LoginResponse / AuthService)

进阶级 🥈

  1. 用 Python socket 模块实现 WebSocket 二进制帧的解码函数,能正确解析 opcode、payload length 和 payload
  2. 部署一个本地 gRPC 服务(Unary + Server Streaming),用 grpcurl 工具不写代码调用它
  3. 对比 gRPC 和传统 HTTP POST 的吞吐量——为什么 gRPC 快?

专家级 🥇

  1. 在 WebSocket 之上实现简单的 STOMP 协议(消息广播 + 订阅模式)
  2. 用 gRPC 的拦截器(Interceptor)实现请求日志和 token 验证
  3. 搭建 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 服务最少代码路径:写 .protoprotoc → 实现 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章从域名解析法阵到内容分发驿道到负载均衡——你把法术域名变成驿道坐标、把法术请求指向最近的信标塔、把魔力流量均匀分摊到后端法阵集群。这些构成现代驿道的入口骨架,下一章带你走进这个看不见的导航系统。

Built with VitePress | Software Systems Atlas