Loading

[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("连接空闲,关闭连接..."));
    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());
            }
        }, 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; width: 300px"></textarea>
    <input type="button" onclick="send()" value="send"/>
    <textarea id="responseText" style="height: 300px; width: 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
posted @ 2022-03-29 22:59  tree6x7  阅读(148)  评论(0编辑  收藏  举报