Spring Boot 整合 WebSocket 使用记录

前言

由于项目需要定时将消息从Web端推送至客户端
通常使用的方式有:AJAX轮询、XHR长轮询、iframe、Comet、websocket等

部分详情可见:https://blog.csdn.net/qq_43225978/article/details/105396640

考虑实现的难度及复杂度,最终选用WebSocket方式。

WebSocket 简介

WebSocket 是 HTML5 开始提供的一种在单个 TCP 连接上进行全双工通讯的协议。

WebSocket 使得客户端和服务器之间的数据交换变得更加简单,允许服务端主动向客户端推送数据。在 WebSocket API 中,浏览器和服务器只需要完成一次握手,两者之间就直接可以创建持久性的连接,并进行双向数据传输。

在 WebSocket API 中,浏览器和服务器只需要做一个握手的动作,然后,浏览器和服务器之间就形成了一条快速通道。两者之间就直接可以数据互相传送。

现在,很多网站为了实现推送技术,所用的技术都是 Ajax 轮询。轮询是在特定的的时间间隔(如每1秒),由浏览器对服务器发出HTTP请求,然后由服务器返回最新的数据给客户端的浏览器。这种传统的模式带来很明显的缺点,即浏览器需要不断的向服务器发出请求,然而HTTP请求可能包含较长的头部,其中真正有效的数据可能只是很小的一部分,显然这样会浪费很多的带宽等资源。

HTML5 定义的 WebSocket 协议,能更好的节省服务器资源和带宽,并且能够更实时地进行通讯。

在这里插入图片描述

浏览器通过 JavaScript 向服务器发出建立 WebSocket 连接的请求,连接建立以后,客户端和服务器端就可以通过 TCP 连接直接交换数据。

当你获取 Web Socket 连接后,你可以通过 send() 方法来向服务器发送数据,并通过 onmessage 事件来接收服务器返回的数据。

以下 API 用于创建 WebSocket 对象。

WebSocket 简介及API
https://www.runoob.com/html/html5-websocket.html
http://www.ruanyifeng.com/blog/2017/05/websocket.html
https://developer.mozilla.org/zh-CN/docs/Web/API/WebSocket

WebSocket 客户端(javascript前端)实现

javascript 实现

var websocket = null;
var websocket_connected_count = 0;
var onclose_connected_count = 0;
// 初始化WebSocket连接
function initWebSocket() {
    
    // 判断当前环境是否支持websocket
    if(window.WebSocket){
        if(!websocket){
        	// 获取协议类型
            var protocol = window.location.protocol;
            // console.info(protocol)
            // 通过访问协议类型,判断使用的websocket协议类型
            var ws_url = protocol=='http:'?'ws://':'wss://'
            // 获取域名
            var host = window.location.host;
            // 获取端口号
            var port = window.location.port;
            // 获取项目访问路由
            var pathName = window.location.pathname;
            // 截取项目名
            var projectName = pathName.substring(0, pathName.substr(1).indexOf('/') + 1);
            // 拼接websocket访问地址
            ws_url += host + projectName + "/webSocket/user_1";
            // console.info(ws_url);
            // 创建websocket对象
            websocket = new WebSocket(ws_url);
        }
    }else{
        var content = '【当前浏览器不支持WEBSOCKET,无法获取预警提醒消息,为获得良好的使用体验,推荐您下载使用<a style="color: orange;text-decoration: underline;" target="_blank" href="https://jhyj.ahga.gov.cn/updown/41_chrome_installer.exe">Chrome浏览器</a>】';
        alter(content);
    }

    //连接成功建立的回调方法
    websocket.onopen = function () {
        console.log('WebSocket连接成功');
        // 成功建立连接后,重置心跳检测
        heartCheck.reset().start();
    }
    //连接发生错误的回调方法
    websocket.onerror = function () {
        console.log('WebSocket连接发生错误');
        websocket_connected_count++;
        // 重连
        if(websocket_connected_count <= 5){
            initWebSocket();
        }
    };
    //接收到消息的回调方法
    websocket.onmessage = function (event) {
        // console.log("=====WebSocket接收到消息=====");
        // console.log(event);
        // console.log(event.data);

        // 如果获取到消息,说明连接是正常的,重置心跳检测
        heartCheck.reset().start();
    }
    //连接关闭的回调方法
    websocket.onclose = function () {
        console.log('WebSocket连接关闭');
    }

    //监听窗口关闭事件,当窗口关闭时,主动去关闭websocket连接,防止连接还没断开就关闭窗口,server端会抛异常。
    window.onbeforeunload = function () {
        // 关闭WebSocket连接
        websocket.close();
    }

    // 心跳检测, 每隔一段时间检测连接状态,如果处于连接中,就向server端主动发送消息,来重置server端与客户端的最大连接时间,如果已经断开了,发起重连。
    var heartCheck = {
	    // 30s 发一次心跳,比server端设置的连接时间稍微小,在接近断开的情况下以通信的方式去重置连接时间。
        timeout: 30000,
        serverTimeoutObj: null,
        reset: function(){
            clearTimeout(this.timeout);
            clearTimeout(this.serverTimeoutObj);
            return this;
        },
        start: function(){
            var self = this;
            this.serverTimeoutObj = setInterval(function(){
                if(websocket.readyState == 1){
                    console.log("连接状态,心跳保持连接");
                    websocket.send("ping");
                    heartCheck.reset().start();    // 如果获取到消息,说明连接是正常的,重置心跳检测
                }else{
                    console.log("断开状态,尝试重连");
                    initWebSocket();
                }
            }, this.timeout)
        }
    }
}

window.location获取URL中各部分

对于这样一个URL
http://www.x2y2.com:80/fisker/post/0703/window.location.html?ver=1.0&id=6#imhere

我们可以用javascript获得其中的各个部分
1, window.location.href
      整个URl字符串(在浏览器中就是完整的地址栏)
      本例返回值: http://www.x2y2.com:80/fisker/post/0703/window.location.html?ver=1.0&id=6#imhere

2,window.location.protocol
      URL 的协议部分
      本例返回值:http:

3,window.location.host
      URL 的主机部分
      本例返回值:www.x2y2.com

4,window.location.port
      URL 的端口部分
      如果采用默认的80端口(update:即使添加了:80),那么返回值并不是默认的80而是空字符
      本例返回值:""

5,window.location.pathname
      URL 的路径部分(就是文件地址)
      本例返回值:/fisker/post/0703/window.location.html

6,window.location.search
      查询(参数)部分
      除了给动态语言赋值以外,我们同样可以给静态页面,并使用javascript来获得相信应的参数值
      本例返回值:?ver=1.0&id=6

来源 : https://www.cnblogs.com/chaoyuehedy/p/5708165.html

http/https与websocket的ws/wss的关系

websocket在http与https不同协议下实际上按照标准来是有如下对应关系的:
	http -> new WebSocket('ws://xxx')
	https -> new WebSocket('wss://xxx')
也就是在https下应该使用wss协议做安全链接,且wss下不支持ip地址的写法,写成域名形式

部分报错的浏览器的确是因为这个原因导致的代码异常,即在https下把ws换成wss请求即可,看到这里心细的也许会发现,是部分浏览器,实际上浏览器并没有严格的限制http下一定使用ws,而不能使用wss,经过测试http协议下同样可以使用wss协议链接,https下同样也能使用ws链接,那么出问题的是哪一部分呢
1.Firefox环境下https不能使用ws连接

2.chrome内核版本号低于50的浏览器是不允许https下使用ws链接

3.Firefox环境下https下使用wss链接需要安装证书

实际上主要是问题出在Firefox以及低版本的Chrome内核浏览器上,于是在http与https两种协议都支持的情况下可以做兼容处理,即在http协议下使用ws,在https协议下使用wss

可使用以下方式拼接websocket访问地址:
var protocol = windows.location.protocol === 'https:' ? 'wss://localhost:8888'  : 'ws://localhost:8889';

来源 : https://blog.csdn.net/garrettzxd/article/details/81674251

WebSocket 服务端(java后台)实现

Maven 依赖

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

WebSocketServer 实现

package com.xxx.xxxx.webSocket.server;


import com.alibaba.fastjson.JSONObject;
import com.xxx.xxxx.webSocket.MessageCoder.MessageDecoder;
import com.xxx.xxxx.webSocket.MessageCoder.MessageEncoder;
import com.xxx.xxxx.webSocket.config.WebSocketConfig;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Component;

import javax.annotation.PostConstruct;
import javax.websocket.*;
import javax.websocket.server.PathParam;
import javax.websocket.server.ServerEndpoint;
import java.io.IOException;
import java.util.*;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.concurrent.atomic.AtomicInteger;

/**
 * @author xnz
 * @date 2020/03/31
 * @ServerEndpoint 注解是一个类层次的注解,它的功能主要是将目前的类定义成一个websocket服务器端,
 * 注解的值将被用于监听用户连接的终端访问URL地址,客户端可以通过这个URL来连接到WebSocket服务器端
 */
@Component
@ServerEndpoint( value = "/webSocket/{id}",configurator = WebSocketConfig.class, encoders = { MessageEncoder.class }, decoders = { MessageDecoder.class } )
public class WebSocketServer {
    private static Logger log = LoggerFactory.getLogger(WebSocketServer.class);

    @PostConstruct
    public void init() {
        System.out.println("[WebSocket 加载]");
    }
    /**
     * 静态变量,用来记录当前在线连接数。应该把它设计成线程安全的。
     */
    private static final AtomicInteger ONLINECOUNT = new AtomicInteger(0);
    /**
     * 线程安全map,实现服务端与单一客户端通信,其中Key为用户标识  用来存放 与某个客户端的连接会话,需要通过它来给客户端发送数据
     */
    public static ConcurrentHashMap<String,Session> sessionMap = new ConcurrentHashMap<>();
    /**
     * 线程安全list,用来存放 在线客户端账号所属的组织id
     */
    public static List<Long> orgIdList = new CopyOnWriteArrayList<>();
    /**
     * 对应客户端id
     */
    private String sessionId = "";
//    private static Long currentId = null;

    /**
     * 连接建立成功调用的方法
     * @param id
     * @param session
     * @param config    用来获取WebSocketConfig中的配置信息
     * @throws IOException
     */
    @OnOpen
    public void onOpen(@PathParam(value = "id") String id, Session session, EndpointConfig config) throws IOException {
//        currentId = (Long) config.getUserProperties().get("currentId");

        log.info("========" + id);
        // 将当前会话账户所属组织id存储
        String[] userId = id.split("_");
        orgIdList.add(Long.valueOf(userId[1]));

        id = UUID.randomUUID().toString()+id;
        // 存储当前会话
        sessionMap.put(id,session);

        sessionId = id;
        int count = ONLINECOUNT.incrementAndGet();
        log.info("有连接加入,当前连接数为:{}", count);
    }

    /**
     * 连接关闭调用的方法
     */
    @OnClose
    public void onClose(Session session) {
        try {
            session.close();

            sessionMap.remove(sessionId);

            String[] split = sessionId.split("_");
            orgIdList.remove(Long.valueOf(split[1]));
            int count = ONLINECOUNT.decrementAndGet();
            log.info("有连接关闭,当前连接数为:{}", count);
        } catch (IOException e) {
            e.printStackTrace();
        }
    }

    /**
     * 收到客户端消息后调用的方法
     *
     * @param message 客户端发送过来的消息
     */
    @OnMessage
    public void onMessage(String message, Session session) throws IOException, InterruptedException {
        log.info("来自客户端 {} 的消息:{} , 当前连接数为:{}",sessionId,message,ONLINECOUNT.get());
        //        sendMessage(session, JSON.toJSONString(message));
    }

    /**
     * 出现错误
     * @param session
     * @param error
     */
    @OnError
    public void onError(Session session, Throwable error) {
        log.error("发生错误,Session ID : {}",session.getId());
        error.printStackTrace();
    }

    /**
     * 指定Session发送消息,实践表明,每次浏览器刷新,session会发生变化。
     * @param session
     * @param message
     */
    private static void sendMessage(Session session, Object message) {
        try {
            session.getBasicRemote().sendText(JSONObject.toJSONString(message));
//            session.getBasicRemote().sendObject(message);
        } catch (Exception e) {
            log.error("发送消息出错:{}", e.getMessage());
            e.printStackTrace();
        }
    }

    /**
     * 通过id 获取会话 发送消息
     *
     * @param message   发送的消息
     */
    public static void sendMessageByOrgId(Object message,Long id)  {
        try {
            Set<Map.Entry<String, Session>> entries = sessionMap.entrySet();
            for (Map.Entry<String,Session>  item : entries) {
                String sessionId = item.getKey();
                if(sessionId.contains(id)){
                    log.info("[===发送消息至===] " + sessionId);
                    Session session = item.getValue();
                    sendMessage(session,message);
                }
            }
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
}

WebSocketConfig 实现

package com.xxx.xxxx.webSocket.config;

import org.apache.catalina.session.StandardSessionFacade;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.socket.server.standard.ServerEndpointExporter;

import javax.servlet.http.HttpSession;
import javax.websocket.HandshakeResponse;
import javax.websocket.server.HandshakeRequest;
import javax.websocket.server.ServerEndpointConfig;

/**
 * 主要的配置类
 * 本类必须要继承Configurator,因为@ServerEndpoint注解中的config属性只接收这个类型
 */
@Configuration
public class WebSocketConfig extends ServerEndpointConfig.Configurator {
    private static final Logger log = LoggerFactory.getLogger(WebSocketConfig.class);

    /**
     * 修改握手,就是在握手协议建立之前修改其中携带的内容
     * @param sec
     * @param request
     * @param response
     */
    @Override
    public void modifyHandshake(ServerEndpointConfig sec, HandshakeRequest request, HandshakeResponse response) {
        /*如果没有监听器,那么这里获取到的HttpSession是null*/
        StandardSessionFacade ssf = (StandardSessionFacade) request.getHttpSession();
        if (ssf != null) {
            HttpSession session = (HttpSession) request.getHttpSession();
            sec.getUserProperties().put("session", session);
            log.info("获取到的SessionID:{}",session.getId());
//            User currentUser = (User) session.getAttribute("currentUser");
//            log.info("获取当前用户currentId:{}",currentUser.getId());
//            sec.getUserProperties().put("currentId", currentUser.getId());
        }else{
            System.out.println("modifyHandshake 获取到null session");
        }
        super.modifyHandshake(sec, request, response);
    }
    /**
     *  注入ServerEndpointExporter,
     *  这个bean会自动注册使用了@ServerEndpoint注解声明的Websocket endpoint
     */
    @Bean
    public ServerEndpointExporter serverEndpointExporter() {
        return new ServerEndpointExporter();
    }

}

MessageDecoder 实现


package com.xxx.xxxx.webSocket.MessageCoder;

import com.alibaba.fastjson.JSON;
import org.apache.commons.lang3.StringUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import javax.websocket.DecodeException;
import javax.websocket.Decoder;
import javax.websocket.EndpointConfig;


public class MessageDecoder implements Decoder.Text<String>{

    private static Logger log = LoggerFactory.getLogger(MessageDecoder.class);

    @Override
    public String decode(String jsonMessage) throws DecodeException {
        log.info("MessageDecoder decode");
        return JSON.parseObject(jsonMessage, String.class);
    }

    @Override
    public boolean willDecode(String jsonMessage) {
        if(StringUtils.isBlank(jsonMessage))
            return false;
        try {
            JSON.parseObject(jsonMessage);
            return true;
        } catch (Exception e) {
            log.info("Message not jsonString");
            return false;
        }
    }

    @Override
    public void init(EndpointConfig endpointConfig) {
        log.info("MessageDecoder init");
    }

    @Override
    public void destroy() {
        log.info("MessageDecoder destroy");
    }
}


MessageEncoder 实现



package com.xxx.xxxx.webSocket.MessageCoder;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import javax.websocket.EncodeException;
import javax.websocket.Encoder;
import javax.websocket.EndpointConfig;

public class MessageEncoder implements Encoder.Text<String>{

    private static Logger log = LoggerFactory.getLogger(MessageEncoder.class);

    @Override
    public String encode(String s) throws EncodeException {
        return null;
    }

    @Override
    public void init(EndpointConfig endpointConfig) {
        log.info("MessageEncoder init");
    }

    @Override
    public void destroy() {
        log.info("MessageEncoder destroy");
    }
}

其他 WebSocket 客户端(javascript前端)及服务端(java后台)实现
https://www.cnblogs.com/freud/p/8397934.html
https://www.cnblogs.com/xdp-gacl/p/5193279.html
https://blog.csdn.net/Doctor_LY/article/details/81362718
spring boot Websocket(使用笔记):https://www.cnblogs.com/bianzy/p/5822426.html

问题

1. WebSocket服务端需要获取到用户使用数据库的用户信息登录后的HttpSession获取个人资料信息

在开发过程中想在 WebSocket服务端需要获取到用户使用数据库的用户信息登录后的HttpSession获取个人资料信息,通过搜索最后在WebSocketConfig类中的modifyHandshake方法中使用ServerEndpointConfig类sec.getUserProperties().put("currentId", currentUser.getId());方法,然后在onOpen方法中使用config.getUserProperties().get("currentId");获取。
详情可见上述实现类。

参考:
https://www.cnblogs.com/smallfa/p/9285844.html
https://www.cnblogs.com/coder163/p/8605645.html

2. 项目打jar包报异常

org.springframework.beans.factory.BeanCreationException: Error creating bean with name 'serverEndpointExporter' defined in org.lwt.WebsocketServerTestApplication: Invocation of init method failed; nested exception is java.lang.IllegalStateException: javax.websocket.server.ServerContainer not available
	at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.initializeBean(AbstractAutowireCapableBeanFactory.java:1745) ~[spring-beans-5.1.4.RELEASE.jar:5.1.4.RELEASE]
	...

原因:
WebSocket是servlet容器所支持的,所以需要加载servlet容器:
webEnvironment参数为springboot指定ApplicationContext类型。
webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT 表示内嵌的服务器将会在一个随机的端口启动。

解决方式

  1. 添加注解
@SpringBootTest(classes = WebsocketServerTestApplication.class, webEnvironment = SpringBootTest.WebEnvironment.DEFINED_PORT)
  1. 把pom里的test依赖删掉,删除测试类
<!--<dependency>-->
	<!--<groupId>org.springframework.boot</groupId>-->
	<!--<artifactId>spring-boot-starter-test</artifactId>-->
<!--</dependency>-->
  1. 使用war包时,springboot项目,去除内置tomcat的时候会把websocket的包也给删除掉,此时手动添加tomcat-embed-websocket包
<!--去除内嵌tomcat-->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-tomcat</artifactId>
    <scope>provided</scope>
</dependency>
<!-- https://mvnrepository.com/artifact/org.apache.tomcat.embed/tomcat-embed-websocket -->
<!--websocket依赖包-->
<dependency>
    <groupId>org.apache.tomcat.embed</groupId>
    <artifactId>tomcat-embed-websocket</artifactId>
    <version>8.5.23</version>
</dependency>

参考:
Error creating bean with name ‘serverEndpointExporter’ defined in class path —https://blog.csdn.net/kxj19980524/article/details/88751114
springboot整合websocket后运行测试类报错:javax.websocket.server.ServerContainer not available —https://blog.csdn.net/fggdgh/article/details/87185555

3. nginx转发无法连接

nginx转发需要配置nginx使nginx支持websocket连接:

server {
      listen   80;
      server_name 域名;
      location / {
      	# 代理转发地址
        proxy_pass   http://127.0.0.1:8080/;
        # 表明使用http版本为1.1
     proxy_http_version 1.1;
     # 超时设置 表明连接成功以后等待服务器响应的时候,如果不配置默认为60s;
        proxy_read_timeout   3600s;
        # 启用支持websocket连接
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection "upgrade";
      }
}

其中重要的是这两行,它表明是websocket连接进入的时候,进行一个连接升级将http连接变成websocket的连接。

proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";

来源 : Nginx 支持websocket的配置 — https://blog.csdn.net/weixin_37264997/article/details/80341911

3. Nginx代理webSocket时60s自动断开 或者 WebSocket发生EOFException异常

如果你的前端心跳间隔设置的大于60s,并且没有配置nginx超时时间,那么就会出现这个问题

解决方式

1. 将心跳间隔调小,小于nginx默认超时时间60s

2. 将nginx超时时间设置大一点

proxy_read_timeout   3600s;

借鉴 : Nginx代理webSocket时60s自动断开, 怎么保持长连接 — https://blog.csdn.net/cm786526/article/details/79939687

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章