Java网络编程08 - Netty中的粘包,半包,长连接和心跳

Netty中的粘包,半包,长连接和心跳

一:粘包半包问题解决策略

1:什么是粘包和半包

粘包

指通信双方中的一端发送了多个数据包,但在另一端则被读取成了一个数据包

比如客户端发送123、ABC两个数据包,但服务端却收成的却是123ABC这一个数据包。

造成这个问题的原因主要是因为TPC为了优化传输效率,将多个小包合并成一个大包发送,同时多个小包之间没有界限分割造成的。

在这里插入图片描述

半包

指通信双方中的一端发送一个大的数据包,但在另一端被读取成了多个数据包

例如客户端向服务端发送了一个数据包:ABCDEFGXYZ,而服务端则读取成了ABCEFG、XYZ两个包

这两个包实际上都是一个数据包中的一部分,产生这种现象的原因在于:接收方的数据接收缓冲区过小导致的

在这里插入图片描述

2:Netty中的粘包和半包问题

2.1:粘包问题

服务端

package io_study.netty_package_problem;

import io.netty.bootstrap.ServerBootstrap;
import io.netty.channel.nio.NioEventLoopGroup;
import io.netty.channel.socket.nio.NioServerSocketChannel;

import java.net.InetSocketAddress;

/**
 * <p>
 * 功能描述:粘包问题服务端演示
 * </p>
 *
 * @author cui haida
 * @date 2023/12/21/11:26
 */
public class AdhesivePackageServer {
    public static void main(String[] args) throws InterruptedException {
        // 创建事件循环组 <- 厂区
        NioEventLoopGroup boss = new NioEventLoopGroup();
        NioEventLoopGroup worker = new NioEventLoopGroup();
        // 创建启动器 <- 工厂
        ServerBootstrap server = new ServerBootstrap();
        // 工厂加入厂区
        server.group(boss, worker);
        server.channel(NioServerSocketChannel.class); // 指定通道类型
        server.childHandler(new ServerInitialzer()); // 通用服务端处理器
        // 绑定端口
        server.bind(new InetSocketAddress("127.0.0.1", 8888)).sync();
    }
}
package io_study.netty_package_problem;

import io.netty.channel.ChannelHandlerContext;
import io.netty.channel.ChannelInboundHandlerAdapter;
import io.netty.channel.ChannelInitializer;
import io.netty.channel.socket.SocketChannel;
import io.netty.handler.logging.LogLevel;
import io.netty.handler.logging.LoggingHandler;

/**
 * <p>
 * 功能描述:服务端处理链表
 * </p>
 *
 * @author cui haida
 * @date 2023/12/21/11:32
 */
public class ServerInitialzer extends ChannelInitializer<SocketChannel> {
    @Override
    protected void initChannel(SocketChannel ch) {
        ch.pipeline().addLast(new LoggingHandler(LogLevel.DEBUG));
        ch.pipeline().addLast(new ChannelInboundHandlerAdapter() {
            // 数据就绪事件:当收到客户端数据时会读取通道内的数据
            @Override
            public void channelReadComplete(ChannelHandlerContext ctx) throws Exception {
                // 在这里直接输出通道内的数据信息
                System.out.println(ctx.channel());
                super.channelReadComplete(ctx);
            }
        });
    }
}

客户端

package io_study.netty_package_problem;

import io.netty.bootstrap.Bootstrap;
import io.netty.buffer.ByteBuf;
import io.netty.channel.ChannelHandlerContext;
import io.netty.channel.ChannelInboundHandlerAdapter;
import io.netty.channel.ChannelInitializer;
import io.netty.channel.nio.NioEventLoopGroup;
import io.netty.channel.socket.SocketChannel;
import io.netty.channel.socket.nio.NioSocketChannel;

/**
 * <p>
 * 功能描述:
 * </p>
 *
 * @author cui haida
 * @date 2023/12/21/11:36
 */
public class AdhesivePackageClient {
    public static void main(String[] args) {
        NioEventLoopGroup group = new NioEventLoopGroup();
        Bootstrap client = new Bootstrap();
        try {
            client.group(group);
            client.channel(NioSocketChannel.class);
            client.handler(new ChannelInitializer<SocketChannel>() {
                @Override
                protected void initChannel(SocketChannel ch) {
                    // 添加入站处理器
                    ch.pipeline().addLast(new ChannelInboundHandlerAdapter() {
                        // 在通道准备就绪后会触发的事件
                        @Override
                        public void channelActive(ChannelHandlerContext ctx) {
                            for (int i = 0; i < 10; i++) {
                                System.out.println("正在向服务端:" + i + "次数据");
                                ByteBuf buffer = ctx.alloc().buffer(1); // 每次申请1空间大小的buffer
                                buffer.writeBytes(new byte[]{(byte) i});
                                ctx.writeAndFlush(buffer);
                            }
                        }
                    });
                }
            });
            client.connect("127.0.0.1", 8888).sync();
        } catch (Exception e) {
            e.printStackTrace();
        } finally {
            // 关闭事件循环组
            group.shutdownGracefully();
        }
    }
}

在这里插入图片描述

2.2:半包问题

服务端

package io_study.netty_package_problem;

import io.netty.bootstrap.ServerBootstrap;
import io.netty.channel.ChannelOption;
import io.netty.channel.EventLoopGroup;
import io.netty.channel.nio.NioEventLoopGroup;
import io.netty.channel.socket.nio.NioServerSocketChannel;

/**
 * <p>
 * 功能描述:
 * </p>
 *
 * @author cui haida
 * @date 2023/12/21/14:22
 */
public class HalfPackageServer {
    public static void main(String[] args) {
        EventLoopGroup group = new NioEventLoopGroup();
        ServerBootstrap server = new ServerBootstrap();

        server.group(group);
        server.channel(NioServerSocketChannel.class);
        // 为了演示半包,在这里指定服务端的接收窗口大小为4字节
        server.option(ChannelOption.SO_RCVBUF, 4);
        server.childHandler(new ServerInitialzer());
        server.bind("127.0.0.1", 8888);
    }
}
package io_study.netty_package_problem;

import io.netty.channel.ChannelHandlerContext;
import io.netty.channel.ChannelInboundHandlerAdapter;
import io.netty.channel.ChannelInitializer;
import io.netty.channel.socket.SocketChannel;
import io.netty.handler.logging.LogLevel;
import io.netty.handler.logging.LoggingHandler;

/**
 * <p>
 * 功能描述:服务端处理链表
 * </p>
 *
 * @author cui haida
 * @date 2023/12/21/11:32
 */
public class ServerInitialzer extends ChannelInitializer<SocketChannel> {
    @Override
    protected void initChannel(SocketChannel ch) {
        ch.pipeline().addLast(new LoggingHandler(LogLevel.DEBUG));
        ch.pipeline().addLast(new ChannelInboundHandlerAdapter() {
            // 数据就绪事件:当收到客户端数据时会读取通道内的数据
            @Override
            public void channelReadComplete(ChannelHandlerContext ctx) throws Exception {
                // 在这里直接输出通道内的数据信息
                System.out.println(ctx.channel());
                super.channelReadComplete(ctx);
            }
        });
    }
}

客户端

package io_study.netty_package_problem;

import io.netty.bootstrap.Bootstrap;
import io.netty.buffer.ByteBuf;
import io.netty.channel.ChannelHandlerContext;
import io.netty.channel.ChannelInboundHandlerAdapter;
import io.netty.channel.ChannelInitializer;
import io.netty.channel.EventLoopGroup;
import io.netty.channel.nio.NioEventLoopGroup;
import io.netty.channel.socket.SocketChannel;
import io.netty.channel.socket.nio.NioSocketChannel;

/**
 * <p>
 * 功能描述:
 * </p>
 *
 * @author cui haida
 * @date 2023/12/21/14:27
 */
public class HalfPackageClient {
    public static void main(String[] args) {
        EventLoopGroup worker = new NioEventLoopGroup();
        Bootstrap client = new Bootstrap();
        try {
            client.group(worker);
            client.channel(NioSocketChannel.class);
            client.handler(new ChannelInitializer<SocketChannel>() {
                @Override
                protected void initChannel(SocketChannel ch) throws Exception {
                    ch.pipeline().addLast(new ChannelInboundHandlerAdapter() {
                        // 在通道准备就绪后会触发的事件
                        @Override
                        public void channelActive(ChannelHandlerContext ctx) throws Exception {
                            // 向服务端发送十次数据,每次发送十个字节!
                            for (int i = 0; i < 10; i++) {
                                ByteBuf buffer = ctx.alloc().buffer();
                                buffer.writeBytes(new byte[]{'a', 'b', 'c', 'd', 'e', 'f', 'g', 'x', 'y', 'z'});
                                ctx.writeAndFlush(buffer);
                            }
                        }
                    });
                }
            });
            client.connect("127.0.0.1", 8888).sync();
        } catch (Exception e) {
            e.printStackTrace();
        } finally {
            worker.shutdownGracefully();
        }
    }
}

3:粘包和半包的产生原因

想要弄明白粘包、半包问题的产生原因,还得回到计算机网络的相关部分

3.1:TCP协议的滑动窗口

由于TCP是一种可靠性传输协议,所以在网络通信过程中,会采用一问一答的形式

也就是一端发送数据后,必须得到另一端返回ACK响应后,才会继续发送后续的数据。

但这种一问一答的同步方式,显然会十分影响数据的传输效率。

TCP协议为了解决传输效率的问题,引入了一种名为滑动窗口的技术,也就是在发送方和接收方上各有一个缓冲区,这个缓冲区被称为“窗口”

假设发送方的窗口大小为100KB,那么发送端的前100KB数据,无需等待接收端返回ACK,可以一直发送,直到发满100KB数据为止。

同时,除开发送方有窗口外,接收方也会有一个窗口,接收方只会读取窗口范围之内的数据,如果超出窗口范围的数据并不会读取

这也就意味着不会对窗口之外的数据包返回ACK

所以发送方在未收到ACK时,对应的窗口会停止向后滑动,并在一定时间后对未返回ACK的数据进行重发。

对于TCP的滑动窗口,发送方的窗口起到优化传输效率的作用,而接收端的窗口起到流量控制的作用。

3.2:MSS & MTU

MSS是传输层的最大报文长度限制,而MTU则是链路层的最大数据包大小限制

一般MTU会限制MSS,比如MTU=1500,那么MSS最大只能为1500减去报文头长度,以TCP协议为例,MSS最大为1500-40=1460。

为什么需要这个限制呢?

这是由于网络设备硬件导致的,比如任意类型的网卡,不可能让一个数据包无限增长,因为网卡会有带宽限制,比如一次性传输一个1GB

如果不限制大小直接发送,这会导致网络出现堵塞,并且超出网络硬件设备单次传输的最大限制。

所以当一个数据包超出MSS大小时,TCP协议会自动切割这个数据包,拆分成一个个的小包,然后分批次进行传输,从而实现大文件的传输。

3.3:TCP协议的Nagle算法

基于MSS最大报文限制,可以实现大文件的切割并分批发送

在网络通信中,还有另一种特殊情况,即是极小的数据包传输

因为TCP的报文头默认会有40个字节,如果数据只有1字节,那加上报文头依旧会产生一个41字节的数据包。

如果这种体积较小的数据包在传输中经常出现,这定然会导致网络资源的浪费,毕竟数据包中只有1字节是数据,另外40个字节是报文头,如果出现1W个这样的数据包,也就意味着会产生400MB的报文头,但实际数据只占10MB,这显然是不妥当的。

因此TCP协议中引入了一种名为Nagle的算法,如若连续几次发送的数据都很小,TCP会根据算法把多个数据合并成一个包发出,从而优化网络传输的效率,并且减少对资源的占用

3.4:应用层的缓冲区

对于操作系统的IO函数而言,网络数据不管是发送也好,还是接收也罢,并不会采用“复制”的方式工作

比如现在想要传输一个10MB的数据,不可能直接将这个数据一次性拷贝到缓冲区内,而是一个一个字节进行传输

而应用程序为了发送/接收数据,通常都需要具备两个缓冲区,即所说的接收缓冲区和发送缓冲区

一个用来暂存要发送的数据,另一个则用来暂存接收到的数据,同时这两个缓冲区的大小,可自行调整其大小(Netty默认为1024KB)。

3.5:问题的产生原因

知道了上面的4个概念之后,就可以知道粘包和拆包的根本原因了:

  • 粘包:发送12345、ABCDE两个数据包,被接收成12345ABCDE一个数据包,多个包粘在一起。
    • 应用层:接收方的接收缓冲区太大,导致读取多个数据包一起输出。
    • TCP滑动窗口:接收方窗口较大,导致发送方发出多个数据包,处理不及时造成粘包。
    • Nagle算法:由于发送方的数据包体积过小,导致多个数据包合并成一个包发送。
  • 半包:发送12345ABCDE一个数据包,被接收成12345、ABCDE两个数据包,一个包拆成多个。
    • 应用层:接收方缓冲区太小,无法存方发送方的单个数据包,因此拆开读取。
    • 滑动窗口:接收方的窗口太小,无法一次性放下完整数据包,只能读取其中一部分。
    • MSS限制:发送方的数据包超过MSS限制,被拆分为多个数据包发送。

在这里插入图片描述

4:解决方案

4.1:短连接解决粘包问题

所谓短连接是指客户端在发送一次数据后,就会立马断开与服务端的网络连接,在客户端断开连接后,服务端会收到一个-1的状态码

短连接是HTTP1.0的模式模式,我们可以利用短连接的-1的状态码作为消息(数据)的边界,以此区分不同的数据包

⚠️ 短连接无法解决半包问题,所以一般线上除开特殊场景外,否则不会使用短连接这种形式来单独解决粘包问题

package io_study.netty_package_problem;

import io.netty.bootstrap.Bootstrap;
import io.netty.buffer.ByteBuf;
import io.netty.channel.ChannelHandlerContext;
import io.netty.channel.ChannelInboundHandlerAdapter;
import io.netty.channel.ChannelInitializer;
import io.netty.channel.EventLoopGroup;
import io.netty.channel.nio.NioEventLoopGroup;
import io.netty.channel.socket.nio.NioSocketChannel;

/**
 * <p>
 * 功能描述:短链接的方式解决粘包问题
 * 客户端在发送一次数据后,就会立马断开与服务端的网络连接
 * 在客户端断开连接后,服务端会收到一个-1的状态码,而咱们可以用这个作为消息(数据)的边界,以此区分不同的数据包
 * 这种方式无法解决半包问题
 * </p>
 *
 * @author cui haida
 * @date 2023/12/21/14:39
 */
public class ShortLinkClient {
    public static void main(String[] args) {
        for (int i = 0; i < 3; i++) {
            sendData();
        }
    }

    private static void sendData() {
        EventLoopGroup worker = new NioEventLoopGroup();
        Bootstrap client = new Bootstrap();
        try {
            client.group(worker);
            client.channel(NioSocketChannel.class);
            client.handler(new ChannelInitializer<NioSocketChannel>() {
                @Override
                protected void initChannel(NioSocketChannel ch) throws Exception {
                    ch.pipeline().addLast(new ChannelInboundHandlerAdapter() {
                        // 在通道准备就绪后会触发的事件
                        @Override
                        public void channelActive(ChannelHandlerContext ctx) throws Exception {
                            // 向服务端发送一个20字节的数据包,然后断开连接
                            ByteBuf buffer = ctx.alloc().buffer(1);
                            buffer.writeBytes(new byte[]{'0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'A', 'B', 'C', 'D', 'E', 'M', 'N', 'X', 'Y', 'Z'});
                            ctx.writeAndFlush(buffer);
                            // 发送完成就关闭,形成短连接
                            ctx.channel().close();
                        }
                    });
                }
            });
            client.connect("127.0.0.1", 8888).sync();
        } catch (Exception e) {
            e.printStackTrace();
        } finally {
            worker.shutdownGracefully();
        }
    }
}
4.2:帧解码器(重)

短连接方式,解决粘包问题的思路属于投机取巧行为,同时也需要频繁的建立/断开连接,这无论是从资源利用率、还是程序执行的效率上来说,都并不妥当

Netty中提供了一系列解决粘包、半包问题的实现类,即Netty的帧解码器,Netty中提供了四类帧解码器:

4.2.1:定长帧解码器

所谓定长帧就是通过固定长度解析数据,在处理器中添加一个定长帧解码器并指定定长即可:

package io_study.netty_package_problem.frame;

import io.netty.buffer.ByteBuf;
import io.netty.buffer.ByteBufAllocator;
import io.netty.channel.embedded.EmbeddedChannel;
import io.netty.handler.codec.FixedLengthFrameDecoder;
import io.netty.handler.logging.LogLevel;
import io.netty.handler.logging.LoggingHandler;

/**
 * <p>
 * 功能描述:定长帧解码器
 * </p>
 *
 * @author cui haida
 * @date 2023/12/21/14:55
 */
public class FixedLengthFrameDecoderDemo {
    public static void main(String[] args) {
        // 通过Netty提供的测试通道来代替服务端、客户端
        EmbeddedChannel channel = new EmbeddedChannel(
                // 添加一个定长帧解码器(每条数据以8字节为单位拆包)
            	// 下面两行等价于注释的这两行
                // socketChannel.pipeline().addLast(new FixedLengthFrameDecoder(8));
                // socketChannel.pipeline().addLast(new LoggingHandler(LogLevel.DEBUG));
                new FixedLengthFrameDecoder(8),
                new LoggingHandler(LogLevel.DEBUG)
        );
        int len = 8;
        // 调用三次发送数据的方法(等价于向服务端发送三次数据)
        sendData(channel, "ABCDEGF", len);
        sendData(channel, "XYZ", len);
        sendData(channel, "12345678", len);
    }

    private static void sendData(EmbeddedChannel channel, String data, int len) {
        // 1:获取发送数据的字节长度
        byte[] bytes = data.getBytes();
        int dataLength = bytes.length;

        // 2:根据固定长度补齐要发送的数据
        StringBuilder alignString = new StringBuilder();
        if (dataLength < len) {
            int alignLength = len - bytes.length;
            for (int i = 1; i <= alignLength; i++) {
                alignString.append("*");
            }
        }

        // 3:拼接上补齐字符,得到最终要发送的消息数据
        String msg = data + alignString;
        byte[] msgBytes = msg.getBytes();

        // 4:构建缓冲区,通过channel发送数据
        ByteBuf buffer = ByteBufAllocator.DEFAULT.buffer(8);
        buffer.writeBytes(msgBytes);
        channel.writeInbound(buffer);
    }
}

这种采用固定长度解析数据的方式,的确能够有效避免粘包、半包问题的出现

因为每个数据包之间,会以八个字节的长度作为界限,然后分割数据。

但这种方式也存在三个致命缺陷:

  • 只适用于传输固定长度范围内的数据场景,而且客户端在发送数据前,还需自己根据长度补齐数据。
  • 如果发送的数据超出固定长度,服务端依旧会按固定长度分包,所以仍然会存在半包问题。
  • 对于未达到固定长度的数据,还需要额外传输补齐的*号字符,会占用不必要的网络资源。
4.2.2:行帧解码器

只需在每个要发送的数据末尾,手动拼接上一个\n或\r\n换行符即可,服务端在读取数据时,会按换行符来作为界限分割

package io_study.netty_package_problem.frame;

import io.netty.buffer.ByteBuf;
import io.netty.buffer.ByteBufAllocator;
import io.netty.channel.embedded.EmbeddedChannel;
import io.netty.handler.codec.LineBasedFrameDecoder;
import io.netty.handler.logging.LogLevel;
import io.netty.handler.logging.LoggingHandler;

/**
 * <p>
 * 功能描述:行帧解码器
 * </p>
 *
 * @author cui haida
 * @date 2023/12/21/15:11
 */
public class LineFrameDecoderDemo {
    public static void main(String[] args) {
        // 通过Netty提供的测试通道来代替服务端、客户端
        EmbeddedChannel channel = new EmbeddedChannel(
                // 添加一个行帧解码器(在超出1024后还未检测到换行符,就会停止读取)
                new LineBasedFrameDecoder(1024),
                // 添加logLevel
                new LoggingHandler(LogLevel.DEBUG)
        );

        // 调用三次发送数据的方法(等价于向服务端发送三次数据)
        sendData(channel, "ABCDEGF");
        sendData(channel, "XYZ");
        sendData(channel, "12345678");
    }

    /**
     * 消息发送
     * @param channel 通道
     * @param msg 消息
     */
    private static void sendData(EmbeddedChannel channel, String msg) {
        // 在要发送的数据结尾,拼接上一个\n换行符(\r\n也可以)
        msg = msg + "\n";
        //  获取发送数据的字节长度
        byte[] msgBytes = msg.getBytes();
        // 构建缓冲区,通过channel发送数据
        ByteBuf buffer = ByteBufAllocator.DEFAULT.buffer(8);
        buffer.writeBytes(msgBytes);
        channel.writeInbound(buffer);
    }
}
4.2.3:分隔符帧解码器

这种解码器,能随心所欲的定义自己的分隔符【可以将行解码器想象成为分隔符为\n的分隔符帧解码器】

package io_study.netty_package_problem.frame;

import io.netty.buffer.ByteBuf;
import io.netty.buffer.ByteBufAllocator;
import io.netty.channel.embedded.EmbeddedChannel;
import io.netty.handler.codec.DelimiterBasedFrameDecoder;
import io.netty.handler.logging.LogLevel;
import io.netty.handler.logging.LoggingHandler;

/**
 * <p>
 * 功能描述:分隔符帧解码器
 * </p>
 *
 * @author cui haida
 * @date 2023/12/21/15:23
 */
public class DelimiterFrameDecoderDemo {
    public static void main(String[] args) {
        // 自定义一个分隔符(记得要用ByteBuf对象来包装)
        ByteBuf delimiter = ByteBufAllocator.DEFAULT.buffer(1);
        delimiter.writeByte('*');

        // 通过Netty提供的测试通道来代替服务端、客户端
        EmbeddedChannel channel = new EmbeddedChannel(
                // 添加一个分隔符帧解码器(传入自定义的分隔符)
                new DelimiterBasedFrameDecoder(1024, delimiter),
                new LoggingHandler(LogLevel.DEBUG)
        );

        // 调用三次发送数据的方法(等价于向服务端发送三次数据)
        sendData(channel,"ABCDEGF");
        sendData(channel,"XYZ");
        sendData(channel,"12345678");
    }

    /**
     * 消息发送
     * @param channel channel
     * @param msg msg
     */
    private static void sendData(EmbeddedChannel channel, String msg) {
        // 在要发送的数据结尾,拼接上一个*号(因为前面自定义的分隔符为*号)
        msg = msg + "*";
        //  获取发送数据的字节长度
        byte[] msgBytes = msg.getBytes();

        // 构建缓冲区,通过channel发送数据
        ByteBuf buffer = ByteBufAllocator.DEFAULT.buffer(8);
        buffer.writeBytes(msgBytes);
        channel.writeInbound(buffer);
    }
}

行解码器、自定义分隔符解码器虽然更加灵活,但这两种解码器,依旧存在些许缺点:

  • 对于每一个读取到的字节都需要判断一下:是否为结尾的分隔符,这会影响整体性能。
  • 依旧存在最大长度限制,当数据超出最大长度后,会自动将其分包,在数据传输量较大的情况下,依旧会导致半包现象出现。
4.2.4:LTC帧解码器

无论是哪个,都多多少少会存在些许不完美,因此Netty最终提供了一款LTC解码器

这个解码器也属于实际Netty开发中,应用最为广泛的一种

LTC中存在五个参数:

  • maxFrameLength:数据最大长度,允许单个数据包的最大长度,超出长度后会自动分包。
  • lengthFieldOffset:长度字段偏移量,表示描述数据长度的信息从第几个字段开始。
  • lengthFieldLength:长度字段的占位大小,表示数据中的使用了几个字节描述正文长度。
  • lengthAdjustment:长度调整数,表示在长度字段的N个字节后才是正文数据的开始。
  • initialBytesToStrip:头部剥离字节数,表示先将数据去掉N个字节后,再开始读取数据。

LTC解码器,就是基于这些参数,来确定一条数据的长度、位置,从而读取到精确的数据,避免粘包、半包的现象产生

package io_study.netty_package_problem.frame;

import io.netty.buffer.ByteBuf;
import io.netty.buffer.ByteBufAllocator;
import io.netty.channel.embedded.EmbeddedChannel;
import io.netty.handler.codec.LengthFieldBasedFrameDecoder;
import io.netty.handler.logging.LogLevel;
import io.netty.handler.logging.LoggingHandler;

/**
 * <p>
 * 功能描述:LTC帧解码器
 * netty最终用的是这个
 * </p>
 *
 * @author cui haida
 * @date 2023/12/21/15:30
 */
public class LTCDecoderDemo {
    public static void main(String[] args) {
        int maxFrameLength = 1024; // 数据最大长度,允许单个数据包的最大长度,超出长度后会自动分包
        int lengthFieldOffset = 0; // 长度字段偏移量,表示描述数据长度的信息从第几个字段开始
        int lengthFieldLength = 4; // 长度字段的占位大小,表示数据中的使用了几个字节描述正文长度
        int lengthAdjustment = 0; // 长度调整数,表示在长度字段的N个字节后才是正文数据的开始
        int initialBytesToStrip = 0; // 头部剥离字节数,表示先将数据去掉N个字节后,再开始读取数据
        // 通过Netty提供的测试通道来代替服务端、客户端
        EmbeddedChannel channel = new EmbeddedChannel(
                // 添加一个行帧解码器(在超出1024后还未检测到换行符,就会停止读取)
                new LengthFieldBasedFrameDecoder(maxFrameLength, lengthFieldOffset, lengthFieldLength, lengthAdjustment, initialBytesToStrip),
                new LoggingHandler(LogLevel.DEBUG)
        );

        // 调用三次发送数据的方法(等价于向服务端发送三次数据)
        sendData(channel,"hahaha");
        sendData(channel, "enenene");
    }

    private static void sendData(EmbeddedChannel channel, String data) {
        // 获取要发送的数据字节以及长度
        byte[] dataBytes = data.getBytes();
        int dataLength = dataBytes.length;

        // 先将数据长度写入到缓冲区、再将正文数据写入到缓冲区
        ByteBuf buffer = ByteBufAllocator.DEFAULT.buffer();
        buffer.writeInt(dataLength);
        buffer.writeBytes(dataBytes);

        // 发送最终组装好的数据
        channel.writeInbound(buffer);
    }
}

二:长连接和心跳机制

1:长短连接概述

  • 短连接是每次读写数据完成后,立马断开客户端与服务端的网络连接。
  • 长连接是一次数据交互完成后,服务端和客户端之间继续保持连接,当后续需再次收/发数据时,可直接复用原有的网络连接。

长连接这种模式,在并发较高的情况下能够带来额外的性能收益,因为Netty服务端、客户端绑定IP端口,搭建Channel通道的过程,放到底层实际上就是TCP三次握手的过程,同理,客户端、服务端断开连接的过程,即对应着TCP的四次挥手。

TCP三次握手/四次挥手,这个过程无疑是比较“重量级”的,并发情况下,频繁创建、销毁网络连接,其资源开销、性能开销会比较大,所以使用长连接的方案,能够有效减少创建和销毁网络连接的动作。

2:Netty网络参数(调优用)

ChannelOption是Netty提供的参数调整类,该类中提供了很多常量,分别对应着底层TCP、UDP、计算机网络的一些参数

// 前面演示半包的时候用过
// 为了演示半包,在这里指定服务端的接收窗口大小为4字节
server.option(ChannelOption.SO_RCVBUF, 4);

在创建服务端、客户端时,我们可以通过ChannelOption类来调整网络参数,以此满足不同的业务需求【技术调优就是调整这些参数】

在这里插入图片描述

3:设置长连接

在上述网络配置中,SO_KEEPALIVE用于开启长连接机制,主要将其设置为true,并将其装载到对应的服务端/客户端上即可设置长连接

Netty中提供了两个装载参数的方法:

  • option():发生在连接初始化阶段,也就是程序初始化时,就会装载该方法配置的参数。
  • childOption():发生在连接建立之后,这些参数只有等连接建立后才会被装载。

🎉 option()方法配置的参数是对全局生效的,而childOption()配置的参数,是针对于连接生效的

// 服务端代码
server.childOption(ChannelOption.SO_KEEPALIVE, true);
// 客户端代码
client.option(ChannelOption.SO_KEEPALIVE, true);

通过上述的方式开启长连接之后,TCP默认每两小时会发送一次心跳检测,查看对端是否还存活,如果对端由于网络故障导致下线,TCP会自动断开与对方的连接。

在这里插入图片描述

4:心跳机制(重)

Netty的长连接,其实本质上并不是Netty提供的长连接实现,而是通过调整参数,借助传输层TCP协议提供的长连接机制,从而实现服务端与客户端的长连接支持

不过TCP虽然提供了长连接支持,但其心跳机制并不够完善,因为心跳检测的间隔时间太长了,每隔两小时才检测一次

而两小时对于大流量的服务器是致命的,无法有效检测到机房断电、机器重启、网线拔出、防火墙更新等情况,假设一次心跳结束后,对端就出现了这些故障,依靠TCP自身的心跳频率,需要等到两小时之后才能检测到问题。而这些已经失效的连接应当及时剔除,否则会长时间占用服务端资源,毕竟服务端的可用连接数是有限的。

4.1:通用实现思路

因此,光依靠TCP的心跳机制是不行的,所以都会在应用层再自实现一次心跳机制,而所有的心跳机制的实现思路无外乎两种:

  • 服务端主动探测:每间隔一定时间后,向所有客户端发送一个检测信号

在这里插入图片描述

  • 客户端主动告知:每间隔一定时间后,客户端向服务端发送一个心跳包

在这里插入图片描述
一般来说,一套健全的心跳机制,都会结合上述两种方案一起实现

也就是客户端定时向服务端发送心跳包,当服务端未收到某个客户端心跳包的情况下,再主动向客户端发起探测包

这二步主要是做二次确认,防止由于网络拥塞或其他问题,导致原本客户端发出的心跳包丢失。

在这里插入图片描述

4.2:Netty实现思路

Netty中提供了一个名为IdleStateHandler的类,它可以对一个通道上的读、写、读/写操作设置定时器

其中主要提供了三种类型的心跳检测:

// 当一个Channel(Socket)在指定时间后未触发读事件,会触发这个事件
public static final IdleStateEvent READER_IDLE_STATE_EVENT;
// 当一个Channel(Socket)在指定时间后未触发写事件,会触发这个事件
public static final IdleStateEvent WRITER_IDLE_STATE_EVENT;
// 上述读、写等待事件的结合体
public static final IdleStateEvent ALL_IDLE_STATE_EVENT;

Netty中,当一个已建立连接的通道,超出指定时间后还没有出现数据交互,对应的Channel就会进入闲置Idle状态

根据不同的Socket/Channel事件,会进入不同的闲置状态

而不同的闲置状态又会触发不同的闲置事件,也就是上述提到的三种闲置事件,在Netty中用IdleStateEvent事件类来表示。

这里还需要用到入站处理器中的一个方法:userEventTriggered()

这个钩子方法,会在通道触发任意事件后被调用,这也就意味着:只要通道上触发了事件,都会触发该方法执行,闲置事件也不例外

有了IdleState、userEventTriggered()这两个基础后,就可去实现一个简单的心跳机制,最基本的功能实现如下:

  • 客户端:在闲置一定时间后,能够主动给服务端发送心跳包。
  • 服务端:能够主动检测到未发送数据包的闲置连接,并中断连接。
4.3:Netty心跳简单实现
4.3.1:客户端

客户端心跳处理器

package com.cui.commonboot.mynetty.heartbeat.client;

import io.netty.buffer.ByteBuf;
import io.netty.buffer.Unpooled;
import io.netty.channel.ChannelHandlerContext;
import io.netty.channel.ChannelInboundHandlerAdapter;
import io.netty.handler.timeout.IdleState;
import io.netty.handler.timeout.IdleStateEvent;
import io.netty.util.CharsetUtil;

/**
 * <p>
 * 功能描述:客户端心跳处理器
 * </p>
 *
 * @author cui haida
 * @date 2023/12/22/19:38
 */
public class HeartbeatClientHandler extends ChannelInboundHandlerAdapter {
    // 通用的心跳包数据
    private static final ByteBuf HEARTBEAT_DATA =
            Unpooled.unreleasableBuffer(Unpooled.copiedBuffer("I am Alive", CharsetUtil.UTF_8));

    /**
     * 通道激活的时候触发
     * @param ctx 上下文
     * @throws Exception 异常
     */
    @Override
    public void channelActive(ChannelHandlerContext ctx) throws Exception {
        System.out.println("正在与服务端建立连接....");
        // 建立连接成功之后,先向服务端发送一条数据
        ctx.channel().writeAndFlush("我是会发心跳包的客户端-A!");
        super.channelActive(ctx);
    }
    
    /**
     * userEventTriggered是一个钩子方法, 可以检测到各种事件
     * @param ctx 上下文
     * @param event 事件
     * @throws Exception 异常
     */
    @Override
    public void userEventTriggered(ChannelHandlerContext ctx, Object event) throws Exception {
        // 如果当前触发的闲置事件
        if (event instanceof IdleStateEvent) {
            // 编程闲置事件
            IdleStateEvent idleStateEvent = (IdleStateEvent) event;
            // 如果当前通道触发了写闲置事件
            if (idleStateEvent.state() == IdleState.WRITER_IDLE) {
                // 表示当前客户端有一段时间未向服务端发送数据了,
                // 为了防止服务端关闭当前连接,手动发送一个心跳包
                ctx.channel().writeAndFlush(HEARTBEAT_DATA.duplicate());
                System.out.println("成功向客户端发送了心跳包");
            } else {
                super.userEventTriggered(ctx, event);
            }
        }
    }

    /**
     * 通道非激活的时候触发
     * @param ctx 上下文
     * @throws Exception 异常
     */
    @Override
    public void channelInactive(ChannelHandlerContext ctx) throws Exception {
        System.out.println("服务端主动关闭了连接....");
        super.channelInactive(ctx);
    }
}

客户端netty

package com.cui.commonboot.mynetty.heartbeat.client;

import io.netty.bootstrap.Bootstrap;
import io.netty.channel.ChannelOption;
import io.netty.channel.EventLoopGroup;
import io.netty.channel.nio.NioEventLoopGroup;
import io.netty.channel.socket.nio.NioSocketChannel;

/**
 * <p>
 * 功能描述:客户端
 * </p>
 *
 * @author cui haida
 * @date 2023/12/22/20:14
 */
public class ClientA {
    public static void main(String[] args) {
        EventLoopGroup worker = new NioEventLoopGroup();
        Bootstrap client = new Bootstrap();
        try {
            client.group(worker);
            client.channel(NioSocketChannel.class);
            // 打开长连接配置
            client.option(ChannelOption.SO_KEEPALIVE, true);
            // 指定一个自定义的初始化器
            client.handler(new ClientInitializer());
            client.connect("127.0.0.1", 8888).sync();
        } catch (Exception e){
            e.printStackTrace();
        }
    }
}
package com.cui.commonboot.mynetty.heartbeat.client;

import io.netty.channel.ChannelInitializer;
import io.netty.channel.ChannelPipeline;
import io.netty.channel.socket.SocketChannel;
import io.netty.handler.codec.string.StringDecoder;
import io.netty.handler.codec.string.StringEncoder;
import io.netty.handler.timeout.IdleStateHandler;
import io.netty.util.CharsetUtil;

import java.util.concurrent.TimeUnit;

/**
 * <p>
 * 功能描述:事件处理器链
 * </p>
 *
 * @author cui haida
 * @date 2023/12/22/20:15
 */
public class ClientInitializer extends ChannelInitializer<SocketChannel> {
    @Override
    protected void initChannel(SocketChannel ch) throws Exception {
        // 拿到管道
        ChannelPipeline pipeline = ch.pipeline();
        // 配置如果3s内未触发写事件,就会触发写闲置事件
        // 0,3,0分别表示读操作的闲置事件、写操作的闲置事件、读写操作的闲置事件
        // 如果赋值为0,表示这些闲置事件不需要关心
        pipeline.addLast("IdleStateHandler",
                new IdleStateHandler(0,3,0, TimeUnit.SECONDS));
        // 编码器和解码器
        pipeline.addLast("Encoder",new StringEncoder(CharsetUtil.UTF_8));
        pipeline.addLast("Decoder",new StringDecoder(CharsetUtil.UTF_8));
        // 装载自定义的客户端心跳处理器
        pipeline.addLast("HeartbeatHandler",new HeartbeatClientHandler());
    }
}

没有接入心跳包的客户端

package com.cui.commonboot.mynetty.heartbeat.client;

import io.netty.bootstrap.Bootstrap;
import io.netty.channel.*;
import io.netty.channel.nio.NioEventLoopGroup;
import io.netty.channel.socket.SocketChannel;
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;

/**
 * <p>
 * 功能描述:
 * </p>
 *
 * @author cui haida
 * @date 2023/12/22/20:38
 */
public class ClientB {
    public static void main(String[] args) {
        EventLoopGroup worker = new NioEventLoopGroup();
        Bootstrap client = new Bootstrap();
        try {
            client.group(worker);
            client.channel(NioSocketChannel.class);
            client.option(ChannelOption.SO_KEEPALIVE, true);
            client.handler(new ChannelInitializer<SocketChannel>() {
                @Override
                protected void initChannel(SocketChannel socketChannel) throws Exception {
                    ChannelPipeline pipeline = socketChannel.pipeline();
                    // 编解码器
                    pipeline.addLast("Encoder",new StringEncoder(CharsetUtil.UTF_8));
                    pipeline.addLast("Decoder",new StringDecoder(CharsetUtil.UTF_8));
                    pipeline.addLast(new ChannelInboundHandlerAdapter(){
                        /**
                         * 通道激活的时候触发
                         * @param ctx 上下文
                         * @throws Exception 异常
                         */
                        @Override
                        public void channelActive(ChannelHandlerContext ctx) throws Exception {
                            // 建立连接成功之后,先向服务端发送一条数据
                            ctx.channel().writeAndFlush("我是不会发心跳包的客户端-B!");
                        }
                        // 通道关闭的时候触发
                        @Override
                        public void channelInactive(ChannelHandlerContext ctx) throws Exception {
                            System.out.println("因为没发送心跳包,我将会被移除");
                            // 当通道被关闭时,停止前面启动的线程池
                            worker.shutdownGracefully();
                        }
                    });
                }
            });
            client.connect("127.0.0.1", 8888).sync();
        } catch (Exception e){
            e.printStackTrace();
        }
    }
}
4.3.2:服务端

服务端心跳处理器

package com.cui.commonboot.mynetty.heartbeat.server;

import io.netty.channel.ChannelHandlerContext;
import io.netty.channel.ChannelInboundHandlerAdapter;
import io.netty.handler.timeout.IdleState;
import io.netty.handler.timeout.IdleStateEvent;

/**
 * <p>
 * 功能描述:心跳机制的服务端处理器
 * </p>
 *
 * @author cui haida
 * @date 2023/12/22/20:21
 */
public class HeartbeatServerHandler extends ChannelInboundHandlerAdapter {

    /**
     * 通道激活的时候触发
     * @param ctx 上下文
     * @throws Exception 异常
     */
    @Override
    public void userEventTriggered(ChannelHandlerContext ctx, Object event) throws Exception {
        // 如果当前触发的事件是闲置事件
        if (event instanceof IdleStateEvent) {
            // 通用事件类型转成闲置事件类型
            IdleStateEvent idleEvent = (IdleStateEvent) event;
            // 如果对应的Channel通道触发了写闲置事件
            if (idleEvent.state() == IdleState.READER_IDLE) {
                // 表示对应的客户端没有发送心跳包,则关闭对应的网络连接
                // (心跳包也是一种特殊的数据,会触发读事件,有心跳就不会进这步)
                ctx.channel().close();
                System.out.println("关闭了未发送心跳包的连接....");
            } else {
                super.userEventTriggered(ctx, event);
            }
        }
    }


    /**
     * 当在通道中读到东西的时候激活
     * @param ctx 上下文
     * @param msg 读到的信息
     * @throws Exception 异常
     */
    @Override
    public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception {
        // 如果收到的是心跳包,则给客户端做出一个回复
        if ("I am Alive".equals(msg)){
            ctx.channel().writeAndFlush("I know");
        }
        System.out.println("收到客户端消息:" + msg);
        super.channelRead(ctx, msg);
    }
}

服务端netty

package com.cui.commonboot.mynetty.heartbeat.server;

import io.netty.bootstrap.ServerBootstrap;
import io.netty.channel.ChannelOption;
import io.netty.channel.EventLoopGroup;
import io.netty.channel.nio.NioEventLoopGroup;
import io.netty.channel.socket.nio.NioServerSocketChannel;

/**
 * <p>
 * 功能描述:服务端
 * </p>
 *
 * @author cui haida
 * @date 2023/12/22/20:31
 */
public class Server {
    public static void main(String[] args) {
        EventLoopGroup group = new NioEventLoopGroup();
        ServerBootstrap server = new ServerBootstrap();

        server.group(group);
        server.channel(NioServerSocketChannel .class);
        // 在这里开启了长连接配置,以及配置了自定义的初始化器
        server.childOption(ChannelOption.SO_KEEPALIVE, true);
        server.childHandler(new ServerInitializer());
        server.bind("127.0.0.1",8888);
    }
}
package com.cui.commonboot.mynetty.heartbeat.server;

import io.netty.channel.ChannelInitializer;
import io.netty.channel.ChannelPipeline;
import io.netty.channel.socket.SocketChannel;
import io.netty.handler.timeout.IdleStateHandler;
import io.netty.handler.codec.string.StringDecoder;
import io.netty.handler.codec.string.StringEncoder;
import io.netty.util.CharsetUtil;

import java.util.concurrent.TimeUnit;

/**
 * <p>
 * 功能描述:
 * </p>
 *
 * @author cui haida
 * @date 2023/12/22/20:33
 */
public class ServerInitializer extends ChannelInitializer<SocketChannel> {
    @Override
    protected void initChannel(SocketChannel ch) throws Exception {
        ChannelPipeline pipeline = ch.pipeline();
        // 配置如果5s内未触发写事件,就会触发写闲置事件
        pipeline.addLast("IdleStateHandler",
                new IdleStateHandler(5,0,0, TimeUnit.SECONDS));
        pipeline.addLast("Encoder",new StringEncoder(CharsetUtil.UTF_8));
        pipeline.addLast("Decoder",new StringDecoder(CharsetUtil.UTF_8));
        // 装载自定义的服务端心跳处理器
        pipeline.addLast("HeartbeatHandler",new HeartbeatServerHandler());
    }
}

为什么一个客户端通道长时间未发送数据就需要关闭连接呀?这不是违背了长连接的初衷吗?

因为前面在咱们的客户端中,在通道长时间未触发写事件的情况下,会主动向服务端发送心跳包

而心跳包也是一种特殊的数据包,依旧会触发服务端上的读事件,所以但凡正常发送心跳包的连接,都不会被服务端主动关闭。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值