WebSocket(一):SpringBoot整合与Socketjs心跳重连实现

一.基本概念

1.单播(Unicast) :点对点,私信私聊

2.多播,也叫组播(Multicast)(特地人群): 多人聊天,发布订阅

3.广播(Broadcast)(所有人): 游戏公告,发布订阅

二.Springboot整合Websocket

1.依赖
后端

    
         org.springframework.boot
         spring-boot-starter-websocket
    

2.定义两个消息发送的实体类

    /*
    * 接收的消息
    * from:消息的来源(一般为发送用户id或者sessionid) 是这条消息的标识 通过这个标志可以找到对应的用户(如果是1v1聊天 这个是找到消息发送者的标志)
    * to:消息的部分目的地(一般为接收用户id或者sessionid)
    *   如果消息发是广播和多播 那前端可以忽略该字段 直接在发送时指定一个固定路由(如:群聊、推送实时消息等)
    *   如果消息发是单播 就要通过to后的标识找到要发送的目的地 再进行拼接
    *
    */
    public class InMessage {

        //从哪里来
        private String from;

        //到哪里去(单播必用)
        private String to;

        private String content;

        private Date time = new Date();
    }


    /*
    * 发送的消息
    * from:前端拿到发送的可以做一些事
    * 没有to字段 因为已经发送到接收者 所以没有意义
    */
    public class OutMessage {

        private String from;

        private String content;

        private Date time = new Date();
    }

3.消息接收的方法

@MessageMapping("/v1/chat")
注意:
在@MessageMapping注解的路径里 不用写配置的setApplicationDestinationPrefixes里面配置的路径前缀(即:客户端发送数据给服务器端的前缀)

4.WebSocket的两种推送方式

  1. @SendTo:
    不通用,一个路由接收的消息 因为注解只可以写一个路由 所以只能把该方法的全部消息都发到注解写的一个路径
        @MessageMapping("/v1/chat")
        @SendTo("/topic/game_chat")
        public OutMessage gameInfo(InMessage message){
            return new OutMessage(message.getFrom(),message.getContent());
        }
  1. SimpMessagingTemplate
    灵活,支持多种发送方式(建一个WebSocketService 在类里面写不同场景的发送数据和路由)
  • 消息发送模板
	@Service
        public class WebSocketService {

            @Autowired
            private SimpMessagingTemplate template;

            /*
            * 简单的指定消息到目的地:广播、单播
            *
            * @param dest:目的地的路径
            * @param message:给OutMessage提供content和from
            */
            public void sendTopicMessage(String dest, InMessage message) throws InterruptedException {
                template.convertAndSend(dest, new OutMessage(message.getContent());
            }

            /*
            * 点对点发送消息:给统一的路径后面再加上user的路径
            */
            public void sendChatMessage(InMessage message) {

                //发送路径除了固定的路径 还拼接了特定的接收用户的标识(一般为用户id) 
                //且每个用户单播的情况下 订阅的路径下也是加上含自己id的路径 
                template.convertAndSend("/chat/single/" + message.getTo() ,new OutMessage(message.getFrom() + " 发送:" + message.getContent()));
            }

            
            /*
            * 心跳检测 直接给源路径返回"pang"作标志
            */
            public void sendPong(InMessage message) {
                template.convertAndSend(message.getTo());
            }
        }
  • 接收消息并转给WebSocketService
	@Autowired
        private WebSocketService ws;

        @MessageMapping("/v3/chat")
        public void gameInfo(InMessage message) throws InterruptedException{
            ws.sendTopicMessage("/topic/game_rank",message);
        }

        @MessageMapping("/v3/check")
        public void gameInfo(InMessage message) throws InterruptedException{
            ws.sendPong(message);//心跳检测 给前端返回
        }

注意:
客户端 -> 服务器(服务器订阅):不写config里setApplicationDestinationPrefixes配置的前缀
客户端 -> 服务器(客户端发送):写config里setApplicationDestinationPrefixes配置的前缀
服务器 -> 客户端(服务器发送):写config里enableSimpleBroker配置的前缀
服务器 -> 客户端(客户端订阅):写config里enableSimpleBroker配置的前缀

5.HandshakeInterceptor握手拦截器
(只会在第一次连接时执行一次)

  • 主要作用:

    • 可以转化获得ServletServerHttpRequest 然后通过request可以获得header、session等东西 再放入attributes供后面的Listner和SocketChannelIntecepter使用
    • 看使用情况是否进行拦截操作
  • 使用
    需要在config文件里配置
    implements HandshakeInterceptor

      /**
      * 功能描述:http握手拦截器,最早执行
      * 可以通过这个类的方法获取resuest,和response 给后面使用
      */
      public class HttpHandShakeIntecepter implements HandshakeInterceptor{
    
          @Override
          public boolean beforeHandshake(ServerHttpRequest request,
              ServerHttpResponse response, WebSocketHandler wsHandler,
              Map attributes) throws Exception {
    
              /* 
              * 拦截方法一;获得sessionid 然后判断在线的sessionid是否有该sessionid
              *
              * 先要判断是否有session存在 然后获取sessionid(适用于前后端不分离或不跨域或安卓客户端)
              * if(request instanceof ServletServerHttpRequest) {
              *  	 //强转为ServletServerHttpRequest
              *  	 ServletServerHttpRequest servletRequest = (ServletServerHttpRequest)request;
              *   	HttpSession session = servletRequest.getServletRequest().getSession();
      		  *
              *  	 String sessionId = session.getId();
              *    	 //...进行校验
              *  }
              */ 
    
              /* 
              * 拦截方法二:取出cookie里的tocken解析出用户 该token是jwt加密的 
              * 如何使用jwt请参考我的博客直接使用 https://blog.csdn.net/weixin_43934607/article/details/101356581
              */ 
              HttpServletRequest request= servletRequest.getServletRequest();
              String token= CookieUtils.getCookieValue(request,"Cookie名称");
              UserInfo user = JwtUtils.getInfoFromToken(token, publicKey); 
              String userid= user.getId();
    
              //如果userid为空 或者redis中不存在userid 返回未授权错误码(redis自行整合 在登陆时存入redis)
              if (StringUtils.isBlank(userid) || !redisTemplate.haskey(userid)) {
                  response.setStatusCode( HttpStatus.UNAUTHORIZED );
                  return false;
              }
    
              //把该usrid存入stompAccessHeader 供DisConnect监听器断开连接时从redis中找到对应的并把online设置为false 如果5分钟没没有更新为true就在redis中删掉该用户
              //在断开连接的监听器通过stompHeaderAccessor.getSessionAttributes().get("userid")取得 
                attributes.put("userid", userid);
    
             //把username存入attributes 方便在下面的config中取出后设置成Principal 当有异常发送时用@SendToUser 不然不知道目的地
              attributes.put("userName", user.getName());
    
      	  	//如果是掉线重新设为true
      	 	 if(!redisTemplate.get(userid)){
      			 redisTemplate.set(userid,true);
      		 }
              
              return true;
          }
    
    
    
          @Override
          public void afterHandshake(ServerHttpRequest request, ServerHttpResponse response, WebSocketHandler wsHandler, Exception exception) {
              //该方法一般没有 所以不实现
          }
    
      }
    

6.websocket监听器

  1. websocket模块监听器类型:

SessionSubscribeEvent 订阅事件
SessionUnsubscribeEvent 取消订阅事件
SessionConnectEvent 连接事件
SessionDisconnectEvent 断开连接事件( 注意,对于单个会话,此事件可能会多次引发,因此事件使用时应该是满足幂等的并忽略重复事件)

  1. 使用
    • 需要监听器类需要实现接口ApplicationListener T表示事件类型,下列几种都是对应的websocket事件类型

    • 在监听器类上注解 @Component,spring会把改类纳入管理(不用再config配置文件里设置)

    • StompHeaderAccessor
      简单消息传递协议中处理消息头的基类 (因为规范协议用stom进行包装) 通过这个类,可以获取消息类型(例如:发布订阅,建立连接断开连接),会话id等

    StompHeaderAccessor webSocketheaderAccessor = StompHeaderAccessor.wrap(Message);

		/*
		* 测试
		*/
        @Component
        public class SubscribeEventListener implements ApplicationListener{

            /**
            * 在事件触发的时候调用这个方法
            */
            public void onApplicationEvent(SessionSubscribeEvent event) {
                //通过event获得Message
                StompHeaderAccessor headerAccessor = StompHeaderAccessor.wrap(event.getMessage());
                System.out.println("【SubscribeEventListener监听器事件 类型】"+headerAccessor.getCommand().getMessageType());
                //必须在HandshakeInterceptor拦截之后放入了sessionid才可以取到
                System.out.println("【SubscribeEventListener监听器事件 sessionId】"+headerAccessor.getSessionAttributes().get("sessionId"));

            }
        }
		  /**
            * 在用户断开连接的时候触发 一般会使用到该监听器
            */
		@Component
        public class SubscribeEventListener implements ApplicationListener{
        
            public void onApplicationEvent(SessionSubscribeEvent event) {
                StompHeaderAccessor headerAccessor = StompHeaderAccessor.wrap(event.getMessage());
                //获得上面拦截器放进去的userId
                String userId=headerAccessor.getSessionAttributes().get("userId"));
				
				//在redis中把用户状态设置下线 30分钟没上线就删除
				 redisTemplate.set(userid,true,30, TimeUnit.MINUTES);
            }
        }

7.spring channel拦截器(已过时)

    public class SocketChannelIntecepter extends ChannelInterceptorAdapter{

        /**
        * 在完成发送之后进行调用,不管是否有异常发生,一般用于资源清理
        */
        @Override
        public void afterSendCompletion(Message message, MessageChannel channel, boolean sent, Exception ex) {
            super.afterSendCompletion(message, channel, sent, ex);
        }


        /**
        * 在消息被实际发送到频道之前调用
        * 可以用作登陆验证
        */
        @Override
        public Message preSend(Message message, MessageChannel channel) {
            StompHeaderAccessor accessor = MessageHeaderAccessor.getAccessor(message, StompHeaderAccessor.class);

            //1. 判断是否首次连接请求
            if (StompCommand.CONNECT.equals(accessor.getCommand())) {
                /* 2. 验证是否登录
                * accessor.getNativeHeader()获得的数据时前端传来的求请求头
                *
                *     var headers={
                *          username:$("#username").val(),
                *          password:$("#password").val()
                *     };
                *     stompClient.connect(headers, function (frame) {
                *         stompClient.subscribe('/topic/demo/test', function (result) {
                *          });
                *     });
                */
                String username = accessor.getNativeHeader("username").get(0);
                String password = accessor.getNativeHeader("password").get(0);

                //如果验证失败就return null 那么消息将不会被服务器接收
                if(false){
                    return null;
                }
            }
            //不是首次连接,已经成功登陆
            return message;
        }



        /**
        * 发送消息调用后立即调用 一般用于监听上下线
        */
        @Override
        public void postSend(Message message, MessageChannel channel, boolean sent) {
            StompHeaderAccessor headerAccessor = StompHeaderAccessor.wrap(message);//消息头访问器

            if (headerAccessor.getCommand() == null ) return ;// 避免非stomp消息类型,例如心跳检测

            String sessionId = headerAccessor.getSessionAttributes().get("sessionId").toString();//拿到sessionid 后面可能要用

            switch (headerAccessor.getCommand()) {
                case CONNECT:
                    //连接成功后操作...
                    break;
                case DISCONNECT:
                    //断开连接的操作...
                    break;

                case SUBSCRIBE: break;
                case UNSUBSCRIBE: break;
                default: break;
            }

        }

    }

8.配置文件

        @Configuration
        //开启对websocket的支持,使用stomp协议传输代理消息,
        // 这时控制器使用@MessageMapping和@RequestMaping一样
        @EnableWebSocketMessageBroker
        public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {


            /**
            * Endpoint:注册端点,发布或者订阅消息的时候需要连接此端点
            * Interceptors:前面的握手拦截器
            * AllowedOrigins:非必须,*表示允许其他域进行连接
            * withSockJS:表示开始sockejs支持
            */
            public void registerStompEndpoints(StompEndpointRegistry registry) {
                registry.addEndpoint("/endpoint-websocket").addInterceptors(new HttpHandShakeIntecepter())
                    .setHandshakeHandler(new DefaultHandshakeHandler(){
                        @Override
                        protected Principal determineUser(ServerHttpRequest request, WebSocketHandler wsHandler, Map attributes) {
                            //设置认证用户 从拦截器添加的userName取出
                            return new Principal(attributes.get("userName"));
                        }
                    })
                   /*
                    * 因为Stomp是对websocket的实现 是异步发送 如果有异常 发送者将无法感知  设置完这个后在发送消息异常时  就会调用下面的接收器 把然后把该异常可以返回给消息的发送者 让前端知道发送异常并告知
				    *  @MessageExceptionHandler(Exception.class)
        			*  @SendToUser("/error/quene") //会发送到DefaultHandshakeHandler设置的Principal: /Principal/error/quene
       				*  public Exception handleExceptions(Exception t){
            		*		t.printStackTrace();
            		*		return t;
       				*  }
       				*/

                    .setAllowedOrigins("*")
                    .withSocketJs()
            }

            /**
            * 配置消息代理(中介)
            * enableSimpleBroker 服务端推送给客户端的路径前缀
            * setApplicationDestinationPrefixes 客户端发送数据给服务器端的一个前缀
            */
            @Override
            public void configureMessageBroker(MessageBrokerRegistry registry) {
                registry.enableSimpleBroker("/topic", "/chat", "/check");
                registry.setApplicationDestinationPrefixes("/app");

            }

            /**
            * 消息传输参数配置
            */
            @Override
            public void configureWebSocketTransport(WebSocketTransportRegistration registry) {
                registry.setMessageSizeLimit(8192) //设置消息字节数大小
                .setSendBufferSizeLimit(8192)//设置消息缓存大小
                .setSendTimeLimit(10000); //设置消息发送时间限制毫秒
            }


            /**
            * 配置客户端入站通道拦截器
            */
            @Override
            public void configureClientInboundChannel(ChannelRegistration registration) {
                registration.taskExecutor().corePoolSize(4) //设置消息输入通道的线程池线程数
                .maxPoolSize(8)//最大线程数
                .keepAliveSeconds(60);//线程最大空闲时间
                //前面配置的spring channel拦截器 过时不建议再使用
                registration.interceptors( new SocketChannelIntecepter());
            }

            /**
            * 配置客户端出站通道拦截器
            */
            @Override
            public void configureClientOutboundChannel(ChannelRegistration registration) {
                registration.taskExecutor().corePoolSize(4).maxPoolSize(8);
                //前面配置的spring channel拦截器 过时不建议再使用
                registration.interceptors( new SocketChannelIntecepter());
            }
        }

三.socketJs
1.依赖

  • 建立一个空的maven项目 通过web-jar引入

      
          org.webjars
          sockjs-client
          1.1.2
      
      
          org.webjars
          stomp-websocket
          2.3.3-1
          
      
      <-- 如果要加入jquery -->
      
      
      
      
      
    

2.核心代码(包括心跳重连实现)

    //写成全局 所有操作都要用到
    var stompClient = null;
    var tryTimes=0;//重连次数

    function connect() {
        //让websocket连接的端点
        var socket = new SockJS('http://localhost:8080/endpoint-websocket?userid=xxx');
        stompClient = Stomp.over(socket);

        /** 加入请求头的方式
        *   var headers={
        *     username:$("#username").val(),
        *     password:$("#password").val()
        *   };
        *   stompClient.connect(headers, function (frame) {.....
        */
        stompClient.connect({}, function (frame) {
            heartCheck.reset().start(); //开始心跳检测
            tryTimes= 0;//重置重连次数

            //连接成功之后的其他操作...

            //订阅(该路由专门用于心跳检测)
            stompClient.subscribe('/check/net/'+userid, function (result) {
                 heartCheck.reset().start(); //心跳检测重置
            });

            //普通订阅(如果路径没有指定userid 那么服务器发到这个路径的消息都可以收到)
            //如果要取消订阅 要使用下面subscribe返回的对象 再调用该对象的unsubscribe()
            stompClient.subscribe('/chat/single', function (result) {
                 //获得result的body 再用JSON.parse解析
                 var body=JSON.parse(result.body); 
                 console.log(body.content);
            });
        },
        function(errorCallback){
            //连接失败的操作...(该方法非必须)
            console.log(errorCallback)
            reconnect();
        });
    }

    function reconnect() {
        if(tryTimes>10){
            alert("重连次数以达上限 连接失败")
            return;
        }
        setTimeout(function () { //没连接上会一直重连,设置延迟避免请求过多
            connect();
        }, 3000);
    }
    
    function sendMessage() {
        //stomp协议规定是用son格式传输 所以解析和发送都是json
        //要发送的数据写成json格式 然后用JSON.stringify解析成字符串形式传输给服务器
        stompClient.send("/app/v3/single/chat", {}, JSON.stringify({'content': $("#content").val(), 'to':$("#to").val(), 'from':$("#from").val()}));
    }

    //手动关闭连接
    function disconnect() {
        if (stompClient !== null) {
            stompClient.disconnect();
        }
        //断开连接成功之后的操作... 
    }

    //心跳检测与重连
    var heartCheck = {
        timeout: 10000, //10s发一次心跳
        timeoutObj: null,
        serverTimeoutObj: null,
        reset: function(){
            clearTimeout(this.timeoutObj);//清除定时任务
            clearTimeout(this.serverTimeoutObj);
            return this;
        },
        start: function(){
            var self = this;
            this.timeoutObj = setTimeout(function(){
                //这里发送一个心跳到后端指定路由,后端该路径收到将再发一条消息到前端指定路由,从而完成一次交互(消息content可以为空 只要能到达路由就可以)
                stompClient.send("/app/v3/check", {}, JSON.stringify({'to':"/check/net/"+userid}));
                console.log("ping!")

                //如果超过一定时间还没重置才会执行到这,说明后端主动断开了
                self.serverTimeoutObj = setTimeout(function(){
                    disConnect();
                    connect();
                }, self.timeout)
            }, this.timeout)
        }
    }

小结:

    服务端
        1)单独的SpringBoot项目 用nginx反向代理多个节点(记得配置过期时间 默认1分半)
        2)在握手拦截器进行校验 在redis中检查是否有对应的userid、并通过userid再redis中找到对应的User 如果该字段为false 则把online设置为true(该字段不存入mysql)
              再将userid添加在attributes 方便用户下线时在DisConnect的监听器中取到userid 再把userid再redis中对应User的online设置为false
        3)在要给客户端发送消息时 根据接收者的userid在redis中检查该用户的online是否为true 若是false 这说明没有websocket的连接 则其无法收到消息 
             所以先用数据库把消息存起来 每个用户上线时 在Connect的监听器里 查找未发送消息的数据库查找  如果有自己userid对应的消息 就调用方法给指定目的地进行推送

    发送者
        在connect的路径加上加过密的userid或者token 再握手拦截器进行拦截判断与保存到stomp中

    接收者
        1)只用设置好订阅的路径(如果是点对点 可以设置成:/通用路径/自己的userid)
        2)把连接和监听的代码单独抽离成一份代码 然后每个页面引入该代码 并且在该组件给连接进行心跳检查 保证正常连接
           (因为是和当前页面建立的连接 即每次刷新或跳转到其他页面连接会断开 所以每一份代码引入自动连接的代码 但是要控制好登陆后才可以连接)
        3)如果是安卓端接收:监听到消息后进行提示 先操作本地数据库 再展示新的页面
        4)如果是网页接收:监听到消息后进行提示 并重新在数据库拉取消息并展示

你可能感兴趣的:(开发框架)