客户端异常断网断电,服务端该如何感知?

本文只针对于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.去咨询了上次交流的那个老哥,他说域名加端口就可以了呀,客户端断网了,咋还会给服务端发信息呢?后来才知道,问了下运维的,如果是使用外网的话,会很不安全,所以需要走内网。

  • 1
    点赞
  • 13
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值