• [05] 群聊&心跳检测&长连接


    1. 群聊系统(简版)

    a. 服务端

    package org.example.netty.chat;
    
    import cn.hutool.core.util.StrUtil;
    import io.netty.bootstrap.ServerBootstrap;
    import io.netty.channel.*;
    import io.netty.channel.group.ChannelGroup;
    import io.netty.channel.group.DefaultChannelGroup;
    import io.netty.channel.nio.NioEventLoopGroup;
    import io.netty.channel.socket.SocketChannel;
    import io.netty.channel.socket.nio.NioServerSocketChannel;
    import io.netty.handler.codec.string.StringDecoder;
    import io.netty.handler.codec.string.StringEncoder;
    import io.netty.util.concurrent.GlobalEventExecutor;
    
    import java.time.LocalDateTime;
    import java.time.format.DateTimeFormatter;
    
    /**
     * @author Orson
     * @Description TODO
     * @createTime 2022年02月26日
     */
    public class GroupChatServer {
      private int port;
    
      public GroupChatServer(int port) {
        this.port = port;
      }
    
      public void run() {
        // 1. 创建两个线程组
        NioEventLoopGroup bossGroup = new NioEventLoopGroup(1);
        NioEventLoopGroup workerGroup = new NioEventLoopGroup();
        try {
            ServerBootstrap bootstrap = new ServerBootstrap()
                    // 2. 设置 Reactor 线程
                    .group(bossGroup, workerGroup)
                    // 3. 设置 NIO 类型的 Channel
                    .channel(NioServerSocketChannel.class)
                    // 4. 设置 Channel 选项
                    .childOption(ChannelOption.SO_KEEPALIVE, true)
                    // 5. 装配流水线
                    .childHandler(new ChannelInitializer<SocketChannel>() {
                        @Override
                        protected void initChannel(SocketChannel ch) throws Exception {
                            ch.pipeline()
                                    .addLast("decoder", new StringDecoder())
                                    .addLast("encoder", new StringEncoder())
                                    .addLast("myHandler", new ServerGroupChatHandler());
                        }
                    });
            // 6. 设置监听端口(通过调用sync同步方法阻塞直到绑定成功)
            ChannelFuture channelFuture = bootstrap.bind(port).sync();
            // 7. 监听通道关闭事件, 应用程序会一直等待,直到 Channel 关闭
            channelFuture.channel().closeFuture().sync();
        } catch (Exception e) {
            e.printStackTrace();
        } finally {
            bossGroup.shutdownGracefully();
            workerGroup.shutdownGracefully();
        }
      }
    
      public static void main(String[] args) {
        GroupChatServer server = new GroupChatServer(6677);
        server.run();
      }
    }
    
    /**
     * 自定义处理器类
     */
    class ServerGroupChatHandler extends SimpleChannelInboundHandler<String> {
    
      private static ChannelGroup channelGroup = new DefaultChannelGroup(GlobalEventExecutor.INSTANCE);
      DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
    
      @Override
      public void channelActive(ChannelHandlerContext ctx) throws Exception {
        System.out.println(StrUtil.format("[ServerLog#{}] {} online!",
                formatter.format(LocalDateTime.now()), ctx.channel().remoteAddress()));
      }
    
      @Override
      public void channelInactive(ChannelHandlerContext ctx) throws Exception {
        System.out.println(StrUtil.format("[ServerLog#{}] {} offline!",
                formatter.format(LocalDateTime.now()), ctx.channel().remoteAddress()));
      }
    
      @Override
      protected void channelRead0(ChannelHandlerContext ctx, String msg) throws Exception {
        Channel channel = ctx.channel();
        StrUtil.format("[{}#{}] {}", channel.remoteAddress(), formatter.format(LocalDateTime.now()), msg);
        channelGroup.forEach(ch -> {
          if (ch != channel) {
            ch.writeAndFlush(StrUtil.format("[{}#{}] {}",
                    channel.remoteAddress(), formatter.format(LocalDateTime.now()), msg));
          } else {
            // 回显
            ch.writeAndFlush(StrUtil.format("[我#{}] {}", formatter.format(LocalDateTime.now()), msg));
          }
        });
      }
    
      @Override
      public void handlerAdded(ChannelHandlerContext ctx) throws Exception {
        Channel channel = ctx.channel();
        // 1. 加入 Channel 组
        channelGroup.add(channel);
        // 2. 将客户端加入聊天的信息推送给其他在线的客户端
        channelGroup.writeAndFlush(StrUtil.format("[SystemMsg#{}] {} 加入聊天, 当前聊天室人数:{}",
                formatter.format(LocalDateTime.now()), channel.remoteAddress(), channelGroup.size()));
      }
    
      @Override
      public void handlerRemoved(ChannelHandlerContext ctx) throws Exception {
        // 1. 将客户端离开聊天的信息推送给其他在线的客户端
        channelGroup.writeAndFlush(StrUtil.format("[SystemMsg#{}] {} 退出聊天, 当前聊天室人数:{}",
            formatter.format(LocalDateTime.now()), ctx.channel().remoteAddress(), channelGroup.size()));
        // 2. 无需手动调用 channelGroup 的 remove() 方法,它会自行删除
      }
    
      @Override
      public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) throws Exception {
        // 关闭通道
        ctx.close();
      }
    }
    

    b. 客户端

    package org.example.netty.chat;
    
    import io.netty.bootstrap.Bootstrap;
    import io.netty.channel.*;
    import io.netty.channel.nio.NioEventLoopGroup;
    import io.netty.channel.socket.SocketChannel;
    import io.netty.channel.socket.nio.NioSocketChannel;
    import io.netty.handler.codec.string.StringDecoder;
    import io.netty.handler.codec.string.StringEncoder;
    
    import java.util.Scanner;
    
    /**
     * @author Orson
     * @Description TODO
     * @createTime 2022年02月26日
     */
    public class GroupChatClient {
      private String host;
      private int port;
    
      public GroupChatClient(String host, int port) {
        this.host = host;
        this.port = port;
      }
    
      public void run() {
        EventLoopGroup eventLoopGroup = new NioEventLoopGroup();
        try {
            Bootstrap bootstrap = new Bootstrap()
                    .group(eventLoopGroup)
                    .channel(NioSocketChannel.class)
                    .handler(new ChannelInitializer<SocketChannel>() {
                        @Override
                        protected void initChannel(SocketChannel ch) throws Exception {
                            ch.pipeline()
                                    .addLast("decoder", new StringDecoder())
                                    .addLast("encoder", new StringEncoder())
                                    .addLast("myHandler", new ClientGroupChatHandler());
                        }
                    });
            ChannelFuture channelFuture = bootstrap.connect(host, port).sync();
            Channel channel = channelFuture.channel();
            System.out.println("----------- " + channel.localAddress() + " -----------");
            Scanner scanner = new Scanner(System.in);
            while (scanner.hasNextLine()) {
                String msg = scanner.nextLine();
                channel.writeAndFlush(msg + "\r\n");
            }
        } catch (Exception e) {
            e.printStackTrace();
        } finally {
            eventLoopGroup.shutdownGracefully();
        }
      }
    
      public static void main(String[] args) {
        GroupChatClient chatClient = new GroupChatClient("127.0.0.1", 6677);
        chatClient.run();
      }
    
    }
    
    class ClientGroupChatHandler extends SimpleChannelInboundHandler<String> {
      @Override
      protected void channelRead0(ChannelHandlerContext ctx, String msg) throws Exception {
        System.out.println(msg);
      }
    }
    

    c. 测试

    2. 心跳与空闲检测

    2.1 网络问题

    网络应用程序中普遍会遇到的一个问题:连接假死。

    【现象】在某一段(服务端或客户端)看来,底层的 TCP 连接已经断开,但是应用程序并没有捕捉到,因此会认为这条连接仍然是存在的。从 TCP 层面来说,只有收到四次握手数据包或一个 RST 数据包,才表示连接的状态是断开。

    连接假死会带来以下两大问题:

    1. 对于服务端来说,因为每个连接都会耗费 CPU 和内存资源,大量假死的连接会逐渐耗光服务器的资源,最终导致性能逐渐下降,程序崩溃;
    2. 对于客户端来说,连接加斯会造成发送数据超时,影响用户体验。

    通常,连接假死由以下几个原因造成。

    1. 应用程序出现线程阻塞,无法进行数据的读写;
    2. 客户端或服务端网络相关的设备出现故障,比如网卡、机房故障;
    3. 公网丢包。公网环境相对于内网而言,非常容易出现丢包、网络抖动等现象,如果在一段时间内用户接入的网络连续出现丢包现象,那么对客户端来说,数据一直发送不出去,而服务端也一直收不到客户端的数据,连接就一直耗着。

    如果应用程序是面向用户的,那么公网丢包这个问题出现的概率是非常高的。对于内网来说,内网丢包、抖动也会有一定概率发生。一旦出现此类问题,客户端和服务端都会受到影响。

    接下来分别从服务端和客户端的角度来解决连接的假死问题。

    2.2 服务端空闲检测

    对于客户端来说,客户端的连接如果出现假死,那么服务端将无法收到客户端的数据。也就是说,如果能一直收到客户端发来的数据,则说明这个连接还或者。因此,服务端对于连接假死的应对策略就是「空闲检测」。

    何为空闲检测?

    空闲检测指的是每隔一段时间,检测这段时间内是否有数据读写。简化一下,服务端只需要检测一段时间内,是否收到过客户端发来的数据即可,Netty 自带的 IdelStateHandler 就可以实现这个功能。

    /**
     * @author 6x7
     * @Description 自定义检测到假死连接之后的逻辑
     * @createTime 2022年03月27日
     */
    public class MyIdleStateHandler extends IdleStateHandler {
    
      private static final int READER_IDLE_TIME = 15;
    
      public MyIdleStateHandler() {
        super(READER_IDLE_TIME, 0, 0, TimeUnit.SECONDS);
      }
    
      @Override
      protected void channelIdle(ChannelHandlerContext ctx, IdleStateEvent evt) throws Exception {
        System.out.println(StrUtil.format("{} s 内未读到数据,关闭连接..."));
        ctx.channel().close();
      }
    }
    
    1. 观察一下 MyIdleStateHandler 的构造函数,它调用父类 IdelStateHandler 的构造函数,有 4 个参数:① 读写空闲,指的是在这段时间内如果没有读到数据,就表示连接假死;② 写空闲时间,指的是在这段时间如果没有写数据,就表示连接假死;③ 读写空闲时间,指的是在这段时间内如果没有产生数据读或者写,就表示连接假死,胁空弦和读写空闲均为 0;④ 时间单位;
    2. 连接假死之后会回调 channelIdel() 方法,我们在这个方法里打印消息,并手动关闭连接。然后,我们把这个 Handler 插到服务端 Pipeline 的最前面。而之所以要插到最前面是因为:假如查到最后面,如果这个连接读到了数据,但是在 inbound 传播的过程中出错了或者数据处理完毕就不往后传递了(我们的应用程序属于这类),那么最终 MyIdleStateHandler 就不会读到数据,会导致误判。

    服务端的空闲检测完毕之后,再思考一下,在一段时间内没有读到客户端的数据,是否一定能判断连接假死呢?并不能,如果在这段时间内客户端确实没有发送数据过来,但是连接是正常的,那么这个时候服务端也不能关闭这个连接。为了防止服务端误判,我们还需要在客户端做点什么。

    2.3 客户端定时发心跳数据包

    服务端在一段时间内没有收到客户端的数据,这个现象产生的原因可以分为以下两种。

    1. 连接假死;
    2. 非假死状态下确实没有发送数据;

    我们只需要排除第 2 种可能,那么连接自然就是假死的。要排查第 2 种情况,我们可以在客户端定期发送数据包到服务端,通常这个数据包被称为〈心跳数据包〉。

    /**
     * @author 6x7
     * @Description 该 Handler 定期发送〈心跳数据包〉给服务端
     * @createTime 2022年03月27日
     */
    public class HeartBeatTimerHandler extends ChannelInboundHandlerAdapter {
    
        private static final int HEARTBEAT_INTERVAL = 5;
    
        @Override
        public void channelActive(ChannelHandlerContext ctx) throws Exception {
            scheduleSendHeartBeat(ctx);
            super.channelActive(ctx);
        }
    
        private void scheduleSendHeartBeat(ChannelHandlerContext ctx) {
            ctx.executor().schedule(() -> {
                if (ctx.channel().isActive()) {
                    ctx.writeAndFlush(new HeartBeatRequestPacket());
                    scheduleSendHeartBeat(ctx);
                }
            }, HEARTBEAT_INTERVAL, TimeUnit.SECONDS);
        }
    }
    

    ctx.executor() 方法返回的是当前 Channel 绑定的 NIO 线程。NIO 线程有一个 schedule() 方法,类似 JDK 的延时任务机制,可以隔一段时间执行一个任务。这里的 scheduleSendHeartBeat() 方法实现了每隔 5s 向服务端发送一个心跳数据包,这个间隔时间通常要比服务端的空闲检测时间的一半短一些,可以直接定义为空闲检测时间的 1/3,主要是为了排除公网偶发的秒级抖动。

    2.4 服务端回复心跳与客户端空闲检测

    客户端的空闲检测其实和服务端一样,依旧是在客户端 Pipeline 的最前面插入 MyIdelStateHandler。

    为了排除因为服务端在非假死状态确实没有发送数据的情况,服务端也要定期发送心跳数据包给客户端。

    其实在前面我们已经实现了客户端向服务端定期发送心跳数据包,服务端这边只要在收到心跳数据包之后回复客户端,给客户端发送一个心跳响应包即可。如果在一段时间内客户端没有收到服务端发来的数据包,则可以判定这个连接为假死状态。

    因此,服务端的 Pipeline 中需要再加上一个 Handler —— HeartBeatRequestHandler。

    public class HeartBeatRequestHandler extends SimpleChannelInboundHandler<HeartBeatRequestPackage> {
      @Override
      protected void channelRead0(ChannelHandlerContext ctx, HeartBeatRequestPackage msg) throws Exception {
        ctx.writeAndFlush(new HeartBeatResponsePackage());
      }
    }
    

    实现非常简单,只是简单地回复一个 HeartBeatResponsePackage 数据包即可。客户端在检测到假死连接之后,断开连接,然后可以有一定地策略去重连、重新登录等。

    2.5 小结&示例

    1. 要处理连接假死问题,首先要实现客户端与服务端定期发送心跳数据包。在这里,其实服务端只需要对客户端的定时心跳数据包进行回复即可;
    2. 客户端与服务端如果都需要检测连接假死,那么直接在 Pipeline 的最前面插入一个自定义 IdelStateHandler,在 channelIdel() 方法里自定义连接假死之后的逻辑即可。如下是 channelIdel() 方法的默认实现:
      protected void channelIdle(ChannelHandlerContext ctx, IdleStateEvent evt) throws Exception {
          ctx.fireUserEventTriggered(evt);
      }
      
    3. 通常空闲检测时间比发送心跳数据包的间隔时间的两倍要长一些,这也是为了排除偶发的公网抖动,防止误判。

    【示例】当服务器超过 3s 没有读时,就提示读空闲;当服务器超过 5s 没有写操作时,就提示写空闲;当服务器超过 7s 没有读或者写操作时,就提示读写空闲。

    public class MyServer {
      public static final int readerIdleTime = 3;
      public static final int writerIdleTime = 5;
      public static final int allIdleTime = 7;
      public static final String host = "127.0.0.1";
      public static final int port = 6677;
    
      public static void main(String[] args) {
        EventLoopGroup bossGroup = new NioEventLoopGroup(1);
        EventLoopGroup workerGroup = new NioEventLoopGroup();
        try {
          ServerBootstrap serverBootStrap = new ServerBootstrap()
            .group(bossGroup, workerGroup)
            .channel(NioServerSocketChannel.class)
            .handler(new LoggingHandler(LogLevel.INFO))
            .childHandler(new ChannelInitializer<SocketChannel>() {
                @Override
                protected void initChannel(SocketChannel ch) throws Exception {
                  ch.pipeline()
                    // 在参数时间内没有读/写/读写,就会发送一个心跳检测包
                    // Triggers an IdleStateEvent when a Channel has not performed
                    // read, write, or both operation for a while.
                    .addLast(new IdleStateHandler(readerIdleTime, writerIdleTime, allIdleTime, TimeUnit.SECONDS))
                    // 当 IdleStateEvent 触发后,就会传递给管道的下一个 handler 的 userEventTrigger()
                    // 方法来处理。因此加入对空闲检测进一步处理的handler
                    .addLast(new MyServerHandler());
                }
            });
          ChannelFuture channelFuture = serverBootStrap.bind(port).sync();
          channelFuture.channel().closeFuture().sync();
        } catch (Exception e) {
          e.printStackTrace();
        } finally {
          bossGroup.shutdownGracefully();
          workerGroup.shutdownGracefully();
        }
      }
    
      static class MyServerHandler extends ChannelInboundHandlerAdapter {
    
        @Override
        public void userEventTriggered(ChannelHandlerContext ctx, Object evt) throws Exception {
          if (evt instanceof IdleStateEvent) {
            // 将 evt 向下转型成 IdleStateEvent
            String eventStateName = null;
            IdleStateEvent event = (IdleStateEvent) evt;
            switch (event.state()) {
                case READER_IDLE:
                    eventStateName = "读空闲";
                    break;
                case WRITER_IDLE:
                    eventStateName = "写空闲";
                    break;
                default:
                    eventStateName = "读写空闲";
            }
            System.out.println(StrUtil.format(
                    "[channel:{}] --- {} ---", ctx.channel().remoteAddress(), eventStateName));
          }
        }
      }
    }
    

    控制台打印:

    3. WebSocket 长连接

    服务端代码:

    /**
     * @author 6x7
     * @Description Netty 通过 WebSocket 编程实现服务器和客户端的长连接(全双工)
     * @createTime 2022年02月27日
     */
    public class LongConnectServer {
      public static final int port = 6677;
    
      public static void main(String[] args) {
        NioEventLoopGroup bossGroup = new NioEventLoopGroup(1);
        NioEventLoopGroup workerGroup = new NioEventLoopGroup();
    
        try {
          ServerBootstrap serverBootstrap = new ServerBootstrap()
            .group(bossGroup, workerGroup)
            .channel(NioServerSocketChannel.class)
            .handler(new LoggingHandler(LogLevel.INFO))
            .childHandler(new ChannelInitializer<NioSocketChannel>() {
                @Override
                protected void initChannel(NioSocketChannel ch) throws Exception {
                    ch.pipeline()
                        // 因为基于 HTTP 协议,所以加入 HTTP 编解码器
                        .addLast(new HttpServerCodec())
                        // 是以「块」方式写
                        .addLast(new ChunkedWriteHandler())
                        // HTTP 数据在传输过程中是分段的,HttpObjectAggregator 可以将多个段聚合
                        // (当浏览器发送大量数据时,就会发出多次请求)
                        .addLast(new HttpObjectAggregator(8192))
                        // 对于 WebSocket,它的数据是以「帧」的形式传递
                        // 该 handler 核心功能是把 HTTP 升级为 WS,保持长连接
                        // 设置 websocketPath 为 ws,前端请求 url 得加上 /ws
                        .addLast(new WebSocketServerProtocolHandler("/ws"))
                        // 自定义业务处理 Handler
                        .addLast(new MyTextWebSocketFrameHandler());
                }
            });
          ChannelFuture channelFuture = serverBootstrap.bind(port).sync();
          channelFuture.channel().closeFuture().sync();
        } catch (Exception e) {
            e.printStackTrace();
        } finally {
            bossGroup.shutdownGracefully();
            workerGroup.shutdownGracefully();
        }
      }
    
      /**
       * TextWebSocketFrame 文本帧
       */
      static class MyTextWebSocketFrameHandler extends SimpleChannelInboundHandler<TextWebSocketFrame> {
        DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
    
        @Override
        protected void channelRead0(ChannelHandlerContext ctx, TextWebSocketFrame msg) throws Exception {
            // 打印客户端发送的消息
            System.out.println(StrUtil.format("[{}#{}] {}",
                  ctx.channel().remoteAddress(), formatter.format(LocalDateTime.now()), msg.text()));
            // 回复消息
            ctx.channel().writeAndFlush(new TextWebSocketFrame(
                StrUtil.format("[ServerEcho#{}] {}", formatter.format(LocalDateTime.now()), msg.text())
            ));
        }
    
        @Override
        public void handlerAdded(ChannelHandlerContext ctx) throws Exception {
            System.out.println(StrUtil.format("[handlerAdded#{}] {}",
                formatter.format(LocalDateTime.now()), ctx.channel().id().asLongText()));
        }
    
        @Override
        public void handlerRemoved(ChannelHandlerContext ctx) throws Exception {
            System.out.println(StrUtil.format("[handlerRemoved#{}] {}",
                formatter.format(LocalDateTime.now()), ctx.channel().id().asLongText()));
        }
    
        @Override
        public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) throws Exception {
            System.out.println(StrUtil.format("[exceptionCaught#{}] {}",
                formatter.format(LocalDateTime.now()), ctx.channel().id().asLongText()));
            ctx.close();
        }
      }
    }
    

    页面代码:

    <!DOCTYPE html>
    <html lang="en">
    <head>
        <meta charset="UTF-8">
        <title>长连接</title>
    </head>
    <body>
    <script>
        let socket;
        if (!window.WebSocket) {
            alert("当前浏览器不支持 WebSocket!")
        } else {
            socket = new WebSocket("ws://localhost:6677/ws")
    
            // 连接开启
            socket.onopen = ev => {
                let respText = document.getElementById("responseText")
                respText.value = "========= 连接开启 =========\n"
            }
    
            // 收到消息
            socket.onmessage = ev => {
                let respText = document.getElementById("responseText")
                respText.value += ("\n" + ev.data)
            }
    
            // 连接关闭
            socket.onclose = ev => {
                let respText = document.getElementById("responseText")
                respText.value += "\n========= 连接关闭 ========="
            }
        }
    
        function send() {
            if (!window.socket) return;
            if (socket.readyState == WebSocket.OPEN) {
                socket.send(document.getElementById('message').value)
            } else {
                alert("连接未开启!")
            }
        }
    </script>
    <form onsubmit="return false">
        <textarea id="message" style="height: 300px;  300px"></textarea>
        <input type="button" onclick="send()" value="send"/>
        <textarea id="responseText" style="height: 300px;  300px"></textarea>
        <input type="button" onclick="document.getElementById('responseText').value=''" value="clear"/>
    </form>
    </body>
    </html>
    

    客户端浏览器和服务器端会相互感知,比如服务器关闭了,浏览器会感知;同样浏览器关闭了,服务器也会感知到:

    4. Log4j 整合 Netty

    pom.xml

    <dependency>
        <groupId>log4j</groupId>
        <artifactId>log4j</artifactId>
        <version>1.2.17</version>
    </dependency>
    <dependency>
        <groupId>org.slf4j</groupId>
        <artifactId>slf4j-api</artifactId>
        <version>1.7.25</version>
    </dependency>
    <dependency>
        <groupId>org.slf4j</groupId>
        <artifactId>slf4j-log4j12</artifactId>
        <version>1.7.25</version>
        <scope>test</scope>
    </dependency>
    

    log4j.properties

    log4j.rootLogger=DEBUG, stdout
    log4j.appender.stdout=org.apache.log4j.ConsoleAppender
    log4j.appender.stdout.layout=org.apache.log4j.PatternLayout
    log4j.appender.stdout.layout.ConversionPattern=[%p] %C{1} - %m%n
    
  • 相关阅读:
    八大排序算法——插入排序(动图演示 思路分析 实例代码java 复杂度分析)
    八大排序算法——冒泡排序(动图演示 思路分析 实例代码java 复杂度分析)
    八大排序算法——选择排序(动图演示 思路分析 实例代码Java 复杂度分析)
    蓝桥杯 算法训练 素因子去重 (java)
    蓝桥杯 每周一练 第一周(3n+1问题)
    第八届蓝桥杯程序设计大赛 国赛 填空题第一题 平方十位数
    拼多多 2018 校招编程题 六一儿童节
    泛型的实质
    JAVA 反射之Method
    JAVA反射之 Field (属性)
  • 原文地址:https://www.cnblogs.com/liujiaqi1101/p/16074331.html
Copyright © 2020-2023  润新知