本节目标:把 9090 端口的 WebSocket 握手、路径匹配和升级后的首个事件装进一条可运行的 pipeline。
适用版本:JDK 25 + Netty 4.2.19.Final(netty-codec-http、netty-transport模块)。
承接的问题
9.3 讲完 Channel.isWritable() 和 AUTO_READ 之后,linkd-v2 的 9090 端口已经能稳定地跑 HTTP/1.1:GET /healthz 返回 ok,GET /books/{id} 转发到 libraryd。但卷一 11.1 手写 WebSocket 时留下的三件事还没有搬过来:
- 解析
Upgrade: websocket、Connection: Upgrade和Sec-WebSocket-Key; - 计算
Sec-WebSocket-Accept,回101 Switching Protocols; - 在同一个 TCP 连接上把 HTTP 编解码器换成 WebSocket 帧编解码器。
Netty 把前两件事交给 WebSocketServerProtocolHandler,第三件事由握手器在升级成功后替换 pipeline 里的 codec 完成。看起来只是加一个 handler,实际最容易出错的是装配顺序和路径匹配:顺序错了握手不触发,路径错了连接既不升级也不关闭,客户端只能等到超时。
这一节先看一次真实的 HTTP Upgrade,再把 9090 的四段 pipeline 拼起来,最后用 curl 和浏览器验证。10.2 在同一套连接上处理文本、二进制、分片和控制帧。
核心机制一:WebSocket 仍从 HTTP/1.1 Upgrade 开始
WebSocket 不是凭空建立的二进制协议。客户端先发一个普通的 HTTP/1.1 GET,带四个关键头:
GET /ws/echo HTTP/1.1
Host: 127.0.0.1:9090
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Sec-WebSocket-Version: 13
Sec-WebSocket-Key 是 16 字节随机数的 Base64,Sec-WebSocket-Version 固定为 13。服务端把 key 和固定 GUID 258EAFA5-E914-47DA-95CA-C5AB0DC85B11 拼起来,做 SHA-1 再 Base64,得到 Sec-WebSocket-Accept,然后返回:
HTTP/1.1 101 Switching Protocols
upgrade: websocket
connection: upgrade
sec-websocket-accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=
这是本机运行本节 WebSocketServer 后 curl 抓到的响应,请求用的正是 RFC 6455 示例 key,所以 accept 值与 RFC 一致。101 之后,这条 TCP 连接上不再有 HTTP 请求行和头,字节流直接按 WebSocket 帧解释。这也是为什么 HttpServerCodec 必须在升级后被替换掉:继续用 HTTP 解码器读帧,会把 0x81 0x85 当成非法请求行。
握手头的大小写不敏感,Connection 里也可能有多个 token(例如 keep-alive, Upgrade),所以不要用字符串相等去判断整个头。Netty 的握手器已经处理了这些细节,业务代码不需要自己算 accept。
核心机制二:四段 pipeline 各做什么
linkd-v2 的 WebSocket 服务端骨架如下。它和 2.1 的 ServerBootstrap 只差 childHandler 里的四行装配:
package com.plume.linkd.v2.server;
import io.netty.bootstrap.ServerBootstrap;
import io.netty.channel.Channel;
import io.netty.channel.ChannelInitializer;
import io.netty.channel.ChannelOption;
import io.netty.channel.EventLoopGroup;
import io.netty.channel.nio.NioEventLoopGroup;
import io.netty.channel.socket.SocketChannel;
import io.netty.channel.socket.nio.NioServerSocketChannel;
import io.netty.handler.codec.http.HttpObjectAggregator;
import io.netty.handler.codec.http.HttpServerCodec;
import io.netty.handler.codec.http.websocketx.WebSocketServerProtocolHandler;
import java.net.InetSocketAddress;
public final class WebSocketServer implements AutoCloseable {
private final EventLoopGroup boss = new NioEventLoopGroup(1);
private final EventLoopGroup worker = new NioEventLoopGroup();
private final WebSocketHub hub = new WebSocketHub();
private Channel serverChannel;
public void start(int port) throws InterruptedException {
ServerBootstrap bootstrap = new ServerBootstrap();
bootstrap.group(boss, worker)
.channel(NioServerSocketChannel.class)
.option(ChannelOption.SO_BACKLOG, 1024)
.childOption(ChannelOption.TCP_NODELAY, true)
.childHandler(new ChannelInitializer<SocketChannel>() {
@Override
protected void initChannel(SocketChannel ch) {
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(hub);
}
});
serverChannel = bootstrap.bind(new InetSocketAddress(port)).sync().channel();
}
@Override
public void close() {
if (serverChannel != null) {
serverChannel.close().syncUninterruptibly();
}
boss.shutdownGracefully();
worker.shutdownGracefully();
}
}
四段的分工:
| Handler | 入站做什么 | 出站做什么 |
|---|---|---|
HttpServerCodec | 把字节解成 HttpRequest/HttpContent | 把 HttpResponse 编成字节 |
HttpObjectAggregator | 把请求行、头和 body 合成 FullHttpRequest | 透传 |
WebSocketServerProtocolHandler | 识别 Upgrade、完成握手、之后解码帧 | 编码帧、处理 ping/pong/close |
WebSocketHub | 业务:文本回显、会话表、广播 | 写回帧 |
顺序由事件方向决定:入站从 head 到 tail,所以 HTTP 解码器在最前,业务 handler 在最后;出站从 tail 到 head,所以 ctx.writeAndFlush 会先经过 WebSocketServerProtocolHandler 的帧编码器,再写进 socket。
HttpObjectAggregator 在这里不是握手本身硬性要求的:一个没有 body 的 GET 即使不加聚合器,WebSocketServerProtocolHandshakeHandler 也能拿到 HttpRequest 并完成升级(本节在本机去掉聚合器试过,仍然返回 101)。但 linkd-v2 的 9090 同时承载 /healthz 和 /books/{id},LinkdHttpHandler 的入参是 FullHttpRequest,所以聚合器要保留;它还能避免带 body 的升级请求在切换后留下未消费的 HttpContent。把聚合器放在 WebSocketServerProtocolHandler 之前,普通 HTTP 请求和升级请求走同一条入站路径。
最后一行 ch.pipeline().addLast(hub) 里的 hub 是单例,所以 WebSocketHub 必须标 @ChannelHandler.Sharable。它保存的是 ChannelGroup 这类线程安全结构,没有按连接变化的字段;如果以后加了“当前连接解析到一半的帧”这种成员变量,就必须改成每条连接 new 一个。
核心机制三:路径匹配比的是原始 URI
WebSocketServerProtocolHandler("/ws/echo") 默认用的是精确相等:拿 HttpRequest.uri() 和配置里的路径逐字符比较。HttpRequest.uri() 包含查询串,所以:
/ws/echo 匹配
/ws/echo?userId=alice 不匹配(默认模式)
/ws/echo/ 不匹配
/ws/echo/extra 不匹配
这不是推测。10.3 的 /ws/broadcast?userId=alice 一开始就踩了这个坑:握手请求被当成普通 HTTP 往下传,WebSocketHub 不接受 FullHttpRequest,请求在 tail 被释放,客户端既收不到 101 也收不到 404,curl 一直等到 --max-time 超时。
需要前缀匹配时,用六参数构造器,最后一个 boolean 是 checkStartsWith:
new WebSocketServerProtocolHandler(
"/ws/broadcast", null, true, 64 * 1024, false, true);
参数依次是路径、子协议、是否允许扩展、单帧最大负载、是否允许掩码不匹配、是否前缀匹配。开启前缀匹配后,/ws/broadcast?userId=alice 能命中;它内部的 checkNextUri 还要求路径后面紧跟 / 或 ?,所以 /ws/broadcastXYZ 仍然不会误匹配。
更稳妥的做法是路径不带查询串,用户标识放 header 或首个业务消息里。查询串适合演示和调试,但它会把“路径匹配”和“参数解析”两件事耦合成一个很容易写错的字符串比较。10.3 会用 QueryStringDecoder 在握手前把 userId 存进 AttributeKey,那里再展开。
核心机制四:握手完成事件与失败分支
TCP 连接建立时触发的是 channelActive,此时 HTTP Upgrade 还没发生,不能把连接加进 WebSocket 会话表。升级成功的信号是 WebSocketServerProtocolHandler.ServerHandshakeStateEvent.HANDSHAKE_COMPLETE:
@Override
public void userEventTriggered(ChannelHandlerContext ctx, Object evt) {
if (evt == WebSocketServerProtocolHandler.ServerHandshakeStateEvent.HANDSHAKE_COMPLETE) {
LOG.log(System.Logger.Level.INFO,
"ws handshake complete remote=" + ctx.channel().remoteAddress());
return;
}
ctx.fireUserEventTriggered(evt);
}
事件在 101 响应写出后触发,此后 ctx.channel() 上跑的才是 WebSocket 帧。把会话注册、用户绑定、首条欢迎消息都放在这里,顺序才是对的;放在 channelActive 会把还在等 HTTP 响应的连接提前算进广播目标。
Netty 4.2 在同一个位置还会触发一个新的握手完成事件对象(同在 netty-codec-http 模块),旧的枚举被标记为 deprecated。本节仍用枚举,是因为 4.1 和 4.2 的示例都能直接对照;新项目可以只处理新事件对象,但不要两个都处理,否则同一次握手会注册两次。
握手失败有两类。第一类是请求本身不合法,例如缺少 Sec-WebSocket-Key:
curl -i --max-time 3 \
-H 'Connection: Upgrade' \
-H 'Upgrade: websocket' \
-H 'Sec-WebSocket-Version: 13' \
http://127.0.0.1:9090/ws/echo
本机输出:
HTTP/1.1 400 Bad Request
not a WebSocket request: missing key
WebSocketServerProtocolHandler.exceptionCaught 识别到握手异常后,直接写一个 400 并关闭连接,不会走到业务 handler。
第二类是路径不匹配。默认实现把请求 fireChannelRead 给下一个 handler,不会自动回 404;如果后面没有能处理 FullHttpRequest 的 handler,请求就在 tail 被释放,连接保持打开。本机对 /ws/echoo 发同样的握手请求,curl 没有任何响应,退出码 28(超时)。要在路径写错时给客户端一个明确答复,可以在 WebSocketServerProtocolHandler 之后加一个 SimpleChannelInboundHandler<FullHttpRequest>,回 404 并关闭;这个 handler 只会看到未被升级消费的普通 HTTP 请求。
在 linkd-v2 里落地:最小 WebSocketHub
10.1 的 WebSocketHub 只做文本回显和握手日志,10.2 再加二进制,10.3 再换成带会话表和广播的版本:
package com.plume.linkd.v2.server;
import io.netty.channel.ChannelHandler;
import io.netty.channel.ChannelHandlerContext;
import io.netty.channel.SimpleChannelInboundHandler;
import io.netty.handler.codec.http.websocketx.TextWebSocketFrame;
import io.netty.handler.codec.http.websocketx.WebSocketServerProtocolHandler;
@ChannelHandler.Sharable
public final class WebSocketHub extends SimpleChannelInboundHandler<TextWebSocketFrame> {
private static final System.Logger LOG = System.getLogger(WebSocketHub.class.getName());
@Override
public void userEventTriggered(ChannelHandlerContext ctx, Object evt) {
if (evt == WebSocketServerProtocolHandler.ServerHandshakeStateEvent.HANDSHAKE_COMPLETE) {
LOG.log(System.Logger.Level.INFO,
"ws handshake complete remote=" + ctx.channel().remoteAddress());
return;
}
ctx.fireUserEventTriggered(evt);
}
@Override
protected void channelRead0(ChannelHandlerContext ctx, TextWebSocketFrame frame) {
ctx.writeAndFlush(new TextWebSocketFrame(frame.text()));
}
@Override
public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) {
LOG.log(System.Logger.Level.WARNING,
"ws error remote=" + ctx.channel().remoteAddress(), cause);
ctx.close();
}
}
三个细节:
SimpleChannelInboundHandler<TextWebSocketFrame>在channelRead0返回后自动释放入站帧,所以不能把frame本身存起来或异步再用;回显时用frame.text()取出字符串,再新建一个TextWebSocketFrame,两块内存互不相干。- 只声明
TextWebSocketFrame意味着二进制帧不会被这个 handler 接受,会继续往 tail 走。10.2 会在它后面加一个BinaryEchoHandler,正好利用这个类型过滤。 exceptionCaught里直接ctx.close(),不再尝试写错误帧。握手阶段的异常由WebSocketServerProtocolHandler自己处理;升级后的协议错误再写帧,对端可能已经在关闭流程里。
启动后,浏览器控制台就能验证回显:
const ws = new WebSocket("ws://127.0.0.1:9090/ws/echo");
ws.onopen = () => ws.send("hello");
ws.onmessage = (event) => console.log(event.data);
本机用 JDK 的 java.net.http.WebSocket 客户端发送 hello,收到 echo=hello;服务端日志对应一行 ws handshake complete remote=/127.0.0.1:...。握手本身也可以只用 curl 验证:
curl -i --max-time 2 \
-H 'Connection: Upgrade' \
-H 'Upgrade: websocket' \
-H 'Sec-WebSocket-Version: 13' \
-H 'Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==' \
http://127.0.0.1:9090/ws/echo
--max-time 是必须的:101 之后连接进入 WebSocket 长连接,curl 不会自己退出,只会等到超时,所以退出码 28 在这里是正常现象,不代表握手失败。判断成功看响应是不是 101 和 sec-websocket-accept,不要看 curl 的退出码。
常见错误与边界
错误一:把 WebSocketServerProtocolHandler 放在 HttpServerCodec 之前。 握手 handler 收到的是原始 ByteBuf,不是 HttpRequest,它不会识别 Upgrade,连接会一直停在 HTTP 请求已发送、响应未返回的状态。入站顺序必须是 HTTP 解码在前、WebSocket 协议处理在后。
错误二:在 channelActive 里注册会话。 TCP 连上不等于握手成功。客户端可能只是发了一个普通 GET,或者在 Upgrade 中途断开;提前注册会让广播把消息写给一条还没升级的连接,写出的帧被对端当成非法 HTTP 响应。注册点只能是 HANDSHAKE_COMPLETE。
错误三:路径带查询串却用默认精确匹配。 /ws/broadcast?userId=alice 与 /ws/broadcast 不相等,握手不会发生,而且默认不会回 404。要么开 checkStartsWith,要么把参数移到 header,二选一,不要靠“看起来像”判断。
错误四:以为 curl 退出码 0 才代表成功。 101 之后是长连接,curl 超时退出是常态。用 -i 看状态行,或者用浏览器/WebSocket 客户端看 onopen。
边界:HttpObjectAggregator 的 64 KiB 是 HTTP 请求体上限,不是 WebSocket 帧上限。 后者由 WebSocketServerProtocolHandler 构造器里的 maxFrameSize 控制,10.2 会把它和 WebSocketFrameAggregator 的上限放在一起比较。
边界:同一端口混跑 HTTP 和 WebSocket 时,普通 HTTP handler 要放在 WebSocketServerProtocolHandler 之后。 握手 handler 只消费匹配路径的 Upgrade 请求,其余请求原样往下传;放在它前面会先吃掉升级请求,握手永远不触发。
小结
- WebSocket 从 HTTP/1.1 Upgrade 开始,101 之后同一条 TCP 连接切换为帧协议,HTTP codec 会被握手器替换。
- 9090 的入站顺序是
HttpServerCodec→HttpObjectAggregator→WebSocketServerProtocolHandler→ 业务 handler;出站方向相反。 - 默认路径匹配比较完整
uri(),查询串会导致不匹配;需要时用checkStartsWith=true,它还会检查路径边界。 - 会话注册放在
HANDSHAKE_COMPLETE,不要放在channelActive;握手失败时缺 key 回 400,路径错默认无响应。 - 回显用
frame.text()新建帧,不持有入站ByteBuf;TextWebSocketFrame的类型过滤让二进制帧可以交给后面的 handler。
握手只解决了“连接怎么变成 WebSocket”。下一节处理升级之后的字节:文本帧、二进制帧、ping/pong、close,以及一个消息被拆成多个 continuation 帧时,聚合器应该放在哪里。
阅读导航:上一节:9.3 读背压与 AUTO_READ · 下一节:10.2 帧处理、分片与聚合 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。