《Netty 高性能网络编程》10.1 WebSocketServerProtocolHandler

WebSocket 仍以 HTTP/1.1 Upgrade 开始。本节按 HttpServerCodec、HttpObjectAggregator、WebSocketServerProtocolHandler 的顺序装配 9090 端口,讲路径匹配、握手完成事件与 400 失败分支,并给出可运行的 WebSocketServer 与 curl 验证命令。

本节目标:把 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 帧处理、分片与聚合 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「java」更多文章

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