一个低级错误引发Netty编码解码中文异常

简介: 最近在调研Netty的使用,在编写编码解码模块的时候遇到了一个中文字符串编码和解码异常的情况,后来发现是笔者犯了个低级错误。这里做一个小小的回顾。

前言



最近在调研Netty的使用,在编写编码解码模块的时候遇到了一个中文字符串编码和解码异常的情况,后来发现是笔者犯了个低级错误。这里做一个小小的回顾。


错误重现



在设计Netty的自定义协议的时候,发现了字符串类型的属性,一旦出现中文就会出现解码异常的现象,这个异常并不一定出现了Exception,而是出现了解码之后字符截断出现了人类不可读的字符。编码和解码器的实现如下:


// 实体
@Data
public class ChineseMessage implements Serializable {
    private long id;
    private String message;
}
// 编码器  - <错误示范,不要拷贝>
public class ChineseMessageEncoder extends MessageToByteEncoder<ChineseMessage> {
    @Override
    protected void encode(ChannelHandlerContext ctx, ChineseMessage target, ByteBuf out) throws Exception {
        // 写入ID
        out.writeLong(target.getId());
        String message = target.getMessage();
        int length = message.length();
        // 写入Message长度
        out.writeInt(length);
        // 写入Message字符序列
        out.writeCharSequence(message, StandardCharsets.UTF_8);
    }
}
// 解码器
public class ChineseMessageDecoder extends ByteToMessageDecoder {
    @Override
    protected void decode(ChannelHandlerContext ctx, ByteBuf in, List<Object> out) throws Exception {
        // 读取ID
        long id = in.readLong();
        // 读取Message长度
        int length = in.readInt();
        // 读取Message字符序列
        CharSequence charSequence = in.readCharSequence(length, StandardCharsets.UTF_8);
        ChineseMessage message = new ChineseMessage();
        message.setId(id);
        message.setMessage(charSequence.toString());
        out.add(message);
    }
}
复制代码


简单地编写客户端和服务端代码,然后用客户端服务端发送一条带中文的消息:


// 服务端日志
接收到客户端的请求:ChineseMessage(id=1, message=张)
io.netty.handler.codec.DecoderException: java.lang.IndexOutOfBoundsException: readerIndex(15) + length(8) exceeds writerIndex(21) ......
// 客户端日志
接收到服务端的响应:ChineseMessage(id=2, message=张)
io.netty.handler.codec.DecoderException: java.lang.IndexOutOfBoundsException: readerIndex(15) + length(8) exceeds writerIndex(21) ......
复制代码


其实,问题就隐藏在编码解码模块中。由于笔者前两个月一直996,在疯狂编写CRUD代码,业余在看Netty的时候,有一些基础知识一时短路没有回忆起来。笔者带着这个问题在各大搜索引擎中搜索,有可能是姿势不对或者关键字不准,没有得到答案,加之,很多博客文章都是照搬其他人的Demo,而这些Demo里面恰好都是用英文编写消息体例子,所以这个问题一时陷入了困局(2019年国庆假期之前卡住了大概几天,业务忙也没有花时间去想)。


灵光一现



2019年国庆假期前夕,由于团队一直在赶进度做一个前后端不分离的CRUD后台管理系统,当时有几个同事在做一个页面的时候讨论一个乱码的问题。在他们讨论的过程中,无意蹦出了两个让笔者突然清醒的词语:乱码UTF-8。笔者第一时间想到的是刚用Cnblogs的时候写过的一篇文章:《小伙子又乱码了吧-Java字符编码原理总结》(现在看起来标题起得挺二的)。当时有对字符编码的原理做过一些探究,想想有点惭愧,1年多前看过的东西差不多忘记得一干二净。


直接说原因:UTF-8编码的中文,大部分情况下一个中文字符长度占据3个字节(3 byte,也就是32 x 3或者32 x 4个位),而Java中字符串长度的获取方法String#length()是返回String实例中的Char数组的长度。但是我们多数情况下会使用Netty的字节缓冲区ByteBuf,而ByteBuf读取字符序列的方法需要预先指定读取的长度ByteBuf#readCharSequence(int length, Charset charset);,因此,在编码的时候需要预先写入字符串序列的长度。但是有一个隐藏的问题是:ByteBuf#readCharSequence(int length, Charset charset)方法底层会创建一个length长度的byte数组作为缓冲区读取数据,由于UTF-81 char = 3 or 4 byte,因此ChineseMessageEncoder在写入字符序列长度的时候虽然字符个数是对的,但是每个字符总是丢失2个-3个byte的长度,而ChineseMessageDecoder在读取字符序列长度的时候总是读到一个比原来短的长度,也就是最终会拿到一个不完整或者错误的字符串序列。


解决方案



UTF-8编码的中文在大多数情况下占3个字节,在一些有生僻字的情况下可能占4个字节。可以暴力点直接让写入字节缓冲区的字符序列长度扩大三倍,只需修改编码器的代码:


public class ChineseMessageEncoder extends MessageToByteEncoder<ChineseMessage> {
    @Override
    protected void encode(ChannelHandlerContext ctx, ChineseMessage target, ByteBuf out) throws Exception {
        // 写入ID
        out.writeLong(target.getId());
        String message = target.getMessage();
        int length = message.length() * 3;      // <1> 直接扩大字节序列的预读长度
        // 写入Message长度
        out.writeInt(length);
        // 写入Message字符序列
        out.writeCharSequence(message, StandardCharsets.UTF_8);
    }
}
复制代码


当然,这样做太暴力,硬编码的做法既不规范也不友好。其实Netty已经提供了内置的工具类io.netty.buffer.ByteBufUtil


// 获取UTF-8字符的最大字节序列长度
public static int utf8MaxBytes(CharSequence seq){}
// 写入UTF-8字符序列,返回写入的字节长度 - 建议使用此方法
public static int writeUtf8(ByteBuf buf, CharSequence seq){}
复制代码


我们可以先记录一下writerIndex,先写一个假的值(例如0),再使用ByteBufUtil#writeUtf8()写字符序列,然后根据返回的写入的字节长度,通过writerIndex覆盖之前写入的假值:


public class ChineseMessageEncoder extends MessageToByteEncoder<ChineseMessage> {
    @Override
    protected void encode(ChannelHandlerContext ctx, ChineseMessage target, ByteBuf out) throws Exception {
        out.writeLong(target.getId());
        String message = target.getMessage();
        // 记录写入游标
        int writerIndex = out.writerIndex();
        // 预写入一个假的length
        out.writeInt(0);
        // 写入UTF-8字符序列
        int length = ByteBufUtil.writeUtf8(out, message);
        // 覆盖length
        out.setInt(writerIndex, length);
    }
}
复制代码


至此,问题解决。如果遇到其他Netty编码解码问题,解决的思路是一致的。


小结



Netty学习过程中,编码解码占一半,网络协议知识和调优占另一半。

Netty的源码很优秀,很有美感,阅读起来很舒适。

Netty真好玩。


微信截图_20220512193710.png



附录



引入依赖:


<dependency>
    <groupId>io.netty</groupId>
    <artifactId>netty-all</artifactId>
    <version>4.1.41.Final</version>
</dependency>
<dependency>
    <groupId>org.projectlombok</groupId>
    <artifactId>lombok</artifactId>
    <version>1.18.10</version>
    <scope>provided</scope>
</dependency>
复制代码


代码:


// 实体
@Data
public class ChineseMessage implements Serializable {
    private long id;
    private String message;
}
// 编码器
public class ChineseMessageEncoder extends MessageToByteEncoder<ChineseMessage> {
    @Override
    protected void encode(ChannelHandlerContext ctx, ChineseMessage target, ByteBuf out) throws Exception {
        out.writeLong(target.getId());
        String message = target.getMessage();
        int writerIndex = out.writerIndex();
        out.writeInt(0);
        int length = ByteBufUtil.writeUtf8(out, message);
        out.setInt(writerIndex, length);
    }
}
// 解码器
public class ChineseMessageDecoder extends ByteToMessageDecoder {
    @Override
    protected void decode(ChannelHandlerContext ctx, ByteBuf in, List<Object> out) throws Exception {
        long id = in.readLong();
        int length = in.readInt();
        CharSequence charSequence = in.readCharSequence(length, StandardCharsets.UTF_8);
        ChineseMessage message = new ChineseMessage();
        message.setId(id);
        message.setMessage(charSequence.toString());
        out.add(message);
    }
}
// 客户端
@Slf4j
public class ChineseNettyClient {
    public static void main(String[] args) throws Exception {
        EventLoopGroup workerGroup = new NioEventLoopGroup();
        Bootstrap bootstrap = new Bootstrap();
        try {
            bootstrap.group(workerGroup);
            bootstrap.channel(NioSocketChannel.class);
            bootstrap.option(ChannelOption.SO_KEEPALIVE, true);
            bootstrap.option(ChannelOption.TCP_NODELAY, Boolean.TRUE);
            bootstrap.handler(new ChannelInitializer<SocketChannel>() {
                @Override
                protected void initChannel(SocketChannel ch) throws Exception {
                    ch.pipeline().addLast(new LengthFieldBasedFrameDecoder(1024, 0, 4, 0, 4));
                    ch.pipeline().addLast(new LengthFieldPrepender(4));
                    ch.pipeline().addLast(new ChineseMessageEncoder());
                    ch.pipeline().addLast(new ChineseMessageDecoder());
                    ch.pipeline().addLast(new SimpleChannelInboundHandler<ChineseMessage>() {
                        @Override
                        protected void channelRead0(ChannelHandlerContext ctx, ChineseMessage message) throws Exception {
                            log.info("接收到服务端的响应:{}", message);
                        }
                    });
                }
            });
            ChannelFuture future = bootstrap.connect("localhost", 9092).sync();
            System.out.println("客户端启动成功...");
            Channel channel = future.channel();
            ChineseMessage message = new ChineseMessage();
            message.setId(1L);
            message.setMessage("张大狗");
            channel.writeAndFlush(message);
            future.channel().closeFuture().sync();
        } finally {
            workerGroup.shutdownGracefully();
        }
    }
}
// 服务端
@Slf4j
public class ChineseNettyServer {
    public static void main(String[] args) throws Exception {
        int port = 9092;
        ServerBootstrap bootstrap = new ServerBootstrap();
        EventLoopGroup bossGroup = new NioEventLoopGroup();
        EventLoopGroup workerGroup = new NioEventLoopGroup();
        try {
            bootstrap.group(bossGroup, workerGroup)
                    .channel(NioServerSocketChannel.class)
                    .childHandler(new ChannelInitializer<SocketChannel>() {
                        @Override
                        protected void initChannel(SocketChannel ch) throws Exception {
                            ch.pipeline().addLast(new LengthFieldBasedFrameDecoder(1024, 0, 4, 0, 4));
                            ch.pipeline().addLast(new LengthFieldPrepender(4));
                            ch.pipeline().addLast(new ChineseMessageEncoder());
                            ch.pipeline().addLast(new ChineseMessageDecoder());
                            ch.pipeline().addLast(new SimpleChannelInboundHandler<ChineseMessage>() {
                                @Override
                                protected void channelRead0(ChannelHandlerContext ctx, ChineseMessage message) throws Exception {
                                    log.info("接收到客户端的请求:{}", message);
                                    ChineseMessage chineseMessage = new ChineseMessage();
                                    chineseMessage.setId(message.getId() + 1L);
                                    chineseMessage.setMessage("张小狗");
                                    ctx.writeAndFlush(chineseMessage);
                                }
                            });
                        }
                    });
            ChannelFuture future = bootstrap.bind(port).sync();
            log.info("启动Server成功...");
            future.channel().closeFuture().sync();
        } finally {
            workerGroup.shutdownGracefully();
            bossGroup.shutdownGracefully();
        }
    }
}
复制代码


链接




相关文章
|
移动开发 编解码 Java
Netty编码器和解码器
Netty从底层Java通道读到ByteBuf二进制数据,传入Netty通道的流水线,随后开始入站处理。在入站处理过程中,需要将ByteBuf二进制类型解码成Java POJO对象。这个解码过程可以通过Netty的Decoder解码器去完成。在出站处理过程中,业务处理后的结果需要从某个Java POJO对象编码为最终的ByteBuf二进制数据,然后通过底层 Java通道发送到对端。在编码过程中,需要用到Netty的Encoder编码器去完成数据的编码工作。
netty的异常分析 IllegalReferenceCountException refCnt: 0, decrement: 1
netty的异常分析 IllegalReferenceCountException refCnt: 0, decrement: 1
548 0
|
移动开发 网络协议 算法
(十)Netty进阶篇:漫谈网络粘包、半包问题、解码器与长连接、心跳机制实战
在前面关于《Netty入门篇》的文章中,咱们已经初步对Netty这个著名的网络框架有了认知,本章的目的则是承接上文,再对Netty中的一些进阶知识进行阐述,毕竟前面的内容中,仅阐述了一些Netty的核心组件,想要真正掌握Netty框架,对于它我们应该具备更为全面的认知。
802 2
|
移动开发 网络协议 Java
通信密码学:探秘Netty中解码器的神奇力量
通信密码学:探秘Netty中解码器的神奇力量
360 0
|
编解码
Netty Review - 优化Netty通信:如何应对粘包和拆包挑战_自定义长度分包编解码码器
Netty Review - 优化Netty通信:如何应对粘包和拆包挑战_自定义长度分包编解码码器
277 0
|
编解码 前端开发 网络协议
Netty Review - ObjectEncoder对象和ObjectDecoder对象解码器的使用与源码解读
Netty Review - ObjectEncoder对象和ObjectDecoder对象解码器的使用与源码解读
359 0
|
编解码 安全 前端开发
Netty Review - StringEncoder字符串编码器和StringDecoder 解码器的使用与源码解读
Netty Review - StringEncoder字符串编码器和StringDecoder 解码器的使用与源码解读
410 0
|
存储 编解码 Java
Netty使用篇:自定义编解码器
Netty使用篇:自定义编解码器
|
设计模式 JSON 编解码
Netty使用篇:编解码器
Netty使用篇:编解码器
|
XML 存储 编解码
Netty入门到超神系列-Netty使用Protobuf编码解码
当我们的Netty客户端和服务端进行通信时数据在传输的过程中需要进行序列化,比如以二进制数据进行传输,那么我们的业务数据就需要有相应的编码器进行编码为二进制数据,当服务端拿到二进制数据后需要有相应的解码器进行解码得到真实的业务数据。
417 0