Netty实战--使用netty构建WebSocket服务

前言

前面几章系统性的介绍过了netty的源码与原理,本章笔者将通过netty搭建一个企业级的WebSocket服务,充分展现作为一款优秀的底层通讯框架,netty的有点与普适性。

WebSocket服务

这是一个WebSocket服务示例

java 复制代码
@Service
public class PushServer implements ApplicationListener<ContextRefreshedEvent> {
 
    private static final int ON_EVENT_COUNT = 2;

    @Autowired
    private TextWebSocketFrameHandler bizHandler;

    @Autowired
    private SslContextService sslContextService;

    private Runnable runnable;

    private int onEventCount = 0;

    @Override
    public void onApplicationEvent(ContextRefreshedEvent event) {
        onEventCount++;
        if (onEventCount == ON_EVENT_COUNT) {
            start();
        }
    }

    public void start() {
        EventLoopGroup bossGroup = new NioEventLoopGroup(1);
        EventLoopGroup workerGroup = new NioEventLoopGroup(10);

        final SslContext sslContext = sslContextService.load();
        ServerBootstrap bootstrap = new ServerBootstrap();
        bootstrap.group(bossGroup, workerGroup)
                .channel(NioServerSocketChannel.class)
                .childOption(ChannelOption.TCP_NODELAY, true)
                .childOption(ChannelOption.SO_KEEPALIVE, true)
                .childOption(ChannelOption.SO_LINGER,1)
                .childHandler(new ChannelInitializer<SocketChannel>() {
                    @Override
                    protected void initChannel(SocketChannel ch) throws Exception {
                        ChannelPipeline pipeline = ch.pipeline();
                        pipeline.addLast(sslContext.newHandler(ch.alloc()));
                        pipeline.addLast(new HttpServerCodec());
                        pipeline.addLast(new HttpObjectAggregator(64 * 1024));
                        pipeline.addLast(new WebSocketServerProtocolHandler("/ws", true));
                        pipeline.addLast(new IdleStateHandler(60, -1, -1));
                        }
                        pipeline.addLast(bizHandler);
                    }
                });

        ChannelFuture f = bootstrap.bind(new InetSocketAddress(1111));
        f.addListener((ChannelFutureListener) future -> {
            if (future.isSuccess()) {
                log.info("push server bind on port: 1111");
            } else {
                log.error("push server bind error: ", future.cause());
            }
        });

        runnable = () -> {
            bossGroup.shutdownGracefully();
            workerGroup.shutdownGracefully();
        };

        Thread shutdownHook = new Thread(runnable, "关闭推送服务");
        Runtime.getRuntime().addShutdownHook(shutdownHook);
    }

    public void shutdown() {
        if (runnable != null) {
            runnable.run();
            runnable = null;
        }
    }
}

要搭建websocket服务,首先需要明白什么是websocket服务。如果缺少概念,可以参考什么是WebWocket服务这篇文章,这里就不再多做介绍。

PushServer这个类交由spring管理,监听容器刷新事件,我们知道spring项目启动时,spring容器和mvc容器会先后启动

ini 复制代码
public void onApplicationEvent(ContextRefreshedEvent event) {
    onEventCount++; 
   if (onEventCount == ON_EVENT_COUNT) {
       start(); 
   } 
}

这段代码保证了start()方法在执行时,外部环境已经准备好。

start()方法

  • 首先创建boss线程和worker线程
  • 接着通过sslContextService.load()方法加载支持wss协议需要的SslContext对象,这个对象是netty用来完成tsl/ssl加密所用的handler。详见这篇文章
  • 之后为bootstrap添加各种属性和设置参数,尤其是为连接添加各种handler,这个我们接下来会说。
  • 接着通过ChannelFuture来监听bind()方法是否执行成功
  • 最后对runnable定义,优雅关闭线程组并将其提交到shutdown Hook(jvm的关闭的钩子),使得程序退出时能够优雅关闭线程池

shutdown()方法

执行runnable,暴露关闭WebSocket的能力

WebSocket服务需要的handler

new HttpServerCodec()

根据 RFC6455,WebSocket 握手建立在 HTTP/1.1 协议之上,因此服务端必须能够处理 HTTP 请求。
HttpServerCodec 是 Netty 提供的 HTTP 编解码器:

scala 复制代码
public final class HttpServerCodec extends CombinedChannelDuplexHandler<HttpRequestDecoder, HttpResponseEncoder>

它内部组合了 HttpRequestDecoderHttpResponseEncoder,能同时完成:

  • 请求解码 :把 TCP 字节流解析成多个 HttpObjectHttpRequestHttpContent 等)
  • 响应编码 :将 HttpResponse 转换成字节流回写给客户端

HTTP 的消息边界通过协议规则划分,例如:

  • 请求行与头部使用 \r\n
  • 头部结束使用 \r\n\r\n
  • body 长度由 Content-Length 或 chunked 分块大小决定

因此 HttpServerCodec 输出的是多个 HttpObject,而不是一个完整的 HTTP 请求。

new HttpObjectAggregator(64 * 1024)

HttpObjectAggregator 用于将 HttpServerCodec 拆分出的多个 HttpObject 自动聚合成一个完整的 FullHttpRequestFullHttpResponse。内部工作流程为:

  1. 接收到 HttpRequest → 标记消息开始
  2. 多次接收到 HttpContent → 累积 body 数据
  3. 接收到 LastHttpContent → 标记消息结束
  4. 合成一个完整的 FullHttpRequest 并向下传播
  5. 同时会检查聚合后的消息大小是否超过限制(如 64KB),避免大包攻击

因此,使用它之后,业务 handler 可以直接处理一个完整的 HTTP 请求对象,而无需手动处理多段内容。

new WebSocketServerProtocolHandler("/ws", true)

WebSocketServerProtocolHandlerNetty WebSocket 服务端的核心 Handler

它的主要职责包括:

  • 处理 HTTP → WebSocket 的握手升级流程

  • 在握手成功后,自动切换 pipeline 到 WebSocket Frame 模式

  • 自动管理并处理 WebSocket 协议层细节:

    • ping / pong
    • close frame
    • fragmented frame(分片帧)
  • 避免业务代码直接接触 WebSocket 协议细节

一句话总结:

它把"HTTP 协议世界"和"WebSocket Frame 世界"隔离开来,让业务只需要关心 WebSocket 消息本身。

handlerAdded:WebSocket pipeline 的"预部署阶段"

scss 复制代码
@Override
public void handlerAdded(ChannelHandlerContext ctx) {
    ChannelPipeline cp = ctx.pipeline();
    if (cp.get(WebSocketServerProtocolHandshakeHandler.class) == null) {
        ctx.pipeline().addBefore(
            ctx.name(),
            WebSocketServerProtocolHandshakeHandler.class.getName(),
            new WebSocketServerProtocolHandshakeHandler(...)
        );
    }
    if (cp.get(Utf8FrameValidator.class) == null) {
        ctx.pipeline().addBefore(
            ctx.name(),
            Utf8FrameValidator.class.getName(),
            new Utf8FrameValidator()
        );
    }
}

这一步做了什么?

WebSocketServerProtocolHandler 被加入 pipeline 时,Netty 会立即回调 handlerAdded()

在这个阶段,它会 为当前 Channel 提前"布置"两个关键 Handler


1️⃣ WebSocketServerProtocolHandshakeHandler
  • 位置 :位于 WebSocketServerProtocolHandler 之前

  • 职责

    • 接收并解析浏览器(或客户端)发来的 HTTP Upgrade 请求

    • 执行 WebSocket 握手

    • 在握手成功后:

      • 动态替换 HTTP 相关 handler
      • 切换 pipeline 到 WebSocket Frame 编解码体系

可以理解为:
这是 HTTP 世界的"最后一站"


2️⃣ Utf8FrameValidator
  • 位置:位于 handshake handler 之前

  • 职责

    • 校验 TextWebSocketFrame 的 payload 是否符合 UTF-8 编码
    • 支持 fragmented frame(跨 frame 校验)

这是 WebSocket 协议的强制要求

RFC 6455 §5.6
WebSocket 文本消息必须是合法 UTF-8,否则必须关闭连接

这也是为什么 Netty 把 UTF-8 校验内置,而不是交给业务代码。


WebSocketServerProtocolHandshakeHandler:真正的升级入口

java 复制代码
public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception {
    final FullHttpRequest req = (FullHttpRequest) msg;

注意这里的前提:

  • 能拿到 FullHttpRequest
  • 说明 HttpServerCodec + HttpObjectAggregator 已经完成了解码与聚合

握手流程整体分为四步


① 构造 HandshakerFactory
ini 复制代码
final WebSocketServerHandshakerFactory wsFactory =
    new WebSocketServerHandshakerFactory(
        getWebSocketLocation(ctx.pipeline(), req, websocketPath),
        subprotocols,
        allowExtensions,
        maxFramePayloadSize,
        allowMaskMismatch
    );
WebSocket URL 拼装逻辑
typescript 复制代码
private static String getWebSocketLocation(...) {
    String protocol = "ws";
    if (cp.get(SslHandler.class) != null) {
        protocol = "wss";
    }
    return protocol + "://" + host + path;
}

这里非常关键的一点是:

  • 是否使用 ws 还是 wss,并不是由 URL 决定
  • 而是由 pipeline 中是否存在 SslHandler 决定

这也是为什么 TLS 一定要放在 pipeline 最前面


② 根据请求版本创建 Handshaker
ini 复制代码
CharSequence version = req.headers().get(HttpHeaderNames.SEC_WEBSOCKET_VERSION);
csharp 复制代码
if (version.equals("13")) {
    return new WebSocketServerHandshaker13(...);
}
  • Netty 会根据请求头中的 Sec-WebSocket-Version
  • 创建对应版本的 WebSocketServerHandshaker
  • 目前主流版本是 V13(RFC 6455)

不同版本的区别主要体现在:

  • 握手响应格式
  • Key 的计算方式
  • Frame 编解码细节

③ 执行握手:真正的"协议切换"
ini 复制代码
final ChannelFuture handshakeFuture =
    handshaker.handshake(ctx.channel(), req);

这是整个流程中 最关键的一步


handshake 内部做了哪些事?

1️⃣ 构造 HTTP 101 响应
ini 复制代码
FullHttpResponse response = newHandshakeResponse(req, responseHeaders);

即:

makefile 复制代码
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: xxx

2️⃣ 清理 HTTP 阶段的 Handler
csharp 复制代码
p.remove(HttpObjectAggregator.class);
p.remove(HttpContentCompressor.class);

原因很简单:

WebSocket 是 Frame 协议,不再需要 HTTP 聚合与压缩


3️⃣ 替换 HTTP Codec → WebSocket Codec
less 复制代码
p.addBefore(ctx.name(), "wsdecoder", newWebsocketDecoder());
p.addBefore(ctx.name(), "wsencoder", newWebSocketEncoder());

这一步完成后:

  • pipeline 从"HTTP 消息模型"
  • 切换为"WebSocket Frame 模型"

4️⃣ 回写响应,并在回调中完成最终切换
arduino 复制代码
channel.writeAndFlush(response).addListener(future -> {
    if (future.isSuccess()) {
        p.remove(encoderName);
        promise.setSuccess();
    } else {
        promise.setFailure(future.cause());
    }
});

⚠️ 为什么一定要在回调里 remove HttpServerCodec?

因为:

  • response 本身 仍然需要 HTTP encoder 编码
  • 如果提前移除,会导致响应无法写出

这是确保协议平滑切换的关键细节。


④ 善后与事件通知
ini 复制代码
ctx.fireUserEventTriggered(
    ServerHandshakeStateEvent.HANDSHAKE_COMPLETE
);

Netty 会通过 UserEvent 的方式通知:

  • 握手已完成
  • WebSocket 连接已正式建立

业务层如果关心握手完成时机,可以监听该事件。

new IdleStateHandler(60, -1, -1)

IdleStateHandler 的作用与设计背景

IdleStateHandler 是 Netty 提供的一个 用于检测连接空闲状态的 Handler ,本质上是一个 基于时间的心跳/存活检测机制

我们知道,TCP 协议本身并不具备应用层的心跳与存活检测能力。在以下场景中:

  • 客户端断网
  • 客户端进程宕机
  • 浏览器页面被关闭
  • 移动网络切换(IP 变化)

服务器端往往 无法立即感知连接已经失效 ,此时连接可能长期停留在 ESTABLISHEDCLOSE_WAIT 状态。

如果不做处理,随着时间推移,将会导致:

  • 大量 无效连接堆积
  • CLOSE_WAIT / TIME_WAIT 数量持续增长
  • 无效连接占用 线程、内存、FD(文件描述符)等系统资源
  • 最终引发性能下降甚至服务不可用

因此,服务端必须具备检测"客户端是否还活着"的能力


常见的心跳检测方案

在实际工程中,常见的做法是:

  • 客户端

    • 每隔 X 秒发送一次心跳

      • WebSocket 场景:PingWebSocketFrame
      • 普通 TCP 场景:自定义心跳包
  • 服务端

    • 每次读到数据时,重置连接的 idle 计时器

    • 如果在 readIdleTime 时间内 没有收到任何数据

      • 判定客户端已失活
      • 主动关闭连接,释放资源

IdleStateHandler 的核心职责

IdleStateHandler 的职责正是为上述机制提供 通用、可靠的基础能力,其主要功能包括:

  1. 记录连接的读 / 写时间

    • 最近一次 channelRead
    • 最近一次 write
  2. 向 EventLoop(EventExecutor)提交定时任务

    • 定期检查连接是否发生:

      • 读空闲(READER_IDLE
      • 写空闲(WRITER_IDLE
      • 读写空闲(ALL_IDLE
  3. 在超时发生时触发事件通知

    • 通过触发 IdleStateEvent

    • 回调下游 Handler 的 userEventTriggered() 方法

    • 通常由业务 Handler 来决定:

      • 发送心跳
      • 或直接关闭连接

总结

本文通过一段 WebSocket 服务端的完整示例代码 ,展示了如何构建一个 企业级 WebSocket 服务的基础框架。如果读者从本系列第一篇文章一路阅读至此,那么本章的内容理解起来应该并不存在太大难度。至于剩余的业务处理逻辑,则需要结合具体业务场景进行具体分析;只要能够真正读懂 Netty 的源码实现,相信在实际落地过程中都可以做到游刃有余。

至此,Netty 系列文章也正式告一段落。在整个系列中,我们依次介绍了 Netty 的启动流程、线程模型、连接接入流程、网络数据包的生命周期 ,以及 Netty 的几个核心组件与关键设计思想 ,整体上帮助读者建立起了对 Netty 相对系统、深入的认知。

对于本文未能展开说明的部分,也正是 Netty 更为丰富和精妙的所在,期待各位读者在实际项目和源码阅读中持续深入探索

相关推荐
网安INF4 小时前
AKA协议认证与密钥协商的核心原理
网络协议·安全·网络安全·密码学·aka
BuffaloBit4 小时前
5G 架构演进的关键思想
网络协议·5g·架构
Dovis(誓平步青云)4 小时前
《Linux内核视角:自定义协议与TCP的协同通信之道》
网络·网络协议·tcp/ip
while(1){yan}4 小时前
HTTP的数据报格式
java·开发语言·网络·网络协议·http·青少年编程·面试
真上帝的左手4 小时前
15. 实时数据-SpringBoot集成WebSocket
spring boot·后端·websocket
元气满满-樱5 小时前
Http概述
网络·网络协议·http
油丶酸萝卜别吃5 小时前
如何使用http-server --cors启动页面?
网络·网络协议·http
Biteagle5 小时前
P2PK:比特币的「原始密码锁」与比特鹰的技术考古
网络·网络协议·p2p
while(1){yan}1 天前
网络协议TCP
java·网络·网络协议·tcp/ip·青少年编程·电脑常识