最近我从零开始搭一套轻量级 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 里处理器的添加顺序


一开始我也觉得顺序很反直觉

按照常规逻辑,数据流应该是:

  1. 收到原始字节;
  2. 解码成业务对象;
  3. 业务逻辑处理;
  4. 处理完要发回去,再编码成字节。

那 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 的时候直接抛异常。


那“解码完立刻编码”会发生吗?

不会。

因为 解码发生在入站流程,编码发生在出站流程,两者压根不在同一条路上。

举个例子:

  1. 客户端发来一个 JSON 字符串;
  2. Netty 把它变成 TextWebSocketFrame
  3. 经过 MessageDecoder,变成 LoginRequest 对象;
  4. 传给 MyHandler.channelRead(),你在这里做登录校验;
  5. 校验完,你主动调 ctx.write(new WelcomeResponse(...))
  6. 这才触发出站流程,走到 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:否则出站时找不到它;
  • 不会“解码完立刻编码”:因为入站和出站是两条独立路径;
  • 记住:出站是从右往左执行的,这是理解顺序的关键。
Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐