每日一句
会当凌绝顶,一览众山小——杜甫《望岳》
回顾
上一章咱们用了大量篇幅来讲解netty的入门代码,当然也碍于笔者水平有限,可能有些地方解释的还不是很清晰,不知您理解的怎么样。可能您已经大概参透了Netty编程的的固有套路,也可能是还处于有些懵的状态,都没关系,这一章我们再来练习一个demo,是我摘自官方netty-example依赖中的一个例子,算是回顾复习,然后结合这个例子顺带再引申出这一章的重点内容,也就是使用tcp协议通信时在应用层会出现的半拆包/粘包问题。
给老铁们先介绍一下粘拆包
所谓半包粘包,就是说我们在做网络通信的时候,依靠的其实是底层操作系统中的socket函数,socket函数又提供了两个最核心的send和receive方法,你就完全可以把它理解为一个无情的搬砖机器,所负责的工作就是接到远方数据,转给上层(应用程序/用户进程),接着干活,接到远方数据,转给上层,接着干活,或者是反过来接到本机数据,发给网卡,接着干活,接到本机数据,发给网卡,接着干活,就是这样周而复始,无限循环。而这里边有一个很重要的点,那就是它不会去理解我们上层进程中传输的业务数据到底是什么,而是如水一般,把消息进行流式化处理,不知道在哪开始也不知道到哪里算是结束,不管你发的是一个非常重要的超级大客户的订单信息,还是1句简单的聊天回复消息“好的,收到”,它都不会去在意,它就只是按照自己的工作机制不停的收发。
那刚才也说了,tcp是不会理会消息到底是什么的,也不会理会消息具体有多长,它只在乎可靠的把消息发送出去,以及结合自己缓冲区的大小,把包进行自认为合理的形式进行划分,所以转化到业务层面就会出现这样一种现象:一个完整的包会被TCP拆分成多个包进行发送(意味着您的包太大了,TCP要进行拆解),也有可能把多个小包封装成一个大的数据包进行合并发送(这是TCP自带的一种优化算法),这就是所谓的TCP粘包和拆包问题。
你可能会说这不是胡闹吗,这TCP也太自以为是了,一条就是一条,两条就是两条,这么个玩法,那不全都乱套了,是的,如果业务层自己不处理的话,那确实会乱套,所以这个事也就需要单独拿出来说道说道。到底该怎么解决它。可能现在说的还是太抽象,不够具象,没关系,一会我会上动图给你看。加深理解。
请注意,TCP粘包拆包问题与Netty是没有关系的,就是说当你在编写网络程序时,无论你使用的是BIO,NIO,还是Netty,只要你不在代码中加以处理来规避这个问题,那这个问题始终都是存在的。
再提一嘴,我这里在文中涉及的一些网络知识只是九牛一毛,冰山一角,都只是一些和服务端开发相关的,最最基础的内容,其实如果展开了说的话,TCP协议,UDP协议,socket函数,缓冲区大小,窗口机制等等,哪个话题都够说上个三天三夜,所以如果您想继续深入学习的话,可以查阅官方网站,或者一些权威的经典书籍来学习。比如tcp指南网, TCP/IP指南卷1, TCP/IP网络编程, Java TCP/IP Socket编程 等等。
赶紧举个栗子
我使用Bio模式编写了一个服务端和一个客户端,逻辑其实很简单,客户端只负责发送两条消息,服务端启动监听,然后当接收到客户端发送过来的消息时在控制台输出即可,按照正常逻辑分析,服务端应该打印出两句话。但事实却与分析的结果偏差很大。
server
import java.io.IOException;
import java.io.InputStream;
import java.net.ServerSocket;
import java.net.Socket;
public class BIOServer {
public static void main(String[] args) throws IOException {
// 创建 ServerSocket, 服务端监听5678端口
ServerSocket serverSocket = new ServerSocket(5678);
System.out.println("======服务器启动======");
while (true) {
final Socket socket = serverSocket.accept();
new Thread(() -> handler(socket)).start();
}
}
public static void handler(Socket socket) {
byte[] bytes = new byte[4096];
try {
// 某一条线程正在处理
System.out.println("【收到客户端发来的1条消息: 】");
InputStream inputStream = socket.getInputStream();
// 这一行代码也会阻塞,直到客户端发来数据,经历了网卡 --> 内核 --> 最后拷贝给这个JAVA进程,这行代码才会得到数据
int read = inputStream.read(bytes);
System.out.println(new String(bytes, 0, read));
} catch (IOException e) {
e.printStackTrace();
} finally {
System.out.println(">>>>关闭和client的连接");
}
}
client
import java.io.IOException;
import java.io.OutputStream;
import java.io.PrintStream;
import java.net.Socket;
public class BioClient {
public static void main(String[] args) throws IOException {
Socket socket = new Socket("localhost", 5678);
OutputStream outputStream = socket.getOutputStream();
PrintStream ps = new PrintStream(outputStream);
String info1 = "今天天气不错。";
byte[] bytes = info1.getBytes();
ps.write(bytes);
ps.flush();
String info2 = "要一起去公园吗?";
byte[] bytes2 = info2.getBytes();
ps.write(bytes2);
ps.flush();
ps.close();
outputStream.close();
socket.close();
}
运行效果
好了,如你所见,我发了2条消息,但是却被识别成了1条消息,正所谓你跺你也麻,就是说不出意外的话,这一小段代码放在你的机器上执行肯定也是一样的结果:消息被偷偷的合二为一了。
再看一段Netty的例子
这原本是一段来自netty-example包中的一个入门例程,作用也是用来快速理解netty的编程范式,熟悉编程套路,被我给稍加改动了一下,拿来复现粘包问题。
client
import io.netty.bootstrap.Bootstrap;
import io.netty.buffer.ByteBuf;
import io.netty.buffer.Unpooled;
import io.netty.channel.*;
import io.netty.channel.nio.NioEventLoopGroup;
import io.netty.channel.socket.SocketChannel;
import io.netty.channel.socket.nio.NioSocketChannel;
public final class EchoClient {
public static void main(String[] args) throws Exception {
EventLoopGroup group = new NioEventLoopGroup();
try {
Bootstrap b = new Bootstrap();
b.group(group)
.channel(NioSocketChannel.class)
.handler(new ChannelInitializer<SocketChannel>() {
@Override
public void initChannel(SocketChannel ch) throws Exception {
ChannelPipeline p = ch.pipeline();
p.addLast(new EchoClientHandler());
}
});
ChannelFuture f = b.connect("127.0.0.1", 8007).sync();
f.channel().closeFuture().sync();
} finally {
group.shutdownGracefully();
}
}
static class EchoClientHandler extends ChannelInboundHandlerAdapter {
@Override
public void channelActive(ChannelHandlerContext ctx) {
ByteBuf byteBuf1 = Unpooled.copiedBuffer(("你好呀!").getBytes());
ctx.writeAndFlush(byteBuf1);
ByteBuf byteBuf2 = Unpooled.copiedBuffer(("小明!").getBytes());
ctx.writeAndFlush(byteBuf2);
ByteBuf byteBuf3 = Unpooled.copiedBuffer(("我是阿强!").getBytes());
ctx.writeAndFlush(byteBuf3);
System.out.println("链接建立完毕,3条消息发送完毕");
}
@Override
public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) {
ctx.close();
}
}
}
server
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.SocketChannel;
import io.netty.channel.socket.nio.NioServerSocketChannel;
import static java.nio.charset.StandardCharsets.UTF_8;
public final class EchoServer {
public static void main(String[] args) throws Exception {
EventLoopGroup bossGroup = new NioEventLoopGroup(1);
EventLoopGroup workerGroup = new NioEventLoopGroup();
final EchoServerHandler serverHandler = new EchoServerHandler();
try {
ServerBootstrap b = new ServerBootstrap();
b.group(bossGroup, workerGroup)
.channel(NioServerSocketChannel.class)
.childHandler(new ChannelInitializer<SocketChannel>() {
@Override
public void initChannel(SocketChannel ch) throws Exception {
ChannelPipeline p = ch.pipeline();
p.addLast(serverHandler);
}
});
ChannelFuture f = b.bind(8007).sync();
System.out.println(">>>>服务端启动完成,监听8007端口");
f.channel().closeFuture().sync();
} finally {
bossGroup.shutdownGracefully();
workerGroup.shutdownGracefully();
}
}
@ChannelHandler.Sharable
static class EchoServerHandler extends ChannelInboundHandlerAdapter {
@Override
public void channelRead(ChannelHandlerContext ctx, Object msg) {
String str = "";
if (msg instanceof String) {
str = (String) msg;
} else {
ByteBuf byteBuf = (ByteBuf) msg;
byte[] bytes = new byte[byteBuf.readableBytes()];
byteBuf.getBytes(byteBuf.readerIndex(), bytes);
str = new String(bytes, UTF_8);
}
System.out.println(">>>>收到客户端发送过来的消息: " + str);
}
@Override
public void channelReadComplete(ChannelHandlerContext ctx) {
ctx.flush();
}
@Override
public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) {
ctx.close();
}
@Override
public void channelInactive(ChannelHandlerContext ctx) throws Exception {
System.out.println("断开连接");
super.channelInactive(ctx);
}
}
}
运行效果
综上,就是所谓的粘包现象了。拆包现象也是同理,您就准备一段几万字符的消息,然后发给服务端,看看服务端接收到的是不是其中的部分消息就行了,碍于篇幅原因,笔者就先不展示了。
再把问题细化一下
假设客户端需要连续的发给服务端两条消息M1和M2,底层通信函数就相当于拿到了2个数据包,那么这时就会发生5种情况:
- 服务端分两次收到了两个独立的包,分别就是M1和M2,没有任何异常现象;
- 服务端只收到了1条消息,M1和M2粘合在一起了,也就是TCP粘包;
- 服务端分2次读取到了2个数据包,第1个包是完整的M1以及M2的部分内容,第2个包是M2的剩余内容;
- 服务端还是分2次收到2个包,但第1次是M1的部分内容,第2次M1的剩余内容加上M2的完整内容;
- 服务端分了多次才把这2个包接收完整,每次都是只接收到1个包的一点点内容部分。
剖析一下发生这个问题的原因
- 需要发送的消息的长度,大于了socket(套接字)发送缓冲区的大小,那就必定会被拆分发送;
- TCP双方建立通信之后对最大报文段长度的设置(MSS);
- 当数据包大于MTU时,IP协议对数据进行的分片处理;
笔者再唠叨一句,虽然在说原因时只有这简简单单的3句话,但是其中的各种名词,背后的TCP/IP知识体系,每一个拿出来都是一个大部头,都是构成我们每天所离不开的互联网的基石,重要性不言而喻,您要是对文中的名词有不理解,还是那句话,没关系,查阅资料,弄懂为止。
Netty提供的解决方案
好了,问题的提出,问题的复现,问题的原因,问题的细化我们都讲完了,接下来也该解决了。
先不说代码,先拍脑袋想几个解决办法
- 把消息的长度固定化,结合业务,定义一个足够装得下的长度,比如就是200字节,或者500字节等等,剩了位置就补空格即可,这样每次收到消息之后截取一下即可,比如只拿前200字节,后边部分的那就属于下一条消息了,或者不够200字节的话就先缓存,等收到下一条消息凑够200字节了之后再转给业务层进行处理;
- 在每条消息末尾都加个标识符,比如回车符,或者两个@@,4个$$,都可以,只要客户端服务端约定好即可,那就是也不用管消息长度是多少了,只需找到约定好的特殊符号即可,就可以把每次的完整消息都准确的截取出来;
这种策略有个前提,那就是消息内容本身不能包含标识符,否则还是会出现截取错乱的情况。
- 将消息区分为消息头和消息体,把本次消息的总消息长度封装到消息头中,然后先解析消息头,再截取消息体,这样就可以实现去识别动态长度的消息内容了
抛砖引玉
本篇就先只介绍其中的一个工具来解决半包读写的问题,使用方案2提到的回车标识符来解决,代码的改造也很简单,只需要在pipeline中添加LineBasedFrameDecoder和StringDecoder两个解码器即可,这是netty内置的利用回车标识符来识别半包和拆包,所以在客户端发消息的时候只需要在每条消息后边加上一个回车符号即可,这个解码器就会根据识别到的符号作为消息的分割标识来准确识别消息,stringdecode是一个将数据简化为字符串的工具,因为对于netty而言,所有交互的数据最终都是byteBuf的格式,如果我们的服务端和客户端已经能够完全明确交互的数据就是字符串,那我们就可以直接在其中一道处理中,将数据转化为字符串即可。好了,话不多说,端上代码。
包含半拆包处理器的server端
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.SocketChannel;
import io.netty.channel.socket.nio.NioServerSocketChannel;
import io.netty.handler.codec.LineBasedFrameDecoder;
import io.netty.handler.codec.string.StringDecoder;
import static java.nio.charset.StandardCharsets.UTF_8;
public final class EchoServer {
public static void main(String[] args) throws Exception {
EventLoopGroup bossGroup = new NioEventLoopGroup(1);
EventLoopGroup workerGroup = new NioEventLoopGroup();
final EchoServerHandler serverHandler = new EchoServerHandler();
try {
ServerBootstrap b = new ServerBootstrap();
b.group(bossGroup, workerGroup).channel(NioServerSocketChannel.class).childHandler(new ChannelInitializer<SocketChannel>() {
@Override
public void initChannel(SocketChannel ch) throws Exception {
ChannelPipeline p = ch.pipeline();
p.addLast(new LineBasedFrameDecoder(1024));
p.addLast(new StringDecoder());
p.addLast(serverHandler);
}
});
ChannelFuture f = b.bind(8007).sync();
System.out.println("netty服务端启动成功");
f.channel().closeFuture().sync();
} finally {
bossGroup.shutdownGracefully();
workerGroup.shutdownGracefully();
}
}
@ChannelHandler.Sharable
static class EchoServerHandler extends ChannelInboundHandlerAdapter {
@Override
public void channelRead(ChannelHandlerContext ctx, Object msg) {
String str = "";
if (msg instanceof String) {
str = (String)msg;
} else {
ByteBuf byteBuf = (ByteBuf) msg;
byte[] bytes = new byte[byteBuf.readableBytes()];
byteBuf.getBytes(byteBuf.readerIndex(), bytes);
str = new String(bytes, UTF_8);
}
System.out.println("收到客户端发送过来的1条消息: " + str);
}
@Override
public void channelReadComplete(ChannelHandlerContext ctx) {
ctx.flush();
}
}
}
发消息时包含回车标识符的client端
import io.netty.bootstrap.Bootstrap;
import io.netty.buffer.ByteBuf;
import io.netty.buffer.Unpooled;
import io.netty.channel.*;
import io.netty.channel.nio.NioEventLoopGroup;
import io.netty.channel.socket.SocketChannel;
import io.netty.channel.socket.nio.NioSocketChannel;
public final class EchoClient {
public static void main(String[] args) throws Exception {
EventLoopGroup group = new NioEventLoopGroup();
try {
Bootstrap b = new Bootstrap();
b.group(group).channel(NioSocketChannel.class).handler(new ChannelInitializer<SocketChannel>() {
@Override
public void initChannel(SocketChannel ch) throws Exception {
ChannelPipeline p = ch.pipeline();
p.addLast(new EchoClientHandler());
}
});
ChannelFuture f = b.connect("127.0.0.1", 8007).sync();
f.channel().closeFuture().sync();
} finally {
group.shutdownGracefully();
}
}
static class EchoClientHandler extends ChannelInboundHandlerAdapter {
@Override
public void channelActive(ChannelHandlerContext ctx) {
ByteBuf byteBuf1 = Unpooled.copiedBuffer(("你好~" + "\r\n").getBytes());
ctx.writeAndFlush(byteBuf1);
ByteBuf byteBuf2 = Unpooled.copiedBuffer(("小明~" + "\r\n").getBytes());
ctx.writeAndFlush(byteBuf2);
ByteBuf byteBuf3 = Unpooled.copiedBuffer(("我是阿强~" + "\r\n").getBytes());
ctx.writeAndFlush(byteBuf3);
System.out.println("客户端3条消息发送完毕");
}
}
}
可以看到,代码很简洁,照比不带半拆包处理的代码,就只是多了一点点改动,但是带来的收益却是巨大的。
效果演示
小总结
本篇为了照顾各个学习阶段的读者,所以在前半部分花了大量篇幅来详细介绍半包拆包问题的现象,影响,和效果演示,最后只介绍了Netty中的一种解决方案,其实Netty还有好几种解决方案,而且也都是框架封装好的,开箱即用,咱们下一篇文章再继续学习。
好了,本篇文章到这里就结束了,希望您能有所收获,祝您生活愉快。