1. 程式人生 > >一個低階錯誤引發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個或者4個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真好玩。

附錄

引入依賴:

<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();
        }
    }
}

連結

  • Github Page:http://www.throwable.club/2019/10/03/netty-codec-chinese-exception
  • Coding Page:http://throwable.coding.me/2019/10/03/netty-codec-chinese-exception

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