一個低級錯誤引起Netty編碼解碼中文異常

前言

最近在調研Netty的使用,在編寫編碼解碼模塊的時候遇到了一箇中文字符串編碼和解碼異常的狀況,後來發現是筆者犯了個低級錯誤。這裏作一個小小的回顧。html

錯誤重現

在設計Netty的自定義協議的時候,發現了字符串類型的屬性,一旦出現中文就會出現解碼異常的現象,這個異常並不必定出現了Exception,而是出現瞭解碼以後字符截斷出現了人類不可讀的字符。編碼和解碼器的實現以下:java

// 實體
@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);
    }
}
複製代碼

簡單地編寫客戶端和服務端代碼,而後用客戶端服務端發送一條帶中文的消息:bootstrap

// 服務端日誌
接收到客戶端的請求: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個字節。能夠暴力點直接讓寫入字節緩衝區的字符序列長度擴大三倍,只需修改編碼器的代碼:ide

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覆蓋以前寫入的假值:oop

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真好玩。

附錄

引入依賴:

<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();
        }
    }
}
複製代碼

連接

(本文完 c-2-d e-a-20191003 國慶快樂(*^▽^*)

相關文章
相關標籤/搜索