WebSocket

Spring MVC使用WebSocket統計即時在線人數

東東 2020/02/17 16:01:51
3768

    目前昕力大學網站是用JAVASring MVC框架開發,人數統計系統是利用session來實現,雖然這種方式十分直覺且方便,亦是一般常被使用的方式,但此種統計方式具有某些缺陷:

    其一是每個session會保留至少30分鐘,因此統計出來的並非真正的在線人數,而是代表30分鐘內瀏覽過昕力大學的人數。所以昕力大學網頁標頭顯示的人數,看起來總像是灌過水就是這個緣故。

    其二為網頁開啟後,只要不刷新頁面,看到的人數就會一直保持在當初開啟網頁時的數字,不會即時更新,結合第一點,我們看到的數字與實際線上人數其實有很大的差距。

 

  為了揭開事情的真相,取得精確的人數,解決數字灌水的問題,因此我捨棄了session的作法改用WebSocket進行人數統計。詳細作法如下:

 

一、pom.xml添加依賴

        <!-- websocket -->
        <dependency>
            <groupId>org.springframework</groupId>
            <artifactId>spring-websocket</artifactId>
            <version>${spring-framework.version}</version>
        </dependency>

 

二、 建立MessageHandler

public class MessageHandler extends TextWebSocketHandler {

    private List<WebSocketSession> sessions = new CopyOnWriteArrayList<>();

    //統計總共有多少CSRF Token用來代表人數
    private HashMap<String , String> userMap = new HashMap<String, String>();

    //關閉連線 刪除session
    @Override
    public void afterConnectionClosed(WebSocketSession session, CloseStatus status) throws Exception {
        sessions.remove(session); //從List中移除
        userMap.remove(session.getId());//從HashMap中移除
    }

    //連線成功 新增session
    @Override
    public void afterConnectionEstablished(WebSocketSession session) throws Exception {
        sessions.add(session);
    }

    //處理用戶發送的訊息 再回傳給其他用戶
    //這裡只回傳人數統計
    @Override
    public void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception {
        //連線成功時 會傳一個包含csrf token的訊息 將其加入HashMap
        String id = session.getId();
        String token = message.getPayload();
        userMap.put(id, token);

        //計算總共有多少不同的csrftoken
        //將所有CSRF Token放入Set就不會有重複的值
        Set<String> csrfSet = new HashSet<String>();
        Set<String> keysSet = userMap.keySet();
        for(String key : keysSet) {
            String csrf = userMap.get(key);
            csrfSet.add(csrf);
        }

        //目前人數 = CSRF Token的數量
        String onlineUsersNumber = String.valueOf(csrfSet.size());
        //發送在線人數給所有人
        for(WebSocketSession wss : sessions) {
            wss.sendMessage(new TextMessage(onlineUsersNumber));
        }
    }
}

    此程式用來收發WebSocket的訊息以及統計即時在現人數。其中使用List來儲存用戶WebSocketSession,當人數更新時即發送在線人數給所有用戶。另外再建立一個HashMap來存放用戶ID(包含在WebSocketSession之中)和用戶從前端傳回來的CSRF Token

    至於使用HashMap統計人數的原因,是因為只用WebSocketSession統計人數的話,若同一個人開啟多個分頁,每個分頁會產生各自的WebSocket連線以及WebSocketSession,人數就會重複計算。為避免此種情況,每個用戶勢必要有唯一的辨識方式,剛好昕力大學這專案有使用Spring SecurityCSRF Token功能。因此我就設定在網頁成功建立WebSocket連線時,將CSRF Token傳給WebSocket Server並存在HashMap內。如此一來只要統計CSRF Token就能得知人數。

    或許有人會覺得奇怪,為何在WebSocket斷開連線的時候不發送人數變化的訊息給所有使用者?因為昕力大學目前網站目前為MVC架構,而非前後分離,因此使用者在瀏覽網站的時候會時常切換頁面。用戶在離開舊頁面時斷開WebSocket會使在線人數減一,到新頁面後又會使人數加一。當使用者多,許多人在切換頁面的話,就會導致其他用戶畫面上的在線人數一直做無意義的跳動。

    為避免此情況,因此只在建立新連線時才發送人數訊息給所有用戶。

建立WebSocketConfig

@Configuration
@EnableWebSocket
public class WebSocketConfig implements WebSocketConfigurer {

    @Override
    public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
        registry.addHandler(new MessageHandler(), "/websocket")//設定連結
        .addInterceptors(new HttpSessionHandshakeInterceptor());
    }
}

四、 WebApplicationInitializer註冊WebSocketConfig

public class WebInitializer  implements WebApplicationInitializer {

	@Override
	public void onStartup(ServletContext container) throws ServletException {
		AnnotationConfigWebApplicationContext ctx = new AnnotationConfigWebApplicationContext();
		ctx.register(WebSocketConfig.class);//註冊websocket
		...
	}
}

五、 Spring Security中開啟Websocket的路徑權限

public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Override
	protected void configure(HttpSecurity http) throws Exception {
        http
			.authorizeRequests()
			.antMatchers("/websocket").permitAll()
			...
    }
}

六、 前端網頁Javascript加入Websocket的連線程式

var websocket;
function connect(){ //初始化連線 
	try {
		var protocol = window.location.protocol;
		if(protocol == "https:"){
			websocket = new WebSocket("wss://"+ window.location.host + "/tpu/websocket");
	      }else{
	        	websocket = new WebSocket("ws://"+ window.location.host + "/tpu/websocket");
	      }
	} catch (ex) {
		console.log(ex);
		console.log("websocket連接異常");
	}
	connecting();
	window.addEventListener("load", connecting, false);
}

function connecting() {
	websocket.onopen = function(evt) {
		onOpen(evt)
	}
	websocket.onclose = function(evt) {
		onClose(evt)
	}
	websocket.onmessage = function(evt) {
		onMessage(evt)
	}
	websocket.onerror = function(evt) {
		onError(evt)
	}
}
//連線上事件
function onOpen(evt) {
	console.log("WebSocket 連線成功");	
	websocket.send(csrfToken);//將CSRF Token傳送給WebSocket Server
}
//關閉事件
function onClose(evt) {}
//後端推送事件
function onMessage(evt) {
	console.log("WebSocket獲得目前在線人數:" + evt.data);
	showMessage(evt.data);
}
//發生錯誤
function onError(evt) {}
//瀏覽器主動斷開連線
function wsclose() {
	websocket.close();
}
function showMessage(message) {
	$("#header_visitors").html('線上人數:' + message);
	$("#footer_visitors").html('線上人數:' + message);
}
$(document).ready(function(){
	//啟動連線
	connect();
});

    當連線成功之時會自動將CSRF Token發送給WebSocket ServerWebSocket再將統計出來的人數發送給所有用戶,再顯示在網頁上。

    需要注意的是,當網址的開頭從http改為https時,WebSocket連線的開頭也要從ws改為wws,否則會無法建立連線。由於昕力大學的測試機是使用http,但正式主機是https,所以我這邊有做判斷並分別建立連線。

 

    啟動測試伺服器並打開網頁,就可以看到Console打印出「WebSocket連線成功」以及取得在線人數了!而且即使一人開啟多個分頁,也不會重複統計。

東東
9A6A0EDB718D5E75FF75485EEB308666
2020/02/17 16:27:35

沒考慮到connect斷線的情況

東東
2020/02/18 10:42:18

說得是,謝謝提醒。