本节目标:把升级后的五类帧路由到正确的 handler,并把一条被拆成多个 continuation 帧的消息还原成整条。
适用版本:JDK 25 + Netty 4.2.19.Final(netty-codec-http、netty-codec-base模块)。
承接的问题
10.1 的 /ws/echo 只声明了 SimpleChannelInboundHandler<TextWebSocketFrame>。文本能回显,但另外三类输入会被静默丢掉:
客户端发送 BinaryWebSocketFrame(3 字节)
WebSocketHub 不接受这个类型,帧继续往 tail 走
tail 释放并记录:Discarded inbound message ... that reached at the tail of the pipeline
这不是 bug,是 SimpleChannelInboundHandler<I> 的类型过滤:acceptInboundMessage 只接受 I 的实例,其余原样 fireChannelRead。同样的问题还有两个变体:
- 一条 11 字节的文本被客户端拆成
hello和world两个帧,WebSocketHub只看到第一段,回显就变成hello。 - 客户端发 ping 或 close,
WebSocketHub一个也看不到,因为WebSocketServerProtocolHandler在它之前已经把控制帧消费掉了。
这一节先把五类帧的去向列清楚,再讲 continuation 和 WebSocketFrameAggregator,最后给文本、二进制回显和关闭码的写法。10.3 在这个基础上加会话表和广播。
核心机制一:五类帧与它们被谁消费
WebSocket 的 opcode 决定帧的类型。Netty 在 io.netty.handler.codec.http.websocketx 里为每种帧提供了独立类型,WebSocketServerProtocolHandler 的解码器会在业务 handler 之前做一层分流:
| 帧类型 | opcode | 4.2.19 里的去向 | 业务 handler 能看到吗 |
|---|---|---|---|
TextWebSocketFrame | 0x1 | 继续往下传 | 能 |
BinaryWebSocketFrame | 0x2 | 继续往下传 | 能 |
| continuation | 0x0 | 没有聚合器时继续往下传 | 取决于 handler 类型 |
PingWebSocketFrame | 0x9 | 协议 handler 自动回 PongWebSocketFrame | 不能 |
PongWebSocketFrame | 0xA | 协议 handler 直接丢弃 | 不能 |
CloseWebSocketFrame | 0x8 | 协议 handler 完成关闭握手 | 不能 |
这张表来自 4.2.19 的 WebSocketProtocolHandler.decode 和 WebSocketServerProtocolHandler.decode:ping 分支会 retain 载荷、构造 pong、writeAndFlush,然后 return;pong 分支在 dropPongFrames 为 true(默认)时直接 readIfNeeded 后返回;close 分支交给 WebSocketServerHandshaker.close,回一个 close 帧并关闭连接。控制帧根本不会进入 WebSocketHub。
这条事实直接影响心跳设计。很多手写实现会在业务 handler 里数 pong,或者自己处理 ping;在 Netty 的这条 pipeline 里,pong 已经被协议 handler 吃掉,业务 handler 永远等不到。正确做法是把 IdleStateHandler 放在 WebSocketServerProtocolHandler 之前,让它看到原始入站字节,再根据读空闲触发关闭,10.3 会给出完整代码。
控制帧本身还有两条硬约束:载荷不超过 125 字节,FIN 必须是 1。也就是说 ping、pong、close 不能被分片。close 的载荷是 2 字节状态码加 UTF-8 原因,原因最多 123 字节;服务端不能把 1005、1006、1015 写上线。
核心机制二:分片、continuation 与聚合器
一条 WebSocket 消息可以拆成多个帧:第一帧 FIN=0,opcode 是 0x1 或 0x2;中间帧 FIN=0,opcode 是 0x0(continuation);最后一帧 FIN=1,opcode 仍是 0x0。接收方只有看到 FIN=1 才知道消息结束。
分片是协议允许的,不是异常。客户端可能因为生成消息的缓冲区只有 4 KiB,就把一条 1 MiB 的 JSON 拆成几百帧;也可能一个代理按自己的 MTU 重新切分。服务端如果按“一个帧就是一条消息”处理,就会把一条消息当成多条,或者只处理第一段。
WebSocketFrameAggregator 把同一条消息的多个帧重新拼成一个 TextWebSocketFrame 或 BinaryWebSocketFrame,下游只看到一条完整消息。装配位置是固定的:
ch.pipeline().addLast(new HttpServerCodec());
ch.pipeline().addLast(new HttpObjectAggregator(64 * 1024));
ch.pipeline().addLast(new WebSocketServerProtocolHandler("/ws/echo", null, true, 64 * 1024));
ch.pipeline().addLast(new WebSocketFrameAggregator(64 * 1024));
ch.pipeline().addLast(hub);
ch.pipeline().addLast(new BinaryEchoHandler());
聚合器必须在 WebSocketServerProtocolHandler 之后,因为它消费的是 WebSocketFrame,不是 HTTP 对象;也必须在业务 handler 之前,否则第一段已经被业务 handler 取走,聚合器只能看到后面的 continuation,拼不出完整消息。
两个上限不要混:
WebSocketServerProtocolHandler构造器里的maxFrameSize(上例 64 KiB)限制单个帧的载荷。超了在解码阶段就报错。WebSocketFrameAggregator构造器里的maxContentLength(上例 64 KiB)限制一条消息聚合后的总长度。每个分片都合法、但加起来超限时,在这一层报错。
用原始 socket 发两个掩码帧,第一帧 FIN=0、文本 hello ,第二帧 FIN=1、continuation world,本机抓到的服务端回包是:
HTTP/1.1 101 Switching Protocols
fin=true opcode=0x1 len=11 payload=hello world
服务端回的是一个未分片的文本帧,FIN=1、opcode 0x1、长度 11,说明聚合器把两段拼好后才交给 WebSocketHub。如果没有聚合器,WebSocketHub 会在第一帧就回显 hello ,第二帧因为是 continuation 类型、不被 TextWebSocketFrame 接受,直接落到 tail。
分片的状态机可以归纳成三种情况:
- 空闲时收到
FIN=1的数据帧:这条消息本身就是完整的,不需要聚合。 - 空闲时收到
FIN=0的数据帧:进入聚合,记下首帧的 opcode(文本还是二进制),后续只接受 continuation。 - 聚合中收到
FIN=1的 continuation:消息结束,按首帧的 opcode 产出TextWebSocketFrame或BinaryWebSocketFrame。
聚合中如果又收到一个新的数据帧(opcode 0x1 或 0x2),说明对端没有按协议发 continuation,属于协议错误;WebSocketFrameAggregator 会拒绝这种序列,而不是把它当成两条消息。控制帧可以穿插在分片之间,但控制帧本身不能分片,也不能打断消息的 opcode 序列。
超过聚合上限时,MessageAggregator 默认构造一个 TooLongFrameException 并 fireExceptionCaught,不会自动关闭连接。在还没有做 1009 收口时,本机发 60000 字节加 10000 字节的两段消息,服务端日志是:
警告: ws error remote=/127.0.0.1:...
io.netty.handler.codec.TooLongFrameException: content length exceeded 65536 bytes.
所以 exceptionCaught 里要显式处理这个异常:给客户端一个 1009(消息过大)的 close,再关闭连接。否则连接会停在“聚合器已经放弃、业务还没收到完整消息”的状态。
核心机制三:文本回显与二进制回显
文本回显在 10.1 已经有了,核心仍然是一行:
@Override
protected void channelRead0(ChannelHandlerContext ctx, TextWebSocketFrame frame) {
ctx.writeAndFlush(new TextWebSocketFrame(frame.text()));
}
frame.text() 把载荷解码成 Java String,新帧自己分配缓冲区,不和入站 ByteBuf 共享引用计数。二进制不能照抄这个写法,因为 ByteBuf 是可引用计数的对象。SimpleChannelInboundHandler 会在 channelRead0 返回后自动释放入站帧,所以二进制回显不能直接把 frame.content() 塞进新帧:
// 反例:新帧和原帧共享同一个 ByteBuf,原帧马上被自动释放
ctx.writeAndFlush(new BinaryWebSocketFrame(frame.content()));
正确写法是先 retain(),把引用计数加一,让新帧拥有自己的一份:
package com.plume.linkd.v2.server;
import io.netty.buffer.ByteBuf;
import io.netty.channel.ChannelHandler;
import io.netty.channel.ChannelHandlerContext;
import io.netty.channel.SimpleChannelInboundHandler;
import io.netty.handler.codec.http.websocketx.BinaryWebSocketFrame;
@ChannelHandler.Sharable
public final class BinaryEchoHandler extends SimpleChannelInboundHandler<BinaryWebSocketFrame> {
@Override
protected void channelRead0(ChannelHandlerContext ctx, BinaryWebSocketFrame frame) {
ByteBuf payload = frame.content().retain();
ctx.writeAndFlush(new BinaryWebSocketFrame(payload));
}
}
retain() 之后,入站帧被自动释放时引用计数从 2 变 1,出站帧仍然有效;写出后由出站缓冲区负责最终释放。
为什么把 BinaryEchoHandler 放在 WebSocketHub 之后?因为 WebSocketHub 只接受 TextWebSocketFrame,文本帧在它那里被消费,二进制帧不被接受、继续往后走,正好落到 BinaryEchoHandler。这是一个利用类型过滤做路由的例子,不需要在业务代码里写 instanceof。如果以后要在一个 handler 里处理所有数据类型,就把泛型改成 WebSocketFrame,再按 instanceof 分支;代价是失去“类型即路由”的清晰边界。
本机用 JDK 的 java.net.http.WebSocket 客户端分别验证:
echo=hello
binary echo len=3
第一条是文本回显,第二条是 3 字节二进制原样返回。两个 handler 都是 @Sharable,没有按连接变化的字段;WebSocketFrameAggregator 则每条连接一个实例,因为它持有聚合中的半条消息。
文本帧和二进制帧的边界不只是“有没有字符集”。文本帧的载荷必须是合法 UTF-8,Netty 的帧解码器会校验,非法字节按 1007 关闭;二进制帧是不透明字节,协议层不解释。不要把图片、protobuf 或压缩后的字节塞进 TextWebSocketFrame,否则要么在发送端编码失败,要么在接收端被当成非法 UTF-8 断开。
还有 RSV 位。allowExtensions=true 只表示“允许帧里带扩展位”,不代表压缩已经开启。linkd-v2 没有装 permessage-deflate 的压缩 handler,客户端如果把 RSV1 置 1 并发送压缩载荷,解码器不会解压,业务 handler 拿到的是压缩后的字节;如果客户端没有压缩却把 RSV1 置 1,帧的语义同样对不上。要压缩就显式加压缩 handler 并在握手时协商,不能只改构造器参数。把 allowExtensions 设为 false 时,带 RSV 位的帧才会在一开始就被当成协议错误。
核心机制四:关闭码与服务端不能加掩码
close 帧(opcode 0x8)的载荷是“2 字节状态码 + UTF-8 原因”。服务端主动关闭时,构造 CloseWebSocketFrame 并写出:
ctx.writeAndFlush(new CloseWebSocketFrame(1001, "server shutdown"))
.addListener(ChannelFutureListener.CLOSE);
常用状态码:
| 码 | 含义 | 能否上线 |
|---|---|---|
| 1000 | 正常关闭 | 能 |
| 1001 | 端点离开,如服务重启 | 能 |
| 1002 | 协议错误 | 能 |
| 1003 | 收到不支持的数据类型 | 能 |
| 1007 | 载荷不是合法 UTF-8 | 能 |
| 1008 | 违反策略 | 能 |
| 1009 | 消息过大 | 能 |
| 1011 | 服务端内部错误 | 能 |
| 1013 | 稍后重试,通常表示过载 | 能 |
| 3000–4999 | 应用或私有用途 | 能 |
| 1005/1006/1015 | 本地状态,不能出现在线上 | 不能 |
1006 尤其容易写错:它表示“没有收到 close 帧就断了”,是本地记录,不是可发送的状态码。把 1006 写进 close 帧,对端会按协议错误处理。
Netty 也提供 WebSocketCloseStatus 常量类(同在 netty-codec-http 模块),但本节只依赖 CloseWebSocketFrame(int, String) 构造器,避免把状态码和类名绑死。真正要校验的是两件事:状态码在白名单里,原因 UTF-8 编码后不超过 123 字节。
掩码方向是另一个高频错误。RFC 6455 规定客户端发往服务端的帧必须加掩码,服务端发往客户端的帧必须不加掩码。Netty 的服务端编码器按 frame.isMasked() 决定是否写 4 字节掩码键,而 TextWebSocketFrame、BinaryWebSocketFrame 的普通构造器创建的帧默认未掩码,所以服务端回显不需要做任何事。反过来说:
- 不要给服务端写出的帧手动设置掩码;
- 不要直接把客户端发来的原始字节原样写回,除非你确认它是解码后的帧对象而不是带掩码的裸载荷;
allowMaskMismatch默认 false,客户端发了未掩码帧会被解码器按协议错误拒绝并关闭。
核心机制五:大帧的内存与背压
WebSocketFrameAggregator 的代价是“整条消息进内存”。它内部用 CompositeByteBuf 把各分片串起来,但所有字节仍然留在堆外内存里,直到业务 handler 消费完。一条 1 MiB 的消息在聚合期间至少占 1 MiB;1000 条连接同时传 1 MiB,峰值就是 1 GiB,这和 6.1 讲 ByteToMessageDecoder 的 cumulation 是同一类问题。
三个可调的点:
maxFrameSize限制单帧,防止一个帧头声明超大长度后让解码器等一大块内存;maxContentLength限制聚合后的消息,防止“每帧都小、拼起来巨大”;- 业务层的
Channel.isWritable()限制回显和广播的写出速度,防止慢客户端把出站缓冲撑爆。
前两个是“拒绝”,第三个是“减速”。WebSocket 没有应用层流量控制,TCP 的滑动窗口是唯一的背压信号;Netty 把它映射成 isWritable() 和 WriteBufferWaterMark(9.1)。当客户端发得比服务端写得快时,入站消息仍然会到达,聚合器仍然会分配内存,所以入站大消息要在聚合上限处拒绝,而不是指望 isWritable()。
linkd-v2 的选择是:/ws/echo 的文本和二进制上限都设为 1 MiB,超过就 1009 关闭;广播只发小消息,不把大文件塞进 WebSocket。大文件走 9090 的 HTTP 流式路径,由 HttpObjectAggregator 的 maxContentLength 或分块 HttpContent 处理。把上限设成 Integer.MAX_VALUE 只是把 OOM 从“可预期的 1009”变成“不可预期的进程崩溃”。
在 linkd-v2 里落地:异常收口
聚合器抛出的 TooLongFrameException 会沿着 pipeline 往 tail 走,最终进入 WebSocketHub.exceptionCaught。在这里把它翻译成 1009:
@Override
public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) {
if (cause instanceof TooLongFrameException) {
ctx.writeAndFlush(new CloseWebSocketFrame(1009, "message too big"))
.addListener(ChannelFutureListener.CLOSE);
return;
}
LOG.log(System.Logger.Level.WARNING,
"ws error remote=" + ctx.channel().remoteAddress(), cause);
ctx.close();
}
TooLongFrameException 来自 netty-codec-base 模块的 io.netty.handler.codec 包,和 6.3 处理粘包错误用的是同一个异常类型。写 close 帧时从 WebSocketHub 的 ctx 出发,出站会经过 WebSocketServerProtocolHandler,它认识 CloseWebSocketFrame,会记录“close 已发”并启动力度较小的关闭流程;ChannelFutureListener.CLOSE 则保证写完后一定关掉 TCP。
如果同时要处理二进制,WebSocketHub 和 BinaryEchoHandler 各有一个 exceptionCaught。异常只会进入它传播路径上的 handler,不会自动广播给所有 handler;聚合器在 hub 之前,所以 hub 能收到;如果以后把聚合器放到 hub 之后,异常就会先进入别的 handler,这段收口代码要跟着移动。
常见错误与边界
错误一:不装聚合器却按整条消息处理。 客户端发三个分片,TextWebSocketFrame handler 只收到第一片 hello ,后面两片是 continuation 类型,不被接受,落到 tail。表现是“消息被截断”,而不是抛异常;日志里能看到 tail 的 Discarded inbound message。
错误二:聚合器放在业务 handler 之后。 第一片已经被业务 handler 消费,聚合器只看到 continuation,找不到“起始消息”,要么丢弃,要么在 MessageAggregator 内部报状态错误。聚合器必须在业务 handler 之前、协议 handler 之后。
错误三:二进制回显不 retain。 new BinaryWebSocketFrame(frame.content()) 让两个帧共享同一个 ByteBuf,SimpleChannelInboundHandler 自动释放原帧后,出站帧的引用计数归零,编码时抛 IllegalReferenceCountException,客户端收到半帧或连接断开。
错误四:在业务 handler 里等 pong。 PongWebSocketFrame 被 WebSocketServerProtocolHandler 丢弃,业务 handler 永远收不到。心跳要用 IdleStateHandler 的读空闲事件,或者自己实现协议 handler,不能靠数 pong。
错误五:把 1006 写进 close 帧。 1006 是本地“异常断开”的记录,不是线上状态码。发送前用白名单校验,1005、1006、1015 直接拒绝。
边界:控制帧不能分片,载荷不超过 125 字节。 ping 里要塞业务数据,最多 125 字节;close 的原因最多 123 字节(还要留 2 字节状态码)。超过这个长度的“心跳载荷”不是控制帧能承担的东西。
边界:分片是正常流量,不是攻击。 不能因为看到 FIN=0 就断开;要按聚合后的总长度限流,而不是按帧数限流。反过来说,一个客户端可以用大量 1 字节分片消耗 CPU,聚合器仍要逐帧处理,必要时在协议层限制分片数量。
小结
TextWebSocketFrame和BinaryWebSocketFrame会进入业务 handler;ping、pong、close 由WebSocketServerProtocolHandler消费,业务层看不到。- 分片用
FIN和 continuation opcode 表达;WebSocketFrameAggregator放在协议 handler 之后、业务 handler 之前,把分片还原成一条消息。 maxFrameSize限制单帧,maxContentLength限制聚合后的整条消息;两者都超限时分别在不同层报错。- 二进制回显必须
retain()入站content();文本回显用frame.text()新建字符串,不共享缓冲区。 - close 载荷是 2 字节码加 UTF-8 原因;1005、1006、1015 不能上线,服务端写出的帧不能加掩码。
- 大帧的内存成本按“连接数 × 消息上限”增长;超限用 1009 拒绝,慢客户端用
isWritable()减速。
帧和聚合解决的是“一条消息长什么样”。下一节把这些连接组织成会话表:用 ChannelGroup 做广播、用 AttributeKey 绑用户、用 IdleStateHandler 和 ping/pong 清理死连接。
阅读导航:上一节:10.1 WebSocketServerProtocolHandler · 下一节:10.3 会话表、心跳与广播 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。