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();
        }
    }
}
相关推荐
王ASC12 分钟前
SpringMVC的URL组成,以及URI中对/斜杠的处理,解决IllegalStateException: Ambiguous mapping
java·mvc·springboot·web
是小崔啊14 分钟前
开源轮子 - Apache Common
java·开源·apache
因我你好久不见18 分钟前
springboot java ffmpeg 视频压缩、提取视频帧图片、获取视频分辨率
java·spring boot·ffmpeg
程序员shen16161120 分钟前
抖音短视频saas矩阵源码系统开发所需掌握的技术
java·前端·数据库·python·算法
Ling_suu1 小时前
SpringBoot3——Web开发
java·服务器·前端
hanglove_lucky1 小时前
本地摄像头视频流在html中打开
前端·后端·html
天使day1 小时前
SpringMVC
java·spring·java-ee
CodeClimb1 小时前
【华为OD-E卷-简单的自动曝光 100分(python、java、c++、js、c)】
java·python·华为od
风清云淡_A2 小时前
【java基础系列】实现数字的首位交换算法
java·算法
Gao_xu_sheng2 小时前
Java程序打包成exe,无Java环境也能运行
java·开发语言