Netty 进阶

粘包与半包

        粘包和半包问题的出现原因主要是因为 TCP 协议是面向流的,而不是面向报文的。即发送方给接收方传输的是一整个数据流,但是接收方并不知道数据流中的哪一部分才是一个完整的数据报,需要自行判断。

        如果是在发送方解决,通常采用的策略是在发送数据前将数据按照固定长度拆分成多个数据包,每个数据包附带特殊标记;如果发送变长数据,则在发送时加上数据的长度信息,接收方在接收到指定长度的数据后就可以认为是一个数据包。这样可以保证每个数据包都是完整的,从而避免了粘包和半包问题的出现。


在网络通信中,黏包是指发送方发送的多个小包被接收方一次性接收到的情况,从接收方的角度看,这些小包被"黏"在一起了。

在Netty中,黏包问题主要是由以下两个原因引起的:

TCP的传输特性:TCP是一种面向流的传输协议,它并不像UDP一样具有明显的消息边界。TCP是通过将数据流分割成一个一个的数据段进行传输的。因此,当发送方连续快速地发送多个小包时,这些小包可能会被底层TCP协议在传输过程中合并成一个更大的数据段传输,从而导致接收方无法分辨出原始的消息边界。

应用程序处理不及时:在Netty中,接收方通常需要将接收到的数据进行缓存,并在合适的时机进行处理。如果接收方没有及时从缓存中读取数据进行处理,而是等待更多的数据到达,就有可能导致多个小包被合并到同一个缓存中,从而形成黏包。

为了解决黏包问题,可以采取以下一些常用的方法:

消息长度字段:发送方在发送消息前,将消息的长度作为固定长度字段加入消息头部。接收方在接收到数据后,根据消息长度字段来正确解析出每个完整的消息。

分隔符:发送方在消息之间插入一个特定的分隔符,接收方根据分隔符来切割接收到的数据,将其拆分为多个完整的消息。

固定长度:发送方将所有的消息固定为相同的长度,接收方根据固定的长度来切割接收到的数据,将其拆分为多个完整的消息。

使用专业的编码/解码器:Netty提供了一系列的编码器和解码器,用于处理常见的黏包问题,例如LengthFieldBasedFrameDecoder、LineBasedFrameDecoder、DelimiterBasedFrameDecoder等,可以根据需要选择合适的解决方案。

通过以上的方法,可以有效地解决黏包问题,确保消息的准确性和完整性。

示例代码

服务方

public class HelloWorldServer {
    static final Logger log = LoggerFactory.getLogger(HelloWorldServer.class);
    void start() {
        NioEventLoopGroup boss = new NioEventLoopGroup(1);
        NioEventLoopGroup worker = new NioEventLoopGroup();
        try {
            ServerBootstrap serverBootstrap = new ServerBootstrap();
            serverBootstrap.channel(NioServerSocketChannel.class);
            serverBootstrap.group(boss, worker);
            serverBootstrap.childHandler(new ChannelInitializer<SocketChannel>() {
                @Override
                protected void initChannel(SocketChannel ch) throws Exception {
                    ch.pipeline().addLast(new LoggingHandler(LogLevel.DEBUG));
                    ch.pipeline().addLast(new ChannelInboundHandlerAdapter() {
                        @Override
                        public void channelActive(ChannelHandlerContext ctx) throws Exception {
                            log.debug("connected {}", ctx.channel());
                            super.channelActive(ctx);
                        }

                        @Override
                        public void channelInactive(ChannelHandlerContext ctx) throws Exception {
                            log.debug("disconnect {}", ctx.channel());
                            super.channelInactive(ctx);
                        }
                    });
                }
            });
            ChannelFuture channelFuture = serverBootstrap.bind(8080);
            log.debug("{} binding...", channelFuture.channel());
            channelFuture.sync();
            log.debug("{} bound...", channelFuture.channel());
            channelFuture.channel().closeFuture().sync();
        } catch (InterruptedException e) {
            log.error("server error", e);
        } finally {
            boss.shutdownGracefully();
            worker.shutdownGracefully();
            log.debug("stoped");
        }
    }

    public static void main(String[] args) {
        new HelloWorldServer().start();
    }
}

客户端

TODO

1.3 现象分析

粘包

  • 现象,发送 abc def,接收 abcdef

  • 原因

    • 应用层:接收方 ByteBuf 设置太大(Netty 默认 1024)

    • 滑动窗口:假设发送方 256 bytes 表示一个完整报文,但由于接收方处理不及时且窗口大小足够大,这 256 bytes 字节就会缓冲在接收方的滑动窗口中,当滑动窗口中缓冲了多个报文就会粘包

    • Nagle 算法:会造成粘包

半包

  • 现象,发送 abcdef,接收 abc def

  • 原因

    • 应用层:接收方 ByteBuf 小于实际发送数据量

    • 滑动窗口:假设接收方的窗口只剩了 128 bytes,发送方的报文大小是 256 bytes,这时放不下了,只能先发送前 128 bytes,等待 ack 后才能发送剩余部分,这就造成了半包

    • MSS 限制:当发送的数据超过 MSS 限制后,会将数据切分发送,就会造成半包

本质是因为 TCP 是流式协议,消息无边界

滑动窗口

  • TCP 以一个段(segment)为单位,每发送一个段就需要进行一次确认应答(ack)处理,但如果这么做,缺点是包的往返时间越长性能就越差

 为了解决此问题,引入了窗口概念,窗口大小即决定了无需等待应答而可以继续发送的数据最大值

窗口实际就起到一个缓冲区的作用,同时也能起到流量控制的作用

  • 图中深色的部分即要发送的数据,高亮的部分即窗口

  • 窗口内的数据才允许被发送,当应答未到达前,窗口必须停止滑动

  • 如果 1001~2000 这个段的数据 ack 回来了,窗口就可以向前滑动

  • 接收方也会维护一个窗口,只有落在窗口内的数据才能允许接收

Nagle 算法

  • 即使发送一个字节,也需要加入 tcp 头和 ip 头,也就是总字节数会使用 41 bytes,非常不经济。因此为了提高网络利用率,tcp 希望尽可能发送足够大的数据,这就是 Nagle 算法产生的缘由

  • 该算法是指发送端即使还有应该发送的数据,但如果这部分数据很少的话,则进行延迟发送

    • 如果 SO_SNDBUF 的数据达到 MSS,则需要发送

    • 如果 SO_SNDBUF 中含有 FIN(表示需要连接关闭)这时将剩余数据发送,再关闭

    • 如果 TCP_NODELAY = true,则需要发送

    • 已发送的数据都收到 ack 时,则需要发送

    • 上述条件不满足,但发生超时(一般为 200ms)则需要发送

    • 除上述情况,延迟发送

1.4 解决方案

短链接

发一个包建立一次连接,这样连接建立到连接断开之间就是消息的边界,缺点效率太低 (半包用这种办法还是不好解决,因为接收方的缓冲区大小是有限的 )

 ctx.writeAndFlush(buffer);
// 发完即关
ctx.close();
消息采用固定长度

缺点浪费空间

// 服务端优化, 此 Inbound 请加入到LoggingHandler 之前只能使用这一个解码器
ch.pipeline().addLast(new FixedLengthFrameDecoder(8));


    
    // io.netty.channel.ChannelInboundHandlerAdapter#channelActive 执行的方法请使用此段
    private static void sendMessage1(ChannelHandlerContext ctx) {
        log.debug("Send sticky packet message");
        Random r = new Random();
        char c = 'a';
        ByteBuf buffer = ctx.alloc().buffer();
        for (int i = 0; i < 10; i++) {
            // 每次都分配8字节长度的数组
            byte[] bytes = new byte[8];
            for (int j = 0; j < r.nextInt(8); j++) {
                bytes[j] = (byte) c;
            }
            c++;
            buffer.writeBytes(bytes);
        }
        ctx.writeAndFlush(buffer);
    }

 服务端接收到的消息

 客户端发送的消息

消息采用分隔符

例如 \n,缺点需要转义 FixedLengthFrameDecoder

// 服务端优化, 此 Inbound 请加入到LoggingHandler 之前,只能使用这一个解码器
ch.pipeline().addLast(new LineBasedFrameDecoder(1024));


// io.netty.channel.ChannelInboundHandlerAdapter#channelActive 执行的方法请使用此段
private static void sendMessage3(ChannelHandlerContext ctx) {
        log.debug("sending...");
        Random r = new Random();
        char c = 'a';
        ByteBuf buffer = ctx.alloc().buffer();
        for (int i = 0; i < 10; i++) {
            for (int j = 1; j <= r.nextInt(16)+1; j++) {
                buffer.writeByte((byte) c);
            }
            // 在 ASCII 表中,byte 10 表示换行符(\n)
            buffer.writeByte(10);
            c++;
        }
        ctx.writeAndFlush(buffer);
    }
每一条消息分为 length 和 body(正文及其他)

length 代表 body 的长度(或者 body  + length 的总长度)

// 服务端替换解码器
ch.pipeline().addLast(new LengthFieldBasedFrameDecoder(1024, 0, 1, 0, 1));


// 服务端替换发送数据方法
// 预设长度
    private static void sendMessage4(ChannelHandlerContext ctx) {
        log.debug("sending...");
        Random r = new Random();
        char c = 'a';
        ByteBuf buffer = ctx.alloc().buffer();
        for (int i = 0; i < 10; i++) {
            byte length = (byte) (r.nextInt(16) + 1);
            // 先写入长度
            buffer.writeByte(length);
            // 再写入数据
            for (int j = 1; j <= length; j++) {
                buffer.writeByte((byte) c);
            }
            c++;
        }
        ctx.writeAndFlush(buffer);
    }

io.netty.handler.codec.LengthFieldBasedFrameDecoder

 该解码器关系两部分数据,一部分是数据长度(可能是消息的总长度 16bytes 也可能是仅实际消息长度12 bytes).

参数1        lengthFieldOffset 表示lengthField的偏移位置 

参数2        lengthFieldLength 表示lengthField占用的长度(第一行表示 2字节长度)

参数3         lengthAdjustment 表示( 当前 lengthField为16, 要接受的内容(实际内容+hed2)长度总和是13 所lengthField值要减小 -3)

参数4        initialBytesToStrip 表示(发送前数据的长度与接受数据的长度差)

redis 协议举例

// 更换连接端口 6379
 bootstrap.connect("127.0.0.1", 6379).sync();

// 加入 类静态字段
static byte[] LINE = {13, 10};

    // io.netty.channel.ChannelInboundHandlerAdapter#channelActive 内方法换为此段
    private static void set(ChannelHandlerContext ctx) {
        ByteBuf buf = ctx.alloc().buffer();
        buf.writeBytes("*3".getBytes());
        buf.writeBytes(LINE);
        buf.writeBytes("$3".getBytes());
        buf.writeBytes(LINE);
        buf.writeBytes("set".getBytes());
        buf.writeBytes(LINE);
        buf.writeBytes("$3".getBytes());
        buf.writeBytes(LINE);
        buf.writeBytes("aaa".getBytes());
        buf.writeBytes(LINE);
        buf.writeBytes("$3".getBytes());
        buf.writeBytes(LINE);
        buf.writeBytes("bbb".getBytes());
        buf.writeBytes(LINE);
        ctx.writeAndFlush(buf);
    }

edis 协议是 Redis 使用的一种文本协议,它是一种行协议,即在一个 TCP 连接中,将多条 Redis 指令分别写入不同的行中,Redis 服务器读取每一行指令并返回执行结果。

Redis 协议的格式很简单,一条 Redis 指令由以下几个部分组成:

  • 指令的参数个数:指令的第一个参数表示指令后面还有多少个参数;
  • 参数的长度:参数的字节数;
  • 参数的内容:参数的具体内容。

一个简单的例子:

*3\r\n    
$3\r\n    SET\r\n
$4\r\n    key1\r\n
$5\r\n    value\r\n
// 换行符和制表符是不必要的,此处是为了数据工整

        上述指令表示执行 Redis 的 SET key1 value 指令,其中 *3 表示有 3 个参数,$3 表示第一个参数长度为 3,即 SET, $4 表示第二个参数长度为 4,即 key1,$5 表示第三个参数长度为 5,即 value。

http 协议举例


import io.netty.bootstrap.ServerBootstrap;
import io.netty.channel.ChannelHandlerContext;
import io.netty.channel.ChannelInboundHandlerAdapter;
import io.netty.channel.ChannelInitializer;
import io.netty.channel.SimpleChannelInboundHandler;
import io.netty.channel.nio.NioEventLoopGroup;
import io.netty.channel.socket.nio.NioServerSocketChannel;
import io.netty.channel.socket.nio.NioSocketChannel;
import io.netty.handler.codec.http.DefaultFullHttpResponse;
import io.netty.handler.codec.http.HttpRequest;
import io.netty.handler.codec.http.HttpResponseStatus;
import io.netty.handler.codec.http.HttpServerCodec;
import io.netty.handler.logging.LoggingHandler;
import lombok.extern.slf4j.Slf4j;

import static io.netty.handler.codec.http.HttpHeaderNames.CONTENT_LENGTH;

@Slf4j
public class TestHttp {
    public static void main(String[] args) throws InterruptedException {
        ChannelInboundHandlerAdapter handler = new ChannelInboundHandlerAdapter() {
            @Override
            public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception {
                // 会收到客户端的两个请求  DefaultHttpRequest LastHttpContent$1 // get方式 Content 的内容是空的。

                System.out.println(msg.getClass());
                super.channelRead(ctx, msg);
            }
        };

        new ServerBootstrap()
                .channel(NioServerSocketChannel.class)
                .group(new NioEventLoopGroup())
                .childHandler(new ChannelInitializer<NioSocketChannel>() {
                    @Override
                    protected void initChannel(NioSocketChannel ch) throws Exception {
                        ch.pipeline().addLast(new HttpServerCodec());
                        ch.pipeline().addLast(new LoggingHandler());
                        ch.pipeline().addLast(handler);
                        ch.pipeline().addLast(new SimpleChannelInboundHandler<HttpRequest>() {
                            @Override
                            protected void channelRead0(ChannelHandlerContext ctx, HttpRequest msg) throws Exception {
                                log.debug(msg.getUri());
                                // 给客户端响应数据
                                DefaultFullHttpResponse response = new DefaultFullHttpResponse(msg.protocolVersion(), HttpResponseStatus.OK);
                                String content = "<h1>Hello World</h1>";
                                // 需要传递头信息,长度,不然客户端会一直接受消息
                                response.headers().setInt(CONTENT_LENGTH, content.length());
                                response.content().writeBytes(content.getBytes());
                                ctx.writeAndFlush(response);
                            }
                        });
                    }
                })
                .bind(8080)
                .sync().channel();

    }
}

自定义协议要素

  • 魔数,用来在第一时间判定是否是无效数据包

  • 版本号,可以支持协议的升级

  • 序列化算法,消息正文到底采用哪种序列化反序列化方式,可以由此扩展,例如:json、protobuf、hessian、jdk

  • 指令类型,是登录、注册、单聊、群聊... 跟业务相关

  • 请求序号,为了双工通信,提供异步能力

  • 正文长度

  • 消息正文

编解码器

根据上面的要素,设计一个登录请求消息和登录响应消息,并使用 Netty 完成收发

@Slf4j
public class MessageCodec extends ByteToMessageCodec<Message> {

    @Override
    protected void encode(ChannelHandlerContext ctx, Message msg, ByteBuf out) throws Exception {
        // 1. 4 字节的魔数
        out.writeBytes(new byte[]{1, 2, 3, 4});
        // 2. 1 字节的版本,
        out.writeByte(1);
        // 3. 1 字节的序列化方式 jdk 0 , json 1
        out.writeByte(0);
        // 4. 1 字节的指令类型
        out.writeByte(msg.getMessageType());
        // 5. 4 个字节
        out.writeInt(msg.getSequenceId());
        // 无意义,对齐填充
        out.writeByte(0xff);
        // 6. 获取内容的字节数组
        ByteArrayOutputStream bos = new ByteArrayOutputStream();
        ObjectOutputStream oos = new ObjectOutputStream(bos);
        oos.writeObject(msg);
        byte[] bytes = bos.toByteArray();
        // 7. 长度
        out.writeInt(bytes.length);
        // 8. 写入内容
        out.writeBytes(bytes);
    }

    @Override
    protected void decode(ChannelHandlerContext ctx, ByteBuf in, List<Object> out) throws Exception {
        int magicNum = in.readInt();
        byte version = in.readByte();
        byte serializerType = in.readByte();
        byte messageType = in.readByte();
        int sequenceId = in.readInt();
        in.readByte();
        int length = in.readInt();
        byte[] bytes = new byte[length];
        in.readBytes(bytes, 0, length);
        ObjectInputStream ois = new ObjectInputStream(new ByteArrayInputStream(bytes));
        Message message = (Message) ois.readObject();
        log.debug("{}, {}, {}, {}, {}, {}", magicNum, version, serializerType, messageType, sequenceId, length);
        log.debug("{}", message);
        out.add(message);
    }
}

测试

EmbeddedChannel channel = new EmbeddedChannel(
    new LoggingHandler(),
    new LengthFieldBasedFrameDecoder(
        1024, 12, 4, 0, 0),
    new MessageCodec()
);
// encode
LoginRequestMessage message = new LoginRequestMessage("zhangsan", "123", "张三");
//        channel.writeOutbound(message);
// decode
ByteBuf buf = ByteBufAllocator.DEFAULT.buffer();
new MessageCodec().encode(null, message, buf);

ByteBuf s1 = buf.slice(0, 100);
ByteBuf s2 = buf.slice(100, buf.readableBytes() - 100);
s1.retain(); // 引用计数 2
channel.writeInbound(s1); // release 1
channel.writeInbound(s2);

 💡 什么时候可以加 @Sharable

  • 当 handler 不保存状态(无字段)时,就可以安全地在多线程下被共享

  • 但要注意对于编解码器类,不能继承 ByteToMessageCodec 或 CombinedChannelDuplexHandler 父类,他们的构造方法对 @Sharable 有限制

  • 如果能确保编解码器不会保存状态,可以继承 MessageToMessageCodec 父类

对MessageCodec 的优化

@Slf4j
@ChannelHandler.Sharable
/**
 * 必须和 LengthFieldBasedFrameDecoder 一起使用,确保接到的 ByteBuf 消息是完整的
 */
public class MessageCodecSharable extends MessageToMessageCodec<ByteBuf, Message> {
    @Override
    protected void encode(ChannelHandlerContext ctx, Message msg, List<Object> outList) throws Exception {
        ByteBuf out = ctx.alloc().buffer();
        // 1. 4 字节的魔数
        out.writeBytes(new byte[]{1, 2, 3, 4});
        // 2. 1 字节的版本,
        out.writeByte(1);
        // 3. 1 字节的序列化方式 jdk 0 , json 1
        out.writeByte(0);
        // 4. 1 字节的指令类型
        out.writeByte(msg.getMessageType());
        // 5. 4 个字节
        out.writeInt(msg.getSequenceId());
        // 无意义,对齐填充
        out.writeByte(0xff);
        // 6. 获取内容的字节数组
        ByteArrayOutputStream bos = new ByteArrayOutputStream();
        ObjectOutputStream oos = new ObjectOutputStream(bos);
        oos.writeObject(msg);
        byte[] bytes = bos.toByteArray();
        // 7. 长度
        out.writeInt(bytes.length);
        // 8. 写入内容
        out.writeBytes(bytes);
        outList.add(out);
    }

    @Override
    protected void decode(ChannelHandlerContext ctx, ByteBuf in, List<Object> out) throws Exception {
        int magicNum = in.readInt();
        byte version = in.readByte();
        byte serializerType = in.readByte();
        byte messageType = in.readByte();
        int sequenceId = in.readInt();
        in.readByte();
        int length = in.readInt();
        byte[] bytes = new byte[length];
        in.readBytes(bytes, 0, length);
        ObjectInputStream ois = new ObjectInputStream(new ByteArrayInputStream(bytes));
        Message message = (Message) ois.readObject();
        log.debug("{}, {}, {}, {}, {}, {}", magicNum, version, serializerType, messageType, sequenceId, length);
        log.debug("{}", message);
        out.add(message);
    }
}

  IdleStateHandler 的空闲检查与心跳发送

服务器空闲检查

  • 怎么判断客户端连接是否假死呢?如果能收到客户端数据,说明没有假死。因此策略就可以定为,每隔一段时间就检查这段时间内是否接收到客户端数据,没有就可以判定为连接假死

// 用来判断是不是 读空闲时间过长,或 写空闲时间过长
// 5s 内如果没有收到 channel 的数据,会触发一个 IdleState#READER_IDLE 事件
ch.pipeline().addLast(new IdleStateHandler(5, 0, 0));
// ChannelDuplexHandler 可以同时作为入站和出站处理器
ch.pipeline().addLast(new ChannelDuplexHandler() {
    // 用来触发特殊事件
    @Override
    public void userEventTriggered(ChannelHandlerContext ctx, Object evt) throws Exception{
        IdleStateEvent event = (IdleStateEvent) evt;
        // 触发了读空闲事件
        if (event.state() == IdleState.READER_IDLE) {
            log.debug("已经 5s 没有读到数据了");
            ctx.channel().close();
        }
    }
});

客户端定时心跳

  • 客户端可以定时向服务器端发送数据,只要这个时间间隔小于服务器定义的空闲检测的时间间隔,那么就能防止前面提到的误判,客户端可以定义如下心跳处理器

// 用来判断是不是 读空闲时间过长,或 写空闲时间过长
// 3s 内如果没有向服务器写数据,会触发一个 IdleState#WRITER_IDLE 事件
ch.pipeline().addLast(new IdleStateHandler(0, 3, 0));
// ChannelDuplexHandler 可以同时作为入站和出站处理器
ch.pipeline().addLast(new ChannelDuplexHandler() {
    // 用来触发特殊事件
    @Override
    public void userEventTriggered(ChannelHandlerContext ctx, Object evt) throws Exception{
        IdleStateEvent event = (IdleStateEvent) evt;
        // 触发了写空闲事件
        if (event.state() == IdleState.WRITER_IDLE) {
            //                                log.debug("3s 没有写数据了,发送一个心跳包");
            ctx.writeAndFlush(new PingMessage());
        }
    }
});

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值