粘包/半包
比如发送两条消息:ABC
和DEF
,正常情况对方应该是收到两条消息ABC
和DEF
。
非正常情况,对方可能一次性就收到两条消息的内容,即ABCDEF
,也可能会分多次收到,比如:AB
、CD
、EF
。
对方一次性接收了多条消息,称之为粘包现象。比如收到ABCDEF
一条消息。
对方多次接收了不完整消息,称之为半包现象。比如收到AB
、CD
、EF
三条消息。
粘包/半包原因
粘包原因
我们知道,TCP 发送消息的时候是有缓冲区的,当消息的内容远小于缓冲区的时候,这条消息不会立马发送出去,而是跟其它的消息合并之后再发送出去,这样合并发送是明显能够提高效率的。
接收消息也是会通过 TCP 的缓冲区的,如果接收方读取得不及时,也有可能出现粘包现象。
比如,缓冲区里面的ABC
还没来得及读取,又来了一条消息DEF
,这时候两条消息就合并在一起了,也就出现了粘包了。
出现粘包的两个主要原因:
- 发送方发送的消息 < 缓冲区大小
- 接收方读取消息不及时
半包原因
如果发送的消息太大,已经超过了缓冲区的大小,这时候就必须拆分发送,也就形成了半包现象。
还有一种情况,网络协议各层是有最大负载的,所以,对应到各种协议它们是有最大发送数据的限制的,这种可以发送的最大数据称作 MTU(Maximum Transmission Unit,最大传输单元)。
出现半包的两个主要原因:
- 发送方发送的消息 > 缓冲区的大小
- 发送方发送的消息 > 协议的 MTU
本质原因
TCP 是流式协议,消息无边界。
TCP 协议本身像水流一样,源源不断,完全不知道消息的边界在哪里。
UDP 协议不会出现粘包 / 半包现象,它的消息是有明确边界的,不会像 TCP 一样出现粘包 / 半包现象。
解决粘包/半包
三种常用的解决粘包 / 半包问题的方法:定长法、分割符法、长度 + 内容法。
定长法
固定长度确定消息的边界,比如传输的消息分别为ABC
、D
、EF
。
就找最长的那条消息,这里是ABC
,那就以 3 为固定长度,不足三位的补足三位。
发送三条消息:ABC
、D
、EF
发送方缓冲区:ABCDXXEFX
接收方缓冲区:ABCDXXEFX
接收到三条消息:ABC
、D
、EF
这种方式最大的缺点就是浪费空间,所以不推荐。
分隔符法
使用固定的分割符分割消息,比如传输的消息分别为ABC
、DEFG
、HI\n
,假如使用\n
作为分割符。
发送三条消息:ABC
、DEFG
、HI\n
发送方缓冲区:ABC\nDEFG\nHI\n\n
接收方缓冲区:ABC\nDEFG\nHI\n\n
接收到三条消息:ABC
、DEFG
、HI\n
那么,就在消息的边界处加一个\n
作为分割符,这样接收方拿到消息之后使用 \n
去分割消息即可。
这种方式的缺点:一是分割符本身作为传输内容时要转义,二是要扫描消息的内容才能确定消息的边界在哪里,所以也不是特别推荐。
长度+内容法
固定的字节数存储消息的长度,后面跟上消息的内容,读取消息的时候先读取长度,再一次性把消息的内容读取出来。
比如,传输的消息分别为 ABC
、DEFG
、HI
。
那么,就在消息前面分别加上长度一起传输,后面再跟上内容,这样即使三条消息一起传输也可以分得清清楚楚。
发送三条消息:ABC
、DEFG
、HI
发送方缓冲区:3ABC4DEFG2HI
接收方缓冲区:3ABC4DEFG2HI
接收到三条消息:ABC
、DEFG
、HI
这种方式的缺点是需要预先知道消息的最大长度。
比较
方法 | 如何确定消息边界 | 优点 | 缺点 | 推荐度 |
---|---|---|---|---|
定长法 | 使用固定长度分割消息 | 简单 | 空间浪费 | 不推荐 |
分割符法 | 使用固定分割符分割消息 | 简单 | 分割符本身需要转义,且需要扫描消息的内容 | 不特别推荐 |
长度 + 内容法 | 先获取消息的长度,再按长度读取内容 | 精确获取消息的内容 | 需要预先知道消息的最大长度 | 推荐 |
Netty解决粘包/半包
方法 | 编码 | 解码 |
---|---|---|
定长法 | 无 | FixedLengthFrameDecoder |
分割符法 | 无 | DelimiterBasedFrameDecoder |
长度 + 内容法 | LengthFieldPrepender | LengthFieldBasedFrameDecoder |
定长法和分割符法没有编码对应的类,Netty没有实现。
使用的时候只需要在 childHandler 中添加一个解码器就可以了。
省略......
.childHandler(new ChannelInitializer<SocketChannel>() {
@Override
public void initChannel(SocketChannel ch) throws Exception {
ChannelPipeline p = ch.pipeline();
// 可以添加多个子Handler
p.addLast(new LoggingHandler(LogLevel.INFO));
// 只需要改这一个地方就可以了
// 添加固定长度解码器,长度为3
// p.addLast(new FixedLengthFrameDecoder(3));
// 添加分割符法解码器, 设置最大长度为10, 分隔符为#
// p.addLast(new DelimiterBasedFrameDecoder(10, Unpooled.wrappedBuffer(new byte[] {'#'})));
......
}
});
Netty编解码方式
一次编解码与二次编解码
这里以解码为例
一次解码主要用于解决粘包 / 半包的问题,将缓冲区中的字节数组按照协议本身的格式进行分割,分割后的数据还是字节数组。
分割后的字节数组如何转换成 Java 里面使用的对象?通过二次解码,可以将字节数组转换成 Java 对象,然后传入我们自定义的 Handler 里面进行业务逻辑的处理。
例如:使用固定长度为3的解码器,缓冲区的消息内容为123456
- 运用一次解码将
123456
的字节数组拆分成123
和456
的字节数组 - 运用二次解码将
123
和456
字节数组转换成Java
的String
类型的对象
Netty定义了下面两组类来分别表示一次编解码和二次编解码:
- 一次编解码:
MessageToByteEncoder
、ByteToMessageDecoder
- 二次编解码:
MessageToMessageEncoder
、MessageToMessageDecoder
服务端接收请求的过程也是先拿到字节数组(在Netty中可以理解为ByteBuf
),然后通过ByteToMessageDecoder
转换成协议格式的字节数组,再把协议格式的字节数组通过MessageToMessageDecoder
转换成Java对象。
常见的二次编解码方式
比如XML、JSON、Java 序列化等。特别是 JSON,基本上基于 Web 开发都使用 JSON 来传输数据。
还有一种序列化方式比较流行 ——Google 的Protobuf,它主要运用在客户端与服务端需要长连接的场景,比如游戏行业,另外,Go 语言中也喜欢用 Protobuf,非常方便,而且高效。
方式 | 优点 | 缺点 |
---|---|---|
serialization(优化过的 Java 序列化) | Java 原生,使用方便 | 报文太大,不便于阅读,只能 Java 使用 |
JSON | 结构清晰,便于阅读,效率较高,跨语言 | 报文较大 |
Protobuf | 使用方便,效率很高,报文很小,跨语言 | 不便于阅读 |
对于性能要求不是特别高的系统,推荐使用 JSON 这种方式的,写起来简单,看起来也简单。
如果对于性能要求比较高,推荐使用 Protobuf,性能非常高,而且也不用写多少代码,还能很好地定义客户端与服务端之间的协议。
长度内容编解码
LengthFieldPrepender
LengthFieldPrepender
继承MessageToMessageEncoder<ByteBuf>
,是一个长度前置编码器,它负责在消息的头部设置消息的长度。
/**
* 例如原始数据为12个字节
* <pre>
* +----------------+
* | "HELLO, WORLD" |
* +----------------+
* </pre>
* 消息头加入数据长度, 数据长度占用2个字节
* <pre>
* +--------+----------------+
* + 0x000C | "HELLO, WORLD" |
* +--------+----------------+
* </pre>
* If you turned on the {@code lengthIncludesLengthFieldLength} flag in the
* constructor, the encoded data would look like the following
* (12 (original data) + 2 (prepended data) = 14 (0xE)):
* <pre>
* +--------+----------------+
* + 0x000E | "HELLO, WORLD" |
* +--------+----------------+
* </pre>
*/
LengthFieldPrepender
主要有四个成员变量
public class LengthFieldPrepender extends MessageToMessageEncoder<ByteBuf> {
// 设置字节序, 默认大字端, 在缓冲区处理数据是以大字端方式, 还是以小字端方式
private final ByteOrder byteOrder;
// 数据长度所占用的字节数, 没有默认值, 必须设置
private final int lengthFieldLength;
// 默认false, 数据长度中是否包含数据长度本身的长度
private final boolean lengthIncludesLengthFieldLength;
// 默认0, 长度调整字节数, 消息体的长度等于数据长度加上长度调整字节数
private final int lengthAdjustment;
}
查看构造函数可以知道,lengthFieldLength
长度字段的值只能为 1,2,3,4,8,否则抛异常。这是因为:
-
byte
类型的数据占用 1 个字节,可以writeByte
和readByt
,一次读写一个字节。 -
short
类型的数据占用2个字节,可以writeShort
和readShort
,一次读写2个字节。 -
medium
类型的字段占用3个字节,可以writeMedium
和readMedium
,一次读写3个字节。 -
int
类型的字段占用 4 个字节,可以writeInt
和readInt
,一次读写4个字节。 -
double
类型的数据占用 8 个字节,可以writeDouble
和readDouble
,一次读写8个字节。
下面看LengthFieldPrepender
的核心方法encode
方法,看看它是如何在消息的头部加入数据长度的。
public class LengthFieldPrepender extends MessageToMessageEncoder<ByteBuf> {
// 设置字节序, 默认大字端, 在缓冲区处理数据是以大字端方式, 还是以小字端方式
private final ByteOrder byteOrder;
// 数据长度所占用的字节数, 没有默认值, 必须设置
private final int lengthFieldLength;
// 默认false, 数据长度中是否包含数据长度本身的长度
private final boolean lengthIncludesLengthFieldLength;
// 默认0, 长度调整字节数, 消息体的长度等于数据长度加上长度调整字节数
private final int lengthAdjustment;
@Override
protected void encode(ChannelHandlerContext ctx, ByteBuf msg, List<Object> out) throws Exception {
// 长度等于消息体的可读字节数加上长度调整字节数
int length = msg.readableBytes() + lengthAdjustment;
if (lengthIncludesLengthFieldLength) {
// 如果数据长度字节中包含长度本身的长度时, 把长度本身的长度也加上。
length += lengthFieldLength;
}
// 检查length不能小于0
checkPositiveOrZero(length, "length");
switch (lengthFieldLength) {
case 1:
if (length >= 256) {
throw new IllegalArgumentException(
"length does not fit into a byte: " + length);
}
// 分配1个字节分缓冲区并写入length
out.add(ctx.alloc().buffer(1).order(byteOrder).writeByte((byte) length));
break;
case 2:
if (length >= 65536) {
throw new IllegalArgumentException(
"length does not fit into a short integer: " + length);
}
// 分配2个字节分缓冲区并写入length
out.add(ctx.alloc().buffer(2).order(byteOrder).writeShort((short) length));
break;
case 3:
if (length >= 16777216) {
throw new IllegalArgumentException(
"length does not fit into a medium integer: " + length);
}
// 分配3个字节分缓冲区并写入length
out.add(ctx.alloc().buffer(3).order(byteOrder).writeMedium(length));
break;
case 4:
// 分配4个字节分缓冲区并写入length
out.add(ctx.alloc().buffer(4).order(byteOrder).writeInt(length));
break;
case 8:
// 分配8个字节分缓冲区并写入length
out.add(ctx.alloc().buffer(8).order(byteOrder).writeLong(length));
break;
default:
throw new Error("should not reach here");
}
// 将传递过来的msg写入缓存, 交给下一个编码器处理
out.add(msg.retain());
}
}
LengthFieldBasedFrameDecoder
LengthFieldBasedFrameDecoder
继承ByteToMessageDecoder
,它是一个解码器,根据消息中的长度动态拆分ByteBuf
。对设置了数据长度的消息体解析特别有用。
public class LengthFieldBasedFrameDecoder extends ByteToMessageDecoder {
// 字节序,默认大字端
private final ByteOrder byteOrder;
// 一个数据包允许的最大长度, 初始化时必须设置
private final int maxFrameLength;
// 数据长度所在位置偏移量, 从第几位开始读数据长度
private final int lengthFieldOffset;
// 数据长度所占用的字节数
private final int lengthFieldLength;
// 默认值为 0, 结束偏移量
private final int lengthFieldEndOffset;
// 默认值为 0, 长度调整字节数
private final int lengthAdjustment;
// 默认值为0, 要剥离的初始字节
private final int initialBytesToStrip;
// 快速失败, 默认 true, 如果为 true 时, 不读完数据包就抛出异常, 否则读完数据包再抛出异常
private final boolean failFast;
// 是否跳过超出存储范围的字节, 默认false
private boolean discardingTooLongFrame;
// 最长的包长
private long tooLongFrameLength;
// 需要跳过的字节数
private long bytesToDiscard;
}
在类的注释上,有很多范例,可以根据这些范例来理解这些成员变量是如何使用的。
第一种数据格式
-
解码前:数据长度(消息体的长度)+ 消息体
-
解码后:数据长度(消息体的长度)+ 消息体
/**
* <b>lengthFieldOffset</b> = <b>0</b>
* <b>lengthFieldLength</b> = <b>2</b>
* lengthAdjustment = 0
* initialBytesToStrip = 0 (= do not strip header)
*
* BEFORE DECODE (14 bytes) AFTER DECODE (14 bytes)
* +--------+----------------+ +--------+----------------+
* | Length | Actual Content |----->| Length | Actual Content |
* | 0x000C | "HELLO, WORLD" | | 0x000C | "HELLO, WORLD" |
* +--------+----------------+ +--------+----------------+
*/
解码前的协议,数据长度占用2个字节,消息体为HELLO, WORLD
字符串,消息体的长度为12个字节,因此数据长度位置填充12的十六进制数据0x000C
。
解码后的协议,解码后的协议和解析前的协议保持一致。
第二种数据格式
- 解码前:数据长度(消息体的长度)+ 消息体
- 解码后:消息体
/**
* lengthFieldOffset = 0
* lengthFieldLength = 2
* lengthAdjustment = 0
* initialBytesToStrip = 2 (= the length of the Length field)
*
* BEFORE DECODE (14 bytes) AFTER DECODE (12 bytes)
* +--------+----------------+ +----------------+
* | Length | Actual Content |----->| Actual Content |
* | 0x000C | "HELLO, WORLD" | | "HELLO, WORLD" |
* +--------+----------------+ +----------------+
*/
解码后的数据长度不见了,只剩消息体。只需要把要跳过的初始字节initialBytesToStrip
设置为数据长度所占用的字节数即可。
第三种数据格式
-
解码前:数据长度(数据长度的长度 + 消息体的长度)+ 消息体
-
解码后:数据长度(数据长度的长度 + 消息体的长度)+ 消息体
/**
* lengthFieldOffset = 0
* lengthFieldLength = 2
* lengthAdjustment = -2 (= the length of the Length field)
* initialBytesToStrip = 0
*
* BEFORE DECODE (14 bytes) AFTER DECODE (14 bytes)
* +--------+----------------+ +--------+----------------+
* | Length | Actual Content |----->| Length | Actual Content |
* | 0x000E | "HELLO, WORLD" | | 0x000E | "HELLO, WORLD" |
* +--------+----------------+ +--------+----------------+
*/
解码前和解码后的数据格式是一样的,唯一的不同是数据位的长度。消息体的长度只有12个字节,这里是14个字节,多了2个字节,多出的这2个字节是数据长度本身所占用的字节。
也就是说,数据长度位置的数据包含了数据长度本身所占用的字节数,数据长度位置的数据是整个数据包的数据长度,而不单单是消息体的长度。
在设置参数时,除了设置数据长度lengthFieldLength
所占用的字节数,还需要将数据长度调整参数lengthAdjustment
设置为-2 。
暂时先看这几种数据格式。
Demo
服务端代码
public class TestServer {
static final int PORT = 8001;
public static void main(String[] args) {
// 1. 声明线程池
EventLoopGroup bossGroup = new NioEventLoopGroup(1);
EventLoopGroup workerGroup = new NioEventLoopGroup();
try {
// 2. 服务端引导器
ServerBootstrap serverBootstrap = new ServerBootstrap();
// 3. 设置线程池
serverBootstrap.group(bossGroup, workerGroup)
// 4. 设置ServerSocketChannel的类型
.channel(NioServerSocketChannel.class)
// 5. 设置参数
.option(ChannelOption.SO_BACKLOG, 100)
// 6. 设置ServerSocketChannel对应的Handler,只能设置一个
.handler(new LoggingHandler(LogLevel.INFO))
// 7. 设置SocketChannel对应的Handler
.childHandler(new ChannelInitializer<SocketChannel>() {
@Override
public void initChannel(SocketChannel ch) throws Exception {
ChannelPipeline p = ch.pipeline();
// 可以添加多个子Handler
// p.addLast(new LoggingHandler(LogLevel.INFO));
// 解码器
// 数据包最大长度65535, 长度偏移量0, 长度2个字节, 长度调整0个字节, 要剥离2个初始字节
ch.pipeline().addLast(new LengthFieldBasedFrameDecoder(65535, 0, 2, 0, 2));
// ByteBuf转字符串
ch.pipeline().addLast(new StringDecoder(CharsetUtil.UTF_8));
// 编码器
// 数据长度占用两个字节,数据长度字段中不包含数据长度本身所占用的字节数
ch.pipeline().addLast(new LengthFieldPrepender(2));
// 字符串转ByteBuf
ch.pipeline().addLast(new StringEncoder(CharsetUtil.UTF_8));
p.addLast(new ServerHandler());
}
});
// 8. 绑定端口
ChannelFuture f = serverBootstrap.bind(PORT).sync();
// 服务端启动监听事件
f.addListener(new GenericFutureListener<Future<? super Void>>() {
public void operationComplete(Future<? super Void> future) throws Exception {
//启动成功后的处理
if (future.isSuccess()) {
System.out.println("Started Successed:" + PORT);
} else {
System.out.println("Started Failed:" + PORT);
}
}
});
// 9. 等待服务端监听端口关闭,这里会阻塞主线程
f.channel().closeFuture().sync();
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
// 10. 优雅地关闭两个线程池
bossGroup.shutdownGracefully();
workerGroup.shutdownGracefully();
}
}
}
class ServerHandler extends SimpleChannelInboundHandler<String>{
@Override
protected void channelRead0(ChannelHandlerContext ctx, String msg) throws Exception {
System.out.println(ctx.channel() + ":" + msg);
// 返回信息给客户端
ctx.writeAndFlush("已收到:" + msg);
}
}
客户端代码
public class TestClient {
static final int PORT = 8001;
public static void main(String[] args) throws Exception {
// 工作线程池
NioEventLoopGroup workerGroup = new NioEventLoopGroup();
try {
Bootstrap bootstrap = new Bootstrap();
bootstrap.group(workerGroup);
bootstrap.channel(NioSocketChannel.class);
bootstrap.handler(new ChannelInitializer<SocketChannel>() {
@Override
protected void initChannel(SocketChannel ch) throws Exception {
ChannelPipeline pipeline = ch.pipeline();
// pipeline.addLast(new LoggingHandler(LogLevel.INFO));
// 解码器
// 数据包最大长度65535, 长度偏移量0, 长度2个字节, 长度调整0个字节, 要剥离2个初始字节
pipeline.addLast(new LengthFieldBasedFrameDecoder(65535, 0, 2, 0, 2));
// ByteBuf转字符串
pipeline.addLast(new StringDecoder(CharsetUtil.UTF_8));
// 编码器
// 数据长度占用两个字节,数据长度字段中不包含数据长度本身所占用的字节数
pipeline.addLast(new LengthFieldPrepender(2));
// 字符串转ByteBuf
pipeline.addLast(new StringEncoder(CharsetUtil.UTF_8));
pipeline.addLast(new ClientHandler());
}
});
// 连接到服务端
ChannelFuture future = bootstrap.connect(new InetSocketAddress(PORT)).sync();
System.out.println("connect to server success");
// 调用后这里会阻塞
// future.channel().closeFuture().sync();
// 这里实现可以在控制台输入发送信息
Channel channel = future.channel();
Scanner scanner = new Scanner(System.in);
while (true) {
String msg = scanner.nextLine();
channel.writeAndFlush(Unpooled.copiedBuffer(msg, CharsetUtil.UTF_8));
if ("quit".equals(msg)) {
channel.close();
break;
}
}
} finally {
workerGroup.shutdownGracefully();
}
}
}
class ClientHandler extends SimpleChannelInboundHandler<String> {
@Override
protected void channelRead0(ChannelHandlerContext ctx, String msg) throws Exception {
System.out.println(ctx.channel() + ":" + msg);
}
}