netty实战
1. 概述
在网络编程中,数据总是以字节的形式在流动,我们在实际开发中,总是要知道一段消息从哪开始从哪结束。解决好这个问题实际上就解决了TCP的粘包和拆包问题。
2. TCP粘包/拆包
TCP协议的字节流是有序而无实际意义的二进制,我们实际开发中给数据限定的业务意义对字节流来说是透明的,对底层TCP协议来说我们所谓的业务数据可能是一个块,也可能是分多个块传输,所以出现了粘包和拆包的问题,即粘包和拆包是依据业务数据进行的字节流的数据处理。
2.1 出现原因
出现原因一般有以下几种:
- 写入的多发出去的少
- 超出TCP协议的最大分段大小
2.2 解决办法
基于TCP协议的可靠性和有序性,实际中使用的办法有以下几种:
- 消息定长
消息长度是固定的,指定具体长度的字节值。 - 回车换行结束符
在消息末尾添加回车换行符。 - 添加消息头
在消息头中包含消息总长度或消息体长度。 - 自定义协议格式
依据实际业务指定比较复杂的协议格式。
3. Netty解码方案
3.1 消息定长
Netty提供一个定长解码器,按照定义的长度对消息自动解码。
3.1.1 FixedLengthFrameDecoder
按照定义长度对缓冲区消息自动分隔,比如缓冲区接收’A’ ‘BC’ ‘DEFG’ 'HI’四个包,假如解码器定长为3,那么消息解码为:‘ABC’ ‘DEF’ 'GHI’三个完整消息。
- decode方法
protected Object decode(
@SuppressWarnings("UnusedParameters") ChannelHandlerContext ctx, ByteBuf in) throws Exception {
// buffer可读字节小于定长返回为null
if (in.readableBytes() < frameLength) {
return null;
} else {
// buffer可读字节大于等于定长直接按照定义长度获取切片
return in.readRetainedSlice(frameLength);
}
}
实践
客户端发送’1234567890123456789012345678901234567890’数据,如果解码器指定长度为10,那么将会解码为’1234567890’ ‘1234567890’ ‘1234567890’ '1234567890’四个完整消息。
3.2 回车换行结束符、指定分隔符
3.2.1 LineBasedFrameDecoder
一个以行为单位分割的解码器,一行算是一个完整的消息。行尾标志既可以是“\n”也可以是“\r\n”。
使用该解码器的时候,字节流应使用UTF-8或者ASCII编码,字节被转换为字符后,会和低范围的ASCII码比较,UTF-8编码使用2到4字节表示中文,不会使用低范围[0…0x7F]字节值来表示多字节码点,所以该解码器完全支持UTF-8编码的字节流。
- findEndOfLine方法分析
/**
* 返回行尾标识符的索引值
* 找不到行尾标识符返回-1
*/
private int findEndOfLine(final ByteBuf buffer) {
// 获取buffer可读字节长度
int totalLength = buffer.readableBytes();
// offset记录最后一次扫描位置
// 从buffer.readerIndex() + offset位置开始,到buffer.readerIndex() + offset + totalLength - offset -1结束,查找'\n'
int i = buffer.forEachByte(buffer.readerIndex() + offset, totalLength - offset, ByteProcessor.FIND_LF);
// 如果找到'\n'位置
if (i >= 0) {
offset = 0;
// 判断是否存在'\r'字符,如果存在i-1
if (i > 0 && buffer.getByte(i - 1) == '\r') {
i--;
}
} else {
// 没找到'\n'位置,记录最后一次扫描索引位置并返回-1
offset = totalLength;
}
return i;
}
- decode方法分析
protected Object decode(ChannelHandlerContext ctx, ByteBuf buffer) throws Exception {
// 查找行尾标识,如果找不到eol=-1
final int eol = findEndOfLine(buffer);
if (!discarding) {
// 解码的数据包没有超过缓冲区最大字节
// 找到行尾标识符位置
if (eol >= 0) {
final ByteBuf frame;
// buffer中某一行的字节长度
final int length = eol - buffer.readerIndex();
// 分隔符的长度,'\r\n'结尾为2,'\n'结尾为1
final int delimLength = buffer.getByte(eol) == '\r'? 2 : 1;
// ***超过maxLength抛出异常***(1)
if (length > maxLength) {
buffer.readerIndex(eol + delimLength);
fail(ctx, length);
return null;
}
// 解码时是否去掉行尾标识符
if (stripDelimiter) {
// 读取整行数据
frame = buffer.readRetainedSlice(length);
// 跳过行尾标识符的长度,buffer的读索引增加delimLength
buffer.skipBytes(delimLength);
} else {
// 读取包含行尾标识符的整行数据
frame = buffer.readRetainedSlice(length + delimLength);
}
// 返回整包数据
return frame;
} else {
// 没找到行尾标识符
final int length = buffer.readableBytes();
// 缓冲区可读字节长度大于解码器最大长度需要做丢弃处理
if (length > maxLength) {
// 丢弃字节数赋值,等于当前可读的字节长度
discardedBytes = length;
// 缓冲区读索引值改变
buffer.readerIndex(buffer.writerIndex());
// 丢弃标识为true
discarding = true;
offset = 0;
if (failFast) {
fail(ctx, "over " + discardedBytes);
}
}
return null;
}
} else {
// 缓冲区丢弃处理,因为缓冲区中可读字节大于maxLength
if (eol >= 0) {
// 如果新的buffer找到了行尾字符,那么新的buffer里面直接修改索引值
final int length = discardedBytes + eol - buffer.readerIndex();
final int delimLength = buffer.getByte(eol) == '\r'? 2 : 1;
// 缓冲区丢弃
buffer.readerIndex(eol + delimLength);
// 变量重置
discardedBytes = 0;
discarding = false;
if (!failFast) {
fail(ctx, length);
}
} else {
// 如果还是找不到行尾结束符,继续做丢弃处理
discardedBytes += buffer.readableBytes();
buffer.readerIndex(buffer.writerIndex());
// We skip everything in the buffer, we need to set the offset to 0 again.
offset = 0;
}
return null;
}
}
注释说明:超过maxLength抛出异常(1)
我通过客户端模拟数据包多次发送一个完整的消息,只要不超过maxLength,不管将消息拆解成几部分,都能完美的处理粘包问题。如果一个完整的消息超过了maxLength,不管发送几次要么直接走该注释部分代码,要么被丢弃。
3.2.2 DelimiterBasedFrameDecoder
通过特定分隔符处理消息的一种解码器,尤其是用来处理以’NUL (0x00)’,’\r’和’\n’为分隔符的消息。另外可以按需自定义分隔符(可多个不同),如果在缓冲区找到多个分隔符,将按照最短原则处理。
如果使用’\r’和’\n’为分隔符,那么解码时会使用LineBasedFrameDecoder 进行处理。
- DelimiterBasedFrameDecoder构造器分析
public DelimiterBasedFrameDecoder(
int maxFrameLength, boolean stripDelimiter, boolean failFast, ByteBuf... delimiters) {
// 检查maxFrameLength必须大于0
validateMaxFrameLength(maxFrameLength);
// 检查delimiters既不为null也不为empty
ObjectUtil.checkNonEmpty(delimiters, "delimiters");
// 如果用'\r'或者'\n'结尾直接调用LineBasedFrameDecoder
if (isLineBased(delimiters) && !isSubclass()) {
lineBasedDecoder = new LineBasedFrameDecoder(maxFrameLength, stripDelimiter, failFast);
this.delimiters = null;
} else {
// 如果指定多个分隔符,由于分隔符可以是多个,每个分隔符可以是多字符,所以需要循环获取分隔符切片
this.delimiters = new ByteBuf[delimiters.length];
for (int i = 0; i < delimiters.length; i ++) {
ByteBuf d = delimiters[i];
validateDelimiter(d);
this.delimiters[i] = d.slice(d.readerIndex(), d.readableBytes());
}
lineBasedDecoder = null;
}
this.maxFrameLength = maxFrameLength;
this.stripDelimiter = stripDelimiter;
this.failFast = failFast;
}
- decode方法分析
protected Object decode(ChannelHandlerContext ctx, ByteBuf buffer) throws Exception {
// 如过使用'\r'和'\n'分隔
if (lineBasedDecoder != null) {
return lineBasedDecoder.decode(ctx, buffer);
}
//
int minFrameLength = Integer.MAX_VALUE;
ByteBuf minDelim = null;
// 多个分隔符最短原则逻辑处理(1),此处最为关键
/**
* 例如:Hello world!&Hello#Hel&
*/
for (ByteBuf delim: delimiters) {
int frameLength = indexOf(buffer, delim);
if (frameLength >= 0 && frameLength < minFrameLength) {
minFrameLength = frameLength;
minDelim = delim;
}
}
if (minDelim != null) {
int minDelimLength = minDelim.capacity();
ByteBuf frame;
if (discardingTooLongFrame) {
// We've just finished discarding a very large frame.
// Go back to the initial state.
discardingTooLongFrame = false;
buffer.skipBytes(minFrameLength + minDelimLength);
int tooLongFrameLength = this.tooLongFrameLength;
this.tooLongFrameLength = 0;
if (!failFast) {
fail(tooLongFrameLength);
}
return null;
}
if (minFrameLength > maxFrameLength) {
// Discard read frame.
buffer.skipBytes(minFrameLength + minDelimLength);
fail(minFrameLength);
return null;
}
if (stripDelimiter) {
frame = buffer.readRetainedSlice(minFrameLength);
buffer.skipBytes(minDelimLength);
} else {
frame = buffer.readRetainedSlice(minFrameLength + minDelimLength);
}
return frame;
} else {
if (!discardingTooLongFrame) {
if (buffer.readableBytes() > maxFrameLength) {
// Discard the content of the buffer until a delimiter is found.
tooLongFrameLength = buffer.readableBytes();
buffer.skipBytes(buffer.readableBytes());
discardingTooLongFrame = true;
if (failFast) {
fail(tooLongFrameLength);
}
}
} else {
// Still discarding the buffer since a delimiter is not found.
tooLongFrameLength += buffer.readableBytes();
buffer.skipBytes(buffer.readableBytes());
}
return null;
}
}
decode方法分析注释说明:多个分隔符最短原则逻辑处理(1)
我通过客户端发送’Hello world!&Hello#Hel&’,分隔符指定为’&‘和’#’,第一次找分隔符按照最短原则,应该’Hello world!‘是一个完整消息,再找’Hello’是一个完整消息。因为存在多个分隔符,所以需要按照分隔符循环来找缓冲区中的内容,所以需要遵循这个’the shortest frame’(最短原则)。
3.3 添加消息头
根据消息头指定的报文长度动态的分隔消息。
3.3.1 LengthFieldBasedFrameDecoder
Netty提供的该解码器特别适用于含有一个整形header表示整个消息长度的字节序列。此解码器含有多个参数,可以使用这些参数根据实际需要组合,以便于解码整个消息。
相关参数自我理解如下:
lengthFieldOffset:理解为header容量相关变量,指定0表示lengthFieldLength占header高位。指定N(非0)表示占N+lengthFieldLength字节的后lengthFieldLength位。
lengthFieldLength:header字节长度,支持1、2、3、4、8字节
lengthAdjustment:当header长度表示整段信息的长度时,即包含自己所占字节长度,需要指定一下该值纠正一下。例如客户端传递‘0x000E HELLO, WORLD’时,服务端需要使用此参数纠偏,指定-2;如果客户端传递‘0x00000F HELLO, WORLD’时,指定-3。
initialBytesToStrip:切割header的字节数
- 关键代码分析
案例参数:
服务端new LengthFieldBasedFrameDecoder(1024, 0, 2, -2, 2)入参
客户端new LengthFieldPrepender(2, true)入参,发送’HELLO, WORLD’
字节序列为:00 0e 48 45 4c 4c 4f 2c 20 57 4f 52 4c 44 |…HELLO, WORLD |
00 0e两字节表示长度(14),包含header本身
protected Object decode(ChannelHandlerContext ctx, ByteBuf in) throws Exception {
......
// 获取数据包长度
long frameLength = getUnadjustedFrameLength(in, actualLengthFieldOffset, lengthFieldLength, byteOrder);
......
// 修正数据包长度
frameLength += lengthAdjustment + lengthFieldEndOffset;
......
// never overflows because it's less than maxFrameLength
int frameLengthInt = (int) frameLength;
// 可读字节小于数据包长度,返回null
if (in.readableBytes() < frameLengthInt) {
return null;
}
......
/**
* 用案例参数,in为14字节,initialBytesToStrip为2,执行下面语句后
* in缓冲区读索引变为2
*/
in.skipBytes(initialBytesToStrip);
// readerIndex = 2
int readerIndex = in.readerIndex();
// actualFrameLength = 12
int actualFrameLength = frameLengthInt - initialBytesToStrip;
// 提取frame,frame为12字节的数据
ByteBuf frame = extractFrame(ctx, in, readerIndex, actualFrameLength);
// in设置读索引,值为14
in.readerIndex(readerIndex + actualFrameLength);
// 返回内容
return frame;
}
- demo日志
17:27:31.074 [nioEventLoopGroup-3-1] INFO io.netty.handler.logging.LoggingHandler - [id: 0x9b5c66b7, L:/127.0.0.1:8001 - R:/127.0.0.1:2211] READ: 14B
+-------------------------------------------------+
| 0 1 2 3 4 5 6 7 8 9 a b c d e f |
+--------+-------------------------------------------------+----------------+
|00000000| 00 0e 48 45 4c 4c 4f 2c 20 57 4f 52 4c 44 |..HELLO, WORLD |
+--------+-------------------------------------------------+----------------+
17:27:31.092 [nioEventLoopGroup-3-1] INFO com.ll.length.LengthHandler - this is 1 times receive client [HELLO, WORLD]
17:27:31.093 [nioEventLoopGroup-3-1] INFO io.netty.handler.logging.LoggingHandler - [id: 0x9b5c66b7, L:/127.0.0.1:8001 - R:/127.0.0.1:2211] WRITE: 12B
+-------------------------------------------------+
| 0 1 2 3 4 5 6 7 8 9 a b c d e f |
+--------+-------------------------------------------------+----------------+
|00000000| 48 45 4c 4c 4f 2c 20 57 4f 52 4c 44 |HELLO, WORLD |
+--------+-------------------------------------------------+----------------+
17:27:31.094 [nioEventLoopGroup-3-1] INFO io.netty.handler.logging.LoggingHandler - [id: 0x9b5c66b7, L:/127.0.0.1:8001 - R:/127.0.0.1:2211] FLUSH
17:27:31.095 [nioEventLoopGroup-3-1] INFO io.netty.handler.logging.LoggingHandler - [id: 0x9b5c66b7, L:/127.0.0.1:8001 - R:/127.0.0.1:2211] READ COMPLETE
3.3.2 LengthFieldPrepender
在消息前添加消息长度的编码器,以"HELLO, WORLD"(12B)为例,编码器长度指定为2,则"HELLO, WORLD"前加2字节的长度,变成14B发送。
相关参数自我理解如下:
lengthFieldLength:报文长度所占的字节数,仅支持1、2、3、4和8。
lengthIncludesLengthFieldLength:true或者false,默认false。如果为true,长度包含header字节长度(假如2字节)。以"HELLO, WORLD"为例,如果为true,长度为0x000E(14B);如果为false,长度为0x000C(12B)。
lengthAdjustment:the compensation value to add to the value of the length field(目前理解存在偏差,后期持续关注)
- 关键代码分析
/**
* 注意大小端的问题;
* 只支持1、2、3、4、8字节长度;
*/
@Override
protected void encode(ChannelHandlerContext ctx, ByteBuf msg, List<Object> out) throws Exception {
int length = msg.readableBytes() + lengthAdjustment;
if (lengthIncludesLengthFieldLength) {
length += lengthFieldLength;
}
checkPositiveOrZero(length, "length");
// 分配消息长度字节空间
switch (lengthFieldLength) {
case 1:
if (length >= 256) {
throw new IllegalArgumentException(
"length does not fit into a byte: " + 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);
}
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);
}
out.add(ctx.alloc().buffer(3).order(byteOrder).writeMedium(length));
break;
case 4:
out.add(ctx.alloc().buffer(4).order(byteOrder).writeInt(length));
break;
case 8:
out.add(ctx.alloc().buffer(8).order(byteOrder).writeLong(length));
break;
default:
throw new Error("should not reach here");
}
// 添加消息内容
out.add(msg.retain());
}
- demo日志
17:27:31.037 [nioEventLoopGroup-2-1] INFO io.netty.handler.logging.LoggingHandler - [id: 0x6e3adf1f, L:/127.0.0.1:2211 - R:/127.0.0.1:8001] WRITE: 2B
+-------------------------------------------------+
| 0 1 2 3 4 5 6 7 8 9 a b c d e f |
+--------+-------------------------------------------------+----------------+
|00000000| 00 0e |.. |
+--------+-------------------------------------------------+----------------+
17:27:31.039 [nioEventLoopGroup-2-1] INFO io.netty.handler.logging.LoggingHandler - [id: 0x6e3adf1f, L:/127.0.0.1:2211 - R:/127.0.0.1:8001] WRITE: 12B
+-------------------------------------------------+
| 0 1 2 3 4 5 6 7 8 9 a b c d e f |
+--------+-------------------------------------------------+----------------+
|00000000| 48 45 4c 4c 4f 2c 20 57 4f 52 4c 44 |HELLO, WORLD |
+--------+-------------------------------------------------+----------------+
17:27:31.039 [nioEventLoopGroup-2-1] INFO io.netty.handler.logging.LoggingHandler - [id: 0x6e3adf1f, L:/127.0.0.1:2211 - R:/127.0.0.1:8001] FLUSH
17:27:31.097 [nioEventLoopGroup-2-1] INFO io.netty.handler.logging.LoggingHandler - [id: 0x6e3adf1f, L:/127.0.0.1:2211 - R:/127.0.0.1:8001] READ: 12B
+-------------------------------------------------+
| 0 1 2 3 4 5 6 7 8 9 a b c d e f |
+--------+-------------------------------------------------+----------------+
|00000000| 48 45 4c 4c 4f 2c 20 57 4f 52 4c 44 |HELLO, WORLD |
+--------+-------------------------------------------------+----------------+
17:27:31.098 [nioEventLoopGroup-2-1] INFO com.ll.length.LengthClientHandler - this is 1 times receive server [HELLO, WORLD]
17:27:31.098 [nioEventLoopGroup-2-1] INFO io.netty.handler.logging.LoggingHandler - [id: 0x6e3adf1f, L:/127.0.0.1:2211 - R:/127.0.0.1:8001] READ COMPLETE
17:27:31.098 [nioEventLoopGroup-2-1] INFO io.netty.handler.logging.LoggingHandler - [id: 0x6e3adf1f, L:/127.0.0.1:2211 - R:/127.0.0.1:8001] FLUSH
3.4 自定义协议
举例如下(需要自己编写解码器,暂不详述):
格式:LEN(6位)+DATA
LEN:DATA的长度。若长度项不足位数,则左侧添0。
DATA:数据报文内容,采用UTF-8编码。
说明:数据包长度不含本身所占的6字节,如000006e4bda0,表明数据包长度为6,但报文长度为12。
3.5 附添加消息头demo源码
- LengthServer
public class LengthServer {
public static final Logger log = LoggerFactory.getLogger(LengthServer.class);
private final String ip = "127.0.0.1";
private final String port = "8001";
public void init(){
EventLoopGroup bossGroup = new NioEventLoopGroup(1);
EventLoopGroup workerGroup = new NioEventLoopGroup();
ServerBootstrap bs = new ServerBootstrap();
bs.group(bossGroup, workerGroup)
.channel(NioServerSocketChannel.class)
.option(ChannelOption.SO_BACKLOG, 1024)
.childHandler(new ChannelInitializer<Channel>(){
@Override
protected void initChannel(Channel ch) throws Exception {
final ChannelPipeline pipeline = ch.pipeline();
pipeline.addLast(new LoggingHandler(LogLevel.INFO));
pipeline.addLast(new LengthFieldBasedFrameDecoder(1024, 0, 2, -2, 2));
pipeline.addLast(new StringDecoder());
pipeline.addLast(new LengthHandler());
}
});
try {
ChannelFuture channelFuture = bs.bind(ip, Integer.parseInt(port)).sync();
log.info("Netty Server 启动成功! Ip: " + channelFuture.channel().localAddress().toString() + " ! ");
channelFuture.channel().closeFuture().sync();
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
bossGroup.shutdownGracefully();
workerGroup.shutdownGracefully();
}
}
}
- LengthHandler
public class LengthHandler extends ChannelInboundHandlerAdapter {
public static final Logger log = LoggerFactory.getLogger(LengthHandler.class);
int counter = 0;
@Override
public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception {
String body = (String)msg;
log.info("this is " + ++counter + " times receive client [" + body + "]");
ByteBuf echo = Unpooled.copiedBuffer(body.getBytes());
ctx.writeAndFlush(echo);
}
@Override
public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) throws Exception {
ctx.close();
}
}
- LengthClient
public class LengthClient {
public static final Logger log = LoggerFactory.getLogger(LengthClient.class);
public void connect(int port, String host) throws InterruptedException {
EventLoopGroup group = new NioEventLoopGroup();
Bootstrap bootstrap = new Bootstrap();
bootstrap.group(group)
.channel(NioSocketChannel.class)
.option(ChannelOption.TCP_NODELAY, true)
.handler(new ChannelInitializer<NioSocketChannel>() {
@Override
protected void initChannel(NioSocketChannel ch) throws Exception {
final ChannelPipeline pipeline = ch.pipeline();
pipeline.addLast(new LoggingHandler(LogLevel.INFO));
// true表示header长度包含本身,既整个消息长度
pipeline.addLast(new LengthFieldPrepender(2, true));
pipeline.addLast(new StringDecoder());
pipeline.addLast(new LengthClientHandler());
}
});
ChannelFuture future = bootstrap.connect(host, port).sync();
future.channel().closeFuture().sync();
}
public static void main(String[] args) throws InterruptedException {
new LengthClient().connect(8001, "127.0.0.1");
}
}
- LengthClientHandler
public class LengthClientHandler extends ChannelInboundHandlerAdapter {
public static final Logger log = LoggerFactory.getLogger(LengthClientHandler.class);
int counter = 0;
String req = "HELLO, WORLD";
@Override
public void channelActive(ChannelHandlerContext ctx) throws Exception {
ctx.writeAndFlush(Unpooled.copiedBuffer(req.getBytes()));
}
@Override
public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception {
log.info("this is " + ++counter + " times receive server [" + msg + "]");
}
@Override
public void channelReadComplete(ChannelHandlerContext ctx) throws Exception {
ctx.flush();
}
@Override
public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) throws Exception {
ctx.close();
}
}
4. 参考
<<Netty权威指南(第2版)>> 李林锋/著
<<Netty实战>> 何品/译
netty-all-4.1.55.Final.jar 源码