本节目标:能从一段字节流里切出完整的 WebSocket 帧,并正确处理掩码、长度编码和分片消息。
适用版本:JDK 25(Temurin 25.0.5+8);只使用java.nio与 JDK 标准库。
承接的问题
11.1 把连接切到了 WebSocket,但 linkd 的 /ws/echo 还不能回显。101 之后的字节没有 HTTP 的 \r\n\r\n 可以找,也没有 Content-Length 可以数;一帧的长度写在帧头里,而帧头本身又可能是半截。一次 read 可能只拿到 1 个字节,也可能拿到 3 帧加半帧。
这和第 4 章的「长度域帧」是同一个问题,只是长度域的位置和宽度变了:WebSocket 的长度域在第二个字节的低 7 位,超过 125 时用 126/127 做标记,再跟 2 或 8 字节的扩展长度。把这三段读对,帧边界就稳定了。
本机口径:Apple M1 Pro,10 核,32 GB,macOS 26.3 (25D125);Temurin 25.0.5+8。
核心机制一:两字节头与三种长度编码
每个帧至少两个字节:
| 字节 | 位 | 字段 | 含义 |
|---|---|---|---|
| 0 | 0x80 | FIN | 1 表示消息最后一帧 |
| 0 | 0x40/0x20/0x10 | RSV1/2/3 | 扩展位,没有协商扩展时必须为 0 |
| 0 | 0x0F | opcode | 0 continuation、1 text、2 binary、8 close、9 ping、A pong |
| 1 | 0x80 | MASK | 1 表示载荷被掩码 |
| 1 | 0x7F | length | 0–125 直接表示长度,126/127 是标记 |
长度域有三档:
| 第二字节低 7 位 | 后续字节 | 实际长度 |
|---|---|---|
| 0–125 | 无 | 就是这个值 |
| 126 | 2 字节无符号大端 | 16 位长度,最大 65535 |
| 127 | 8 字节大端,最高位必须为 0 | 64 位长度,本实现上限 16 MiB |
linkd 的本机编码结果可以直接对照:
server frame = 810548656c6c6f
len300 header = 827e012c
len70000 header = 827f0000000000011170
masked len70000 header = 82ff000000000001117037fa213d
第一行 81 05 表示 FIN=1、opcode=1(text)、长度 5,后面是 Hello 的 ASCII。第二行 82 7e 01 2c 里,0x7e 是 126,真正的长度是接下来的 0x012c,也就是 300;如果把 126 当成长度,解析器会只读 126 字节,剩下的 174 字节被当成下一帧的帧头。第三行 82 7f 后面 8 字节是 0x0000000000011170,等于 70000。第四行是客户端版本:0xff 的最高位是 MASK,低 7 位仍是 127,随后 8 字节长度,再跟 4 字节掩码键。
共享类型只保存语义,不保存线上编码:
package com.plume.linkd.ws;
public record WebSocketFrame(boolean fin, int opcode, byte[] payload) {
public static final int OP_CONTINUATION = 0x0;
public static final int OP_TEXT = 0x1;
public static final int OP_BINARY = 0x2;
public static final int OP_CLOSE = 0x8;
public static final int OP_PING = 0x9;
public static final int OP_PONG = 0xA;
public WebSocketFrame {
if ((opcode >= 0x3 && opcode <= 0x7) || opcode >= 0xB) {
throw new IllegalArgumentException("reserved opcode");
}
if (payload == null) {
payload = new byte[0];
}
if (isControl(opcode) && (!fin || payload.length > 125)) {
throw new IllegalArgumentException("bad control frame");
}
if (opcode == OP_CLOSE && payload.length == 1) {
throw new IllegalArgumentException("close payload must be empty or at least 2 bytes");
}
}
public static boolean isControl(int opcode) {
return (opcode & 0x8) != 0;
}
}
控制帧的 opcode 最高位是 1(0x8 到 0xF),所以 isControl 只看这一位。0x3–0x7 和 0xB–0xF 是保留值,构造器直接拒绝;否则一个保留 opcode 会被当成未知数据帧悄悄吞掉。
长度字段只算载荷,不算 2 字节头、扩展长度和 4 字节掩码键。判断整帧是否到齐,要用 2 + 扩展长度 + (masked ? 4 : 0) + payloadLength,少加任何一段都会把下一帧的帧头当成载荷。
核心机制二:客户端掩码与 XOR
客户端发往服务端的帧必须掩码,服务端发往客户端的帧必须不掩码。方向搞反,对端会以 1002 关闭连接。掩码算法只有一步:
payload[i] = payload[i] XOR maskKey[i mod 4]
掩码键是 4 字节,每帧重新生成。用固定键 37 fa 21 3d 掩码 Hello,本机结果是:
client frame = 818537fa213d7f9f4d5158
拆开看:81 还是 FIN=1, opcode=1;85 是 0x80 | 0x05,最高位表示 masked,低 7 位是长度 5;37 fa 21 3d 是掩码键;后面 5 字节是 48^37=7f、65^fa=9f、6c^21=4d、6c^3d=51、6f^37=58。解码时对同一段字节再做一次 XOR,就还原出 Hello。
掩码不是为了加密。键就明文写在帧里,任何人拿到帧都能还原载荷。它的目的是让攻击者无法预先构造一段「看起来像 HTTP 请求」的字节,通过代理缓存污染攻击下一个用户;每帧随机异或之后,载荷不再可控地以 GET / 开头。因此键必须不可预测:用 SecureRandom,不要用 Random、时间戳或递增计数器。
编码方法要同时处理三种长度和可选掩码:
public ByteBuffer encode(WebSocketFrame frame, byte[] maskKey) {
byte[] payload = frame.payload();
int length = payload.length;
boolean masked = maskKey != null;
if (masked && maskKey.length != 4) {
throw new IllegalArgumentException("mask key must be 4 bytes");
}
int extended = length <= 125 ? 0 : length <= 0xFFFF ? 2 : 8;
ByteBuffer out = ByteBuffer.allocate(2 + extended + (masked ? 4 : 0) + length);
out.put((byte) ((frame.fin() ? 0x80 : 0) | frame.opcode()));
int maskBit = masked ? 0x80 : 0;
if (length <= 125) {
out.put((byte) (maskBit | length));
} else if (length <= 0xFFFF) {
out.put((byte) (maskBit | 126));
out.putShort((short) length);
} else {
out.put((byte) (maskBit | 127));
out.putLong(length);
}
if (masked) {
out.put(maskKey);
for (int i = 0; i < length; i++) {
out.put((byte) (payload[i] ^ maskKey[i & 3]));
}
} else {
out.put(payload);
}
out.flip();
return out;
}
linkd 用两个工厂把方向固定下来,避免调用方传错布尔值:
public static WebSocketFrameCodec server() {
return new WebSocketFrameCodec(false, true); // 发出不掩码,收到的必须掩码
}
public static WebSocketFrameCodec client() {
return new WebSocketFrameCodec(true, false); // 发出掩码,收到的不许掩码
}
服务端收到未掩码的客户端帧时,本机输出是:
unmasked client frame: close=1002 client frame is not masked
这条检查不能省。浏览器不会发未掩码帧,但手写客户端、压测脚本和中间人很容易漏掉;如果服务端默默接受,掩码的安全前提就失效了,而且两边对帧长度的理解可能已经不一致。
核心机制三:continuation 分片与控制帧
一条消息可以拆成多帧。第一帧的 opcode 是 text 或 binary,FIN=0;后续帧的 opcode 是 0(continuation),最后一帧 FIN=1。接收方只有在最后一帧到达后,才能把载荷拼成完整消息。
分片有三条硬规则:
- 控制帧(close、ping、pong)不能分片,
FIN必须为 1,载荷不超过 125 字节。 - 控制帧可以插在两条 continuation 之间,接收方不能因为它出现就重置分片状态。
- 一条消息没结束时,不能再开第二条消息;收到新的 text/binary 或孤立的 continuation 都是 1002。
重组器只做「累积 + 最后一次交付」,控制帧原样放行:
static final class Assembler {
private final int maxMessage;
private int messageOpcode = -1;
private final ByteArrayOutputStream buffer = new ByteArrayOutputStream();
Assembler(int maxMessage) {
this.maxMessage = maxMessage;
}
WebSocketFrame accept(WebSocketFrame frame) {
if (frame.isControl()) {
return frame;
}
if (frame.opcode() == WebSocketFrame.OP_CONTINUATION) {
if (messageOpcode < 0) {
throw new ProtocolException(1002, "continuation without a started message");
}
} else {
if (messageOpcode >= 0) {
throw new ProtocolException(1002, "new message before continuation finished");
}
messageOpcode = frame.opcode();
}
if (buffer.size() + frame.payload().length > maxMessage) {
reset();
throw new ProtocolException(1009, "message exceeds " + maxMessage + " bytes");
}
buffer.writeBytes(frame.payload());
if (!frame.fin()) {
return null;
}
byte[] data = buffer.toByteArray();
if (messageOpcode == WebSocketFrame.OP_TEXT) {
validateUtf8(data);
}
WebSocketFrame message = new WebSocketFrame(true, messageOpcode, data);
reset();
return message;
}
private void reset() {
messageOpcode = -1;
buffer.reset();
}
private static void validateUtf8(byte[] data) {
try {
StandardCharsets.UTF_8.newDecoder()
.onMalformedInput(CodingErrorAction.REPORT)
.onUnmappableCharacter(CodingErrorAction.REPORT)
.decode(ByteBuffer.wrap(data));
} catch (CharacterCodingException e) {
throw new ProtocolException(1007, "invalid UTF-8 in text message");
}
}
}
ProtocolException 是 WebSocketFrameCodec 的嵌套异常,携带关闭码;上层据此决定回 1002、1007 还是 1009。
本机把 Hel(FIN=0)、一个 ping、lo(continuation,FIN=1)依次喂进去:
assemble part1 = null
assemble ping = fin=true opcode=0x9 payload=hb
assemble part2 = fin=true opcode=0x1 payload=Hello
第一帧返回 null,表示消息还没完整;ping 原样返回,分片状态没有被清掉;最后一帧返回 opcode 1、载荷 Hello。如果 ping 的分支顺手调用了 reset(),Hel 就会丢,最后只能拼出 lo。
UTF-8 校验必须放在重组之后。一个 3 字节的汉字可能被拆成 1+2 两帧,单看第一帧一定不是合法 UTF-8;按帧校验会把正常消息判成 1007。完整消息拼好后再用 CharsetDecoder 的 REPORT 模式解一次,才能既接受跨帧字符,又拒绝真正的非法字节。
在 linkd 里落地:WebSocketFrameCodec 的解码循环
WebSocketFrameCodec 是 com.plume.linkd.ws 下的 public final 类;它是有状态的:记住方向(是否要求掩码)和单帧上限,但不记住分片,分片交给上文的 Assembler。解码循环的关键是「不够就退出,且不移动 position」:
public List<WebSocketFrame> decode(ByteBuffer in) {
List<WebSocketFrame> frames = new ArrayList<>();
while (true) {
int start = in.position();
if (in.remaining() < 2) {
break;
}
int b0 = in.get(start) & 0xFF;
int b1 = in.get(start + 1) & 0xFF;
if ((b0 & 0x70) != 0) {
throw new ProtocolException(1002, "RSV bits must be zero");
}
boolean fin = (b0 & 0x80) != 0;
int opcode = b0 & 0x0F;
boolean masked = (b1 & 0x80) != 0;
int length7 = b1 & 0x7F;
int prefix = 2 + (length7 == 126 ? 2 : length7 == 127 ? 8 : 0);
if (in.remaining() < prefix + (masked ? 4 : 0)) {
break;
}
long length;
if (length7 == 126) {
length = in.getShort(start + 2) & 0xFFFF;
} else if (length7 == 127) {
length = in.getLong(start + 2);
if (length < 0) {
throw new ProtocolException(1002, "payload length high bit set");
}
} else {
length = length7;
}
if (length > maxPayload) {
throw new ProtocolException(1009, "payload " + length + " exceeds " + maxPayload);
}
if (masked != requireMasked) {
throw new ProtocolException(1002,
masked ? "peer masked a server frame" : "client frame is not masked");
}
int headerLength = prefix + (masked ? 4 : 0);
long total = headerLength + length;
if (in.remaining() < total) {
break;
}
byte[] payload = new byte[(int) length];
int payloadOffset = start + headerLength;
if (masked) {
for (int i = 0; i < length; i++) {
payload[i] = (byte) (in.get(payloadOffset + i) ^ in.get(start + prefix + (i & 3)));
}
} else {
for (int i = 0; i < length; i++) {
payload[i] = in.get(payloadOffset + i);
}
}
in.position(start + (int) total);
frames.add(new WebSocketFrame(fin, opcode, payload));
}
return frames;
}
本机把一个完整的客户端帧拆成 7 字节和 4 字节两次喂入:
partial decode = 0 frame(s)
after rest = 1 frame(s)
第一次只剩 7 字节,不够「2 字节头 + 2 字节长度 + 4 字节掩码 + 5 字节载荷」共 13 字节,解码器返回空列表且 position 停在 0;第二次补齐后切出一帧,position 前进 13。读循环看到空列表时不应该关闭连接,而应该保留缓冲区、继续注册读事件。
linkd 的读路径最终只有一行:
session.onReadable(leftover);
WebSocketSessionImpl 在 11.3 实现,它内部调用 codec.decode(in),再把每个帧交给 handle。编解码器不碰连接、不碰线程,所以可以用同一个实例处理同一条连接的所有读事件;不要把它做成全局单例,因为 requireMasked 和 maxPayload 是每个连接方向独立的。
常见错误与边界
错误一:把 126/127 当成真实长度。 0x7e 和 0x7f 只是「后面还有扩展长度」的标记。看到 126 就只读 126 字节,接下来会把载荷中间的字节当成帧头,表现为随机 opcode、随机长度,最后以 1002 或 1009 断开。
错误二:读长度时忘记 & 0xFF 或 & 0xFFFF。 byte 提升成 int 会做符号扩展,in.getShort() 返回 short,长度 0xFFFF 会变成 -1。16 位长度必须写成 in.getShort(...) & 0xFFFF;64 位长度读出负数时直接以 1002 拒绝。
错误三:服务端回显时把客户端掩码原样带回。 服务端帧必须不掩码。把收到的掩码帧直接 enqueue 回去,浏览器会以 1002 关闭,因为客户端明确要求服务端帧不带 MASK 位。回显要重新构造 WebSocketFrame,由服务端的 codec 重新编码。
错误四:在分片中间解析 UTF-8。 多字节字符跨帧时,单帧解码必然失败。文本消息要等 FIN=1 拼完再校验;二进制消息不做 UTF-8 校验,否则会把合法的任意字节判成 1007。
错误五:控制帧带 126/127 长度或 FIN=0。 控制帧载荷上限 125 字节,所以永远用不到扩展长度;分片控制帧也是非法的。收到这两种情况都应回 1002,而不是尝试解析。
边界:本实现不协商扩展。 如果握手时答应了 permessage-deflate,帧的 RSV1 会被置位,载荷是压缩数据,本节「RSV 必须为 0」的检查会直接断开连接。linkd 的 101 响应不包含 Sec-WebSocket-Extensions,就是不协商扩展;要支持压缩,必须同时改握手、帧解码和消息重组三处。
边界:单帧上限 16 MiB 是工程选择。 协议允许更大的 64 位长度,但一次性 new byte[length] 会把内存交给对端控制。linkd 在分配前用 maxPayload 拒绝,并以 1009 关闭;这个值要小于进程可承受的峰值,而不是贴着堆大小设置。
小结
- 帧头至少 2 字节:FIN/RSV/opcode 在第一个字节,MASK 和 7 位长度在第二个字节。
- 长度 0–125 直接表示,126 后跟 2 字节,127 后跟 8 字节;扩展长度都不含帧头和掩码键。
- 客户端帧必须掩码,服务端帧必须不掩码;掩码是每帧 4 字节键的 XOR,不是加密。
- 半帧时解码器返回空列表且不移动
position,等下一次读事件补齐。 - 分片用 opcode 0 的 continuation,控制帧可穿插但不能分片,消息上限和 UTF-8 在重组后检查。
- 本实现不协商扩展,RSV 非 0 一律按 1002 处理。
帧边界稳定后,剩下的是连接的生命周期:怎么判断对端还活着、怎么关得干净、怎么把一条消息发给所有连接。下一节把 ping/pong、关闭握手和广播放进 WebSocketHub。
阅读导航:上一节:11.1 WebSocket 握手与 Upgrade · 下一节:11.3 心跳、关闭与广播 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。