假设客户端分别发送了两个数据包D1和D2给服务端,由于服务端一次读取到的字节数是不确定的,故可能存在以下情况。
(1)服务端一次接收到了两个数据包,D1和D2粘合在一起,被称为TCP黏包;
(2)服务端分两次读取到了两个数据包,第一次读取到了完整的D1包和D2包的部分内容,第二次读取到了D2包的剩余内容,这被称为TCP半包;
NIO的解决方法:
客户端和服务器约定一个包的固定大小,服务器按预定长度读取,缺点是浪费宽带。
客户端发送消息的时候指定一个分隔符,如\n,但是此方法效率较低,需要一个一个去找分隔符。
(常用)跟TCP一样,TLV类型,(Type,Length,Value),数据包含有一个固定大小的包头,指定后面的数据有多大,分配合适的buffer,缺点是buffer需提前分配,如果内容过大,影响server吞吐量
只要使用的是TCP协议传输都会有黏包和半包问题
Netty基础 NIO SocketChannel 网络编程 自制小型服务器,多线程优化*_清风拂来水波不兴的博客-CSDN博客
上面的这段描述是Nio学习时的解决方法,比较复杂,可以参考之前Nio的教程,链接如上。
Netty已经给我们封装好了一系列解决方法,下面将讲解Netty的黏包和半包问题
服务器代码:
@Slf4j
public class NettyServer {
public static void main(String[] args) {
NioEventLoopGroup boss = new NioEventLoopGroup();
NioEventLoopGroup worker = new NioEventLoopGroup(7);
ServerBootstrap bootstrap = new ServerBootstrap()
.group(boss, worker)
.channel(NioServerSocketChannel.class);
bootstrap.childHandler(new ChannelInitializer() {
@Override
protected void initChannel(NioSocketChannel ch) throws Exception {
ch.pipeline().addLast(new LoggingHandler());
ch.pipeline().addLast(new ChannelInboundHandlerAdapter(){
@Override
public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception {
ByteBuf buf = (ByteBuf) msg;
System.out.println(Thread.currentThread().getName()+buf.toString());
}
});
}
});
try {
ChannelFuture channelFuture = bootstrap.bind(8081).sync();
channelFuture.channel().closeFuture().sync();
} catch (InterruptedException e) {
log.error("server error",e);
e.printStackTrace();
}finally {
boss.shutdownGracefully();
worker.shutdownGracefully();
}
}
}
客户端代码:
@Slf4j
public class NettyClient {
public static void main(String[] args) {
NioEventLoopGroup group = new NioEventLoopGroup();
Bootstrap bootstrap = new Bootstrap().group(group).channel(NioSocketChannel.class)
.handler(new ChannelInitializer() {
@Override
protected void initChannel(NioSocketChannel ch) throws Exception {
ch.pipeline().addLast(new ChannelInboundHandlerAdapter(){
@Override
//会在channel建立好后触发Active事件
public void channelActive(ChannelHandlerContext ctx) throws Exception {
for (int i = 0; i < 10; i++) {
ByteBuf buf=ctx.alloc().buffer(16);
buf.writeBytes(new byte[]{'0','1','2','3','4','5','6','7','8','9','a','b','c','d','e','f'});
ctx.writeAndFlush(buf);
}
}
});
}
});
try {
ChannelFuture future = bootstrap.connect(new InetSocketAddress("localhost", 8081)).sync();
future.channel().closeFuture().sync();
} catch (InterruptedException e) {
log.error("client",e);
}finally {
group.shutdownGracefully();
}
}
}
解读:客户端连接至服务器之后循环10次,每次发送16个字节的数据,服务器接收之后打印出来。
可以看到,服务器把160B当做一个包来处理了,而我们预期的是服务器接收10次,这就是典型的一个黏包现象,
在服务器代码设置全局接收缓冲区为10个字节
结果如下
第一次32B,第二次50B,既有黏包也有半包问题,但为什么不是每次10B呢?具体还是跟tcp缓冲区采用的算法,以及接收方接收数据包到缓存的速率和应用程序从tcp缓冲区读取数据的速率有关
要理解原理需要知道TCP协议的一些只是,这里只做比较粗略的解释^_^
TCP以一个段(segment)为单位,每发送一个段就需要进行一次确认应答(ack)处理,但如果这么做,缺点是包的往返时间越长性能就越差
客户端和服务器都维护了一个滑动窗口,服务一次最多只能接收一定字节数量的数据,进行流量控制和缓冲数据。
传输层可以把数据分段,把一个完整的数据分成几个部分,这样就有可能一个窗口内不能发出所有数据,造成半包问题;接收方的窗口如果比较空闲,一次性把好几个部分的数据都接收了就发生了黏包。
黏包:
半包:
客户端发起连接,发送完我认为的一条完整消息,就立刻断开连接,消息的连接和建立就当做消息的边界。可以看出此方法比较鸡肋。
服务器代码不变,客户端代码稍微调整了一下,即主线程循环十次,每次代码都是新的对象,新的连接。发生了10次tcp连接
public static void main(String[] args) {
for (int i = 0; i < 10; i++) {
send();
}
}
private static void send() {
//省略……
ByteBuf buf=ctx.alloc().buffer(16);
buf.writeBytes(new byte[]{'0','1','2','3','4','5','6','7','8','9','a','b','c','d','e','f'});
ctx.writeAndFlush(buf);
//发送完后就立即把channel进行关闭
ctx.channel().close();
//省略……
}
结果正确解决了黏包问题。
但是这种方法却不能解决半包问题
我们调整服务器的接收缓冲区,调成16,每次只能从对应的channl里接收16字节(因为这是最小值),客户端改为每次发送18字节的数据。
ServerBootstrap bootstrap = new ServerBootstrap()
//调整系统的接收缓存去(滑动窗口),默认1024
// .option(ChannelOption.SO_RCVBUF,10)//option是针对全局的
//调整netty的接收缓冲区(ByteBuf大小),这个参数指一次从接收缓冲区最多读多少,默认1024,最小16
//childOption是针对每个channel连接的接收的
.childOption(ChannelOption.RCVBUF_ALLOCATOR,new AdaptiveRecvByteBufAllocator(16,16,16))
.group(boss, worker)
.channel(NioServerSocketChannel.class);
可以看到半包问题出现了,客户端虽然每次一次性发18字节,但是服务器一次读不了那么多,分两次读取了。把原本一条完整的数据分成了两个数据
这是Netty自带的一种解决这种黏包半包的问题的。如下package io.netty.handler.codec包下的类
服务器代码:
@Override
protected void initChannel(NioSocketChannel ch) throws Exception {
//设定固定的消息大小为3字节,不出意外都放最前面
ch.pipeline().addLast(new FixedLengthFrameDecoder(3));
ch.pipeline().addLast(new LoggingHandler());
ch.pipeline().addLast(new ChannelInboundHandlerAdapter(){
@Override
public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception {
ByteBuf buf = (ByteBuf) msg;
System.out.println(Thread.currentThread().getName()+buf.toString());
}
});
客户端:
buf.writeBytes(new byte[]{'A'});
buf.writeBytes(new byte[]{'B','C'});
buf.writeBytes(new byte[]{'D','E','F','G'});
buf.writeBytes(new byte[]{'H','I'});
客户端发送数据如果一条消息少于规定的字符数,必须在后面填充数据,达到规定的字节数,缺点就是浪费资源
使用分隔符来确定消息的边界,客户端和服务器确定一个结尾分隔符,服务器一个一个解码,如果读到该分隔符,表明当前读取的数据包为一个,开始读取下一个包。
Netty已经提供了两种解码器如下
1)以换行符作为分隔符的类,也就是支持"\n"和"\r\n"作为分隔符,有兴趣的可查看decode源码
该类其中的maxLength为最大接收长度,如果迟迟遇不到分隔符,达到maxLength后会抛异常。
2)自定义解码器,由我们自己diy分隔符
演示:
同样在服务器代码中给第一个管道加入一个以换行符结尾的解码器
ch.pipeline().addLast(new LineBasedFrameDecoder(2048));
客户端发送一段消息,每条独立的消息以换行符结尾
buf.writeBytes(new byte[]{'1','2','\r','\n','3','4','5','\n','6','\n'});
服务器正确的接收到12 345 6这三条消息。
缺点:服务器需要每个每个字符的去查找分隔符,效率较低;处理字符数据比较合适,但如果内容本身包含了分隔符(字节数据常常会有此情况),那么就会解析错误
基于长度解析字段,看如下类
几个比较重要的字段
源码提供了很多的说明文档,这里我带大家看一看:
1)
lengthFieldOffset为0表我这个消息的第0字节开始就是代表我的长度的,lengthFieldLength为2表示我一共用2个字节来表示我这个数据的长读,长度0x00C十机制为12,也就是"HELLO, WORLD"
2)
lengthBytesToStrip为2表示从头开始把前两个字节的数据剥离出来
3)
lengthAdjustment为2表示从长度字段之后再调整2个字节的数据才是数据内容
4)
相信看了前面三个示例,这里大家都能看懂为什么AFTER DECODER为此数据了吧!
当该解码器发现实际收到的消息长度少于帧中的消息长度,那他就不会立马交给下一个Handler,他会继续接收,等收到的消息到达指定的长度后,才算接收完一个完整消息,发给下一个handler
我们的服务器如果采用此解码器解析数据,我们就需要设计应用层协议,设计一个统一的消息格式
演示:
同样在Pipeline第一个位置加入该Handler,构造方法如下,需要根据自己设定的协议构造
第一个参数为最大帧长度,后面四个就是我们前面讲过的四个字段!
客户端设计一个简单的方法,设计一个固定消息格式的协议,也就是发送消息前把该消息头部加一个长度标识。
//设计协议,把Str的数据写到buf前,把长度写入
public static void writeBuf(ByteBuf buf,String str){
byte[] bytes = str.getBytes();
buf.writeInt(bytes.length);//长度为首
buf.writeBytes(bytes);
}
发送如下数据
可以看到服务器能够正确的分割消息,但是前面的长度给我们也打印出来了,我们可以在构造方法里把lengthBytesToStrip设置为4,这时候就不会显示上面的无用字符了,反正这些都是我们根据需求设计的。
LengthFieldBasedFrameDecoder解码器特别适合我们下一篇的自定义协议消息格式,让你欣赏下它的真正威力!
下一篇讲解自定义协议设计与解析