SpringBoot Integrated WebSocket+Socketjs Heart Reconnection Implementation

Posted by JsusSalv on Fri, 30 Aug 2019 07:35:47 +0200

I. Basic concepts

1. Unicast: point-to-point, private chat

2. Multicast, also known as Multicast (Special Crowds): Multi-person chat, publish and subscribe

3. Broadcast (everyone): Game announcements, publishing and subscribing

2. Springboot Integration Websocket

1. Dependence
back-end

    <dependency>
         <groupId>org.springframework.boot</groupId>
         <artifactId>spring-boot-starter-websocket</artifactId>
    </dependency>

2. Define two entity classes for message sending

    /*
    * Received messages
    * from: The source of the message (typically the sender ID or session id) is the message's identity through which the corresponding user can be found (if it's a 1v1 chat, this is the sign to find the sender of the message)
    * to: Part of the destination of the message (usually the receiving user ID or session id)
    *   If the message is broadcast and multicast, the front end can ignore the field and specify a fixed route directly when sending (e.g. group chat, push real-time message, etc.).
    *   If the message is unicast, the destination to be sent should be identified by the tototoid and then spliced.
    *
    */
    public class InMessage {

        //Where do you come from?
        private String from;

        //Where to go (unicast must be used)
        private String to;

        private String content;

        private Date time = new Date();
    }


    /*
    * Messages sent
    * from: There are some things that can be done when the front end gets sent.
    * No to field is meaningless because it has been sent to the recipient
    */
    public class OutMessage {

        private String from;

        private String content;

        private Date time = new Date();
    }

3. Method of message reception

@MessageMapping("/v1/chat")
Be careful:
In the path of the @MessageMapping annotation, you do not need to write the path prefix configured in the configuration setApplication Destination Prefixes (that is, the prefix that the client sends data to the server)

4. Two Ways of Pushing WebSocket

  1. @SendTo:
    Not universal. A route receives messages because annotations can only write one route, so it can only send all messages of this method to one annotated path.
        @MessageMapping("/v1/chat")
        @SendTo("/topic/game_chat")
        public OutMessage gameInfo(InMessage message){
            return new OutMessage(message.getFrom(),message.getContent());
        }
  1. SimpMessagingTemplate
    Flexible, support multiple delivery methods (build a WebSocketService to write different scenarios in the class sending data and routing)
  • Message Sending Template
	@Service
        public class WebSocketService {

            @Autowired
            private SimpMessagingTemplate template;

            /*
            * Simple specified message to destination: broadcast, unicast
            *
            * @param dest:Path of Destination
            * @param message: Provide content and from for OutMessage
            */
            public void sendTopicMessage(String dest, InMessage message) throws InterruptedException {
                template.convertAndSend(dest, new OutMessage(message.getContent());
            }

            /*
            * Point-to-point messaging: adding user's path to the unified path
            */
            public void sendChatMessage(InMessage message) {

                //In addition to the fixed path, the sending path splices the identity of the specific receiving user (usually the user id). 
                //And the subscription path for each user unicast is also the path with its own id. 
                template.convertAndSend("/chat/single/" + message.getTo() ,new OutMessage(message.getFrom() + " Send out:" + message.getContent()));
            }

            
            /*
            * Heart rate detection directly marks the return of the source path to "pang"
            */
            public void sendPong(InMessage message) {
                template.convertAndSend(message.getTo());
            }
        }
  • Receiving messages and forwarding them to 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);//Heart beat detection returns to the front end
        }

Be careful:
Client - > Server (Server Subscription): Do not write the prefix of the setApplication Destination Prefixes configuration in config
Client - > Server (Client Send): Write the prefix of the setApplication Destination Prefixes configuration in config
Server - > Client (Server Send): Write the prefix of enableSimpleBroker configuration in config
Server - > Client (Client Subscription): Write the prefix of enableSimpleBroker configuration in config

5.websocket listener

  1. websocket module listener type:

Session SubscribeEvent Subscribe Event Subscription Event
Session Unsubscribe Event unsubscribe event
Session Connect Event Connection Event Connection Event
Session Disconnect Event disconnect event (note that for a single session, this event may be raised multiple times, so the event should be idempotent and overlook duplicate events)

  1. Use
    • The listener class needs to implement the interface ApplicationListener T to represent the event type. The following are the corresponding websocket event types

    • Annotate @Component on the listener class, and spring will incorporate the change into management (no more config configuration file settings)

    • StompHeaderAccessor
      The base class for handling headers in simple messaging protocols (because the specification protocol is wrapped in stom) through which message types (e.g. publishing subscriptions, establishing disconnected connections), session id s, etc.) can be obtained.

StompHeaderAccessor webSocketheaderAccessor = StompHeaderAccessor.wrap(Message);

    @Component
    public class SubscribeEventListener implements ApplicationListener<SessionSubscribeEvent>{

        /**
        * Call this method when an event triggers
        */
        public void onApplicationEvent(SessionSubscribeEvent event) {
            //Getting Message through event
            StompHeaderAccessor headerAccessor = StompHeaderAccessor.wrap(event.getMessage());
            System.out.println("[SubscribeEventListener Listener Event Type]"+headerAccessor.getCommand().getMessageType());
            //The session ID must be inserted after the Handshake Interceptor intercepts before it can be retrieved.
            System.out.println("[SubscribeEventListener Listener event sessionId]"+headerAccessor.getSessionAttributes().get("sessionId"));

        }
    }

6. Handshake Interceptor Handshake Interceptor
(Only once on the first connection)

  • Main roles:

    • Servlet Server HttpRequest can be converted to get header, session, etc. by request, and then put into attributes for later Lister and Socket Channel Inteceptor to use.
    • See if interception is performed
  • Use
    You need to configure it in the config file
    implements HandshakeInterceptor

      /**
      * Function description: http handshake interceptor, the earliest execution
      * The resuest and response can be obtained through the method of this class for later use
      */
      public class HttpHandShakeIntecepter implements HandshakeInterceptor{
    
          @Override
          public boolean beforeHandshake(ServerHttpRequest request,
              ServerHttpResponse response, WebSocketHandler wsHandler,
              Map<String, Object> attributes) throws Exception {
    
              /* 
              * Interception method 1: Obtain the session ID and then determine whether the online session ID has the session ID or not.
              *
              * First determine whether a session exists and then get the session ID (for front-end, back-end, or Android clients)
              * if(request instanceof ServletServerHttpRequest) {
                     //Strong to Servlet Server HttpRequest
                  ServletServerHttpRequest servletRequest = (ServletServerHttpRequest)request;
                  HttpSession session = servletRequest.getServletRequest().getSession();
    
                  String sessionId = session.getId();
                   //...Check
                }
              */ 
    
              /* 
              * Interception method 2: Add parameter http://localhost:8080/endpoint to the url of stomp.Connect request?Token=access_token
              *  Take out the tocken in the parameter and parse the user or pass the encrypted userid directly.
              */ 
             String userid= request.getServletRequest().getHeader("userid");
    
              //If userid is empty or there is no userid in redis, return unauthorized error code
              if (StringUtils.isBlank(userid) || !redisTemplate.haskey(userid)) {
                  response.setStatusCode( HttpStatus.UNAUTHORIZED );
                  return false;
              }
    
              //Save the usrid in stomp Access Header to find the corresponding User from redis when the DisConnect listener disconnects and set the online to false
                //Obtained by stompHeaderAccessor.getSessionAttributes().get("userid") 
                attributes.put("userid", userid);
    
              //Save username in @SendToUser when an exception is sent, otherwise you don't know the destination.
              User user=findUserById(userid);
                                             user.setOnline(true);//Set online to false. This field is not stored in mysql
              attributes.put("userName", user.getName());
              
              return true;
          }
    
    
    
          @Override
          public void afterHandshake(ServerHttpRequest request, ServerHttpResponse response, WebSocketHandler wsHandler, Exception exception) {
              //This method is generally not available, so it is not implemented.
          }
    
      }
    

7. Sending message exception handling
(Stomp is an implementation of websocket that asynchronously sends an exception if it is not perceived by the sender, so the exception can be returned to the sender of the message so that the front end knows the sending exception and informs the sender.)

    @MessageExceptionHandler(Exception.class)
    @SendToUser("/error/quene") //It will be sent to / Principal/error/quene set by DefaultHandshakeHandler
    public Exception handleExceptions(Exception t){
        t.printStackTrace();
        return t;
    }

8.spring channel interceptor (outdated)

    public class SocketChannelIntecepter extends ChannelInterceptorAdapter{

        /**
        * Calling after sending, whether or not an exception occurs, is generally used for resource cleanup
        */
        @Override
        public void afterSendCompletion(Message<?> message, MessageChannel channel, boolean sent, Exception ex) {
            super.afterSendCompletion(message, channel, sent, ex);
        }


        /**
        * Called before the message is actually sent to the channel
        * Can be used for landing verification
        */
        @Override
        public Message<?> preSend(Message<?> message, MessageChannel channel) {
            StompHeaderAccessor accessor = MessageHeaderAccessor.getAccessor(message, StompHeaderAccessor.class);

            //1. Determine whether the first connection request is made
            if (StompCommand.CONNECT.equals(accessor.getCommand())) {
                /* 2. Verify logon
                * accessor.getNativeHeader()The request header from the front end when the data is obtained
                *
                *     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);

                //If the validation fails, return null, then the message will not be received by the server
                if(false){
                    return null;
                }
            }
            //It's not the first connection. It's landed successfully.
            return message;
        }



        /**
        * Calling immediately after sending a message call is usually used to listen on and off the line.
        */
        @Override
        public void postSend(Message<?> message, MessageChannel channel, boolean sent) {
            StompHeaderAccessor headerAccessor = StompHeaderAccessor.wrap(message);//Header Accessor

            if (headerAccessor.getCommand() == null ) return ;// Avoid non-stomp message types, such as heartbeat detection

            String sessionId = headerAccessor.getSessionAttributes().get("sessionId").toString();//You may need to use the session ID after you get it.

            switch (headerAccessor.getCommand()) {
                case CONNECT:
                    //Operation after successful connection.
                    break;
                case DISCONNECT:
                    //Disconnecting operation ____________.
                    break;

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

        }

    }

9. Configuration file

    @Configuration
    //Open support for websocket and use stomp protocol to transfer proxy messages.
    // At this point, the controller uses @MessageMapping as well as @RequestMaping.
    @EnableWebSocketMessageBroker
    public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {


        /**
        * Endpoint: Register endpoints, which need to be connected when publishing or subscribing to messages
        * Interceptors: Front handshake interceptor
        * AllowedOrigins: Not required, * means that other domains are allowed to connect
        * withSockJS: Represents the start of sockejs support
        */
        public void registerStompEndpoints(StompEndpointRegistry registry) {
            registry.addEndpoint("/endpoint-websocket").addInterceptors(new HttpHandShakeIntecepter())
                .setHandshakeHandler(new DefaultHandshakeHandler(){
                    @Override
                    protected Principal determineUser(ServerHttpRequest request, WebSocketHandler wsHandler, Map<String, Object> attributes) {
                        //Set Authenticated User to Remove userName Added by Interceptor
                        return new Principal(attributes.get("userName"));
                    }
                }
                .setAllowedOrigins("*")
                .withSocketJs()
        }

        /**
        * Configure message broker (mediation)
        * enableSimpleBroker Path prefix pushed by server to client
        * setApplicationDestinationPrefixes A prefix for client sending data to server
        */
        @Override
        public void configureMessageBroker(MessageBrokerRegistry registry) {
            registry.enableSimpleBroker("/topic", "/chat", "/check");
            registry.setApplicationDestinationPrefixes("/app");

        }

        /**
        * Message Transfer Parameter Configuration
        */
        @Override
        public void configureWebSocketTransport(WebSocketTransportRegistration registry) {
            registry.setMessageSizeLimit(8192) //Setting message byte size
            .setSendBufferSizeLimit(8192)//Setting message cache size
            .setSendTimeLimit(10000); //Set message delivery time limit in milliseconds
        }


        /**
        * Configure the client inbound channel interceptor
        */
        @Override
        public void configureClientInboundChannel(ChannelRegistration registration) {
            registration.taskExecutor().corePoolSize(4) //Setting the number of thread pool threads for message input channels
            .maxPoolSize(8)//Maximum number of threads
            .keepAliveSeconds(60);//Maximum idle time of threads
            //The previously configured spring channel interceptor is out of date and is not recommended for reuse
            registration.interceptors( new SocketChannelIntecepter());
        }

        /**
        * Configuring Client Outbound Channel Interceptor
        */
        @Override
        public void configureClientOutboundChannel(ChannelRegistration registration) {
            registration.taskExecutor().corePoolSize(4).maxPoolSize(8);
            //The previously configured spring channel interceptor is out of date and is not recommended for reuse
            registration.interceptors( new SocketChannelIntecepter());
        }
    }

3. socketJs
1. Dependence
(Create an empty maven project introduced through web-jar)

    <dependency>
        <groupId>org.webjars</groupId>
        <artifactId>sockjs-client</artifactId>
        <version>1.1.2</version>
    </dependency>
    <dependency>
        <groupId>org.webjars</groupId>
        <artifactId>stomp-websocket</artifactId>
        <version>2.3.3-1</version>
    </dependency>        
    <dependency>
        <groupId>org.webjars</groupId>
        <artifactId>jquery</artifactId>
        <version>3.1.0</version>
    </dependency>

2. Core code (including heartbeat reconnection)

    //Write global all operations need to be used
    var stompClient = null;
    var tryTimes=0;//Number of reconnections

    function connect() {
        //Endpoints that allow websocket connections
        var socket = new SockJS('http://localhost:8080/endpoint-websocket?userid=xxx');
        stompClient = Stomp.over(socket);

        /** How to join the request header
        *   var headers={
        *     username:$("#username").val(),
        *     password:$("#password").val()
        *   };
        *   stompClient.connect(headers, function (frame) {.....
        */
        stompClient.connect({}, function (frame) {
                                            heartCheck.reset().start(); //Start heartbeat detection
            tryTimes= 0;//Number of reset reconnections

            //Other operations after successful connection...

            //Subscription (this route is dedicated to heartbeat detection)
            stompClient.subscribe('/check/net/'+userid, function (result) {
                                                      heartCheck.reset().start(); //Heart beat detection reset
            });

            //Ordinary subscriptions (if the path does not specify a userid, all messages sent by the server to that path can be received)
            //If you want to unsubscribe, use the object returned by subscribe below to call unsubscribe()
            stompClient.subscribe('/chat/single', function (result) {
                 //Re-parse JSON.parse for body with result
                 var body=JSON.parse(result.body); 
                 console.log(body.content);
            });
        },
        function(errorCallback){
            //Connection failed operation... (This method is not required)
            console.log(errorCallback)
            reconnect();
        });
    }

    function reconnect() {
        if(tryTimes>10){
            alert("The number of reconnections to reach the upper limit failed")
            return;
        }
        setTimeout(function () { //No connection will always be reconnected, set the delay to avoid too many requests
            connect();
        }, 3000);
    }
    
    function sendMessage() {
        //stomp protocol stipulates that transmission is in son format, so both parsing and sending are json
        //The data to be sent is written in json format and then parsed into strings by JSON.stringify and transmitted to the server
        stompClient.send("/app/v3/single/chat", {}, JSON.stringify({'content': $("#content").val(), 'to':$("#to").val(), 'from':$("#from").val()}));
    }

    //Close the connection manually
    function disconnect() {
        if (stompClient !== null) {
            stompClient.disconnect();
        }
        //Operation after successful disconnection. 
    }

    //Heart beat detection and reconnection
    var heartCheck = {
        timeout: 10000, //10 s heartbeat
        timeoutObj: null,
        serverTimeoutObj: null,
        reset: function(){
            clearTimeout(this.timeoutObj);//Clear timed tasks
            clearTimeout(this.serverTimeoutObj);
            return this;
        },
        start: function(){
            var self = this;
            this.timeoutObj = setTimeout(function(){
                //Here, a heartbeat is sent to the designated route at the back end, which receives a message that will be sent to the designated route at the front end, thus completing an interaction (message content can be empty as long as it can reach the route).
                stompClient.send("/app/v3/check", {}, JSON.stringify({'to':"/check/net/"+userid}));
                console.log("ping!")

                //If it has not been reset for more than a certain period of time before it can be executed, it means that the back end is actively disconnected.
                self.serverTimeoutObj = setTimeout(function(){
                    disConnect();
                    connect();
                }, self.timeout)
            }, this.timeout)
        }
    }

Summary:

    Server
        1) Individual SpringBook projects use nginx to reverse proxy multiple nodes (remember to configure expiration time by default of 1.5 points)
        2) Check whether there is a corresponding userid in redis by handshake interceptor, and set the online to true (the field is not stored in mysql) by finding the corresponding User in userid redis.
              Adding userid to attributes to facilitate User offline, fetch userid from DisConnect's listener, and then set the online of the corresponding User in userid redis to false.
        3) When sending a message to a client, check whether the user's online is true in redis according to the receiver's userid. If false, it means that the user can't receive a message without a websocket connection. 
             So first use the database to store the messages, when each user is online, look for the database that has not sent the message in the Connect listener, and if there is a message corresponding to its own userid, call the method to push to the designated destination.

    sender
        Intercept judgment and save to stomp by adding an encrypted userid or token to connect path and shaking hands again

    Recipient
        1) Set the subscription path only (if it is peer-to-peer, it can be set to: / Universal path / its own userid)
        2) Separate the connection and monitoring code into a single code, then introduce the code into each page, and check the heartbeat of the connection in the component to ensure the normal connection.
           (Since the connection established with the current page is disconnected every time a link is refreshed or jumped to another page, each code introduces an automatic connection code, but it can only be connected after the login is controlled.)
        3) If it is received by Android, monitor the message, prompt, operate the local database and display the new page.
        4) If the web page receives: after listening to the news, prompt and re-display the information in the database.

Topics: Session JSON Redis Spring