AI 生成声明:本文内容由 AI 生成或经 AI 辅助完成,请注意甄别。

有同学在项目里写了这样一段 WebSocket 消息序列化代码,然后在评审时连续抛了几个好问题:

message = json.dumps({
    "type": event_type.value,
    "data": data
}, ensure_ascii=False, cls=CustomEncoder, indent=2)

is_binary = event_type == EventType.AFTER_DATA
message_bytes = message.encode('utf-8') if is_binary else None

围绕这 7 行代码,我们把下面这些话题从"看一眼就懂"聊到了"真正踩过坑才会注意"的层次:

  1. message_bytes 是 zlib 压缩流吗?为什么一看到 encode('utf-8') 就能断定不是?
  2. "类型上是 bytes 叫二进制" 和 "内容上是真的二进制格式" 是两回事——怎么区分?
  3. 为什么后端明明发了 bytes,前端拿到的还是 Blob 而不是 ArrayBuffer?
  4. 一条 15MB 的消息,Blob 收 vs ArrayBuffer 收,性能差距能有多大?
  5. 后端要不要加 zlib.compress?前端要不要 pako.inflate?为什么?
  6. Python 没有 ArrayBuffer,那为什么"后端不用改成 ArrayBuffer"?

逐条展开,先从最容易被误解的"zlib 头"说起。


一、message_bytes 是 zlib 头吗?——看头两字节就够了

答案很直接:不是。message_bytes 只是 UTF-8 编码后的 JSON 文本字节,这段代码从头到尾没有调用过 zlib。

判断依据分三层:

1. 代码里根本没有 zlib 调用

message_bytes = message.encode('utf-8') if is_binary else None

整段只有 json.dumps + str.encode('utf-8')。既没有 import zlib,也没有 zlib.compress(...)。json.dumps 产出的是 str,encode('utf-8') 产出的是 bytes——是"文本转字节",不是"压缩流"。

2. zlib 头 vs JSON 头

zlib 压缩流的开头是两字节 CMF/FLG,常见组合固定:

头两字节 压缩级别
0x78 0x01 低级别
0x78 0x5E 级别 1~5
0x78 0x9C 默认级别(最常见)
0x78 0xDA 最佳级别

而 message_bytes 是 JSON 文本的 UTF-8 编码,第一个字节是 { → 0x7B,然后是 " → 0x22、t、y……开头就是可读的 JSON。0x7B 和 0x78 一眼就能分辨。

3. 快速验证方法

如果你手里有实际发出的字节,直接看前四个字节的 hex:

print(message_bytes[:4].hex())
# 带 indent=2 的 JSON 开头会是: 7b0a2020 → 对应 {\n␣␣
# 如果是 zlib 默认压缩:          789cxxxx  → 典型 zlib 头

二、那它是"二进制"吗?——两种含义别混淆

这是讨论里最容易绕进去的点。"二进制"这个词有两层完全不同的含义:

从 Python 类型看:是二进制

message.encode('utf-8') 返回 bytes 类型——Python 里它就叫"二进制数据"(binary data),和 str 是两个独立的类型。在 WebSocket 接口里也走不同分支:

  • ws.send(str) → 文本帧(text frame)
  • ws.send(bytes) → 二进制帧(binary frame)

所以这段代码的作者把开关叫 is_binary、把变量叫 message_bytes,完全合理——它确实是"以二进制方式发送"。

从内容看:它只是文本的字节表示

关键在于,这个 bytes 的内容是一段 JSON 文本:

7b 0a 20 20 22 74 79 70 65 ...
 ├─ 对应 ─┤
{
  "type

每个字节都对应一个可读字符,整体就是文本按 UTF-8 规则翻译成的字节序列。它不是压缩流、不是自定义协议帧、不是 protobuf/msgpack 这类真·二进制格式。

一句话把两层说清

message_bytes 在类型上是 bytes(二进制类型),发送时确实会以二进制帧送出去;但它的内容只是 UTF-8 编码的 JSON 文本。

换句话说:用二进制通道发送了一段文本,不是发送了真正意义上的二进制协议数据。


三、为什么后端发的是 bytes,前端拿到的还是 Blob?

这是最关键的误解源头。讨论到这里才引出了那张 15MB 大消息的对比图:

后端用 bytes 发 → WebSocket 走二进制帧 → 浏览器收到这帧二进制。但是浏览器默认把二进制帧包装成 Blob 交给前端代码,而不是 ArrayBuffer。

Blob vs ArrayBuffer 对比

对比维度 Blob(默认) ArrayBuffer
本质 类文件对象,底层数据不一定连续 固定长度的原始字节缓冲区
拿原始字节 需要 await blob.arrayBuffer() 异步读 直接就是字节,new Uint8Array(buf) 同步访问
内存拷贝 读取时多一次拷贝 零拷贝视图,Uint8Array 直接引用底层内存
能否直接解压 不能,先异步转 ArrayBuffer 才能喂 pako 可以,直接 pako.inflate(new Uint8Array(buf))
15MB 大消息 异步等待 + 拷贝开销显著 立即可用,直接进解压/解析

决定一切的那一行

ws.binaryType = 'arraybuffer';  // 加上这个

不设这行 → 所有二进制帧都是 Blob。设了 → 直接拿到 ArrayBuffer。后端发什么、怎么发,完全不影响这一选择。


四、前端该怎么改?——三种配套写法

最小改动版

const ws = new WebSocket('ws://your-server/...');
ws.binaryType = 'arraybuffer'; // 关键这一行

ws.onmessage = (event) => {
  const text = new TextDecoder('utf-8').decode(event.data);
  const msg = JSON.parse(text);
  console.log(msg.type, msg.data);
};

new WebSocket(...) 之后立刻设就行,不用等 onopen。只要在第一个二进制消息到达前设好就生效。

更稳妥:复用 decoder + 区分帧类型

后端 is_binary 为 False 时发的是 str(文本帧),前端收到的是 string;True 时发 bytes,前端才收到 ArrayBuffer。两种帧要分开处理:

const ws = new WebSocket('ws://your-server/...');
ws.binaryType = 'arraybuffer';

const decoder = new TextDecoder('utf-8');

ws.onmessage = (event) => {
  let text;
  if (typeof event.data === 'string') {
    // 后端 is_binary=False:文本帧,直接拿 string
    text = event.data;
  } else {
    // 后端 is_binary=True:二进制帧,ArrayBuffer → 文本
    text = decoder.decode(event.data);
  }
  const msg = JSON.parse(text);
  handle(msg.type, msg.data);
};

和后端的 is_binary = event_type == EventType.AFTER_DATA 完全对齐。

如果后端将来加了 zlib 压缩

假设后端把 message.encode('utf-8') 包了一层 zlib.compress(...),前端就要先解压。好在 zlib 头 0x78 xx 和未压缩 JSON 头 0x7B 永远不会撞,靠头字节就能安全区分:

import pako from 'pako';

const ws = new WebSocket('ws://your-server/...');
ws.binaryType = 'arraybuffer';
const decoder = new TextDecoder('utf-8');

ws.onmessage = (event) => {
  if (typeof event.data === 'string') {
    handle(JSON.parse(event.data));
    return;
  }

  const bytes = new Uint8Array(event.data); // 零拷贝视图
  const text = (bytes.length >= 2 && bytes[0] === 0x78)
    ? decoder.decode(pako.inflate(bytes))  // zlib 压缩
    : decoder.decode(bytes);                // 未压缩的 UTF-8 文本
  handle(JSON.parse(text));
};

new Uint8Array(event.data) 是零拷贝的——它不复制数据,只是给 ArrayBuffer 套一层可读写视图。这正是"ArrayBuffer 快"的核心原因:整条链路里没有一次额外的内存拷贝。


五、后端需要改吗?——看你的目标

只为让前端拿到 ArrayBuffer → 后端完全不用动

Python 里根本没有 ArrayBuffer 这个类型。后端能做的只有往网络写字节,字节到浏览器之后,是 Blob 还是 ArrayBuffer 完全由前端 binaryType 决定。整条链路:

Python bytes → 网络字节流 → 浏览器 → binaryType='arraybuffer' → JS 拿到 ArrayBuffer
                              └→ binaryType 未设 (默认 blob)  → JS 拿到 Blob

后端那一格始终是 bytes。后端唯一要确认的是:调用发送接口时传的是 message_bytes(bytes)而不是 message(str)——传错了就变成文本帧,前端无论怎么设 binaryType 都拿不到 ArrayBuffer。

如果想进一步优化那 15MB 大消息 → 后端加 zlib 压缩

当前代码发的是未压缩的 UTF-8 JSON。15MB 级别的 JSON 文本可压缩性非常高(压缩比常到 5~10 倍),值得在后端压一下再发:

import zlib

COMPRESS_THRESHOLD = 1024  # 1KB 以上才压,避免小消息压完反而变大

if is_binary:
    raw = message.encode('utf-8')
    if len(raw) >= COMPRESS_THRESHOLD:
        message_bytes = zlib.compress(raw, level=6)  # 默认级别,性价比最高
    else:
        message_bytes = raw  # 小消息不压,直接发
else:
    message_bytes = None

压缩收益是双重的:

  • 网络传输量直接降到几分之一,跨机房或公网链路最明显
  • 前端链路配合 ArrayBuffer + pako,全程零拷贝。换成 Blob 的话,光 await blob.arrayBuffer() 这一步就是一次实打实的内存拷贝和异步等待

六、几个容易踩的坑

1. binaryType 别写错位置

它是 WebSocket 实例的属性,必须在实例上设。别写成 WebSocket.binaryType(类上没有),也别放到 onmessage 里面(那样第一帧可能还是 Blob)。

2. 设了 arraybuffer 就别再用 FileReader

FileReader 是 Blob 时代的写法。现在 event.data 直接就是 ArrayBuffer,new Uint8Array(event.data) 立刻可用,一步到位。

3. TextDecoder 要复用

每条消息 new TextDecoder('utf-8') 在高频场景下会有 GC 压力,提到模块作用域复用一个。它本身是可重复调用的。

4. 压缩级别别盲目拉满

zlib.compress(..., level=6) 是默认级别,压缩率和 CPU 耗时的性价比最高。level=9 压得再多一点点,但 CPU 明显更贵。高频小消息甚至可以 level=1 求速度。

5. 缩进空格在压缩场景下不重要

json.dumps(..., indent=2) 产出的缩进空格会被 zlib 轻松压掉,对最终传输体积影响很小。但如果后端在序列化阶段就让内存吃紧(15MB 字符串本身占 15MB 内存),去掉 indent 能让中间字符串小一截,省的是后端内存而不是网络。


总结

回到开头那 7 行代码,讨论完一圈得到的核心结论:

  1. message_bytes 是 UTF-8 编码的 JSON 文本字节,不是 zlib 流——看头两字节 0x7B 就确定了
  2. "是二进制"指的是类型是 bytes,内容还是 JSON 文本——两种含义别混淆
  3. 后端发 bytes 就够了,前端拿 Blob 还是 ArrayBuffer 完全由 ws.binaryType 决定
  4. 15MB 大消息:ArrayBuffer + Uint8Array 零拷贝视图比 Blob + FileReader 异步读快一截
  5. 想进一步省网络:后端加 zlib.compress,前端加 pako.inflate,靠 0x78 vs 0x7B 头安全区分
  6. Python 没有 ArrayBuffer,也不需要有——后端永远只管发 bytes,协议形态由前端选

整条 WebSocket 二进制通道的"性能差",往往就藏在这几行设置和头字节判断里。


原文地址: https://www.cveoy.top/t/topic/qHWd 著作权归作者所有。请勿转载和采集!

免费AI点我,无需注册和登录