Netty(3)进阶篇|半包粘包、编解码器

前言

本文主要介绍关于 Netty 中大名鼎鼎的半包问题和粘包问题,并提供相关的解决方案和代码演示。

一、问题引入

1.1 粘包现象

服务端程序

java 复制代码
package com.xiaolei.netty.haflAndAllQuestion;
import io.netty.bootstrap.ServerBootstrap;
import io.netty.channel.*;
import io.netty.channel.nio.NioEventLoopGroup;
import io.netty.channel.socket.nio.NioServerSocketChannel;
import io.netty.channel.socket.nio.NioSocketChannel;
import io.netty.handler.codec.string.StringDecoder;
import io.netty.handler.codec.string.StringEncoder;
import io.netty.util.CharsetUtil;
import java.nio.charset.Charset;
/*
@Author xiaolei
@Email lei.xu@op-energy.com
@Description
@Date 2023/8/23 13:07
**/
public class NettyServer {
    public static void main(String[] args) {
        // 创建两个EventLoopGroup,boss:处理连接事件,worker处理I/O事件
        EventLoopGroup boss = new NioEventLoopGroup();
        EventLoopGroup worker = new NioEventLoopGroup();
        // 创建一个ServerBootstrap服务端(同之前的ServerSocket类似)
        ServerBootstrap server = new ServerBootstrap();
        try {
            // 将前面创建的两个EventLoopGroup绑定在server上
            server.group(boss,worker)
                    // 指定服务端的通道为Nio类型
                    .channel(NioServerSocketChannel.class)
                    // 为到来的客户端Socket添加处理器
                    .childHandler(new ChannelInitializer<NioSocketChannel>() {
                        // 这个只会执行一次(主要是用于添加更多的处理器)
                        @Override
                        protected void initChannel(NioSocketChannel ch) {
                            // 添加一个字符解码处理器:对客户端的数据解码
                            ch.pipeline().addLast(new StringDecoder(CharsetUtil.UTF_8));
                            // 添加一个入站处理器,对收到的数据进行处理
                            ch.pipeline().addLast(new ChannelInboundHandlerAdapter(){
                                @Override
                                public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception {
                                    System.out.println("读取到的数据是:"+msg);
                                    super.channelRead(ctx, msg);
                                }
                            });
                        }
                    });
            // 为当前服务端绑定IP与端口地址(sync是同步阻塞至连接成功为止)
            ChannelFuture cf = server.bind("127.0.0.1",8888).sync();
            // 关闭服务端的方法(之后不会在这里关闭)
            cf.channel().closeFuture().sync();
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        } finally {
            // 优雅停止之前创建的两个Group
            boss.shutdownGracefully();
            worker.shutdownGracefully();
        }
    }
}

发送的 demo

ini 复制代码
public class SocketDemo {
    public static void main(String[] args) {
        try {
            //发送到8888端口
            Socket socket = new Socket("127.0.0.1", 8888);
            //输出流
            OutputStream outputStream = socket.getOutputStream();
            PrintWriter printWriter = new PrintWriter(outputStream);
            for (int i = 0; i < 100; i++) {
                printWriter.write("hello:"+i);
                printWriter.flush();
            }
            //关闭资源
            printWriter.close();
            outputStream.close();
            socket.close();
        } catch (UnknownHostException e) {
            e.printStackTrace();
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}

在 demo 中,发送的结果如下,可以看到预期的 100 条数据,并未按理想的每条接收和发送,而是每次读取的数据中,存在很多条的消息都粘包在一起,这个就是 Netty 中著名的粘包问题。

粘包问题,并非是 Netty 本身造成的,而是 TCP 造成的。

1.2 半包现象引入

半包,指的是一条数据包被分割成了多条发送,即发送的数据不完整。

为了演示半包问题,服务端修改一下接收缓冲区,其它代码不变

serverBootstrap.option(ChannelOption.SO_RCVBUF, 10);

这段代码影响的底层接收缓冲区(即滑动窗口)大小,仅决定了 netty 读取的最小单位,netty 实际每次读取的一般是它的整数倍。

可以看到,出现的半包问题如上,接收的数据不完整。

1.3 现象分析

从两个角度看粘包和半包。

1、收发角度:一个发送可能被多次接收(半包),多个发送可能被一次接收(粘包)

2、传输角度:一个发送可能占用多个传输包(半包),多个发送可能公用一个传输包(粘包)

1.3.1 粘包现象

在上面的演示代码中,多条 hello 数据在服务端接收的时候,接收的时候都粘在一起。

根本原因是,TCP 协议是面向连接的,可靠的,基于字节流的传输层协议,是一种流式协议,消息无边界。所谓流式协议,就是没有边界的一串二进制数据,TCP 作为传输层并不了解上层业务的逻辑边界。它是根据自己对缓冲区的大小,默默的搬运这块的消息进行发送,所以造成上面的两种现象。

原因 1发送方每次写入数据 < 套接字缓冲区大小。

服务端每次接收的 ByteBuf 的数据是 1024 个字节,当消息小于这个时候,后面的消息就有可能在一起被服务端消费接收。

原因 2滑动窗口:接收方读取套接字缓冲区数据不够及时

消息接收不及时,那么就有可能造成后面的消息放在一起发送。假设发送方 256 bytes 表示一个完整报文,但是由于接收方处理不及时且窗口大小足够大,这 256 bytes 字节就会缓冲在接收方的滑动窗口中,当滑动窗口中缓冲了多个报文就会粘包

  • 拓展:滑动窗口
  • TCP 以一个段(segment)为单位,每发送一个段就需要进行一次确认应答(ack)处理,但如果这么做,缺点是包的往返时间越长性能就越差
  • 为了解决此问题,引入了窗口概念,窗口大小即决定了无需等待应答而可以继续发送的数据最大值
  • 窗口实际就起到一个缓冲区的作用,同时也能起到流量控制的作用

    • 图中深色的部分即要发送的数据,高亮的部分即窗口
    • 窗口内的数据才允许被发送,当应答未到达前,窗口必须停止滑动
    • 如果 1001~2000 这个段的数据 ack 回来了,窗口就可以向前滑动
    • 接收方也会维护一个窗口,只有落在窗口内的数据才能允许接收

原因 3:Nagle 算法(默认开启)

TCP 默认使用的算法,用于处理小报文段的发送问题,当一个数据包小于 MSS 的时候,该算法会把类似的数据包归纳成一个分组进行发送。

  • 拓展 1:Nagle 算法

  • 即使发送一个字节,也需要加入 tcp 头和 ip 头,也就是总字节数会使用 41 bytes,非常不经济。因此为了提高网络利用率,tcp 希望尽可能发送足够大的数据,这就是 Nagle 算法产生的缘由

  • 该算法是指发送端即使还有应该发送的数据,但如果这部分数据很少的话,则进行延迟发送

    • 如果 SO_SNDBUF 的数据达到 MSS,则需要发送
    • 如果 SO_SNDBUF 中含有 FIN(表示需要连接关闭)这时将剩余数据发送,再关闭
    • 如果 TCP_NODELAY = true,则需要发送
    • 已发送的数据都收到 ack 时,则需要发送
    • 上述条件不满足,但发生超时(一般为 200ms)则需要发送
    • 除上述情况,延迟发送
  • 拓展 2:MSS 限制

  • 链路层对一次能够发送的最大数据有限制,这个限制称之为 MTU(maximum transmission unit),不同的链路设备的 MTU 值也有所不同,例如

  • 以太网的 MTU 是 1500

  • FDDI(光纤分布式数据接口)的 MTU 是 4352

  • 本地回环地址的 MTU 是 65535 - 本地测试不走网卡

  • MSS 是最大段长度(maximum segment size),它是 MTU 刨去 tcp 头和 ip 头后剩余能够作为数据传输的字节数

  • ipv4 tcp 头占用 20 bytes,ip 头占用 20 bytes,因此以太网 MSS 的值为 1500 - 40 = 1460

  • TCP 在传递大量数据时,会按照 MSS 大小将数据进行分割发送

  • MSS 的值在三次握手时通知对方自己 MSS 的值,然后在两者之间选择一个小值作为 MSS

1.3.2 半包现象

在上面的演示环境中,发送的消息被分割成多端消息发送。

原因 1:发送方写入数据 > 套接字缓冲区大小

接收方 ByteBuf 小于实际发送数据量。

原因 2:滑动窗口

假设接收方的窗口只剩了 128 bytes,发送方的报文大小是 256 bytes,这时放不下了,只能先发送前 128 bytes,等待 ack 后才能发送剩余部分,这就造成了半包

原因 3:MSS 限制

当发送的数据超过 MSS 限制后,会将数据切分发送,就会造成半包

二、解决方案

目的:解决该问题,就是找出消息的边界。

1、短链接,发一个包建立一次连接,这样连接建立到连接断开之间就是消息的边界,缺点是效率太低。(不推荐)

2、每条消息采用固定长度,缺点是浪费空间(不推荐)

3、每条消息采用分隔符,例如 \n , 缺点是需要转义(推荐)

4、每条消息分为 head 和 body ,head 中包含 body 的长度。(推荐)

Netty 提供了三种封装帧的处理方式。

解码 编码
固定长度 FixedLengthFrameDecoder 简单
行帧分隔符 LineBasedFrameDecoder 客户端发送消息需要在每条消息末尾加上 \n 或者 \r\n 代表结束标志
分隔符 DelimiterBasedFrameDecoder 客户端发送消息需要在每条消息末尾加上指定分隔符
固定长度字段内容存个长度信息 LengthFieldBasedFrameDecoder LengthFieldPrepender

那什么叫编码器呢?编码器其实是一个出站处理器,负责处理出站数据,其次编码器则负责入站数据处理。将入站的数据进行编码或格式转换。业务处理后的结果(出站数据),需要从某个 Java POJO 对象,编码成最终的 ByteBuf 二进制数据,然后通过底层 Java 通道发送到对端。

2.1 短连接演示

修改发送端的例子的代码

ini 复制代码
public class SocketDemo {
    public static void main(String[] args) {
        try {
            //发送到8888端口
            for (int i = 0; i < 50; i++) {
                Socket socket = new Socket("127.0.0.1", 8888);
                //输出流
                OutputStream outputStream = socket.getOutputStream();
                PrintWriter printWriter = new PrintWriter(outputStream);
                printWriter.write("123456789");
                printWriter.flush();
                //关闭资源
                printWriter.close();
                outputStream.close();
                socket.close();
            }
        } catch (UnknownHostException e) {
            e.printStackTrace();
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}

客户端每次发完数据后,就关闭 socket 连接,服务端接收的数据正常。

将 TCP 连接改成短连接,一个请求一个连接,就制造出了消息边界:建立连接和释放连接中间的消息即为一条完整的消息。

这种方法非常简单,也不需要我们在服务端修改什么代码,但是缺点也很大!

由于每次断开连接,建立连接,都会涉及到 TCP 的三次握手和四次挥手,每个消息都会涉及到这些操作,十分浪费性能,因此,并不推荐。

2.2 固定长度演示

让所有的数据包长度固定,服务端加入下面代码

scss 复制代码
ch.pipeline().addLast(new FixedLengthFrameDecoder(8));

发送端代码 :

ini 复制代码
public class SocketDemo {
    public static void main(String[] args) {
        try {
            //发送到8888端口
            Socket socket = new Socket("127.0.0.1", 8999);
            //输出流
            OutputStream outputStream = socket.getOutputStream();
            for (int i = 0; i < 50; i++) {
                byte[] data = "12345".getBytes();
                byte[] bytes = new byte[10];
                // 将data数组中的元素复制到bytes数组中
                System.arraycopy(data, 0, bytes, 0, data.length);
                // 补齐剩余的元素为 *
                Arrays.fill(bytes, data.length, bytes.length, (byte) '*');
                System.out.println(Arrays.toString(bytes));
                outputStream.write(bytes);
                outputStream.flush();
            }
            //关闭资源
            outputStream.close();
            socket.close();
        } catch (UnknownHostException e) {
            e.printStackTrace();
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}

服务端代码

java 复制代码
package com.xiaolei.netty.haflAndAllQuestion;
import io.netty.bootstrap.ServerBootstrap;
import io.netty.buffer.ByteBuf;
import io.netty.channel.*;
import io.netty.channel.nio.NioEventLoopGroup;
import io.netty.channel.socket.nio.NioServerSocketChannel;
import io.netty.channel.socket.nio.NioSocketChannel;
import io.netty.handler.codec.FixedLengthFrameDecoder;
import io.netty.handler.codec.string.StringDecoder;
import io.netty.handler.codec.string.StringEncoder;
import io.netty.util.CharsetUtil;
import java.nio.charset.Charset;
/*
@Author xiaolei
@Email lei.xu@op-energy.com
@Description
@Date 2023/8/23 13:07
**/
public class NettyServer {
    public static void main(String[] args) {
        // 创建两个EventLoopGroup,boss:处理连接事件,worker处理I/O事件
        EventLoopGroup boss = new NioEventLoopGroup();
        EventLoopGroup worker = new NioEventLoopGroup();
        // 创建一个ServerBootstrap服务端(同之前的ServerSocket类似)
        ServerBootstrap server = new ServerBootstrap();
//        server.option(ChannelOption.SO_RCVBUF, 10);
        try {
            // 将前面创建的两个EventLoopGroup绑定在server上
            server.group(boss,worker)
                    // 指定服务端的通道为Nio类型
                    .channel(NioServerSocketChannel.class)
                    // 为到来的客户端Socket添加处理器
                    .childHandler(new ChannelInitializer<NioSocketChannel>() {
                        // 这个只会执行一次(主要是用于添加更多的处理器)
                        @Override
                        protected void initChannel(NioSocketChannel ch) {
                            // 添加一个入站处理器,对收到的数据进行处
                            ch.pipeline().addLast(new FixedLengthFrameDecoder(10));
                            // 添加一个字符解码处理器:对客户端的数据解码
                            ch.pipeline().addLast(new StringDecoder(CharsetUtil.UTF_8));
                            ch.pipeline().addLast(new ChannelInboundHandlerAdapter(){
                                @Override
                                public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception {
                                    System.out.println("接收的消息是:"+msg);
                                    super.channelRead(ctx, msg);
                                }
                            });
                        }
                    });
            // 为当前服务端绑定IP与端口地址(sync是同步阻塞至连接成功为止)
            ChannelFuture cf = server.bind("127.0.0.1",8999).sync();
            // 关闭服务端的方法(之后不会在这里关闭)
            cf.channel().closeFuture().sync();
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        } finally {
            // 优雅停止之前创建的两个Group
            boss.shutdownGracefully();
            worker.shutdownGracefully();
        }
    }
}

接收端日志

在这个例子中,服务端会以 10 个字节为单位,对数据包进行处理,但是,需要客户端进行数据补全,比如上面的方法复制中,如果数据包长度不足 10,则以 * 号作为补充。

这种方式也存在很大问题,只适用于简单的场合,在固定场景的消息发送中使用。

  • 如果数据长度不足,以*来代替,就会占用网络资源开销。

  • 如果数据长度过长,还是会存在半包问题。

2.3 行帧解码器

上面说的固定长度解码器,只适合消息定长的场景,而使用行帧解码器,也是一种解决方案。

需要给服务端添加 LineBasedFrameDecoder(1024) 行解码器。

  • 服务端代码
java 复制代码
package com.xiaolei.netty.haflAndAllQuestion;
import io.netty.bootstrap.ServerBootstrap;
import io.netty.buffer.ByteBuf;
import io.netty.channel.*;
import io.netty.channel.nio.NioEventLoopGroup;
import io.netty.channel.socket.nio.NioServerSocketChannel;
import io.netty.channel.socket.nio.NioSocketChannel;
import io.netty.handler.codec.FixedLengthFrameDecoder;
import io.netty.handler.codec.LineBasedFrameDecoder;
import io.netty.handler.codec.string.StringDecoder;
import io.netty.handler.codec.string.StringEncoder;
import io.netty.util.CharsetUtil;
import java.nio.charset.Charset;
/*
@Author xiaolei
@Email lei.xu@op-energy.com
@Description
@Date 2023/8/23 13:07
**/
public class NettyServer {
    public static void main(String[] args) {
        // 创建两个EventLoopGroup,boss:处理连接事件,worker处理I/O事件
        EventLoopGroup boss = new NioEventLoopGroup();
        EventLoopGroup worker = new NioEventLoopGroup();
        // 创建一个ServerBootstrap服务端(同之前的ServerSocket类似)
        ServerBootstrap server = new ServerBootstrap();
//        server.option(ChannelOption.SO_RCVBUF, 10);
        try {
            // 将前面创建的两个EventLoopGroup绑定在server上
            server.group(boss,worker)
                    // 指定服务端的通道为Nio类型
                    .channel(NioServerSocketChannel.class)
                    // 为到来的客户端Socket添加处理器
                    .childHandler(new ChannelInitializer<NioSocketChannel>() {
                        // 这个只会执行一次(主要是用于添加更多的处理器)
                        @Override
                        protected void initChannel(NioSocketChannel ch) {
                            // 添加一个入站处理器,对收到的数据进行处
                            // 方式 1 : 固定长度解决方案
//                            ch.pipeline().addLast(new FixedLengthFrameDecoder(10));
                            // 方式 2 : 行帧解码器
                            ch.pipeline().addLast(new LineBasedFrameDecoder(1024));
                            // 添加一个字符解码处理器:对客户端的数据解码
                            ch.pipeline().addLast(new StringDecoder(CharsetUtil.UTF_8));
                            ch.pipeline().addLast(new ChannelInboundHandlerAdapter(){
                                @Override
                                public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception {
                                    System.out.println("接收的消息是:"+msg);
                                    super.channelRead(ctx, msg);
                                }
                            });
                        }
                    });
            // 为当前服务端绑定IP与端口地址(sync是同步阻塞至连接成功为止)
            ChannelFuture cf = server.bind("127.0.0.1",8999).sync();
            // 关闭服务端的方法(之后不会在这里关闭)
            cf.channel().closeFuture().sync();
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        } finally {
            // 优雅停止之前创建的两个Group
            boss.shutdownGracefully();
            worker.shutdownGracefully();
        }
    }
}
  • 客户端代码
java 复制代码
package com.xiaolei.netty.haflAndAllQuestion;
import io.netty.buffer.ByteBuf;
import java.io.IOException;
import java.io.OutputStream;
import java.io.PrintWriter;
import java.net.Socket;
import java.net.UnknownHostException;
import java.nio.charset.StandardCharsets;
import java.util.Arrays;
/*
@Author xiaolei
@Email lei.xu@op-energy.com
@Description
@Date 2023/10/30 15:07
**/
public class SocketDemo {
    public static void main(String[] args) {
        try {
            //发送到8888端口
            Socket socket = new Socket("127.0.0.1", 8999);
            //输出流
            OutputStream outputStream = socket.getOutputStream();
            for (int i = 0; i < 50; i++) {
                byte[] data = "12345".getBytes();
                byte[] bytes = new byte[10];
                // 将data数组中的元素复制到bytes数组中
                System.arraycopy(data, 0, bytes, 0, data.length);
                // 补齐剩余的元素为 *
                Arrays.fill(bytes, data.length, bytes.length, (byte) '*');
                System.out.println(Arrays.toString(bytes));
                outputStream.write(bytes);
                outputStream.flush();
            }
            //关闭资源
            outputStream.close();
            socket.close();
        } catch (UnknownHostException e) {
            e.printStackTrace();
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}

消息的接收结果:

在这个案例中,给服务端添加了 LineBasedFrameDecoder(1024) 行解码器后,同时在客户端发送消息的末尾加上 \n 或者 \r\n 换行符,就可以按照接线划分,1024 代表最大字节,总不能一直往后识别找有没有 \n 吧,当加上 1024 后,如果长度超过 1024,就把这些数据看作是一个完整的数据包进行发送。

2.4 固定分隔符演示

上面是固定使用 \n 和 \r\n 进行按行分割数据报文。

而 Netty 也提供了固定分隔符的方式,使用自定义分隔符的解码器,随意自定义解码器。

加上编解码器:

scss 复制代码
new DelimiterBasedFrameDecoder(1024,delimiter)

客户端:

ini 复制代码
public class SocketDemo {
    public static void main(String[] args) {
        try {
            //发送到8888端口
            Socket socket = new Socket("127.0.0.1", 8999);
            //输出流
            OutputStream outputStream = socket.getOutputStream();
            for (int i = 0; i < 50; i++) {
                String msg ="12345"+i+"*";
                byte[] data = msg.getBytes();
                outputStream.write(data);
                outputStream.flush();
            }
            //关闭资源
            outputStream.close();
            socket.close();
        } catch (UnknownHostException e) {
            e.printStackTrace();
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}

服务端:

java 复制代码
public class NettyServer {
    public static void main(String[] args) {
        // 创建两个EventLoopGroup,boss:处理连接事件,worker处理I/O事件
        EventLoopGroup boss = new NioEventLoopGroup();
        EventLoopGroup worker = new NioEventLoopGroup();
        // 创建一个ServerBootstrap服务端(同之前的ServerSocket类似)
        ByteBuf delimiter = ByteBufAllocator.DEFAULT.buffer(1);
        delimiter.writeByte('*');
        ServerBootstrap server = new ServerBootstrap();
//        server.option(ChannelOption.SO_RCVBUF, 10);
        try {
            // 将前面创建的两个EventLoopGroup绑定在server上
            server.group(boss,worker)
                    // 指定服务端的通道为Nio类型
                    .channel(NioServerSocketChannel.class)
                    // 为到来的客户端Socket添加处理器
                    .childHandler(new ChannelInitializer<NioSocketChannel>() {
                        // 这个只会执行一次(主要是用于添加更多的处理器)
                        @Override
                        protected void initChannel(NioSocketChannel ch) {
                            // 添加一个入站处理器,对收到的数据进行处
                            // 方式 1 : 固定长度解决方案
//                            ch.pipeline().addLast(new FixedLengthFrameDecoder(10));
                            // 方式 2 : 行帧解码器
//                            ch.pipeline().addLast(new LineBasedFrameDecoder(1024));
                            // 方式 3: 自定义分隔符
                            ch.pipeline().addLast(new DelimiterBasedFrameDecoder(1024,delimiter));
                            // 添加一个字符解码处理器:对客户端的数据解码
                            ch.pipeline().addLast(new StringDecoder(CharsetUtil.UTF_8));
                            ch.pipeline().addLast(new ChannelInboundHandlerAdapter(){
                                @Override
                                public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception {
                                    System.out.println("接收的消息是:"+msg);
                                    super.channelRead(ctx, msg);
                                }
                            });
                        }
                    });
            // 为当前服务端绑定IP与端口地址(sync是同步阻塞至连接成功为止)
            ChannelFuture cf = server.bind("127.0.0.1",8999).sync();
            // 关闭服务端的方法(之后不会在这里关闭)
            cf.channel().closeFuture().sync();
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        } finally {
            // 优雅停止之前创建的两个Group
            boss.shutdownGracefully();
            worker.shutdownGracefully();
        }
    }
}

服务端接收的数据如下

使用这种编解码器,可以自由的定义分隔符,在很多场景下也可以使用。

但是缺点也有,它需要对读进来的每个字节都要判断下,是否是结尾标志,也很影响性能,其次,当数据超过最大长度限制,也不可避免的出现半包问题。

2.5 LTC 解码器预设长度演示

在发送消息之前,先约定长字节表示接下来的数据长度。

scss 复制代码
// 最大长度,长度偏移,长度占用字节,长度调整,剥离字节数
ch.pipeline().addLast(new LengthFieldBasedFrameDecoder(1024, 0, 1, 0, 1));

LTC 中文简称基于长度的域编码器,需要理解下这五个参数信息。

找到源码的位置。

  • maxFrameLength : 帧的最大长度,如果帧的长度大于此值,则会抛出 TooLongFrameException
  • lengthFieldOffset: 长度字段的偏移,正常情况下,读取的数据从偏移为 0 的地方开始读取,如果有需要可以从其他偏移量处读取
  • lengthFieldLength:长度域占用的字节数。
  • lengthAdjustment: 长度调整,在长度域与 内容 content 域中间是否需要填充其他字节数
  • initialBytesToStrip: 解码后跳过的字节数,先将数据去掉 N 个字节后,再开始读取数据。
java 复制代码
    /**
     * Creates a new instance.
     *
     * @param maxFrameLength
     *        the maximum length of the frame.  If the length of the frame is
     *        greater than this value, {@link TooLongFrameException} will be
     *        thrown.
     * @param lengthFieldOffset
     *        the offset of the length field
     * @param lengthFieldLength
     *        the length of the length field
     * @param lengthAdjustment
     *        the compensation value to add to the value of the length field
     * @param initialBytesToStrip
     *        the number of first bytes to strip out from the decoded frame
     */
    public LengthFieldBasedFrameDecoder(
            int maxFrameLength,
            int lengthFieldOffset, int lengthFieldLength,
            int lengthAdjustment, int initialBytesToStrip) {
        this(
                maxFrameLength,
                lengthFieldOffset, lengthFieldLength, lengthAdjustment,
                initialBytesToStrip, true);
    }

演示 demo,在服务端添加下面这句

scss 复制代码
// 最大长度,长度偏移,长度占用字节,长度调整,剥离字节数
ch.pipeline().addLast(new LengthFieldBasedFrameDecoder(1024, 0, 4, 0, 4)); // 超过1024字节后就停止读取,长度4个字节

客户端代码:客户端采用 Socket 发送原生的 ByteBuffer 数据

ini 复制代码
public class SocketDemo {
    public static void main(String[] args) {
        try {
            // 创建一个SocketChannel
            SocketChannel socketChannel = SocketChannel.open();
            socketChannel.connect(new InetSocketAddress("127.0.0.1", 8999));
            String msg ="12345";
            byte[] data = msg.getBytes();
            int dataLength = data.length;
            // 创建一个容量为10的ByteBuffer
            ByteBuffer buffer = ByteBuffer.allocate(10);
            // 向ByteBuffer中写入数据
            buffer.putInt(dataLength);
            buffer.put(data);
            buffer.flip(); // 读写指针指倒缓冲区的第一个位置

            // 循环发送数据
            for (int i = 0; i < 50; i++) {
                buffer.rewind(); // 将position重置为0,以便重新发送数据
                socketChannel.write(buffer);
            }
            //关闭资源
            socketChannel.close();
        } catch (UnknownHostException e) {
            e.printStackTrace();
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}

服务端代码

java 复制代码
public class NettyServer {
    public static void main(String[] args) {
        // 创建两个EventLoopGroup,boss:处理连接事件,worker处理I/O事件
        EventLoopGroup boss = new NioEventLoopGroup();
        EventLoopGroup worker = new NioEventLoopGroup();
        // 创建一个ServerBootstrap服务端(同之前的ServerSocket类似)
        ByteBuf delimiter = ByteBufAllocator.DEFAULT.buffer(1);
        delimiter.writeByte('*');
        ServerBootstrap server = new ServerBootstrap();
//        server.option(ChannelOption.SO_RCVBUF, 10);
        try {
            // 将前面创建的两个EventLoopGroup绑定在server上
            server.group(boss,worker)
                    // 指定服务端的通道为Nio类型
                    .channel(NioServerSocketChannel.class)
                    // 为到来的客户端Socket添加处理器
                    .childHandler(new ChannelInitializer<NioSocketChannel>() {
                        // 这个只会执行一次(主要是用于添加更多的处理器)
                        @Override
                        protected void initChannel(NioSocketChannel ch) {
                            // 添加一个入站处理器,对收到的数据进行处
                            // 方式 1 : 固定长度解决方案
//                            ch.pipeline().addLast(new FixedLengthFrameDecoder(10));
                            // 方式 2 : 行帧解码器
//                            ch.pipeline().addLast(new LineBasedFrameDecoder(1024));
                            // 方式 3: 自定义分隔符
//                            ch.pipeline().addLast(new DelimiterBasedFrameDecoder(1024,delimiter));
                            // 方式 4: LTC
                            ch.pipeline().addLast(new LengthFieldBasedFrameDecoder(1024, 0,
                                    4, 0, 4));
                            // 添加一个字符解码处理器:对客户端的数据解码
                            ch.pipeline().addLast(new StringDecoder(CharsetUtil.UTF_8));
                            ch.pipeline().addLast(new ChannelInboundHandlerAdapter(){
                                @Override
                                public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception {
                                    System.out.println("接收的消息是:"+msg);
                                    super.channelRead(ctx, msg);
                                }
                            });
                        }
                    });
            // 为当前服务端绑定IP与端口地址(sync是同步阻塞至连接成功为止)
            ChannelFuture cf = server.bind("127.0.0.1",8999).sync();
            // 关闭服务端的方法(之后不会在这里关闭)
            cf.channel().closeFuture().sync();
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        } finally {
            // 优雅停止之前创建的两个Group
            boss.shutdownGracefully();
            worker.shutdownGracefully();
        }
    }
}

服务端接收的数据:

结合我们数据,可以发现

ch.pipeline().addLast(new LengthFieldBasedFrameDecoder(1024, 0,4, 0, 4))

  • 第一个 4 是代表 int 的字节长度
  • 第二个 4 是代表跳过 int 的字节长度,读取数据

这种解码器使用效果比前面的几种都好上不少,它不用逐个字节进行判断。不过在具体的使用场景中,选择合适的编解码器是我们应该做的。

三、二次编解码

Netty 的核心组件 Decoder(编码器)和 Encoder 是非常重要的,为什么呢?因为在 Netty 中进行数据传输使用的是 ByteBuf 二进制数据,我们需要对其进行 Decoder 转换成 Java 的 POJO 对象。同时,发送给对端的时候,需要把 Java 的 POJO 对象转换成最终的 ByteBuf 二进制数据,而这是通过 Encoder 完成的。

3.1 二次编码器核心概念

Netty codec : 二次编解码。

我们把解决半包粘包问题的常用三种解码器叫一次解码器,其作用是将原始数据流

(可能会出现粘包和半包的数据流)转换为用户数据(ByteBuf 中存储),但仍然是字节数据,所以我们需要二次解码器将字节数组转换为 Java 对象,或者将一种格式转换为另一种格式,方便上层应用程序使用。

  • 一次解码器继承自:ByteToMessageDecoder:用于将字节转为消息,需要检测缓冲区是否有足够的字节
  • 二次解码器继承自:MessageToMessageDecoder:从一种消息格式转化为其他消息格式,例如 UserVO -> UserEntity,将一种pojo对象解码成另外一个 pojo 对象。

但他们的本质都是继承 ChannelInboundHandlerAdapter。

常用的二次编解码方式:

  • Json 序列化:不推荐使用,占用空间大

  • Marshaling:比 java 序列化稍好

  • XML:可读性好,但是占用空间大

  • JSON:可读性也好,空间较小

  • MessagePack:占用空间比 Json 小,可读性不如 Json,但也还行

  • Protobuf:性能高,体积小,但是可读性差

  • hessian:跨语言,高效的二进制序列化协议,整体性能和 protobuf 差不多

还有 ByteToMessageCodec 编解码器,它是一个抽象类,从功能上来说,继承它就等同于 ByteToMessageDecoder 解码器和 MessageToByteEncoder 解码器这两个基类。

同时实现它的编码和解码方法。

eg:

java 复制代码
import io.netty.buffer.ByteBuf;
import io.netty.channel.ChannelHandlerContext;
import io.netty.handler.codec.ByteToMessageCodec;
import java.util.List;

public class IntArrayCodec extends ByteToMessageCodec<int[]> {

    // 定义每个整数占用的字节数(这里假设是4字节,即int类型)
    private static final int INT_SIZE = 4;

    @Override
    protected void decode(ChannelHandlerContext ctx, ByteBuf in, List<Object> out) throws Exception {
        // 检查是否有足够的字节来解码一个整数
        while (in.readableBytes() >= INT_SIZE) {
            // 读取一个整数并添加到输出列表中
            int value = in.readInt();
            // 由于我们需要解码为int数组,这里可以稍作修改,累积整数直到遇到某个分隔符或达到某个条件
            // 但为了简单起见,我们假设每个整数都是一个单独的消息(这在实际中是不常见的)
            // 因此,我们每次只解码一个整数并将其作为长度为1的数组添加
            int[] intArray = new int[]{value};
            out.add(intArray);
        }
    }

    @Override
    protected void encode(ChannelHandlerContext ctx, int[] msg, ByteBuf out) throws Exception {
        // 遍历整数数组并将每个整数写入输出缓冲区
        for (int value : msg) {
            out.writeInt(value);
        }
    }
}

3.2 自定义解码器-ByteToMessageDecoder

ByteToMessageDecoder是一个非常重要的解码器基类,它是一个抽象类,实现了解码的基础逻辑和流程。ByteToMessageDecoder 继承自 ChannelInboundHandlerAdapter 适配器,是一个入站处理器,实现了从 ByteBuf 到 Java POJO 对象的解码功能。

处理流程大致如下:首先,它会将上一站传过来的输入到 Bytebuf 中的数据进行解码,解码出一个 List对象列表;然后,迭代 List 列表,逐个将 Java POJO 对象传入下一站 Inbound 入站处理器。

暂时无法在飞书文档外展示此内容

ByteToMessageDecoder 是一个抽象类,不能以实例化方式创建对象,也就是说,我们得创建出解码器来继承抽象类 ByteToMessageDecoder 实现具体的转换逻辑。

注意:一般情况下,解码器都是位于入站的第一位,方便后续业务处理

1、定义解码器

scala 复制代码
public class ByteToIntergerDecoder extends ByteToMessageDecoder {
    @Override
    protected void decode(ChannelHandlerContext channelHandlerContext, ByteBuf byteBuf,
                          List<Object> list) throws Exception {
        // 因为 int 类型的字节大小是 4 个字节
while (byteBuf.readableBytes() >=4 ){
            Integer i = byteBuf.readInt();
            list.add(i);
            // 这个参数是我们通过解码器解码完成后,将解码后的数据传递给 inbound
 // 通道中的下一个 handler 继续进行业务的处理
}
    }
}

2、定义业务handler

scala 复制代码
public class IntegerPorcessHandler extends ChannelInboundHandlerAdapter {
    @Override
    public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception {
        // 此时的 msg 不是 buftebuf了,因为已经解码了。
 // 解码器中的,第三个参数 list<Object>
System.out.println("业务数据处理:"+(Integer)msg);
    }
}

3、测试类

java 复制代码
public class Byte2IntegerDecoderTest {
    public static void main(String[] args) {
        test();
    }

    public static void test(){
        ChannelInitializer channelInitializer = new ChannelInitializer() {
            @Override
            protected void initChannel(Channel channel) throws Exception {
                channel.pipeline().addLast(new ByteToIntergerDecoder());
                channel.pipeline().addLast(new IntegerPorcessHandler());
            }
        };

        // 嵌入式通道,无序启动 netty服务端和客户端
EmbeddedChannel channel = new EmbeddedChannel(channelInitializer);
        for (int i = 0; i < 10; i++) {
            ByteBuf buffer = Unpooled.buffer();
            buffer.writeInt(i);
            channel.writeInbound(buffer);
        }
    }
}

这段实例的逻辑大致是,在decoder 方法中,通过 ByteBuf 的 readInt 实例方法,从输入缓冲区读取到整数,其作用是将二进制数据解码成一个一个的整数。因为整数是4个字节为一个单位,然后将解析出来的数据放入到List 中,完成后,会把得到的整数都传给下一个入站处理器。

3.3 自定义解码器-二次解码器

MessageToMessageDecoder 属于二次解码器,需要继承一这个解码器基类,在继承它的时候,需要明确它的泛型实参,确定入站的消息Java POJO。

MessageToMessageDecoder 同样使用了模板模式,也有一个 decoder 抽象方法,其具体解码的逻辑需要子类去实现,下面通过实现一个整数 Integer 到字符串 String 转换的解码器,演示下。

程序结构如下:

typescript 复制代码
@Data
public class UserVO {
    private Integer age;
    private String name;

    @Override
    public String toString() {
        return "UserVO{" +
                "age=" + age +
                ", name='" + name + ''' +
                '}';
    }
}

修改第二个InterToUserDecoder 放第二个入口处理

java 复制代码
ChannelInitializer channelInitializer = new ChannelInitializer() {
    @Override
    protected void initChannel(Channel channel) throws Exception {
        channel.pipeline().addLast(new ByteToIntergerDecoder());
        channel.pipeline().addLast(new IntegerToUserDecoder());
        channel.pipeline().addLast(new IntegerPorcessHandler());
    }
};

自定义解码器

scala 复制代码
public class IntegerToUserDecoder extends MessageToMessageDecoder<Integer> {
    @Override
    protected void decode(ChannelHandlerContext ctx, Integer msg, List<Object> out) throws Exception {
        UserVO userVO = new UserVO();
        userVO.setAge(msg);
        userVO.setName(UUID.randomUUID().toString());
        out.add(userVO);
    }
}

输出结果:

3.4 自定义编码器

在 Netty 的业务处理中,业务处理的结果往往是某个 Java POJO 对象,需要编码成最终的 ByteBuf 二进制类型,通过流水线写入到对端通道,这就需要 Encoder 了。

编码器,是一个Outbound 出站处理器,负责处理出站数据。

编码器首先会把上一站的输入数据进行编码,然后传递到下一个站的出站处理器。

我们可以使用自己的编码器,继承 MessageToByteEncoder基类,实现 encode 方法。

其功能是将 Java 整数编码成二进制 ByteBuf 数据包。

scala 复制代码
public static void test2() {
    // 创建解码器
class IntegerDecoder extends ByteToMessageDecoder {
        @Override
        protected void decode(ChannelHandlerContext ctx, ByteBuf in, List<Object> out) throws Exception {
            if (in.readableBytes() >= 4) {
                out.add(in.readInt());
            }
        }
    }

    // 创建编码器
class IntegerEncoder extends MessageToByteEncoder<Integer> {
        @Override
        protected void encode(ChannelHandlerContext ctx, Integer msg, ByteBuf out) throws Exception {
            out.writeInt(msg);
        }
    }

    // 设置 EmbeddedChannel 的 pipeline
EmbeddedChannel channel = new EmbeddedChannel(new ChannelInitializer<EmbeddedChannel>() {
        @Override
        protected void initChannel(EmbeddedChannel ch) throws Exception {
            ChannelPipeline pipeline = ch.pipeline();
            pipeline.addLast(new IntegerDecoder()); // 添加解码器
pipeline.addLast(new IntegerEncoder()); // 添加编码器(虽然在这个测试中不会用到)
}
    });

    // 测试解码器:向入站方向写入 ByteBuf
for (int i = 0; i < 10; i++) {
        ByteBuf buffer = Unpooled.buffer();
        buffer.writeInt(i);
        channel.writeInbound(buffer); // 解码器会处理这个 ByteBuf
}
    channel.flushInbound(); // 确保所有入站数据都被处理


 // 测试编码器(可选):向出站方向写入 Integer
for (int i = 0; i < 10; i++) {
        channel.writeAndFlush(i); // 编码器会处理这个 Integer
}
    channel.flushOutbound(); // 确保所有出站数据都被处理

 // 读取编码后的 ByteBuf(可选)
ByteBuf encodedBuf;
    while ((encodedBuf = (ByteBuf) channel.readOutbound()) != null) {
        // 在这个例子中,我们不会直接读取 encodedBuf 的内容,
 // 因为我们已经知道编码器只是简单地将整数转换为 ByteBuf。
 // 但为了完整性,这里展示了如何读取 ByteBuf。
encodedBuf.markReaderIndex(); // 标记当前读索引位置
int encodedInt = encodedBuf.readInt(); // 读取整数(应该与写入的整数相同)
System.out.println("Encoded integer (from ByteBuf): " + encodedInt);
        encodedBuf.release(); // 释放 ByteBuf
}
}

3.3 自定义协议开发

clientHandler

scala 复制代码
public class ClientHandler extends SimpleChannelInboundHandler<Message>{
    @Override
    protected void channelRead0(ChannelHandlerContext ctx, Message msg) throws Exception {
        System.out.println("Received message: " + msg.getContent());
    }
    @Override
    public void channelActive(ChannelHandlerContext ctx) throws Exception {
        // 在连接建立后发送消息
        Message message = new Message(5, "Hello");
        ctx.writeAndFlush(message);
    }
}

message

arduino 复制代码
public class Message {
    private int length;
    private String content;
    public Message(int length, String content) {
        this.length = length;
        this.content = content;
    }
    public int getLength() {
        return length;
    }
    public String getContent() {
        return content;
    }
}

mesageDecoder

scala 复制代码
public class MessageDecoder extends MessageToMessageDecoder<ByteBuf> {
    @Override
    protected void decode(ChannelHandlerContext ctx, ByteBuf in, List<Object> out) throws Exception {
        // 检查是否有足够的字节可读
        if (in.readableBytes() < 4) {
            return;
        }
        // 读取消息长度
        int length = in.readInt();
        // 检查是否有足够的字节可读
        if (in.readableBytes() < length) {
            in.resetReaderIndex();
            return;
        }
        // 读取消息内容
        byte[] contentBytes = new byte[length];
        in.readBytes(contentBytes);
        String content = new String(contentBytes);
        // 创建Message对象并添加到输出列表
        Message message = new Message(length, content);
        out.add(message);
    }
}

messageEncoder

scala 复制代码
public class MessageEncoder extends MessageToByteEncoder<Message> {
    @Override
    protected void encode(ChannelHandlerContext ctx, Message message, ByteBuf out) throws Exception {
        // 将消息长度写入字节流
        out.writeInt(message.getLength());
        // 将消息内容写入字节流
        out.writeBytes(message.getContent().getBytes());
    }
}

nettyclient

java 复制代码
public class NettyClient {
    private static final String HOST = "127.0.0.1";
    private static final int PORT = 8999;
    public static void main(String[] args) throws InterruptedException {
        EventLoopGroup group = new NioEventLoopGroup();
        try {
            Bootstrap bootstrap = new Bootstrap();
            bootstrap.group(group)
                    .channel(NioSocketChannel.class)
                    .handler(new ChannelInitializer<SocketChannel>() {
                        @Override
                        protected void initChannel(SocketChannel ch) throws Exception {
                            ChannelPipeline pipeline = ch.pipeline();
                            pipeline.addLast(new MessageDecoder());
                            pipeline.addLast(new MessageEncoder());
                            pipeline.addLast(new ClientHandler());
                        }
                    });
            ChannelFuture channelFuture = bootstrap.connect(HOST, PORT).sync();
            channelFuture.channel().closeFuture().sync();
        } finally {
            group.shutdownGracefully();
        }
    }
}

nettyserver

java 复制代码
public class NettyServer {
    private static final int PORT = 8999;
    public static void main(String[] args) throws InterruptedException {
        EventLoopGroup bossGroup = new NioEventLoopGroup();
        EventLoopGroup workerGroup = new NioEventLoopGroup();
        try {
            ServerBootstrap serverBootstrap = new ServerBootstrap();
            serverBootstrap.group(bossGroup, workerGroup)
                    .channel(NioServerSocketChannel.class)
                    .childHandler(new ChannelInitializer<SocketChannel>() {
                        @Override
                        protected void initChannel(SocketChannel ch) throws Exception {
                            ChannelPipeline pipeline = ch.pipeline();
                            pipeline.addLast(new MessageDecoder());
                            pipeline.addLast(new MessageEncoder());
                            pipeline.addLast(new ServerHandler());
                        }
                    });
            ChannelFuture channelFuture = serverBootstrap.bind(PORT).sync();
            channelFuture.channel().closeFuture().sync();
        } finally {
            bossGroup.shutdownGracefully();
            workerGroup.shutdownGracefully();
        }
    }
}
相关推荐
魔道不误砍柴功43 分钟前
简单叙述 Spring Boot 启动过程
java·数据库·spring boot
失落的香蕉1 小时前
C语言串讲-2之指针和结构体
java·c语言·开发语言
枫叶_v1 小时前
【SpringBoot】22 Txt、Csv文件的读取和写入
java·spring boot·后端
wclass-zhengge1 小时前
SpringCloud篇(配置中心 - Nacos)
java·spring·spring cloud
路在脚下@1 小时前
Springboot 的Servlet Web 应用、响应式 Web 应用(Reactive)以及非 Web 应用(None)的特点和适用场景
java·spring boot·servlet
黑马师兄1 小时前
SpringBoot
java·spring
数据小小爬虫1 小时前
如何用Java爬虫“偷窥”淘宝商品类目API的返回值
java·爬虫·php
暮春二十四1 小时前
关于用postman调用接口成功但是使用Java代码调用却失败的问题
java·测试工具·postman
杜杜的man1 小时前
【go从零单排】Closing Channels通道关闭、Range over Channels
开发语言·后端·golang
java小吕布2 小时前
Java中Properties的使用详解
java·开发语言·后端