Netty 的 ChannelPipeline 顺序到底该怎么写?我在做 IM 系统时踩过的坑
最近我从零开始搭一套轻量级 IM 系统(WeTalk: 基于netty + springboot + react的简易im系统),用 Netty 做通信层,WebSocket 承载消息。一切看起来很顺利,直到我开始加自定义消息编解码——结果一启动就报错:
1io.netty.handler.codec.UnsupportedMessageTypeException:
2cannot encode message of type class com.xxx.im.protocol.ChatMessage
我当时就懵了:我明明写了 MessageEncoder,为什么 Netty 说它不认识我的消息类型?
排查半天才发现,问题出在一个看似微不足道的地方:ChannelPipeline 里处理器的添加顺序。
一开始我也觉得顺序很反直觉
按照常规逻辑,数据流应该是:
- 收到原始字节;
- 解码成业务对象;
- 业务逻辑处理;
- 处理完要发回去,再编码成字节。
那 pipeline 顺序不就该是:
pipeline.addLast(new Decoder());
pipeline.addLast(new BusinessHandler());
pipeline.addLast(new Encoder()); // ← 看起来最“顺”
但如果你真这么写,程序跑起来会直接报错:
UnsupportedMessageTypeException: cannot encode message of type ...
为什么?因为 Netty 根本没调用你的 Encoder。
关键在于:Netty 的 pipeline 是双向的
很多人以为 pipeline 是个单向队列,数据从头走到尾。其实不是。
Netty 的 ChannelPipeline 内部维护了两条链:
- 入站(inbound):处理从网络读进来的数据,比如
channelRead(),方向是从 head 到 tail; - 出站(outbound):处理你要写出去的数据,比如
write(),方向是从 tail 到 head。
而 addLast() 这个方法,对两种处理器的影响是不同的:
- 对 inbound handler(比如 Decoder),它是加在入站链的末尾;
- 对 outbound handler(比如 Encoder),它实际上是加在出站链的开头。
听起来有点绕?我们画个图。
假设你这样加:
pipeline.addLast("decoder", new MessageDecoder());
pipeline.addLast("encoder", new MessageEncoder());
pipeline.addLast("handler", new MyHandler());
那么实际结构是:
入站方向(读数据):
head → decoder → handler → tail
出站方向(写数据):
head ← encoder ← handler ← tail
注意:出站是从右往左走的!
所以当你在 MyHandler 里调 ctx.write(myObject),Netty 会从 handler 开始,往左找下一个 outbound handler —— 正好是 encoder。
✅ 能正常编码。
但如果你把 encoder 写在最后:
pipeline.addLast("decoder", new MessageDecoder());
pipeline.addLast("handler", new MyHandler());
pipeline.addLast("encoder", new MessageEncoder()); // ← 最后加
出站链就变成:
head ← handler ← encoder ← tail
这时候从 handler 往左找,左边已经没东西了,encoder 在右边(更靠近 tail),根本不会被命中。
❌ 所以 write 的时候直接抛异常。
那“解码完立刻编码”会发生吗?
不会。
因为 解码发生在入站流程,编码发生在出站流程,两者压根不在同一条路上。
举个例子:
- 客户端发来一个 JSON 字符串;
- Netty 把它变成
TextWebSocketFrame; - 经过
MessageDecoder,变成LoginRequest对象; - 传给
MyHandler.channelRead(),你在这里做登录校验; - 校验完,你主动调
ctx.write(new WelcomeResponse(...)); - 这才触发出站流程,走到
MessageEncoder,把对象转回帧。
整个过程里,解码的结果不会自动变成“要发送的数据”,除非你显式调 write()。
所以根本不存在“刚解完码就被编码”的情况——那是两个独立的动作。
实际项目中的推荐写法
我现在的项目里,pipeline 基本都这么配:
pipeline.addLast(new HttpServerCodec());
pipeline.addLast(new HttpObjectAggregator(64 * 1024));
pipeline.addLast(new WebSocketServerProtocolHandler("/ws"));
// 编解码器放一起,清晰明了
pipeline.addLast(new MessageDecoder());
pipeline.addLast(new MessageEncoder());
// 最后是业务逻辑
pipeline.addLast(new WebSocketHandler());
虽然 MessageEncoder 在代码上写在 WebSocketHandler 前面,但它只在你 write 的时候才起作用,平时完全“隐身”。
这种写法也符合 Netty 官方示例和主流开源项目的习惯(比如 gRPC-Java、Dubbo 的 Netty 层)。
小技巧:怎么验证 encoder 被调用了?
在 MessageEncoder.encode() 里打个日志:
@Override
protected void encode(ChannelHandlerContext ctx, MyMsg msg, List<Object> out) {
System.out.println("Encoding: " + msg);
// ...
}
你会发现:
- 收消息时,这条日志不会打印;
- 只有你主动
write之后,才会出现。
这就证明了:编解码器各司其职,互不干扰。
总结
- Decoder 必须在业务 handler 之前:否则业务拿不到解码后的对象;
- Encoder 必须在业务 handler 之前 add:否则出站时找不到它;
- 不会“解码完立刻编码”:因为入站和出站是两条独立路径;
- 记住:出站是从右往左执行的,这是理解顺序的关键。
更多推荐



所有评论(0)