[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 数据包,才表示连接的状态是断开。
连接假死会带来以下两大问题:
- 对于服务端来说,因为每个连接都会耗费 CPU 和内存资源,大量假死的连接会逐渐耗光服务器的资源,最终导致性能逐渐下降,程序崩溃;
- 对于客户端来说,连接假死会造成发送数据超时,影响用户体验。
通常,连接假死由以下几个原因造成。
- 应用程序出现线程阻塞,无法进行数据的读写;
- 客户端或服务端网络相关的设备出现故障,比如网卡、机房故障;
- 公网丢包。公网环境相对于内网而言,非常容易出现丢包、网络抖动等现象,如果在一段时间内用户接入的网络连续出现丢包现象,那么对客户端来说,数据一直发送不出去,而服务端也一直收不到客户端的数据,连接就一直耗着。
如果应用程序是面向用户的,那么公网丢包这个问题出现的概率是非常高的。对于内网来说,内网丢包、抖动也会有一定概率发生。一旦出现此类问题,客户端和服务端都会受到影响。
接下来分别从服务端和客户端的角度来解决连接的假死问题。
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();
}
}
- 观察一下 MyIdleStateHandler 的构造函数,它调用父类 IdelStateHandler 的构造函数,有 4 个参数:① 读空闲时间,指的是在这段时间内如果没有读到数据,就表示连接假死;② 写空闲时间,指的是在这段时间如果没有写数据,就表示连接假死;③ 读写空闲时间,指的是在这段时间内如果没有产生数据读或者写,就表示连接假死,写空闲和读写空闲均为 0;④ 时间单位;
- 连接假死之后会回调 channelIdel() 方法,我们在这个方法里打印消息,并手动关闭连接。然后,我们把这个 Handler 插到服务端 Pipeline 的最前面。之所以要插到最前面是因为:假如插到最后面,如果这个连接读到了数据,但是在 inbound 传播的过程中出错了或者数据处理完毕就不往后传递了(我们的应用程序属于这类),那么最终 MyIdleStateHandler 就不会读到数据,会导致误判。
服务端的空闲检测完毕之后,再思考一下,在一段时间内没有读到客户端的数据,是否一定能判断连接假死呢?
并不能!如果在这段时间内客户端确实没有发送数据过来,但是连接是正常的,那么这个时候服务端也不能关闭这个连接。为了防止服务端误判,我们还需要在客户端做点什么。
2.3 客户端定时发心跳数据包
服务端在一段时间内没有收到客户端的数据,这个现象产生的原因可以分为以下两种。
- 连接假死;
- 非假死状态下确实没有发送数据;
我们只需要排除第 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 小结&示例
- 要处理连接假死问题,首先要实现客户端与服务端定期发送心跳数据包。在这里,其实服务端只需要对客户端的定时心跳数据包进行回复即可;
- 客户端与服务端如果都需要检测连接假死,那么直接在 Pipeline 的最前面插入一个自定义 IdelStateHandler,在 channelIdel() 方法里自定义连接假死之后的逻辑即可。如下是 channelIdel() 方法的默认实现:
protected void channelIdle(ChannelHandlerContext ctx, IdleStateEvent evt) throws Exception { ctx.fireUserEventTriggered(evt); }
- 通常空闲检测时间比发送心跳数据包的间隔时间的两倍要长一些,这也是为了排除偶发的公网抖动,防止误判。
【示例】当服务器超过 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