前言
由於項目需要定時將消息從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 表示內嵌的服務器將會在一個隨機的端口啓動。
解決方式
- 添加註解
@SpringBootTest(classes = WebsocketServerTestApplication.class, webEnvironment = SpringBootTest.WebEnvironment.DEFINED_PORT)
- 把pom裏的test依賴刪掉,刪除測試類
<!--<dependency>-->
<!--<groupId>org.springframework.boot</groupId>-->
<!--<artifactId>spring-boot-starter-test</artifactId>-->
<!--</dependency>-->
- 使用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