《Java 网络编程基础》11.2 帧格式、掩码与分片

从 101 之后的第一段字节开始,拆开 WebSocket 的两字节帧头:FIN、RSV、opcode、MASK 与 7/16/64 位长度编码,讲清客户端掩码的 XOR 算法和控制帧的 125 字节上限,并用 WebSocketFrameCodec 处理半帧、continuation 分片与 UTF-8 校验。

本节目标:能从一段字节流里切出完整的 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。

核心机制一:两字节头与三种长度编码

每个帧至少两个字节:

字节位字段含义
00x80FIN1 表示消息最后一帧
00x40/0x20/0x10RSV1/2/3扩展位,没有协商扩展时必须为 0
00x0Fopcode0 continuation、1 text、2 binary、8 close、9 ping、A pong
10x80MASK1 表示载荷被掩码
10x7Flength0–125 直接表示长度,126/127 是标记

长度域有三档:

第二字节低 7 位后续字节实际长度
0–125无就是这个值
1262 字节无符号大端16 位长度,最大 65535
1278 字节大端,最高位必须为 064 位长度,本实现上限 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 心跳、关闭与广播 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「java」更多文章

  1. 《Netty 高性能网络编程》15.3 linkd-v2 验收与复盘
  2. 《Netty 高性能网络编程》15.2 限流、超时与降级
  3. 《Netty 高性能网络编程》15.1 网关路由与转发