Netty 编解码器学习记:从粘包拆包到自定义协议

张开发
2026/4/20 2:48:18 15 分钟阅读

分享文章

Netty 编解码器学习记:从粘包拆包到自定义协议
Netty 编解码器学习记从粘包拆包到自定义协议这是一个小白的学习记录边学边练把踩过的坑都记下来为啥要学编解码器学了线程模型感觉 Netty 跑起来挺快的。但要想数据传输稳定必须学会编解码器。我一开始还纳闷编解码器不就是序列化和反序列化吗有啥好学的后来才发现Netty 的编解码器还真不简单特别是粘包/拆包问题不学还真搞不定。一、编解码器是啥我理解的编解码器编解码器就是把数据在网络传输格式和应用程序格式之间转换的工具。编码器Encoder把应用程序的数据转换成网络能传输的格式解码器Decoder把网络传输的格式转换成应用程序能理解的格式二、常用编解码器1. 字符串编解码器这是最常用的把字节数据转换成字符串。ch.pipeline().addLast(newStringDecoder(StandardCharsets.UTF_8));ch.pipeline().addLast(newStringEncoder(StandardCharsets.UTF_8));2. 基于长度的帧解码器这个是解决粘包/拆包问题的神器。LengthFieldBasedFrameDecoder// 长度字段占 4 字节位于消息开头LengthFieldBasedFrameDecoderdecodernewLengthFieldBasedFrameDecoder(1024,// 最大帧长度0,// 长度字段偏移量4,// 长度字段长度0,// 长度调整值4// 跳过长度字段);FixedLengthFrameDecoder用于固定长度的消息。// 每个消息固定 100 字节FixedLengthFrameDecoderdecodernewFixedLengthFrameDecoder(100);LineBasedFrameDecoder用于基于换行符的消息。// 最大行长度 1024 字节LineBasedFrameDecoderdecodernewLineBasedFrameDecoder(1024);3. 对象编解码器用于传输 Java 对象。ch.pipeline().addLast(newObjectDecoder(ClassResolvers.cacheDisabled(null)));ch.pipeline().addLast(newObjectEncoder());4. Protobuf 编解码器用于传输 Protobuf 对象性能更好。ch.pipeline().addLast(newProtobufDecoder(MyMessage.getDefaultInstance()));ch.pipeline().addLast(newProtobufEncoder());三、粘包/拆包问题什么是粘包/拆包TCP 是面向流的协议数据会被分割或合并导致粘包多个小数据包被合并成一个大数据包拆包一个大数据包被分割成多个小数据包我踩过的坑刚开始写 Echo 服务器时客户端发Hello服务端收到HelloHelloHello或者收到Hel、“lo”。我当时就懵了这是啥情况后来查资料才明白这就是粘包/拆包问题。解决方案1. 基于长度字段在消息开头加一个长度字段告诉接收方消息有多长。// 编码器写入长度和数据ch.pipeline().addLast(newMessageToByteEncoderString(){Overrideprotectedvoidencode(ChannelHandlerContextctx,Stringmsg,ByteBufout)throwsException{byte[]datamsg.getBytes();out.writeInt(data.length);// 写入长度out.writeBytes(data);// 写入数据}});// 解码器根据长度字段解析ch.pipeline().addLast(newLengthFieldBasedFrameDecoder(1024,0,4,0,4));2. 基于分隔符用特定的分隔符标记消息结束比如换行符。// 编码器加换行符ch.pipeline().addLast(newMessageToByteEncoderString(){Overrideprotectedvoidencode(ChannelHandlerContextctx,Stringmsg,ByteBufout)throwsException{out.writeBytes((msg\n).getBytes());}});// 解码器按换行符分割ch.pipeline().addLast(newLineBasedFrameDecoder(1024));3. 固定长度每个消息长度固定不够的填充。// 编码器填充空格ch.pipeline().addLast(newMessageToByteEncoderString(){Overrideprotectedvoidencode(ChannelHandlerContextctx,Stringmsg,ByteBufout)throwsException{StringpaddedMsgString.format(%-100s,msg);out.writeBytes(paddedMsg.getBytes());}});// 解码器固定长度ch.pipeline().addLast(newFixedLengthFrameDecoder(100));教训粘包/拆包问题一定要处理否则数据传输会乱套四、自定义编解码器实现自定义 DecoderpublicclassCustomDecoderextendsByteToMessageDecoder{Overrideprotectedvoiddecode(ChannelHandlerContextctx,ByteBufin,ListObjectout)throwsException{// 确保有足够的字节可读if(in.readableBytes()4){return;// 不够等更多数据}// 读取长度intlengthin.readInt();if(in.readableBytes()length){in.resetReaderIndex();// 重置读索引return;}// 读取数据byte[]datanewbyte[length];in.readBytes(data);StringmessagenewString(data);out.add(message);}}实现自定义 EncoderpublicclassCustomEncoderextendsMessageToByteEncoderString{Overrideprotectedvoidencode(ChannelHandlerContextctx,Stringmsg,ByteBufout)throwsException{byte[]datamsg.getBytes();out.writeInt(data.length);// 写入长度out.writeBytes(data);// 写入数据}}五、实战实现自定义协议协议设计我设计了一个简单的协议魔数4字节0xCAFEBABE版本1字节1命令1字节1-登录2-消息3-退出长度4字节数据部分的长度数据可变长度具体业务数据实现编解码器解码器publicclassCustomProtocolDecoderextendsByteToMessageDecoder{privatestaticfinalintMAGIC_NUMBER0xCAFEBABE;privatestaticfinalintHEADER_LENGTH4114;// 魔数 版本 命令 长度Overrideprotectedvoiddecode(ChannelHandlerContextctx,ByteBufin,ListObjectout)throwsException{// 检查是否有足够的字节if(in.readableBytes()HEADER_LENGTH){return;}// 标记当前位置in.markReaderIndex();// 读取魔数intmagicin.readInt();if(magic!MAGIC_NUMBER){ctx.close();// 魔数不匹配关闭连接return;}// 读取版本和命令byteversionin.readByte();bytecommandin.readByte();// 读取数据长度intlengthin.readInt();// 检查数据长度if(in.readableBytes()length){in.resetReaderIndex();// 重置等更多数据return;}// 读取数据byte[]datanewbyte[length];in.readBytes(data);// 构造消息CustomMessagemessagenewCustomMessage(version,command,data);out.add(message);}}编码器publicclassCustomProtocolEncoderextendsMessageToByteEncoderCustomMessage{privatestaticfinalintMAGIC_NUMBER0xCAFEBABE;Overrideprotectedvoidencode(ChannelHandlerContextctx,CustomMessagemsg,ByteBufout)throwsException{// 写入魔数out.writeInt(MAGIC_NUMBER);// 写入版本和命令out.writeByte(msg.getVersion());out.writeByte(msg.getCommand());// 写入数据长度out.writeInt(msg.getData().length);// 写入数据out.writeBytes(msg.getData());}}使用编解码器publicclassCustomProtocolServer{publicstaticvoidmain(String[]args)throwsException{EventLoopGroupbossGroupnewNioEventLoopGroup(1);EventLoopGroupworkerGroupnewNioEventLoopGroup();try{ServerBootstrapbnewServerBootstrap();b.group(bossGroup,workerGroup).channel(NioServerSocketChannel.class).childHandler(newChannelInitializerSocketChannel(){OverridepublicvoidinitChannel(SocketChannelch)throwsException{ch.pipeline().addLast(newCustomProtocolDecoder());ch.pipeline().addLast(newCustomProtocolEncoder());ch.pipeline().addLast(newCustomProtocolHandler());}});ChannelFuturefb.bind(8080).sync();System.out.println(Server started on port 8080);f.channel().closeFuture().sync();}finally{bossGroup.shutdownGracefully();workerGroup.shutdownGracefully();}}}六、我踩过的坑粘包/拆包刚开始没处理导致数据错乱长度字段配置错误LengthFieldBasedFrameDecoder 参数配置错了导致解码失败内存溢出没设置最大帧长度导致大消息撑爆内存解码器顺序编解码器顺序放错了导致数据处理失败七、编解码器最佳实践选择合适的编解码器根据协议类型选择正确配置参数特别是 LengthFieldBasedFrameDecoder 的参数设置最大帧长度防止内存溢出处理边界情况比如数据不完整、长度不合法测试编解码器多测试正常、边界、异常情况避免耗时操作编解码器里别做耗时的事验证步骤1. 测试粘包/拆包// 客户端连续发送多条消息for(inti0;i10;i){channel.writeAndFlush(Message i);}预期结果服务端收到 10 条完整的消息没有粘在一起。2. 测试自定义协议// 发送登录命令CustomMessageloginMsgnewCustomMessage((byte)1,(byte)1,user:admin,pwd:123.getBytes());channel.writeAndFlush(loginMsg);// 发送消息命令CustomMessagemsgMsgnewCustomMessage((byte)1,(byte)2,Hello Netty.getBytes());channel.writeAndFlush(msgMsg);预期结果服务端正确解析出登录和消息命令。总结其实 Netty 的编解码器也没那么难就是把数据在不同格式之间转换还要处理粘包/拆包问题。我也是踩了几个坑才明白这些道理的。现在我对编解码器有了点感觉知道什么时候用什么编解码器怎么处理粘包/拆包问题。但要真正掌握还得继续练习。肯定有理解不对的地方欢迎大佬指正。如果你也是新手希望这篇笔记能帮到你。

更多文章