本节目标:说清一条 Channel 上的 handler 链表如何双向传播事件,以及
addLast的顺序和写操作的起点如何改变最终结果。
适用版本:JDK 25 + Netty 4.2.19.Final(netty-transport、netty-codec-http模块)。
承接的问题
第 3 章结束时,linkd-v2 的 9090 端口只有 LinkdHttpHandler 一个 handler,9093 端口也只有编解码器和业务回显。连接能建立,但 pipeline 还只是「一个 handler 挂在 Channel 上」,没有体现 Netty 真正的组织方式。
第一次把监控加进去就出了问题。为了统计每个响应写了多少字节,我把一个出站 OutboundMeter 加在业务 handler 后面:
pipeline.addLast("http", new LinkdHttpHandler(registry));
pipeline.addLast("meter", new OutboundMeter());
按直觉,业务 handler 写出的响应应该经过 meter;实际 meter 一次都没打印。原因不是 handler 没生效,而是 ctx.writeAndFlush() 的起点在「当前 handler 的前一个出站 handler」,不会经过排在它后面的 handler。同一段代码换成 ctx.channel().writeAndFlush(),meter 立刻打印。
这个现象要讲清楚,必须先看 pipeline 的结构。本节先拆 head/tail 哨兵和事件方向,再解释 HTTP 编解码链为什么只能是 HttpServerCodec → HttpObjectAggregator → 业务 handler,最后把 linkd-v2 的 9090 与 9093 两条 pipeline 画出来并打印验证。
本机口径:Apple M1 Pro,10 核,32 GB,macOS 26.3 (25D125);Temurin 25.0.5+8,Netty 4.2.19.Final。下面标注「本机输出」的内容来自这台机器,单次运行,未做统计。
head 与 tail:一条双向链表
ChannelPipeline 不是数组,也不是简单的 List,而是一条由 ChannelHandlerContext 组成的双向链表。每个 handler 被加入 pipeline 时,Netty 为它创建一个 context,context 里保存 handler、所属 Channel、所属 EventLoop 和链表前后指针。pipeline 自己持有两个哨兵:head 和 tail。
head是最前面的哨兵,负责把出站事件交给底层的Channel(真正写 socket)。tail是最后面的哨兵,负责终止没人处理的入站事件,并在异常没人捕获时打印警告。addLast把新 handler 插到tail之前;addFirst插到head之后。pipeline.names()从head.next开始列到tail,所以结果里没有head,但会看到tail。
入站事件从 head 向 tail 走,出站事件从 tail 向 head 走:
入站(读、连接建立、断开)
head ──► handler A ──► handler B ──► handler C ──► tail
出站(写、flush、read 请求、connect)
head ◄── handler A ◄── handler B ◄── handler C ◄── tail
方向不是约定,而是由方法决定:入站 handler 用 ctx.fireChannelRead(msg) 把事件交给「下一个」入站 handler;出站 handler 用 ctx.write(msg, promise) 把写请求交给「前一个」出站 handler。ctx 知道自己站在链表哪个位置,这是它和 channel() 的根本区别。
把 9090 的 pipeline 建出来并打印名称:
ChannelPipeline pipeline = ch.pipeline();
pipeline.addLast("codec", new HttpServerCodec());
pipeline.addLast("aggregator", new HttpObjectAggregator(1 << 20));
pipeline.addLast("http", new LinkdHttpHandler(registry));
System.out.println("names=" + pipeline.names());
本机输出:
names=[codec, aggregator, http, DefaultChannelPipeline$TailContext#0]
列表顺序就是入站顺序,也是 addLast 的调用顺序。TailContext 是哨兵,不是业务 handler;head 没有出现在列表里,因为它不是 head.next。如果给 handler 传了显式名字(上面的 "codec"、"aggregator"、"http"),日志和 pipeline.context("http") 都能稳定找到它;不传名字时 Netty 会生成 HttpServerCodec#0 这类名称,同一个类每次创建的顺序可能变化,不适合写进监控标签。
为什么 HTTP 链必须是 codec → aggregator → 业务
三个 handler 的类型决定了顺序不能换:
| handler | 方向 | 输入 | 输出 |
|---|---|---|---|
HttpServerCodec | 入站 + 出站 | ByteBuf | HttpRequest / HttpContent |
HttpObjectAggregator | 入站 | HttpRequest + HttpContent | FullHttpRequest |
LinkdHttpHandler | 入站 | FullHttpRequest | 业务处理 |
HttpServerCodec 是请求解码器和响应编码器的组合。入站方向它把字节流切成 HttpRequest 和若干 HttpContent;出站方向它把 HttpResponse 序列化成 ByteBuf。HttpObjectAggregator 只处理入站,把分开的请求头和请求体拼成一个 FullHttpRequest。LinkdHttpHandler 继承 SimpleChannelInboundHandler<FullHttpRequest>,只接受完整请求。
顺序反了会静默失败。把业务 handler 放到 aggregator 前面:
// 反例:业务 handler 在 aggregator 之前
pipeline.addLast("http", new LinkdHttpHandler(registry));
pipeline.addLast("aggregator", new HttpObjectAggregator(1 << 20));
SimpleChannelInboundHandler 先用 acceptInboundMessage 判断类型,HttpRequest 不是 FullHttpRequest,于是它把消息原样 fireChannelRead 给后面的 aggregator;aggregator 产出的 FullHttpRequest 出现在业务 handler 之后,直接走向 tail。结果是业务代码一次都没执行,客户端等到超时,日志里也没有异常。这类问题用断点很难看出来,打印 pipeline.names() 或给 handler 加名称反而更快。
出站方向同样依赖顺序。LinkdHttpHandler 调用 ctx.writeAndFlush(response) 后,响应朝 head 走:先经过 aggregator(它只处理入站,出站直接放行),再经过 HttpServerCodec 的出站部分被编码成 ByteBuf,最后到 head 写 socket。如果 HttpServerCodec 被放到业务 handler 后面,响应不会被编码,写操作会以 UnsupportedOperationException 失败。NioSocketChannel 只接受 ByteBuf,不会替你序列化 HttpResponse。
ctx.writeAndFlush 与 channel().writeAndFlush
这两个方法看起来只差一个 ctx,起点却完全不同:
ctx.writeAndFlush(msg)从当前 handler 向前(朝head)找下一个出站 handler,当前 handler 自己和排在它后面的 handler 都不经过。ctx.channel().writeAndFlush(msg)从tail开始,沿整条 pipeline 向head走,会经过当前 handler 之后的所有出站 handler,也会经过当前 handler 自己(如果它是出站 handler)。
用一个出站探针验证。OutboundMeter 只打印经过它的消息:
package com.plume.linkd.v2.server;
import io.netty.channel.ChannelHandlerContext;
import io.netty.channel.ChannelOutboundHandlerAdapter;
import io.netty.channel.ChannelPromise;
public final class OutboundMeter extends ChannelOutboundHandlerAdapter {
private final String name;
public OutboundMeter(String name) {
this.name = name;
}
@Override
public void write(ChannelHandlerContext ctx, Object msg, ChannelPromise promise) {
System.out.println(name + " sees " + msg);
ctx.write(msg, promise);
}
}
业务 handler 依次用两种方式写:
public final class WriteProbe extends ChannelInboundHandlerAdapter {
@Override
public void channelRead(ChannelHandlerContext ctx, Object msg) {
System.out.println("business pipeline=" + ctx.pipeline().names());
ctx.writeAndFlush("via-ctx");
ctx.channel().writeAndFlush("via-channel");
}
}
先把 WriteProbe 加进去,再把 OutboundMeter 加在它后面,本机输出:
business pipeline=[WriteProbe#0, OutboundMeter#0, DefaultChannelPipeline$TailContext#0]
meter sees via-channel
ctx.writeAndFlush("via-ctx") 从 WriteProbe 的位置朝 head 走,OutboundMeter 在它后面,不经过;ctx.channel().writeAndFlush("via-channel") 从 tail 开始,先经过 OutboundMeter,所以只打印一次 via-channel。
把 OutboundMeter 加到 WriteProbe 前面,两种写法都会经过它,本机输出:
business pipeline=[OutboundMeter#0, WriteProbe#0, DefaultChannelPipeline$TailContext#0]
meter sees via-ctx
meter sees via-channel
这解释了两条实践规则。第一,统计、日志、限流这类出站切面要放在业务 handler 之前(靠 head 一侧),否则 ctx.writeAndFlush 会绕过它。第二,在出站 handler 内部只能用 ctx.write() 继续传递,不能用 ctx.channel().writeAndFlush():那会让消息从 tail 重新走一遍,再次进入自己,轻则重复编码,重则无限递归。Netty 自带的 MessageToByteEncoder 内部就是 ctx.write,自己写编码器时照这个规则做。
在 linkd-v2 里落地:两条 pipeline
9090 的 HTTP pipeline 用一个 ChannelInitializer 建起来,顺序固定为 codec、aggregator、业务:
package com.plume.linkd.v2.server;
import io.netty.channel.ChannelInitializer;
import io.netty.channel.ChannelPipeline;
import io.netty.channel.socket.SocketChannel;
import io.netty.handler.codec.http.HttpObjectAggregator;
import io.netty.handler.codec.http.HttpServerCodec;
public final class HttpPipelineInitializer extends ChannelInitializer<SocketChannel> {
private final ConnectionRegistry registry;
public HttpPipelineInitializer(ConnectionRegistry registry) {
this.registry = registry;
}
@Override
protected void initChannel(SocketChannel ch) {
ChannelPipeline pipeline = ch.pipeline();
pipeline.addLast("codec", new HttpServerCodec());
pipeline.addLast("aggregator", new HttpObjectAggregator(1 << 20));
pipeline.addLast("http", new LinkdHttpHandler(registry));
}
}
1 << 20 是 1 MiB 的聚合上限。超过上限时 aggregator 会直接回 413 Request Entity Too Large,不会把超大请求交给业务 handler,避免一个请求把堆外内存吃满。这个数字要按业务的最大请求体设置,不能因为默认值能跑就不管。
9093 的自定义协议 pipeline 是另一条链:
pipeline.addLast("frameDecoder", new LinkdFrameDecoder());
pipeline.addLast("frameEncoder", new LinkdFrameEncoder());
pipeline.addLast("registry", new RegistryHandler(registry));
pipeline.addLast("echo", new LinkdFrameEchoHandler());
回显 handler 本身很小,放在 com.plume.linkd.v2.server:
public final class LinkdFrameEchoHandler extends SimpleChannelInboundHandler<LinkdFrame> {
@Override
protected void channelRead0(ChannelHandlerContext ctx, LinkdFrame frame) {
ctx.writeAndFlush(frame);
}
}
LinkdFrame 是 record,不实现 ReferenceCounted,SimpleChannelInboundHandler 的自动释放对它没有实际作用;如果以后把 payload 换成 ByteBuf,就要按 4.2 的规则用 super(false) 并明确释放责任。
两条 pipeline 的事件方向可以画成:
9090 HTTP/1.1
入站 head → codec → aggregator → http → tail
出站 head ← codec ← aggregator ← http ← tail
9093 自定义协议
入站 head → frameDecoder → frameEncoder → registry → echo → tail
出站 head ← frameDecoder ← frameEncoder ← registry ← echo ← tail
frameEncoder 是出站 handler,入站事件直接穿过它;frameDecoder 是入站 handler,出站事件直接穿过它。把它们放在相邻位置,是因为解码和编码共享同一套帧格式常量,改动时两个类要一起看。RegistryHandler 只处理 channelActive / channelInactive,出站响应经过它不会产生副作用。
要确认运行时结构,在任意一个入站 handler 的 channelActive 里打印一次:
@Override
public void channelActive(ChannelHandlerContext ctx) {
System.out.println("pipeline=" + String.join(" -> ", ctx.pipeline().names()));
ctx.fireChannelActive();
}
示意输出(名称由 addLast 的第一个参数决定):
pipeline=codec -> aggregator -> http -> DefaultChannelPipeline$TailContext#0
pipeline=frameDecoder -> frameEncoder -> registry -> echo -> DefaultChannelPipeline$TailContext#0
列表里没有 ChannelInitializer。它在 initChannel 返回后把自己从 pipeline 移除,所以连接建立后看不到它;这个移除动作和 handlerAdded 的先后关系是 4.2 的主题。列表里也没有 head,原因和前面一样:names() 从 head.next 开始。
常见错误与边界
错误一:业务 handler 放在 aggregator 之前。 表现是业务不执行、客户端超时、没有异常。SimpleChannelInboundHandler 对类型不匹配的消息会继续向后传,aggregator 的输出又落在业务 handler 后面,整条链看起来「都注册了」但永远不交汇。判断方法是看 pipeline.names() 的顺序,而不是看代码里 new 了几个 handler。
错误二:在出站 handler 里调用 ctx.channel().writeAndFlush()。
// 反例:编码器把自己写回的消息又走一遍
@Override
public void write(ChannelHandlerContext ctx, Object msg, ChannelPromise promise) {
ByteBuf out = encode(msg);
ctx.channel().writeAndFlush(out);
}
channel().writeAndFlush 从 tail 开始,会再次进入当前编码器,把已经编码过的 ByteBuf 当成待编码对象,重复编码或直接抛类型错误。正确写法是 ctx.write(out, promise),让事件只朝 head 走一次。
错误三:用 pipeline.names() 判断 ChannelInitializer 是否还在。 初始化器在 initChannel 之后就移除了,连接建立后的 names() 里本来就看不到它。想确认初始化是否执行,应该打印业务 handler 的名称或加日志,而不是找初始化器的类名。
边界:ctx 属于某一个 handler,不能跨 handler 复用。 ChannelHandlerContext 绑定在链表的某个节点上,把它缓存到字段里、在另一个 handler 里调用 write,写操作会从错误的起点开始。需要在别处触发写操作时,用 channel.writeAndFlush() 从 tail 开始,或者把 ctx 只保存在当前 handler 内。
小结
ChannelPipeline是ChannelHandlerContext组成的双向链表,head和tail是哨兵;names()从head.next列到tail,所以看不到head。- 入站事件从
head到tail,出站事件从tail到head;addLast的顺序就是入站顺序。 - HTTP 链必须
HttpServerCodec → HttpObjectAggregator → LinkdHttpHandler;反序会让业务 handler 永远收不到FullHttpRequest,而且不报错。 ctx.writeAndFlush()从当前 handler 朝head走,ctx.channel().writeAndFlush()从tail走;出站切面要放在业务 handler 之前,编码器内部用ctx.write()。- linkd-v2 的 9090 和 9093 是两条独立 pipeline,handler 实例按连接创建;下一节的生命周期回调决定这些实例何时初始化、何时清理。
pipeline 解决了「事件往哪走」,但没有回答「handler 什么时候被调用、什么时候可以写、什么时候该清理状态」。下一节按时间顺序拆开这些生命周期回调,并用一个有握手状态的 HandshakeHandler 把状态迁移写完整。
阅读导航:上一节:3.3 阻塞隔离与虚拟线程 · 下一节:4.2 Handler 生命周期与状态 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。