Netty应用层处理粘包以及半包

上一篇文章Netty初探记录了使用Netty开发Java NIO的过程,但是并没有处理TCP的粘包与半包。因为TCP是面向流的传输层协议,传输层并不处理粘包半包的任务,所以应用层必须处理粘包与半包的问题。本篇文章记录Netty处理粘包与半包。

粘包与半包解释

先看上图,应用层发两次消息,在TCP层可能出现很多种情况,下面是两种粘包及半包的情况。
消息一ABC可能同消息二D在一个TCP包中发向服务器。也有可能如详细第二种情况,消息一由于一些原因放在两个不同的TCP包中发送,服务端接收到的数据就为A,BCD,或者A,BC,D等。

  • 粘包
    字面意思,就是应用层的多个不同的包被传输层粘在一起发送。
  • 半包
    应用层的一个包被传输层拆成了多个不同的包发送。

粘包和半包产生的情况有如下三种原因,以下部分内容摘自Netty 中的粘包和拆包

  • Socket 缓冲区与滑动窗口

    • Socket缓冲区
      对于 TCP 协议而言,它传输数据是基于字节流传输的。应用层在传输数据时,实际上会先将数据写入到 TCP 套接字的缓冲区,当缓冲区被写满后,数据才会被写出去。每个TCP Socket 在内核中都有一个发送缓冲区(SO_SNDBUF )和一个接收缓冲区(SO_RCVBUF),TCP 的全双工的工作模式以及 TCP 的滑动窗口便是依赖于这两个独立的 buffer 以及此 buffer 的填充状态。
      SO_SNDBUF:
      进程发送的数据的时候假设调用了一个 send 方法,将数据拷贝进入 Socket 的内核发送缓冲区之中,然后 send 便会在上层返回。换句话说,send 返回之时,数据不一定会发送到对端去(和write写文件有点类似),send 仅仅是把应用层 buffer 的数据拷贝进 Socket 的内核发送 buffer 中。

      SO_RCVBUF:

      把接收到的数据缓存入内核,应用进程一直没有调用 read 进行读取的话,此数据会一直缓存在相应 Socket 的接收缓冲区内。不管进程是否读取 Socket,对端发来的数据都会经由内核接收并且缓存到 Socket 的内核接收缓冲区之中。read 所做的工作,就是把内核缓冲区中的数据拷贝到应用层用户的 buffer 里面,仅此而已。

      接收缓冲区保存收到的数据一直到应用进程读走为止。对于 TCP,如果应用进程一直没有读取,buffer 满了之后发生的动作是:通知对端 TCP 协议中的窗口关闭。这个便是滑动窗口的实现。保证 TCP 套接口接收缓冲区不会溢出,从而保证了 TCP 是可靠传输。因为对方不允许发出超过所通告窗口大小的数据。 这就是 TCP 的流量控制,如果对方无视窗口大小而发出了超过窗口大小的数据,则接收方 TCP 将丢弃它。

    • 滑动窗口
      TCP连接在三次握手的时候,会将自己的窗口大小(window size)发送给对方,其实就是 SO_RCVBUF 指定的值。之后在发送数据的时,发送方必须要先确认接收方的窗口没有被填充满,如果没有填满,则可以发送。

      每次发送数据后,发送方将自己维护的对方的 window size 减小,表示对方的 SO_RCVBUF 可用空间变小。

      当接收方处理开始处理 SO_RCVBUF 中的数据时,会将数据从 Socket 在内核中的接受缓冲区读出,此时接收方的 SO_RCVBUF 可用空间变大,即 window size 变大,接受方会以 ack 消息的方式将自己最新的 window size 返回给发送方,此时发送方将自己的维护的接受的方的 window size 设置为ack消息返回的 window size。

      此外,发送方可以连续的给接受方发送消息,只要保证对方的 SO_RCVBUF 空间可以缓存数据即可,即 window size>0。当接收方的 SO_RCVBUF 被填充满时,此时 window size=0,发送方不能再继续发送数据,要等待接收方 ack 消息,以获得最新可用的 window size。

  • MSS/MTU限制
    MTU (Maxitum Transmission Unit,最大传输单元)是链路层对一次可以发送的最大数据的限制。MSS(Maxitum Segment Size,最大分段大小)是 TCP 报文中 data 部分的最大长度,是传输层对一次可以发送的最大数据的限制。
    受MSS(1460 bytes)/MTU(1500 bytes)的影响,当发送缓冲区中待发送数据大于MSS大小时,将会产生拆包。

  • Nagle算法
    TCP/IP 协议中,无论发送多少数据,总是要在数据(data)前面加上协议头(TCP Header+IP Header),同时,对方接收到数据,也需要发送 ACK 表示确认。

    即使从键盘输入的一个字符,占用一个字节,可能在传输上造成 41 字节的包,其中包括 1 字节的有用信息和 40 字节的首部数据。这种情况转变成了 4000% 的消耗,这样的情况对于重负载的网络来是无法接受的。称之为”糊涂窗口综合征”。

    为了尽可能的利用网络带宽,TCP 总是希望尽可能的发送足够大的数据。(一个连接会设置 MSS 参数,因此,TCP/IP 希望每次都能够以 MSS 尺寸的数据块来发送数据)。Nagle 算法就是为了尽可能发送大块数据,避免网络中充斥着许多小数据块。

    Nagle 算法的基本定义是任意时刻,最多只能有一个未被确认的小段。 所谓 “小段”,指的是小于 MSS 尺寸的数据块;所谓“未被确认”,是指一个数据块发送出去后,没有收到对方发送的 ACK 确认该数据已收到。

Netty模拟粘包及半包

我们使用之前Netty初探的案例模拟一下TCP的粘包及半包。服务端主要用于接收打印,客户端连接服务端成功后循环发送数据包,代码如下

  • 服务端代码
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
public class NettyServerHandler extends ChannelInboundHandlerAdapter {

Logger logger = Logger.getLogger(NettyServerHandler.class.getName());

@Override
public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception {
// 读数据
ByteBuf buf = (ByteBuf) msg;
byte[] bytes = new byte[buf.readableBytes()];
buf.readBytes(bytes);
String readMsg = new String(bytes, StandardCharsets.UTF_8);
logger.info("服务端接收到消息: "+readMsg);
ReferenceCountUtil.release(msg);
// ByteBuf writeBuf = Unpooled.copiedBuffer((readMsg+"你好").getBytes(StandardCharsets.UTF_8));
// ctx.writeAndFlush(writeBuf);
}

@Override
public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) throws Exception {
ctx.close();
logger.info("服务端发生异常关闭连接");
}
}

  • 客户端代码
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
public class NettyClientHandler extends ChannelInboundHandlerAdapter {
Logger logger = Logger.getLogger(NettyClientHandler.class.getName());
private ByteBuf buf;

public NettyClientHandler() {
}

@Override
public void channelActive(ChannelHandlerContext ctx) throws Exception {
// 连接成功后发送消息
while (true) {
sendMsg(ctx);
}
}

@Override
public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception {
ByteBuf readBuf = (ByteBuf) msg;
byte[] data = new byte[readBuf.readableBytes()];
readBuf.readBytes(data);
String readMsg = new String(data,StandardCharsets.UTF_8);
logger.info("客户端接收到消息: "+readMsg);
ReferenceCountUtil.release(msg);
sendMsg(ctx);
}

@Override
public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) throws Exception {
ctx.close();
logger.info("客户端发生异常关闭连接...");
}

public void sendMsg(ChannelHandlerContext ctx){
String msg = "官方指南假设你已了解关于 HTML、CSS 和 JavaScript 的中级知识。";
byte[] sendData = msg.getBytes(StandardCharsets.UTF_8);
buf = Unpooled.buffer(sendData.length);
buf.writeBytes(sendData);
if (ctx!=null) {
ctx.writeAndFlush(buf);
}
}
}

运行效果如下:

Netty解决粘包与拆包的方案

定长补齐

比如我们设置消息长度为10个字节,如果发送消息100L,占用8个字节,剩余两个字节就使用空格补齐,这样服务端和客户端就可以每次读取10个字节。不会导致应用层读取到别的应用层包。定长补齐方案简单,但是适用场景很少,浪费带宽。Netty中使用FixedLengthFrameDecoder实现了定长补齐功能。FixedLengthFrameDecoder原理如下

1
2
3
4
5
6
7
8
9
将接收到的ByteBuf按固定字节数分割的解码器。例如,如果您收到以下四个碎片数据包:
+---+----+------+----+
| A | BC | DEFG | HI |
+---+----+------+----+

FixedLengthFrameDecoder (3)会将它们解码为以下三个固定长度的数据包:
+-----+-----+-----+
| ABC | DEF | GHI |
+-----+-----+-----+

代码实现

  • 服务端代码

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    public class StickyServer {
    public void run(int port){
    EventLoopGroup acceptGroup = new NioEventLoopGroup();
    EventLoopGroup rwGroup = new NioEventLoopGroup();
    ServerBootstrap bootstrap = new ServerBootstrap();
    ChannelFuture future;
    try {
    bootstrap.group(acceptGroup,rwGroup).channel(NioServerSocketChannel.class)
    .option(ChannelOption.SO_BACKLOG,1024)
    .childOption(ChannelOption.SO_KEEPALIVE,true)
    .childHandler(new ChannelInitializer<SocketChannel>() {
    @Override
    protected void initChannel(SocketChannel ch) throws Exception {
    // 固定长度解码器
    ch.pipeline().addLast(new FixedLengthFrameDecoder(6));
    // BYTE TO STRING
    ch.pipeline().addLast(new StringDecoder());
    // 消息处理器
    ch.pipeline().addLast(new StickyServerFixLengthHandler());
    }
    });
    future = bootstrap.bind(port).sync();
    future.channel().closeFuture().sync();
    } catch (InterruptedException e) {
    e.printStackTrace();
    }finally {
    acceptGroup.shutdownGracefully();
    rwGroup.shutdownGracefully();
    }
    }
    }


    public class StickyServerFixLengthHandler extends ChannelInboundHandlerAdapter {

    private final Logger logger = Logger.getLogger(StickyServerFixLengthHandler.class.getName());
    private final AtomicInteger counter = new AtomicInteger(0);

    @Override
    public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception {
    // 读完消息打印
    String read = (String) msg;
    logger.info("服务端接收到消息: "+read + "counter is ----"+ counter.incrementAndGet());
    }

    @Override
    public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) throws Exception {
    logger.info("服务端发生异常: "+cause.getMessage());
    ctx.close();
    }
    }
  • 客户端代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53

public class StickyClient {

public void run(String host,int port){
EventLoopGroup rw = new NioEventLoopGroup();
Bootstrap bootstrap = new Bootstrap();
bootstrap.group(rw).channel(NioSocketChannel.class)
.option(ChannelOption.TCP_NODELAY,true)
.handler(new ChannelInitializer<SocketChannel>() {
@Override
protected void initChannel(SocketChannel ch) throws Exception {
// 固定长度解码器
ch.pipeline().addLast(new FixedLengthFrameDecoder(6));
ch.pipeline().addLast(new StringDecoder());
// 消息处理器
ch.pipeline().addLast(new StickyClientFixLengthHandler());
}
});
ChannelFuture future = null;
try {
future = bootstrap.connect(host,port).sync();
future.channel().closeFuture().sync();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}

public class StickyClientFixLengthHandler extends ChannelInboundHandlerAdapter {

private final Logger logger = Logger.getLogger(StickyClientFixLengthHandler.class.getName());
private final AtomicInteger counter = new AtomicInteger(0);

@Override
public void channelActive(ChannelHandlerContext ctx) throws Exception {
for (int i = 0;i<100;i++){
ByteBuf buf = Unpooled.copiedBuffer(("张三").getBytes(StandardCharsets.UTF_8));
ctx.writeAndFlush(buf);
}
}

@Override
public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception {
String buf = (String) msg;
logger.info("客户端收到消息: "+buf +" counter-------"+counter.incrementAndGet());
}

@Override
public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) throws Exception {
logger.info(cause.getMessage());
ctx.close();
}
}
  • 运行测试

特殊字符串分割

特殊字符串分割就是以业务消息没有的特殊字符作为分割,当读取到字符时代表一个完整的包。比如以$_作为分割符。约定的特殊字符要保证唯一性,不能出现在报文的正文中,否则就将正文一分为二了。
Netty 中提供了 DelimiterBasedFrameDecoder 根据特殊字符进行解码,LineBasedFrameDecoder默认以换行符作为分隔符。
DelimiterBasedFrameDecoder的注释如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
通过一个或多个分隔符分割接收到的ByteBuf的解码器。
DelimiterBasedFrameDecoder允许您指定多个分隔符。如果在缓冲区中找到多个分隔符,它会选择产生最短帧的分隔符。例如,如果缓冲区中有以下数据:
+--------------+
| ABC\nDEF\r\n |
+--------------+

DelimiterBasedFrameDecoder ( Delimiters.lineDelimiter() ) 将选择'\n'作为第一个分隔符并生成两个帧:
+-----+-----+
| ABC | DEF |
+-----+-----+

而不是错误地选择'\r\n'作为第一个分隔符:
+----------+
| ABC\nDEF |
+----------+

代码实现

  • 服务端实现
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
public static final String DELIMITER_ONE = "$_";
// 我们以DELIMITER_ONE为分隔符
public class StickyServer {
public void run(int port){
EventLoopGroup acceptGroup = new NioEventLoopGroup();
EventLoopGroup rwGroup = new NioEventLoopGroup();
ServerBootstrap bootstrap = new ServerBootstrap();
ChannelFuture future;
try {
bootstrap.group(acceptGroup,rwGroup).channel(NioServerSocketChannel.class)
.option(ChannelOption.SO_BACKLOG,1024)
.childOption(ChannelOption.SO_KEEPALIVE,true)
.childHandler(new ChannelInitializer<SocketChannel>() {
@Override
protected void initChannel(SocketChannel ch) throws Exception {
ByteBuf delimiter = Unpooled.copiedBuffer(Delimiter.DELIMITER_ONE.getBytes(StandardCharsets.UTF_8));
ch.pipeline().addLast(new DelimiterBasedFrameDecoder(1024,delimiter));
ch.pipeline().addLast(new StringDecoder());
ch.pipeline().addLast(new StickyClientDelimiterHandler());
}
});
future = bootstrap.bind(port).sync();
future.channel().closeFuture().sync();
} catch (InterruptedException e) {
e.printStackTrace();
}finally {
acceptGroup.shutdownGracefully();
rwGroup.shutdownGracefully();
}
}
}


public class StickyServerDelimiterHandler extends ChannelInboundHandlerAdapter {

private final Logger logger = Logger.getLogger(StickyServerDelimiterHandler.class.getName());
private final AtomicInteger counter = new AtomicInteger(0);

@Override
public void channelActive(ChannelHandlerContext ctx) throws Exception {
super.channelActive(ctx);
}

@Override
public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception {
// 读完消息打印
String read = (String) msg;
logger.info("服务端接收到消息: "+read + "counter is ----"+ counter.incrementAndGet());
}

@Override
public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) throws Exception {
logger.info("服务端发生异常: "+cause.getMessage());
ctx.close();
}
}

  • 客户端实现
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
public class StickyClient {

public void run(String host,int port){
EventLoopGroup rw = new NioEventLoopGroup();
Bootstrap bootstrap = new Bootstrap();
bootstrap.group(rw).channel(NioSocketChannel.class)
.option(ChannelOption.TCP_NODELAY,true)
.handler(new ChannelInitializer<SocketChannel>() {
@Override
protected void initChannel(SocketChannel ch) throws Exception {
// 以特殊符号分割
ByteBuf delimiter = Unpooled.copiedBuffer(Delimiter.DELIMITER_ONE.getBytes(StandardCharsets.UTF_8));
ch.pipeline().addLast(new DelimiterBasedFrameDecoder(1024,delimiter));
ch.pipeline().addLast(new StringDecoder());
ch.pipeline().addLast(new StickyClientDelimiterHandler());
}
});
ChannelFuture future = null;
try {
future = bootstrap.connect(host,port).sync();
future.channel().closeFuture().sync();
} catch (InterruptedException e) {
e.printStackTrace();
}

}
}


public class StickyClientDelimiterHandler extends ChannelInboundHandlerAdapter {

private final Logger logger = Logger.getLogger(StickyClientDelimiterHandler.class.getName());
private final AtomicInteger counter = new AtomicInteger(0);

@Override
public void channelActive(ChannelHandlerContext ctx) throws Exception {
for (int i = 0;i<100;i++){
ByteBuf buf = Unpooled.copiedBuffer(("李四"+ Delimiter.DELIMITER_ONE).getBytes(StandardCharsets.UTF_8));
ctx.writeAndFlush(buf);
}
}

@Override
public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception {
String buf = (String) msg;
logger.info("客户端收到消息: "+buf +" counter-------"+counter.incrementAndGet());
}

@Override
public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) throws Exception {
logger.info(cause.getMessage());
ctx.close();
}
}

  • 运行测试

变长协议

变长协议的核心就是:将消息分为消息头和消息体,消息头中标识当前完整的消息体长度。

  • 发送方在发送数据之前先获取数据的二进制字节大小,然后在消息体前面添加消息大小;
  • 接收方在解析消息时先获取消息大小,之后必须读到该大小的字节数才认为是完整的消息

Netty中使用LengthFieldBasedFrameDecoder实现变长协议的编解码

代码实现

  • 服务端

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    public class StickyServer {
    public void run(int port){
    EventLoopGroup acceptGroup = new NioEventLoopGroup();
    EventLoopGroup rwGroup = new NioEventLoopGroup();
    ServerBootstrap bootstrap = new ServerBootstrap();
    ChannelFuture future;
    try {
    bootstrap.group(acceptGroup,rwGroup).channel(NioServerSocketChannel.class)
    .option(ChannelOption.SO_BACKLOG,1024)
    .childOption(ChannelOption.SO_KEEPALIVE,true)
    .childHandler(new ChannelInitializer<SocketChannel>() {
    @Override
    protected void initChannel(SocketChannel ch) throws Exception {
    // 变长解码器
    ch.pipeline().addLast(new LengthFieldPrepender(2));
    ch.pipeline().addLast(new LengthFieldBasedFrameDecoder(65535,0,2,0,2));
    ch.pipeline().addLast(new StringDecoder());
    // 变长协议
    ch.pipeline().addLast(new StickyServerLengthFieldHandler());
    }
    });
    future = bootstrap.bind(port).sync();
    future.channel().closeFuture().sync();
    } catch (InterruptedException e) {
    e.printStackTrace();
    }finally {
    acceptGroup.shutdownGracefully();
    rwGroup.shutdownGracefully();
    }
    }
    }


    public class StickyServerLengthFieldHandler extends ChannelInboundHandlerAdapter {
    private final Logger logger = Logger.getLogger(StickyServerFixLengthHandler.class.getName());
    private final AtomicInteger counter = new AtomicInteger(0);

    @Override
    public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception {
    // 读完消息打印
    String read = (String) msg;
    logger.info("服务端接收到消息: "+read + "counter is ----"+ counter.incrementAndGet());
    }

    @Override
    public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) throws Exception {
    logger.info("服务端发生异常: "+cause.getMessage());
    ctx.close();
    }
    }

  • 客户端

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    public class StickyClient {

    public void run(String host,int port){
    EventLoopGroup rw = new NioEventLoopGroup();
    Bootstrap bootstrap = new Bootstrap();
    bootstrap.group(rw).channel(NioSocketChannel.class)
    .option(ChannelOption.TCP_NODELAY,true)
    .handler(new ChannelInitializer<SocketChannel>() {
    @Override
    protected void initChannel(SocketChannel ch) throws Exception {
    ch.pipeline().addLast(new LengthFieldPrepender(2));
    ch.pipeline().addLast(new LengthFieldBasedFrameDecoder(65535,0,2,0,2));
    ch.pipeline().addLast(new StringDecoder());
    // 变长协议
    ch.pipeline().addLast(new StickyClientLengthFieldHandler());
    }
    });
    ChannelFuture future = null;
    try {
    future = bootstrap.connect(host,port).sync();
    future.channel().closeFuture().sync();
    } catch (InterruptedException e) {
    e.printStackTrace();
    }

    }
    }


    public class StickyClientLengthFieldHandler extends ChannelInboundHandlerAdapter {

    private final Logger logger = Logger.getLogger(StickyClientLengthFieldHandler.class.getName());
    private final AtomicInteger counter = new AtomicInteger(0);

    @Override
    public void channelActive(ChannelHandlerContext ctx) throws Exception {
    for (int i = 0;i<100;i++){
    ByteBuf buf = Unpooled.copiedBuffer(("王五").getBytes(StandardCharsets.UTF_8));
    ctx.writeAndFlush(buf);
    }
    }

    @Override
    public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception {
    String buf = (String) msg;
    logger.info("客户端收到消息: "+buf +" counter-------"+counter.incrementAndGet());
    }

    @Override
    public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) throws Exception {
    logger.info(cause.getMessage());
    ctx.close();
    }
    }
  • 运行效果测试

上面我们使用了两种编解码器,LengthFieldPrependerLengthFieldBasedFrameDecoder,LengthFieldPrepender的作用是在应用层发送数据之前加上length的header数据。而LengthFieldBasedFrameDecoder是对数据进行解码。
纠正一个错误TCP的粘包及半包一直存在,不能说解决TCP的粘包及半包。应该叫应用层处理粘包及半包。

总结

本篇文章记录了Netty应用层处理粘包以及半包,包括为什么了会出现粘包及半包。使用定长数据包,特定符号分隔符及可变长协议处理粘包以及半包。并且提供了Netty中的解决方案。下一篇文章我们详细理一下Netty中的编解码器以及自定义编解码器。

作者

Labradors

发布于

2022-06-03

更新于

2022-06-05

许可协议

评论