SpringBoot WebSocket 消息交互

1. Websocket原理

  • Websocket协议本质上是一个基于TCP的独立协议,能够在浏览器和服务器之间建立双向连接,以基于消息的机制,赋予浏览器和服务器间实时通信能力。
  • WebSocket资源URI采用了自定义模式:ws表示纯文本通信,其连接地址写法为“ws://**”,占用与http相同的80端口;wss表示使用加密信道通信(TCP+TLS),基于SSL的安全传输,占用与TLS相同的443端口。

2. Websocket与HTTP比较

WebSocket 和 HTTP 都是基于 TCP 协议;
TCP是传输层协议,WebSocket 和 HTTP 是应用层协议

HTTP是用于文档传输、简单同步请求的响应式协议,本质上是无状态的应用层协议,半双工的连接特性。Websocket与 HTTP 之间的唯一关系就是它的握手请求可以作为一个升级请求(Upgrade request)经由 HTTP 服务器解释(也就是可以使用Nginx反向代理一个WebSocket)。

联系:

客户端建立WebSocket连接时发送一个header,标记了Upgrade的HTTP请求,表示请求协议升级;
服务器直接在现有的HTTP服务器软件和端口上实现WebSocket,重用现有代码(比如解析和认证这个HTTP请求),然后再回一个状态码为101(协议转换)的HTTP响应完成握手,之后发送数据就跟HTTP没关系了。

区别:

  • 持久性:

HTTP协议:HTTP是非持久的协议(长连接、循环连接除外)
WebSocket协议:Websocket是持久化的协议

  • 生命周期:

HTTP的生命周期通过Request来界定,也就是一个Request 一个Response
HTTP1.0中,这次HTTP请求就结束了;
HTTP1.1中进行了改进,使得有一个keep-alive,也就是说,在一个HTTP连接中,可以发送多个Request,并接收多个Respouse;
在HTTP中永远都是一个Request只有一个Respouse,而且这个Respouse是被动的,不能主动发起。

3. SpringWeb项目搭建

3.1.1 pom.xml

该项目基于maven搭建,使用SpringBoot2.0版本,引入Spring Websocket所需的jar包,以及对传输的消息体进行JSON序列化所需的jar包。

<dependencies>
        <!-- Compile -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-websocket</artifactId>
        </dependency>
        <!-- Test -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </dependency>

        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <scope>provided</scope>
            <optional>true</optional>
        </dependency>
    </dependencies>
pom.xml

3.1.2 WebSocketHandler接口实现

  实现WebSocketHandler接口并重写接口中的方法,为消息的处理实现定制化。Spring WebSocket通过WebSocketSession建立会话,发送消息或关闭会话。Websocket可发送两类消息体,分别为文本消息TextMessage和二进制消息BinaryMessage,两类消息都实现了WebSocketMessage接口(A message that can be handled or sent on a WebSocket connection.)
/**
 * Echo messages by implementing a Spring {@link WebSocketHandler} abstraction.
 */
@Slf4j
    public class EchoWebSocketHandler extends TextWebSocketHandler {

        /**
         * Map 来存储 WebSocketSession,key 用 USER_ID 即在线用户列表
         */
        private static final Map<String, WebSocketSession> users = new HashMap<String, WebSocketSession>();

        /**
         * 用户唯一标识【WebSocketSession 中 getAttributes() 方法获取到的 Map 集合是不同的,因为不同用户 WebSocketSession 不同,
         * 所以不同用户可以使用相同的 key = WEBSOCKET_IDCARD,因为 Map 不同,互不影响】
         */
        private static final String IDCARD = "WEBSOCKET_IDCARD";

        private final EchoService echoService;

        public EchoWebSocketHandler(EchoService echoService) {
            this.echoService = echoService;
        }

        /**
         * 连接成功时候,会触发页面上onopen方法
         */
        @Override
        public void afterConnectionEstablished(WebSocketSession session) {
            Map<String, Object> attributes = session.getAttributes();
            log.info("EchoWebSocketHandler = {}, session = {}, attributes = {}", this, session, attributes);

            String idcard = (String) session.getAttributes().get(IDCARD);
            log.info("idcard = {}用户成功建立 websocket 连接", idcard);
            users.put(idcard, session);
        }

        /**
         * js 调用 websocket.send 时候,会调用该方法
         */
        @Override
        public void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception {
            String echoMessage = this.echoService.getMessage(message.getPayload());
            log.info("前端发送消息,echoMessage = {}", echoMessage);
            session.sendMessage(new TextMessage(echoMessage));
        }

        @Override
        public void handleTransportError(WebSocketSession session, Throwable exception) throws Exception {
            session.close(CloseStatus.SERVER_ERROR);
        }

        /**
         * 关闭连接时触发
         */
        public void afterConnectionClosed(WebSocketSession session, CloseStatus closeStatus) {
            log.info("关闭websocket连接");
            String userId = (String) session.getAttributes().get(IDCARD);
            log.info("用户 userId = {} 已退出!", userId);
            users.remove(userId);
        }

        /**
         * 给某个用户发送消息
         */
        public void sendMessageToUser(String idcard, TextMessage message) {
            try {
                if (users.containsKey(idcard)) {
                    WebSocketSession session = users.get(idcard);
                    if (session.isOpen()) {
                        session.sendMessage(message);
                    }
                }
            } catch (IOException e) {
                log.error("发送消息异常, errerMsg = {}", e.getMessage());
            }
        }

        /**
         * 给所有在线用户发送消息
         */
        public void sendMessageToUsers(TextMessage message) {
            for (String userId : users.keySet()) {
                try {
                    if (users.get(userId).isOpen()) {
                        users.get(userId).sendMessage(message);
                    }
                } catch (IOException e) {
                    log.error("给所有在线用户发送消息异常, errorMsg = {}", e.getMessage());
                }
            }
        }

}
EchoWebSocketHandler

3.1.3 HttpSession存储属性值【key-value】

@RestController
@Slf4j
public class WebSocketController {

    @Autowired
    EchoWebSocketHandler echoWebSocketHandler;

    @RequestMapping("/websocket/login")
    public String login(HttpServletRequest request) {
        String idcard = request.getParameter("idcard");
        log.info("idcard = {} 登录 ", idcard);
        HttpSession session = request.getSession();
        session.setAttribute("WEBSOCKET_IDCARD", idcard);
        return "登录成功";
    }

    @RequestMapping("/websocket/send")
    @ResponseBody
    public void send(HttpServletRequest request) {
        String username = request.getParameter("idcard");
        echoWebSocketHandler.sendMessageToUser(username, new TextMessage("你好,给您推送消息啦!"));
    }

}
WebSocketController

  HttpSession可以记录当前访问的会话用户,但WebSocketSession不能记录当前用户会话,必须要从HttpSession中存储当前用户相关信息idcard,在建立通信握手之前,需要将HttpSession中用户相关属性值存储到WebSocketSession中,服务器才能知道通话的当前用户;【目前怎么将HttpSession中的key-value的值存储到Map集合,并将Map集合同步到WebsocketSession的attributes属性中,这点我没有跟踪到源码,但是我敢确认,肯定设置进去了】

3.1.4 WebSocket激活

@Configuration(proxyBeanMethods = false)
@EnableAutoConfiguration
@EnableWebSocket
@Slf4j
public class SampleTomcatWebSocketApplication extends SpringBootServletInitializer implements WebSocketConfigurer {

    @Override
    public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
        //设置自定义的 WebSocket 握手拦截器
        registry.addHandler(echoWebSocketHandler(), "/echo").addInterceptors(webSocketHandlerIntereptor()).withSockJS();
    }

    @Override
    protected SpringApplicationBuilder configure(SpringApplicationBuilder application) {
        return application.sources(SampleTomcatWebSocketApplication.class);
    }

    @Bean
    public EchoService echoService() {
        return new DefaultEchoService("Did you say "%s"?");
    }

    @Bean
    public GreetingService greetingService() {
        return new SimpleGreetingService();
    }

    @Bean
    public WebSocketHandler echoWebSocketHandler() {
        return new EchoWebSocketHandler(echoService());
    }

    @Bean
    public WebSocketController webSocketController() {
        return new WebSocketController();
    }

    @Bean
    public ServerEndpointExporter serverEndpointExporter() {
        return new ServerEndpointExporter();
    }

    @Bean
    public WebSocketHandlerIntereptor webSocketHandlerIntereptor() {
        return new WebSocketHandlerIntereptor();
    }

    public static void main(String[] args) {
        SpringApplication.run(SampleTomcatWebSocketApplication.class, args);
    }

}
WebSocketConfig

Websocket拦截器类:  

  HttpSession和WebSocketSession是不同的会话对象,如果想记录当前用户的Session对象的属性值,必须要在建立通信握手之前,将HttpSession的值copy到WebSocketSession中,否则获取不到;

  该拦截器实现了HandshakeInterceptor接口,HandshakeInterceptor可拦截Websocket的握手请求(通过HTTP协议)并可设置与Websocket session建立连接的HTTP握手连接的属性值。实例中配置重写了beforeHandshake方法,将HttpSession中对象放入WebSocketSession中,实现后续通信;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.server.ServerHttpRequest;
import org.springframework.http.server.ServerHttpResponse;
import org.springframework.http.server.ServletServerHttpRequest;
import org.springframework.web.socket.WebSocketHandler;
import org.springframework.web.socket.server.support.HttpSessionHandshakeInterceptor;

import javax.servlet.http.HttpSession;
import java.util.Map;

@Slf4j
public class WebSocketHandlerIntereptor extends HttpSessionHandshakeInterceptor {

    @Override
    public boolean beforeHandshake(ServerHttpRequest request, ServerHttpResponse response, WebSocketHandler wsHandler,
                                   Map<String, Object> attributes) throws Exception {
        log.info("Before Handshake");
        if (request instanceof ServletServerHttpRequest) {
            ServletServerHttpRequest servletRequest = (ServletServerHttpRequest) request;
            HttpSession session = servletRequest.getServletRequest().getSession();
            if (session != null) {
                //使用 idcard 区分 WebSocketHandler,以便定向发送消息【一般直接保存 user 实体】
                String idcard = (String) session.getAttribute("idcard");
                if (idcard != null) {
                    attributes.put("WEBSOCKET_IDCARD", idcard);
                }

            }
        }
        return super.beforeHandshake(request, response, wsHandler, attributes);
    }

    @Override
    public void afterHandshake(ServerHttpRequest request, ServerHttpResponse response, WebSocketHandler wsHandler,
                               Exception ex) {
        super.afterHandshake(request, response, wsHandler, ex);
    }

}
WebSocketHandlerIntereptor

3.2 WebSocket前端 

<!DOCTYPE html>
<html>
<head>
    <title>Apache Tomcat WebSocket Examples: Echo</title>
    <style type="text/css">
        #connect-container {
            float: left;
             400px
        }

        #connect-container div {
            padding: 5px;
        }

        #console-container {
            float: left;
            margin-left: 15px;
             400px;
        }

        #console {
            border: 1px solid #CCCCCC;
            border-right-color: #999999;
            border-bottom-color: #999999;
            height: 170px;
            overflow-y: scroll;
            padding: 5px;
             100%;
        }

        #console p {
            padding: 0;
            margin: 0;
        }
    </style>
    <script src="https://cdn.jsdelivr.net/sockjs/0.3.4/sockjs.min.js"></script>
    <script type="text/javascript">
        var ws = null;

        function setConnected(connected) {
            document.getElementById('connect').disabled = connected;
            document.getElementById('disconnect').disabled = !connected;
            document.getElementById('echo').disabled = !connected;
        }

        function connect() {
            var target = document.getElementById('target').value;
            ws = new SockJS(target);
            ws.onopen = function () {
                setConnected(true);
                log('Info: WebSocket connection opened.');
            };
            ws.onmessage = function (event) {
                log('Received: ' + event.data);
            };
            ws.onclose = function () {
                setConnected(false);
                log('Info: WebSocket connection closed.');
            };
        }

        function disconnect() {
            if (ws != null) {
                ws.close();
                ws = null;
            }
            setConnected(false);
        }

        function echo() {
            if (ws != null) {
                var message = document.getElementById('message').value;
                log('Sent: ' + message);
                ws.send(message);
            } else {
                alert('WebSocket connection not established, please connect.');
            }
        }

        function log(message) {
            var console = document.getElementById('console');
            var p = document.createElement('p');
            p.style.wordWrap = 'break-word';
            p.appendChild(document.createTextNode(message));
            console.appendChild(p);
            while (console.childNodes.length > 25) {
                console.removeChild(console.firstChild);
            }
            console.scrollTop = console.scrollHeight;
        }
    </script>
</head>
<body>
<noscript><h2 style="color: #ff0000">Seems your browser doesn't support Javascript! Websockets rely on Javascript being enabled. Please enable
    Javascript and reload this page!</h2></noscript>
<div>
    <div id="connect-container">
        <div>
            <input id="target" type="text" size="40" style=" 350px" value="/echo"/>
        </div>
        <div>
            <button id="connect" onclick="connect();">Connect</button>
            <button id="disconnect" disabled="disabled" onclick="disconnect();">Disconnect</button>
        </div>
        <div>
            <textarea id="message" style=" 350px">Here is a message!</textarea>
        </div>
        <div>
            <button id="echo" onclick="echo();" disabled="disabled">Echo message</button>
        </div>
    </div>
    <div id="console-container">
        <div id="console"></div>
    </div>
</div>
</body>
</html>
WebSocket前端

4. 总结

  当然,上述展示的只是一个小小的Demo,但按照上述思路即可将Websocket运用于其它项目中,为项目锦上添花。可,不知大家有没有注意到一个,上述Websocket协议我们使用的都是ws协议,那什么时候会用到wss协议呢?当我们的通信协议为HTTPS协议的时候,此时需要在服务端应用服务器中安装SSL证书,不然服务端是没法解析wss协议的。
  前后端通信,使用SpringBoot内置的WebSocket通信,如果更加深刻理解WebSocket通信,Debug走一下具体流程,才能理解的更加透彻,在同事的帮助下,我也理解了SpringBoot中WebSocket的通信机制;
原文地址:https://www.cnblogs.com/blogtech/p/11739083.html