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());
}
}
为什么一个客户端通道长时间未发送数据就需要关闭连接呀?这不是违背了长连接的初衷吗?
因为前面在咱们的客户端中,在通道长时间未触发写事件的情况下,会主动向服务端发送心跳包
而心跳包也是一种特殊的数据包,依旧会触发服务端上的读事件,所以但凡正常发送心跳包的连接,都不会被服务端主动关闭。