Netty 粘包和拆包
起因
TCP 是面向 "字节流" 的一个协议,管发送,不管分隔,管杀不管埋。他可以保证你消息的可靠性,像自来水一样,打开~关闭,滔滔不绝。但是由于他并没有包的概念,所以我们在进行网络编程的时候的,肯定要想办法把这连绵不绝的字节流分隔成我们需要的数据包,这便是江湖生盛传已久的拆包问题了(其实我感觉这么叫并不恰当,但是大家都这么叫就这么叫吧)。话不多说,来看下怎么弄。
问题描述
比方说你客户端,往服务端发发了一个 AAAA,然后再发一个 BBBB。可能会出现下面5种情况
- ------AAAA--------BBBB------
- ----------------AAAABBBB----
- ------AAAABB---------BB-----
- ------AAA---------ABBBB-----
- -----AA----AA----BB---BB----
恩。。。等等情况吧
原因分析
由于 TCP/IP 协议的一些优化策略吧,具体我也不是很懂,等以后弄懂了再回来补充。
问题复现
服务端代码
import io.netty.bootstrap.ServerBootstrap;
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.string.StringDecoder;
import io.netty.handler.codec.string.StringEncoder;
import io.netty.handler.logging.LogLevel;
import io.netty.handler.logging.LoggingHandler;
import io.netty.util.CharsetUtil;
/**
* @author Sean Wu
*/
public class Main {
public static void main(String[] args) throws Exception {
// Configure the server.
EventLoopGroup bossGroup = new NioEventLoopGroup(1);
EventLoopGroup workerGroup = new NioEventLoopGroup();
try {
ServerBootstrap b = new ServerBootstrap();
b.group(bossGroup, workerGroup)
.channel(NioServerSocketChannel.class)
.handler(new LoggingHandler(LogLevel.DEBUG))
.childHandler(new ChannelInitializer<SocketChannel>() {
@Override
public void initChannel(SocketChannel ch) throws Exception {
ChannelPipeline p = ch.pipeline();
p.addLast(new StringDecoder(CharsetUtil.UTF_8));
p.addLast(new StringEncoder(CharsetUtil.UTF_8));
p.addLast(new ChannelInboundHandlerAdapter() {
@Override
public void channelRead(ChannelHandlerContext ctx, Object msg) {
System.out.println("收到消息" + msg.toString());
}
@Override
public void channelReadComplete(ChannelHandlerContext ctx) {
ctx.flush();
}
});
}
});
ChannelFuture f = b.bind(6688).sync();
f.channel().closeFuture().sync();
} finally {
bossGroup.shutdownGracefully();
workerGroup.shutdownGracefully();
}
}
}
发送的客户端代码
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;
/**
* @author Sean Wu
*/
public class TestClient {
public static void main(String[] args) throws Exception {
NioEventLoopGroup group = new NioEventLoopGroup();
Bootstrap b = new Bootstrap();
b.group(group)
.channel(NioSocketChannel.class)
.handler(new ChannelInitializer<SocketChannel>() {
protected void initChannel(SocketChannel ch) throws Exception {
ChannelPipeline p = ch.pipeline();
p.addLast(new ChannelInboundHandlerAdapter() {
@Override
public void channelActive(ChannelHandlerContext ctx) throws Exception {
String[] msgs = new String[]{"AAAA", "BBBB"};
ByteBuf message = null;
for (int i = 0; i < 350; i++) {
message = Unpooled.buffer(4);
message.writeBytes(msgs[i % 2].getBytes());
ctx.writeAndFlush(message);
System.out.println("send msg:" + msgs[i % 2]);
}
}
});
}
});
ChannelFuture f = b.connect("127.0.0.1", 6688).sync();
f.channel().closeFuture().sync();
group.shutdownGracefully();
}
}
从结果上,我们可以看到的发送端是在正经发送的,但是服务端就比较奇怪了。那么怎么办呢,继续往下看。
解决思路
- 使用定长的包,长度不足的填充0。
- 使用换行符分隔每一个包(比如FTP协议)
- 使用特殊字符分割每一个包。
- 使用消息头和消息体,消息头中包含包的长度信息。
- 自定义更复杂协议。
定长的包
上面代码里,我们发的包要么就是AAAA
要不就是BBBB
,那么我们完全可以采用定长的策略。netty 里有个叫 FixedLengthFrameDecoder 的类,很简单的就可以搞定定长这种事。只要在上面的 initChannel
里加上
p.addLast(new FixedLengthFrameDecoder(4));
就可以了。
ps. 这个一定要放在第一行。
定长填充0
那如果我们发的包没有规律的长度的包呢。这个时候我们就可以采用定长填充0的策略。最简单的将客户端的for循环改成下面的样子就可以了:
for (int i = 0; i < 350; i++) {
message = Unpooled.buffer(10);
byte[] writeBytes = msgs[i % 2].getBytes();
message.writeBytes(writeBytes);
message.writeBytes(new byte[10 - writeBytes.length]);
ctx.writeAndFlush(message);
System.out.println("send msg:" + msgs[i % 2]);
}
然后你可能要人工过滤一下填充的0。
使用特殊字符分隔
我们也可以使用特殊字符分隔我们的包,比方说换行符或者别的一些奇奇怪怪的你喜欢的字符。
特殊字符分隔
p.addLast(new DelimiterBasedFrameDecoder(10,Unpooled.copiedBuffer("~!".getBytes())));
然后我们使用telnet 来测试下。
cmd 然后输入 telnet 127.0.0.1 6688
,然后输入 Ctrl + ]
, 然后输入 set localecho
打开本地回显,然后在敲回车继续连上,然后就有如下效果了。
如果我们的输入太长会怎么样呢,我们可以试一下。
恩。。。效果如下。
或者我们也可以使用换行符做为分隔符。效果上没什么差,用 new LineBasedFrameDecoder()
就好了。
自定义协议
上面两种方法都有明显的缺陷,最好么,还是定一个自己的协议靠谱。话不多说,赶紧来自定义一个协议。
+-------+--------+-------+
| 包头 | 长度 | 内容 |
+-------+--------+-------+
随手一写,一个完美的协议就被我们定好了。那么包头我们用一个特殊的int来表示 0xCAFEBABE
(这里只是随便选了一个,不要学这个头,容易出问题).然后我们建个协议类如下
public class SimpleProtocol {
public static int PROTOCOL_HEAD = 0XCAFEBABE;
private byte[] content;
public SimpleProtocol(byte[] content) {
this.content = content;
}
public int getLength(){
return content.length;
}
public byte[] getContent() {
return content;
}
public SimpleProtocol setContent(byte[] content) {
this.content = content;
return this;
}
@Override
public String toString() {
return "SimpleProtocol{" +
"content=" + new String(content) +
'}';
}
}
很简单的一个协议类,可以返回内容和长度。然后我们在写一个编码器
public class SimpleProtocolEncode extends MessageToByteEncoder<SimpleProtocol> {
@Override
protected void encode(ChannelHandlerContext ctx, SimpleProtocol msg, ByteBuf out) throws Exception {
// 写入包头
out.writeInt(SimpleProtocol.PROTOCOL_HEAD)
// 写入长度
.writeInt(msg.getLength())
// 写入内容
.writeBytes(msg.getContent());
}
}
看上去似乎也很简单,然后我们在写一个解码器
public class SimpleProtocolDecode extends ByteToMessageDecoder {
/**
* 基本长度,4个字节的包头,和4个字节的长度字段
*/
private static int BASE_LENGTH = 8;
@Override
protected void decode(ChannelHandlerContext ctx, ByteBuf in, List<Object> out) throws Exception {
// 如果数据不够长的话,就 return 掉再等一等
if (in.readableBytes() <= BASE_LENGTH) {
return;
}
int bufferIndex = 0;
while (true) {
// 记录下索引,后面会用到
bufferIndex = in.readerIndex();
in.markReaderIndex();
// 如果读到包头就可以break了
if (in.readInt() == SimpleProtocol.PROTOCOL_HEAD) {
break;
}
// 读不到包头的话还原一下标记,然后丢掉一个字节
in.resetReaderIndex();
in.readByte();
if (in.readableBytes() <= BASE_LENGTH) {
return;
}
}
// 刚才 read 了一个int,是包头
// 现在再 read 一个 int ,就是长度了
int length = in.readInt();
// 这个时候可能包的长度还没有到齐,我们要再判断一下
if (in.readableBytes() < length) {
// 没到齐的话就重置一下标记,然后等到齐了再说
in.readerIndex(bufferIndex);
return ;
}
byte[] data = new byte[length];
in.readBytes(data);
SimpleProtocol simpleProtocol = new SimpleProtocol(data);
out.add(simpleProtocol);
}
}
一个很典型的协议解码器,注释详尽,大家看注释好了,我就不多说了。然后改造我们的 Client
如下
public class TestClient {
public static void main(String[] args) throws Exception {
NioEventLoopGroup group = new NioEventLoopGroup();
Bootstrap b = new Bootstrap();
b.group(group)
.channel(NioSocketChannel.class)
.handler(new ChannelInitializer<SocketChannel>() {
protected void initChannel(SocketChannel ch) throws Exception {
ChannelPipeline p = ch.pipeline();
// 由于只发消息,所以我们只加一个编码器
p.addLast(new SimpleProtocolEncode());
p.addLast(new ChannelInboundHandlerAdapter() {
@Override
public void channelActive(ChannelHandlerContext ctx) throws Exception {
String[] msgs = new String[]{"A", "BB", "CCC", "DDDD"};
for (int i = 0; i < 250; i++) {
byte[] writeBytes = msgs[i % 4].getBytes();
SimpleProtocol simpleProtocol = new SimpleProtocol(writeBytes);
ctx.writeAndFlush(simpleProtocol);
System.out.println("send msg:" + msgs[i % 2]);
}
}
});
}
});
ChannelFuture f = b.connect("127.0.0.1", 6688).sync();
f.channel().closeFuture().sync();
group.shutdownGracefully();
}
}
由于我们的简易Client只发送消息而不接收消息,所以我们加个编码器就可以了。然后我们发 250 次的消息(一个A两个B三个C和四个D),测试是否会出现粘包拆包的情况(没有特殊处理肯定是会的)。
然后我们改造我们的服务端如下
public class Main {
public static void main(String[] args) throws Exception {
// Configure the server.
EventLoopGroup bossGroup = new NioEventLoopGroup(1);
EventLoopGroup workerGroup = new NioEventLoopGroup();
try {
ServerBootstrap b = new ServerBootstrap();
b.group(bossGroup, workerGroup)
.channel(NioServerSocketChannel.class)
.handler(new LoggingHandler(LogLevel.DEBUG))
.childHandler(new ChannelInitializer<SocketChannel>() {
@Override
public void initChannel(SocketChannel ch) throws Exception {
ChannelPipeline p = ch.pipeline();
p.addLast(new SimpleProtocolDecode());
p.addLast(new StringDecoder(CharsetUtil.UTF_8));
p.addLast(new ChannelInboundHandlerAdapter() {
@Override
public void channelRead(ChannelHandlerContext ctx, Object msg) {
SimpleProtocol body = (SimpleProtocol) msg;
System.out.println("收到消息" + body.toString());
}
@Override
public void channelReadComplete(ChannelHandlerContext ctx) {
ctx.flush();
}
});
}
});
ChannelFuture f = b.bind(6688).sync();
f.channel().closeFuture().sync();
} finally {
bossGroup.shutdownGracefully();
workerGroup.shutdownGracefully();
}
}
}
然后运行的服务端,在运行我们的客户端。
可以看到,很完美。
其他
怎么说呢,除了上面这些经常被人提起的编码器,netty 还有一些有意思的编码器,比如JsonObjectDecoder
,Http2FrameCodec
,XmlFrameDecoder
等等,都是对已知的对象的一种封装,大概开发的时候可以去看看ByteToMessageDecoder
的子类,如果netty已经有提供你需要的解码器的话(基本常见的都有的),就可以偷懒不用再自己写了。