本文只针对于web项目。开始想的是使用websocket进行长连接服务,但有个问题就来了,客户端异常断电、异常断网,比如说我现在把电脑炸了,网线掐了,服务端是不知道的,所以无法触发oncolse方法,通道就没办法关闭,该咋办呢?而且还有个缺点,如果用户过多,A用户向服务器发送10000次心跳,那么服务器也要回10000次,压力会很大。
解决方案
采用心跳机制解决。
客户端定时向服务端发送空消息(ping),服务端启动心跳检测,超过一定时间范围没有新的消息进来就默认为客户端已断线,服务端主动执行close()方法断开连接
Netty是一个非常强大的NIO通讯框架,支持多种通讯协议,并且在网络通讯领域有许多成熟的解决方案。
本文是借鉴了一个老哥的文章,但整合发现了问题,无法实现我需要的功能。于是自己对后端和前端代码进行了整改。大概说一下后端代码的意思把,其实也没啥说的。初始化netty,初始化信道,添加handler、设定端口、设定路由、开启监听类,大概就这些把。
具体看一下代码:
1.返回结果类
package org.springframework.security.web.netty;
/**
* 返回结果类
* @Author xiaoxin
* @Date 2021/12/6 20:42
* @Version 1.0
*/
public class CommonResult<T> {
private Integer code;
private String msg;
private T data;
public Integer getCode() {
return code;
}
public void setCode(Integer code) {
this.code = code;
}
public String getMsg() {
return msg;
}
public void setMsg(String msg) {
this.msg = msg;
}
public T getData() {
return data;
}
public void setData(T data) {
this.data = data;
}
public CommonResult(Integer code, String msg) {
this.code = code;
this.msg = msg;
}
@Override
public String toString() {
return "CommonResult{" +
"code=" + code +
", msg='" + msg + '\'' +
", data=" + data +
'}';
}
}
2.netty服务的相关变量和其他变量
package org.springframework.security.web.netty;
import java.util.concurrent.ConcurrentHashMap;
/**
* @describe Netty服务相关的全局变量
* @Author xiaoxin
* @Date 2021/12/6 19:26
* @Version 1.0
*/
public class Constant {
/**端口*/
public static final int PROT = 9502;
/**请求头数据*/
public static final String NONCE = "nonce";
public static final String USER_ID = "userId";
public static final String SYS_TIME = "systime";
public static final String NONCE_VALUE = "12345";
public static final String OPEN_API_KEY = "openapikey";
public static final String OPEN_API_KEY_VALUE = "a702b8e6147";
/**key为channelId value为uid 存储在Map中*/
public static ConcurrentHashMap<String, Object> uidMap = new ConcurrentHashMap<>();
/**测试域环境地址 正式*/
// public static final String LOGOUT_URL = "http://10.248.68.123:7081/gateway/auth/logout";
/**测试域环境地址 测试*/
public static final String LOGOUT_URL = "http://10.248.68.123:8080/gateway/auth/logout";
}
3.时间工具类
package org.springframework.security.web.netty;
import java.text.SimpleDateFormat;
import java.util.Date;
/**
* @describe 时间工具类
* @Author xiaoxin
* @Date 2021/12/7 17:19
* @Version 1.0
*/
public class DateUtil {
/**
* 获取年月日时分秒的纯数字拼接
* 例如:2021-12-07 17:28:32
* 20211207172832
* @return
*/
public static String getTime() {
Date d = new Date();
SimpleDateFormat sbf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
System.out.println(sbf.format(d));
String format = sbf.format(d);
String str = format
.replaceAll("-", "")
.replaceAll(":","")
.replaceAll(" ","");
return str;
}
}
4.心跳超时处理类
package org.springframework.security.web.netty;
import io.netty.channel.ChannelHandlerContext;
import io.netty.channel.ChannelInboundHandlerAdapter;
import io.netty.handler.timeout.IdleState;
import io.netty.handler.timeout.IdleStateEvent;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.http.ResponseEntity;
import org.springframework.web.client.RestTemplate;
/**
* @Author xiaoxin
* @Date 2021/12/4 23:26
* @Version 1.0
*/
@Slf4j
public class HeartBeatInspect extends ChannelInboundHandlerAdapter{
@Override
public void userEventTriggered(ChannelHandlerContext ctx, Object evt) throws Exception {
//超时事件
if (evt instanceof IdleStateEvent) {
log.info("心跳检测超时");
IdleStateEvent idleEvent = (IdleStateEvent) evt;
//读
if (idleEvent.state() == IdleState.READER_IDLE) {
//关闭通道连接
ctx.channel().close();
//根据信道id获取到用户id,然后根据用户id进行令牌失效
String userId = Constant.uidMap.get(ctx.channel().id().toString()).toString();
System.gc();
log.info("心跳检测超时,即将销毁令牌,用户id:{}" + userId);
//token令牌失效操作
RestTemplate restTemplate = new RestTemplate();
HttpHeaders headers = new HttpHeaders();
headers.add(Constant.SYS_TIME, DateUtil.getTime());
headers.add(Constant.OPEN_API_KEY, Constant.OPEN_API_KEY_VALUE);
headers.add(Constant.NONCE, Constant.NONCE_VALUE);
headers.add(Constant.USER_ID, userId);
HttpEntity<String> requestEntity = new HttpEntity<>(null, headers);
ResponseEntity<CommonResult> exchange = restTemplate.exchange(Constant.LOGOUT_URL, HttpMethod.GET, requestEntity, CommonResult.class);
log.info("{}",exchange);
//写
} else if (idleEvent.state() == IdleState.WRITER_IDLE) {
//全部
} else if (idleEvent.state() == IdleState.ALL_IDLE) {
}
}
super.userEventTriggered(ctx, evt);
}
}
5.websocketHanler,当用户登陆成功后,此类会监听到
package org.springframework.security.web.netty;
import io.netty.channel.ChannelHandlerContext;
import io.netty.channel.SimpleChannelInboundHandler;
import io.netty.handler.codec.http.FullHttpRequest;
import io.netty.handler.codec.http.websocketx.TextWebSocketFrame;
import lombok.extern.slf4j.Slf4j;
/**
* @Author xiaoxin
* @Date 2021/12/4 23:26
* @Version 1.0
*/
@Slf4j
public class WebSocketHandler extends SimpleChannelInboundHandler<TextWebSocketFrame> {
/**
* 当一个 client 连接到 server 时,Java 底层 NIO 的 ServerSocketChannel 就会有一个 SelectionKey.OP_ACCEPT
* 的事件就绪,接着就会调用到 NioServerSocketChannel 的 doReadMessages(),然后通过ChannelPipeline调用到channelRead方法
* channelRead
* @param ctx
* @param msg
* @throws Exception
*/
@Override
public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception {
if (msg instanceof FullHttpRequest) {
//拦截请求地址,获取地址上的uid值,并存入Map集合中
FullHttpRequest fh = (FullHttpRequest) msg;
String uid = fh.uri().substring(fh.uri().lastIndexOf("/") + 1);
Constant.uidMap.put(ctx.channel().id().toString(), uid);
log.info("信道id:{}",ctx.channel().id());
log.info("用户连接:{}",uid);
// uri改为 /ws
fh.setUri("/ws");
}
super.channelRead(ctx, msg);
}
/**
* 由于项目架构原因,前端框架在点击导航栏模块、刷新网页都会全部加载js,刷新网页则会
* 触发信道失效操作,测试更改后无果,所以在信道失效后删除了直接删除当前信道,原本是
* 只在心跳超时后才会删除信道。
* 每刷新一次就会创立一个信道,刷新次数过多会造成过多不必要的信道,相当于垃圾,当然
* 信道和其他对象也是一样的,当在一定时间未使用信道,则会被销毁
* @param ctx
* @throws Exception
*/
@Override
public void channelInactive(ChannelHandlerContext ctx) throws Exception {
//获取Uid
String uid = Constant.uidMap.get(ctx.channel().id().toString()).toString();
log.info("该用户已断线:{}",uid);
Constant.uidMap.remove(ctx.channel().id().toString());
System.gc();
}
@Override
protected void channelRead0(ChannelHandlerContext channelHandlerContext, TextWebSocketFrame textWebSocketFrame) throws Exception {
}
@Override
public void channelActive(ChannelHandlerContext ctx) throws Exception {
}
@Override
public void handlerAdded(ChannelHandlerContext ctx) throws Exception {
}
@Override
public void handlerRemoved(ChannelHandlerContext ctx) throws Exception {
}
}
6.netty启动类
package org.springframework.security.web.netty;
import io.netty.bootstrap.ServerBootstrap;
import io.netty.channel.ChannelFuture;
import io.netty.channel.EventLoopGroup;
import io.netty.channel.nio.NioEventLoopGroup;
import io.netty.channel.socket.nio.NioServerSocketChannel;
import org.springframework.stereotype.Component;
/**
* @Author xiaoxin
* @Date 2021/12/4 23:24
* @Version 1.0
*/
@Component
public class WebSocketServer {
public void start(int port){
EventLoopGroup bossGroup = new NioEventLoopGroup(1);
EventLoopGroup workerGroup = new NioEventLoopGroup();
try {
ServerBootstrap bootstrap = new ServerBootstrap();
bootstrap
//设置主从线程组
.group(bossGroup, workerGroup)
//设置nio双向通道
.channel(NioServerSocketChannel.class)
//子处理器,用于处理workerGroup
.childHandler(new WebSocketServerInitializer());
//用于启动server,同时启动方式为同步
ChannelFuture channelFuture = bootstrap.bind(port).sync();
//监听关闭的channel,设置同步方式
channelFuture.channel().closeFuture().sync();
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
bossGroup.shutdownGracefully();
workerGroup.shutdownGracefully();
}
}
}
6.初始化信道,负责添加各种handler,注意添加顺序,这里包括了对心跳的监控时间的设定
package org.springframework.security.web.netty;
import io.netty.channel.ChannelInitializer;
import io.netty.channel.ChannelPipeline;
import io.netty.channel.socket.SocketChannel;
import io.netty.handler.codec.http.HttpObjectAggregator;
import io.netty.handler.codec.http.HttpServerCodec;
import io.netty.handler.codec.http.websocketx.WebSocketServerProtocolHandler;
import io.netty.handler.stream.ChunkedWriteHandler;
import io.netty.handler.timeout.IdleStateHandler;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.socket.config.annotation.EnableWebSocket;
import java.util.concurrent.TimeUnit;
/**
* @describe WebSocket初始化类
* @Author xiaoxin
* @Date 2021/12/4 23:25
* @Version 1.0
*/
@Configuration
@EnableWebSocket
public class WebSocketServerInitializer extends ChannelInitializer<SocketChannel> {
@Override
protected void initChannel(SocketChannel ch) throws Exception {
//获取流水线
ChannelPipeline pipeline = ch.pipeline();
//websocket基于http协议,所以要有http编解码器
pipeline.addLast(new HttpServerCodec());
//对写大数据的支持
pipeline.addLast(new ChunkedWriteHandler());
//对httpMessage进行整合,聚合成FullHttpRequest或FullHttpResponse
pipeline.addLast(new HttpObjectAggregator(1024 * 64));
//心跳检测,读超时时间设置为40s,0表示不监控
ch.pipeline().addLast(new IdleStateHandler(40, 0, 0, TimeUnit.SECONDS));
//心跳超时处理事件
ch.pipeline().addLast(new HeartBeatInspect());
//自定义handler
pipeline.addLast(new WebSocketHandler());
//websocket指定给客户端连接访问的路由:/ws,ws是只针对于http,wss是https
pipeline.addLast(new WebSocketServerProtocolHandler("/ws"));
}
}
7.最后一步,监听tomcat启动和销毁,因为是ssm项目,如果你在tomcat中启动一个其他的服务,默认都是使用主线程启动的,比如8080,所以需要另外起个线程去启动netty,要不然会启动失败。在tomcat启动后,启动netty服务。
package org.springframework.security.web.netty;
/**
* @Author xiaoxin
* @Date 2021/12/7 16:25
* @Version 1.0
* @describe 监听tomcat启动与销毁
*/
import lombok.extern.slf4j.Slf4j;
import javax.servlet.ServletContextEvent;
import javax.servlet.ServletContextListener;
@Slf4j
public class NettyListener implements ServletContextListener{
/**
* tomcat项目中启动netty,因为都是默认使用主线程启动的,如果一起启动,则只会启动一个服务
* 防止启动失败,在tomcat启动完成后,再使用其他线程启动netty
* @param sce
*/
@Override
public void contextInitialized(ServletContextEvent sce) {
log.info("Tomcat初始化开始-------------------------");
new Thread(){
@Override
public void run(){
try {
new WebSocketServer().start(Constant.PROT);
} catch (Exception e) {
e.printStackTrace();
}
}
}.start();
log.info("Tomcat初始化结束-------------------------");
}
@Override
public void contextDestroyed(ServletContextEvent sce) {
log.info("Tomcat销毁-----------------------------");
}
}
8.前端代码,js代码
前端代码解释:
1.在登陆成功后调用此js代码
2.创建wobsocket对象,new WebSocket(),其中的url则是服务器的地址,例如 127.0.0.1、192.168.122.001、或者其他,本地测试的话一般都是什么127.0.0.1或者localhost,如果部署项目到线上报错WebSocket connection to 'ws://127.0.0.1:8888/failed的话,你需要看下你的路径有没有错,服务器是否有nginx,有没有配置代理?如果本地是可以的,是通的,就可以认为是服务器的问题。
3.页面静止20分钟自动关闭连接服务,一般正常的都是超过20分钟不操作,就关闭连接,这里做的操作不是关闭连接,而是停止发送信息,服务器是监听心跳的,如果在30-40秒内没有收到信息,则调用销毁token操作,你再次刷新页面的时候,token失效了,直接会跳转登录页
4.判断网络状态,有人说handlerRemoved方法中其实是有对网络作出判断的,这里我是没办法解决的,因为在进行测试的时候,发现了一个问题,new WebSocket()当服务器开启了8080端口后,前端建立连接去访问,然后这个时候你断网了,然后看浏览器的console打印出的信息,你会发现还在继续发送信息,明明断网了,后来问了运维,即便是没有外网也是可以发送信息的,走的是内网。那这样的话只能去判断网络状态了。
//测试域-测试环境nginx代理路径
var url = "ws://10.248.68.123:端口/ws/";
//测试域-正式环境nginx代理路径
// var url = "ws://10.248.68.123:端口/ws/";
var timeCheck = false
var timers = null
window.onload = function (){
window.CHAT = {
socket: null,
init: function () {
console.log("Execution is about to begin WebSocket Server")
if (window.WebSocket) {
//nginx代理地址,测试域-测试环境
CHAT.socket = new WebSocket(url + getUserId());
CHAT.socket.onopen = function () {
console.log("Websocket Now Open!!!!");
CHAT.heartBeat('1');
console.log("Websocket Creating a Success...");
},
CHAT.socket.onclose = function () {
console.log("连接关闭...");
clearInterval(timers)
},
CHAT.socket.onerror = function () {
console.log("发生错误...");
},
CHAT.socket.onmessage = function (e) {
console.log("接收到消息" + e.data);
}
} else {
alert("浏览器不支持websocket协议...");
}
},
heartBeat: function (val) {
if (CHAT.socket) {
timers = setInterval(function () {
console.log('Send HearBeat Server......')
CHAT.socket.send(val);
}, 10000);
}
}
};
CHAT.init();
}
//页面静止20分钟自动关闭连接服务,也就是用户在任何一个界面超过指定时间不操作,则调用
//clearInterval,这个方法的意思就是停止给服务器发送信息
var timeOut = null
var timeOut = setTimeout(function (){
console.log("Nothing for 20 minutes! Close the service......")
clearInterval(timers)
},20*1000*60)
//判断网络是否连接,if条件是IE浏览器,IE浏览器对于判断网络状态的话,是比较好用的
//例如断网后,他不会立马触发,而是有一定的重连机制,在一定时间重连,网络中断不会触发
if(isIE()){
var EventUtil = {
addHandler: function (element, type, handler) {
if(element.addEventListener) {
element.addEventListener(type, handler, false);
}else if (element.attachEvent) {
element.attachEvent("on" + type, handler);
}else {
element["on" + type] = handler;
}
} };
EventUtil.addHandler(window, "online", function () {
console.log("连上网了!")
timeCheck = false
});
EventUtil.addHandler(window, "offline", function () {
console.log("网络不给力,请检查网络设置!")
timeCheck = true
setTimeout(function() {
if(timeCheck){
clearInterval(timers)
}
}, 10000)
});
//else条件是其他的浏览器,很灵敏,哪怕出现网络波动就会触发已断网
//所以加了setTimeout方法,触发断网后等待10秒,如果网络再次连接,则不会触发clearInterval
//如果没有连接,则触发clearInterval
}else {
const netWorkDownlink = navigator.connection.downlink;
if (navigator.connection && navigator.connection.onchange === null) {
navigator.connection.onchange=function () {
if (netWorkDownlink !== navigator.connection.downlink || navigator.connection.rtt === 0) {
//网络断开
console.log('已断网')
timeCheck = true
setTimeout(function () {
if(timeCheck){
clearInterval(timers)
}
}, 10000)
} else if (netWorkDownlink === navigator.connection.downlink && navigator.connection.rtt !== 0) {
//连接到网络
timeCheck = false
console.log('网络已连接')
}
}
}
}
最后:
1.这样子做有个好处,A用户发送10000条信息给服务端,服务端是不用回复信息的,减轻服务器压力
2.可以判断出客户端是否处于异常状态,异常断网断电,也做了类似重连的功能
3.关于WebSocketHandler方法中的handlerRemoved方法,之前在网上查询,说是此方法对于网络断开
是做了判断的,说是在linunx中配置ipv4的配置,我没有去配。但有个疑问,我这个断网都能访问,发送
信息,走的是内网,是如何判断的?如何触发的?如有大佬知道,指点一下
4.关于创建信道,然后用户断网了,销毁tokne之后,其实我之前是担心创建信道过多,造成很多垃圾,
其实这个问题不必担心,设计之初会考虑的,像对象一样,时间久了,会进行类似于gc的操作
5.记得,一定要配置代理!!!!!WebSocket connection to 'ws://127.0.0.1:8888/failed,就是代理问题
6.感觉感知客户端断网这里,做的好像不是那么合理,但我对不合理的部分做出了优化。希望有经验的老哥提示一下。
7.记得netty服务一定要另外起线程去启动,上面方法中有说到
8.去咨询了上次交流的那个老哥,他说域名加端口就可以了呀,客户端断网了,咋还会给服务端发信息呢?后来才知道,问了下运维的,如果是使用外网的话,会很不安全,所以需要走内网。