Netty进阶学习总结

233 阅读10分钟

本文内容来自B站黑马课程及相关书籍学习总结

1 粘包与半包

  • 粘包:接收端(Receiver)收到一个ByteBuf,包含了发送端(Sender)的多个ByteBuf,发送端的多个ByteBuf在接收端“粘”在了一起。
  • 半包:Receiver将Sender的一个ByteBuf“拆”开了收,收到多个破碎的包。换句话说,Receiver收到了Sender的一个ByteBuf的一小部分。

1.1 粘包现象演示

服务端代码:

@Slf4j
public class HelloWorldServer {
    private void start() {
        NioEventLoopGroup bossLoopGroup = new NioEventLoopGroup(1);
        NioEventLoopGroup workerLoopGroup = new NioEventLoopGroup();
        try {
            ServerBootstrap serverBootstrap = new ServerBootstrap();
            serverBootstrap.channel(NioServerSocketChannel.class);
            serverBootstrap.group(bossLoopGroup, workerLoopGroup);
            serverBootstrap.childHandler(new ChannelInitializer<NioSocketChannel>() {
                @Override
                protected void initChannel(NioSocketChannel ch) throws Exception {
                    ch.pipeline().addLast(new LoggingHandler(LogLevel.DEBUG));
                }
            });
            ChannelFuture channelFuture = serverBootstrap.bind(8080).sync();
            ChannelFuture closeFuture = channelFuture.channel().closeFuture();
            closeFuture.sync();
        } catch (InterruptedException e) {
            log.error("server error", e);
        } finally {
            bossLoopGroup.shutdownGracefully();
            workerLoopGroup.shutdownGracefully();
        }
    }

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

客户端代码:

@Slf4j
public class HelloWorldClient {
    public static void main(String[] args) {
        new HelloWorldClient().startClient();
    }

    private void startClient() {
        NioEventLoopGroup workerLoopGroup = new NioEventLoopGroup();
        try {
            Bootstrap bootstrap = new Bootstrap();
            bootstrap.group(workerLoopGroup);
            bootstrap.channel(NioSocketChannel.class);
            bootstrap.handler(new ChannelInitializer<NioSocketChannel>() {
                @Override
                protected void initChannel(NioSocketChannel ch) throws Exception {
                    ch.pipeline().addLast(new ChannelInboundHandlerAdapter() {
                        //当连接channel建立成功后,触发active事件
                        @Override
                        public void channelActive(ChannelHandlerContext ctx) throws Exception {
                            //发送数据
                            for (int i = 0; i < 10; i++) {
                                ByteBuf buf = ctx.alloc().buffer(16);
                                buf.writeBytes(new byte[]{0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15});
                                ctx.writeAndFlush(buf);
                            }
                        }
                    });
                }
            });
            ChannelFuture channelFuture = bootstrap.connect(new InetSocketAddress("localhost", 8080)).sync();
            channelFuture.channel().closeFuture().sync();
        } catch (InterruptedException e) {
            log.error("client error", e);
        } finally {
            workerLoopGroup.shutdownGracefully();
        }
    }
}

客户端建立连接后,发送10次消息,每次消息16byte的数据,但是服务端接收到一个160byte的消息。

1.2 半包现象演示

服务端调整系统的接受缓冲区

serverBootstrap.option(ChannelOption.SO_RCVBUF,10);

注意

serverBootstrap.option(ChannelOption.SO_RCVBUF, 10) 影响的底层接收缓冲区(即滑动窗口)大小,仅决定了 netty 读取的最小单位,netty 实际每次读取的一般是它的整数倍

按照课程演示,接受到的消息后,消息被拆成多次接收,每次为10的整数倍,但是我没有复现。

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 是流式协议,消息无边界

1.4 解决方案

1.4.1 短链接

每发一个包建立一次连接,这样连接建立到连接断开之间就是消息的边界,缺点效率太低

private void startClient() {
    NioEventLoopGroup workerLoopGroup = new NioEventLoopGroup();
    try {
        Bootstrap bootstrap = new Bootstrap();
        bootstrap.group(workerLoopGroup);
        bootstrap.channel(NioSocketChannel.class);
        bootstrap.handler(new ChannelInitializer<NioSocketChannel>() {
            @Override
            protected void initChannel(NioSocketChannel ch) throws Exception {
                ch.pipeline().addLast(new ChannelInboundHandlerAdapter() {
                    //当连接channel建立成功后,触发active事件
                    @Override
                    public void channelActive(ChannelHandlerContext ctx) throws Exception {
                        //发送数据
                        ByteBuf buf = ctx.alloc().buffer();
                        buf.writeBytes(new byte[]{0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15});
                        ctx.writeAndFlush(buf);
                        ctx.channel().close();//每次发送完成后关闭
                    }
                });
            }
        });
        ChannelFuture channelFuture = bootstrap.connect(new InetSocketAddress("localhost", 8080)).sync();
        channelFuture.channel().closeFuture().sync();
    } catch (InterruptedException e) {
        log.error("client error", e);
    } finally {
        workerLoopGroup.shutdownGracefully();
    }
}

短链接方案可以解决粘包问题,但是半包用这种办法还是不好解决,因为接收方的缓冲区大小是有限的

1.4.2 固定长度

服务端加固定长度数据包解码器(FixedLengthFrameDecoder)

ch.pipeline().addLast(new FixedLengthFrameDecoder(10));

适用场景:

每个接收到的数据包的长度都是固定的。在这种场景下,把FixedLengthFrameDecoder解码器加到流水线中,它就会把入站ByteBuf数据包拆分成一个个长度为固定大小的数据包,然后发往下一个channelHandler入站处理器。

缺点:

数据包的大小不好把握

1.4.3 固定分隔符

服务端加入,默认以 \n 或 \r\n 作为分隔符,如果超出指定长度仍未出现分隔符,则抛出异常

使用行分割数据包解码器(LineBasedFrameDecoder)或自定义分隔符数据包解码器(DelimiterBasedFrameDecoder)

1.4.4 预设长度

使用LengthFieldBasedFrameDecoder解码器进行消息解析

LengthFieldBasedFrameDecoder构造器主要有以下参数:

(1)maxFrameLength:发送的数据包的最大长度。

(2)lengthFieldOffset:长度字段偏移量,指的是长度字段位于整个数据包内部字节数组中的下标索引值。

(3)lengthFieldLength:长度字段所占的字节数。如果长度字段是一个int整数,则为4;

(4)lengthAdjustment:长度的调整值。在传输协议比较复杂的情况下,例如协议包含了长度字段、协议版本号、魔数等,那么解码时就需要进行长度调整。长度调整值的计算公式为:内容字段偏移量-长度字段偏移量-长度字段的字节数。

(5)initialBytesToStrip:丢弃的起始字节数。在有效数据字段Content前面,如果还有一些其他字段的字节,作为最终的解析结果可以丢弃。

案例代码:

public static void main(String[] args) {
    EmbeddedChannel channel = new EmbeddedChannel(
            new LengthFieldBasedFrameDecoder(1024, 0, 4, 0, 4),
            new LoggingHandler(LogLevel.DEBUG)
    );

    ByteBuf buf = ByteBufAllocator.DEFAULT.buffer();
    send(buf, "hello, world");
    send(buf, "hi!");
    channel.writeInbound(buf);
}

private static void send(ByteBuf buf, String content) {
    byte[] bytes = content.getBytes();
    int length = bytes.length;//实际内容的长度
    buf.writeInt(length);
    buf.writeBytes(bytes);
}

2 协议设计与解析

2.1 为什么需要协议

TCP/IP 中消息传输基于流的方式,没有边界。

协议的目的就是划定消息的边界,制定通信双方要共同遵守的通信规则

2.2 redis协议演示

使用Netty实现客户端向Redis发送命令

public static void main(String[] args) {
    NioEventLoopGroup worker = new NioEventLoopGroup();
    byte[] LINE = {13, 10};//换行
    try {
        Bootstrap bootstrap = new Bootstrap();
        bootstrap.channel(NioSocketChannel.class);
        bootstrap.group(worker);
        bootstrap.handler(new ChannelInitializer<SocketChannel>() {
            @Override
            protected void initChannel(SocketChannel ch) {
                ch.pipeline().addLast(new LoggingHandler());
                ch.pipeline().addLast(new ChannelInboundHandlerAdapter() {
                    // 会在连接 channel 建立成功后,会触发 active 事件
                    @Override
                    public void channelActive(ChannelHandlerContext ctx) {
                        auth(ctx);//auth密码
                        set(ctx);//set key value
                        get(ctx);//get key
                    }

                    private void auth(ChannelHandlerContext ctx) {
                        ByteBuf buf = ctx.alloc().buffer();
                        buf.writeBytes("*2".getBytes());
                        buf.writeBytes(LINE);
                        buf.writeBytes("$4".getBytes());
                        buf.writeBytes(LINE);
                        buf.writeBytes("auth".getBytes());
                        buf.writeBytes(LINE);
                        buf.writeBytes("$6".getBytes());
                        buf.writeBytes(LINE);
                        buf.writeBytes("123456".getBytes());
                        buf.writeBytes(LINE);
                        ctx.writeAndFlush(buf);
                    }

                    private void get(ChannelHandlerContext ctx) {
                        ByteBuf buf = ctx.alloc().buffer();
                        buf.writeBytes("*2".getBytes());
                        buf.writeBytes(LINE);
                        buf.writeBytes("$3".getBytes());
                        buf.writeBytes(LINE);
                        buf.writeBytes("get".getBytes());
                        buf.writeBytes(LINE);
                        buf.writeBytes("$3".getBytes());
                        buf.writeBytes(LINE);
                        buf.writeBytes("aaa".getBytes());
                        buf.writeBytes(LINE);
                        ctx.writeAndFlush(buf);
                    }
                    private 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);
                    }

                    @Override
                    public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception {
                        ByteBuf buf = (ByteBuf) msg;
                        System.out.println(buf.toString(Charset.defaultCharset()));
                    }
                });
            }
        });
        ChannelFuture channelFuture = bootstrap.connect("localhost", 6379).sync();
        channelFuture.channel().closeFuture().sync();
    } catch (InterruptedException e) {
        log.error("client error", e);
    } finally {
        worker.shutdownGracefully();
    }
}

2.3 http 协议演示

public static void main(String[] args) {
    NioEventLoopGroup boss = new NioEventLoopGroup(1);
    NioEventLoopGroup worker = new NioEventLoopGroup();
    try {
        ServerBootstrap serverBootstrap = new ServerBootstrap();
        serverBootstrap.group(boss, worker);
        serverBootstrap.channel(NioServerSocketChannel.class);
        serverBootstrap.childHandler(new ChannelInitializer<NioSocketChannel>() {
            @Override
            protected void initChannel(NioSocketChannel ch) throws Exception {
                ch.pipeline().addLast(new LoggingHandler(LogLevel.DEBUG));
                ch.pipeline().addLast(new HttpServerCodec());
                ch.pipeline().addLast(new SimpleChannelInboundHandler<HttpRequest>() {
                    @Override
                    protected void channelRead0(ChannelHandlerContext ctx, HttpRequest msg) throws Exception {
                        log.debug(msg.uri());

                        //返回响应
                        DefaultFullHttpResponse response = new DefaultFullHttpResponse(msg.protocolVersion(), HttpResponseStatus.OK);
                        byte[] bytes = "<h1>hello, world!</h1>".getBytes();
                        response.headers().setInt(CONTENT_LENGTH, bytes.length);
                        response.content().writeBytes(bytes);
                        ctx.writeAndFlush(response);
                    }
                });
            }
        });
        ChannelFuture channelFuture = serverBootstrap.bind(8080).sync();
        channelFuture.channel().closeFuture().sync();
    } catch (InterruptedException e) {
        log.error("server error", e);
    } finally {
        boss.shutdownGracefully();
        worker.shutdownGracefully();
    }
}

2.4 自定义协议要素

  • 魔数,用来在第一时间判定是否是无效数据包
  • 版本号,可以支持协议的升级
  • 序列化算法,消息正文到底采用哪种序列化反序列化方式,可以由此扩展,例如:json、protobuf、hessian、jdk
  • 指令类型,是登录、注册、单聊、群聊... 跟业务相关
  • 请求序号,为了双工通信,提供异步能力
  • 正文长度
  • 消息正文

2.4.1 自定义编解码器

@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);
    }
}

测试:

public static void main(String[] args) throws Exception {
  EmbeddedChannel channel = new EmbeddedChannel(
    new LoggingHandler(LogLevel.DEBUG),
    new LengthFieldBasedFrameDecoder(1024, 12, 4, 0, 0),
    new MessageCodec());
  //encode
  LoginRequestMessage message = new LoginRequestMessage("mimang", "123456");
  //        channel.writeOutbound(message);

  //decode
  ByteBuf buffer = ByteBufAllocator.DEFAULT.buffer();
  new MessageCodec().encode(null, message, buffer);
  //模拟半包
  ByteBuf s1 = buffer.slice(0, 100);
  ByteBuf s2 = buffer.slice(100, buffer.readableBytes() - 100);
  buffer.retain();//引用计数+1
  channel.writeInbound(s1);
  channel.writeInbound(s2);
}

2.4.2 @Sharable注解

什么时候可以加@Sharable

  • 当 handler 不保存状态时,就可以安全地在多线程下被共享
  • 但要注意对于编解码器类,不能继承 ByteToMessageCodec 或 CombinedChannelDuplexHandler 父类,他们的构造方法对 @Sharable 有限制
  • 如果能确保编解码器不会保存状态,可以继承 MessageToMessageCodec 父类

3 空闲检测和心跳发送

3.1 连接假死

原因

  • 网络设备出现故障,例如网卡,机房等,底层的 TCP 连接已经断开了,但应用程序没有感知到,仍然占用着资源。
  • 公网网络不稳定,出现丢包。如果连续出现丢包,这时现象就是客户端数据发不出去,服务端也一直收不到数据,就这么一直耗着
  • 应用程序线程阻塞,无法进行数据读写

问题

  • 假死的连接占用的资源不能自动释放
  • 向假死的连接发送数据,得到的反馈是发送超时

服务器端解决

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

//判断读空闲时间,超时触发读空闲事件
ch.pipeline().addLast(new IdleStateHandler(5, 0, 0));
//读空闲事件处理
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());
        }
    }
});

4 参数调优

1) CONNECT_TIMEOUT_MILLIS

  • 属于 SocketChannal 参数
  • 用在客户端建立连接时,如果在指定毫秒内无法连接,会抛出 timeout 异常
Bootstrap bootstrap = new Bootstrap()
                    .group(group)
                    .option(ChannelOption.CONNECT_TIMEOUT_MILLIS, 300)
                    .channel(NioSocketChannel.class)
                    .handler(new LoggingHandler());

2) SO_BACKLOG

  • 属于 ServerSocketChannal 参数

    服务端在处理客户端新连接请求时(三次握手)是顺序处理的,所以同一时间只能处理一个客户端连接,多个客户端到来的时候,服务端将不能处理的客户端连接请求放在队列中等待处理,队列的大小通过SO_BACKLOG指定。

  • 可以通过 option(ChannelOption.SO_BACKLOG, 值) 来设置大小

3) TCP_NODELAY

  • 属于 SocketChannal 参数

TCP_NODELAY用于开启或关闭Nagle算法。如果设置为true就表示立即发送数据。

4) SO_RCVBUF和SO_SNDBUF

  • SO_SNDBUF 属于 SocketChannal 参数
  • SO_RCVBUF 既可用于 SocketChannal 参数,也可以用于 ServerSocketChannal 参数(建议设置到 ServerSocketChannal 上)

每个TCP socket(套接字)在内核中都有一个发送缓冲区和一个接收缓冲区,这两个选项就是用来设置TCP连接的两个缓冲区大小的

5) ALLOCATOR

  • 属于 SocketChannal 参数
  • 用来分配 ByteBuf, ctx.alloc()