WebSocket的故事(五)—— Springboot中,實現網頁聊天室之自定義消息代理

概述

WebSocket的故事系列計劃分五大篇六章,旨在由淺入深的介紹WebSocket以及在Springboot中如何快速構建和使用WebSocket提供的能力。本系列計劃包含以下幾篇文章:javascript

第一篇,什麼是WebSocket以及它的用途
第二篇,Spring中如何利用STOMP快速構建WebSocket廣播式消息模式
第三篇,Springboot中,如何利用WebSocket和STOMP快速構建點對點的消息模式(1)
第四篇,Springboot中,如何利用WebSocket和STOMP快速構建點對點的消息模式(2)
第五篇,Springboot中,實現網頁聊天室之自定義WebSocket消息代理
第六篇,Springboot中,實現更靈活的WebSockethtml

本篇的主線

本篇將經過一個接近真實的網頁聊天室Demo,來詳細講述如何利用WebSocket來實現一些具體的產品功能。本篇將只採用WebSocket自己,再也不使用STOMP等這些封裝。親自動手實現消息的接收、處理、發送以及WebSocket的會話管理。這也是本系列的最重要的一篇,無論大家激不激動,反正我是激動了。下面咱們就開始。java

本篇適合的讀者

想了解如何在Springboot上自定義實現更爲複雜的WebSocket產品邏輯的同窗以及各路有志青年。git

小小網頁聊天室的需求

爲了可以目標明確的表達本文中所要講述的技術要點,我設計了一個小小聊天室產品,先列出需求,這樣你們在看後面的實現時可以知其因此然。github

以上就是咱們本篇要實現的需求。簡單說,就是:web

用戶可加入,退出某房間,加入後可向房間內全部人發送消息,也可向某我的發送悄悄話消息json

需求分析和設計

設計用戶存儲

很容易想到咱們設計的主體就是用戶、會話和房間,那麼在用戶管理上,咱們就能夠用下面這個圖來表示他們之間的關係:瀏覽器

這樣咱們就能夠用一個簡單的Map來存儲房間<->用戶組這樣的映射關係,在用戶組內咱們再使用一個Map來存儲用戶名<->會話Session這樣的映射關係(假設沒有重名)。這樣,咱們就解決了房間和用戶組、用戶和會話,這些關係的存儲和維護。服務器

設計用戶行爲與用戶的關係

有兄弟看到這說了,「你講這麼半天了,跟以前幾篇講的什麼STOMP,什麼消息代理,有毛線的關係?」大兄弟你先消消氣,咱們學STOMP,學消息代理,學點對點消息,重要的是學思想,你說對不?下面咱們就用上了。session

當用戶加入到某房間以後,房間裏有任何風吹草動,即有人加入、退出或者發公屏消息,都會「通知」給該用戶。到此,咱們就能夠將建立房間理解成「建立消息代理」,將用戶加入房間,當作是對房間這個「消息代理」的一個「訂閱」,將用戶退出房間,當作是對房間這個「消息代理」的一個「解除訂閱」。

那麼,第一個加入房間的人,咱們定義爲「建立房間」,即建立了一個消息代理。爲了好理解,上圖:

其中紅色的小人表示第一個加入房間的用戶,即建立房間的人。當某用戶發送消息時,若是選擇將消息發送給聊天室的全部人,即至關於在房間裏發送了一個廣播,全部訂閱這個房間的用戶,都會收到這個廣播消息;若是選擇發送悄悄話,則只將消息發送給特定用戶名的用戶,即點對點消息。

總結一下咱們要實現的要點:

  • 用戶存儲,即用戶,房間,會話之間的關係和對象訪問方式。
  • 動態建立消息代理(房間),並實現用戶對房間的綁定(訂閱)。
  • 單獨發送給某個用戶消息的能力。

大致設計就到此爲止,還有一些細節,咱們先來看一下演示效果,再來看經過代碼來說解實現。

聊天室效果展現

用瀏覽器打開客戶端頁面後,展現輸入框和加入按鈕。輸入房間號1和用戶名小銘點擊進入房間

進入房間成功後,展現當前房間人數和歡迎語

當有其餘人加入或退出房間時,展現通知信息。能夠發送公屏消息和私聊消息。

下面就讓咱們看一下這些主要功能如何來實現吧。

代碼實現

按照咱們上述的設計,我會着重介紹重點部分的代碼設計和技術要點。

服務端實現

1. 配置WebSocket

@Configuration
@EnableWebSocket
public class WebSocketConfig implements WebSocketConfigurer {
    @Override
    public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
        registry.addHandler(new MyHandler(), "/webSocket/{INFO}").setAllowedOrigins("*")
                .addInterceptors(new WebSocketInterceptor());
    }
}
複製代碼

要點解析:

  • 註冊WebSocketHandlerMyHandler),這是用來處理WebSocket創建以及消息處理的類,後面會詳細介紹。
  • 註冊WebSocketInterceptor攔截器,此攔截器用來在客戶端向服務端發起初次鏈接時,記錄客戶端攔截信息。
  • 註冊WebSocket地址,並附帶了{INFO}參數,用來註冊的時候攜帶用戶信息。

以上都會在後續代碼中詳細介紹。

2. 實現握手攔截器

public class WebSocketInterceptor implements HandshakeInterceptor {
    @Override
    public boolean beforeHandshake(ServerHttpRequest serverHttpRequest, ServerHttpResponse serverHttpResponse, WebSocketHandler webSocketHandler, Map<String, Object> map) throws Exception {
        if (serverHttpRequest instanceof ServletServerHttpRequest) {
            String INFO = serverHttpRequest.getURI().getPath().split("INFO=")[1];
            if (INFO != null && INFO.length() > 0) {
                JSONObject jsonObject = new JSONObject(INFO);
                String command = jsonObject.getString("command");
                if (command != null && MessageKey.ENTER_COMMAND.equals(command)) {
                    System.out.println("當前session的ID="+ jsonObject.getString("name"));
                    ServletServerHttpRequest request = (ServletServerHttpRequest) serverHttpRequest;
                    HttpSession session = request.getServletRequest().getSession();
                    map.put(MessageKey.KEY_WEBSOCKET_USERNAME, jsonObject.getString("name"));
                    map.put(MessageKey.KEY_ROOM_ID, jsonObject.getString("roomId"));
                }
            }
        }
        return true;
    }
}

複製代碼

要點解析:

  • HandshakeInterceptor用來攔截客戶端第一次鏈接服務端時的請求,即客戶端鏈接/webSocket/{INFO}時,咱們能夠獲取到對應INFO的信息。
  • 實現beforeHandshake方法,進行用戶信息保存,這裏咱們將用戶名和房間號保存到Session上。

3. 實現消息處理器WebSocketHandler

public class MyHandler implements WebSocketHandler {

    //用來保存用戶、房間、會話三者。使用雙層Map實現對應關係。
    private static final Map<String, Map<String, WebSocketSession>> sUserMap = new HashMap<>(3);

    //用戶加入房間後,會調用此方法,咱們在這個節點,向其餘用戶發送有用戶加入的通知消息。
    @Override
    public void afterConnectionEstablished(WebSocketSession session) throws Exception {
        System.out.println("成功創建鏈接");
        String INFO = session.getUri().getPath().split("INFO=")[1];
        System.out.println(INFO);
        if (INFO != null && INFO.length() > 0) {
            JSONObject jsonObject = new JSONObject(INFO);
            String command = jsonObject.getString("command");
            String roomId = jsonObject.getString("roomId");
            if (command != null && MessageKey.ENTER_COMMAND.equals(command)) {
                Map<String, WebSocketSession> mapSession = sUserMap.get(roomId);
                if (mapSession == null) {
                    mapSession = new HashMap<>(3);
                    sUserMap.put(roomId, mapSession);
                }
                mapSession.put(jsonObject.getString("name"), session);
                session.sendMessage(new TextMessage("當前房間在線人數" + mapSession.size() + "人"));
                System.out.println(session);
            }
        }
        System.out.println("當前在線人數:" + sUserMap.size());
    }

    //消息處理方法
    @Override
    public void handleMessage(WebSocketSession webSocketSession, WebSocketMessage<?> webSocketMessage) {
        try {
            JSONObject jsonobject = new JSONObject(webSocketMessage.getPayload().toString());
            Message message = new Message(jsonobject.toString());
            System.out.println(jsonobject.toString());
            System.out.println(message + ":來自" + webSocketSession.getAttributes().get(MessageKey.KEY_WEBSOCKET_USERNAME) + "的消息");
            if (message.getName() != null && message.getCommand() != null) {
                switch (message.getCommand()) {
                        //有新人加入房間信息
                    case MessageKey.ENTER_COMMAND:
                        sendMessageToRoomUsers(message.getRoomId(), new TextMessage("【" + getNameFromSession(webSocketSession) + "】加入了房間,歡迎!"));
                        break;
                        //聊天信息
                    case MessageKey.MESSAGE_COMMAND:
                        if (message.getName().equals("all")) {
                            sendMessageToRoomUsers(message.getRoomId(), new TextMessage(getNameFromSession(webSocketSession) +
                                    "說:" + message.getInfo()
                            ));
                        } else {
                            sendMessageToUser(message.getRoomId(), message.getName(), new TextMessage(getNameFromSession(webSocketSession) +
                                    "悄悄對你說:" + message.getInfo()));
                        }
                        break;
                        //有人離開房間信息
                    case MessageKey.LEAVE_COMMAND:
                        sendMessageToRoomUsers(message.getRoomId(), new TextMessage("【" + getNameFromSession(webSocketSession) + "】離開了房間,歡迎下次再來"));
                        break;
                        default:
                            break;
                }
            }
        } catch (Exception e) {
            e.printStackTrace();
        }
    }

    /** * 發送信息給指定用戶 */
    public boolean sendMessageToUser(String roomId, String name, TextMessage message) {
        if (roomId == null || name == null) return false;
        if (sUserMap.get(roomId) == null) return false;
        WebSocketSession session = sUserMap.get(roomId).get(name);
        if (!session.isOpen()) return false;
        try {
            session.sendMessage(message);
        } catch (IOException e) {
            e.printStackTrace();
            return false;
        }
        return true;
    }

    /** * 廣播信息給某房間內的全部用戶 */
    public boolean sendMessageToRoomUsers(String roomId, TextMessage message) {
        if (roomId == null) return false;
        if (sUserMap.get(roomId) == null) return false;
        boolean allSendSuccess = true;
        Collection<WebSocketSession> sessions = sUserMap.get(roomId).values();
        for (WebSocketSession session : sessions) {
            try {
                if (session.isOpen()) {
                    session.sendMessage(message);
                }
            } catch (IOException e) {
                e.printStackTrace();
                allSendSuccess = false;
            }
        }

        return allSendSuccess;
    }

    //退出房間時的處理
    @Override
    public void afterConnectionClosed(WebSocketSession webSocketSession, CloseStatus closeStatus) {
        System.out.println("鏈接已關閉:" + closeStatus);
        Map<String, WebSocketSession> map = sUserMap.get(getRoomIdFromSession(webSocketSession));
        if (map != null) {
            map.remove(getNameFromSession(webSocketSession));
        }
    }
}
複製代碼

要點解析:

  • 使用sUserMap這個靜態變量來保存用戶信息。對應咱們上述的關係圖。
  • 實現afterConnectionEstablished方法,當用戶進入房間成功後,保存用戶信息到Map,並調用sendMessageToRoomUsers廣播新人加入信息。
  • 實現handleMessage方法,處理用戶加入,離開和發送信息三類消息。
  • 實現afterConnectionClosed方法,用來處理當用戶離開房間後的信息銷燬工做。從Map中清除該用戶。
  • 實現sendMessageToUsersendMessageToRoomUsers兩個向客戶端發送消息的方法。直接經過Session便可發送結構化數據到客戶端。sendMessageToUser實現了點對點消息的發送,sendMessageToRoomUsers實現了廣播消息的發送。

客戶端實現

客戶端咱們就使用HTML5爲咱們提供的WebSocket JS接口便可。

<html>
    <script type="text/javascript"> function ToggleConnectionClicked() { if (SocketCreated && (ws.readyState == 0 || ws.readyState == 1)) { lockOn("離開聊天室..."); SocketCreated = false; isUserloggedout = true; var msg = JSON.stringify({'command':'leave', 'roomId':groom , 'name': gname, 'info':'離開房間'}); ws.send(msg); ws.close(); } else if(document.getElementById("roomId").value == "請輸入房間號!") { Log("請輸入房間號!"); } else { lockOn("進入聊天室..."); Log("準備鏈接到聊天服務器 ..."); groom = document.getElementById("roomId").value; gname = document.getElementById("txtName").value; try { if ("WebSocket" in window) { ws = new WebSocket( 'ws://localhost:8080/webSocket/INFO={"command":"enter","name":"'+ gname + '","roomId":"' + groom + '"}'); } else if("MozWebSocket" in window) { ws = new MozWebSocket( 'ws://localhost:8080/webSocket/INFO={"command":"enter","name":"'+ gname + '","roomId":"' + groom + '"}'); } SocketCreated = true; isUserloggedout = false; } catch (ex) { Log(ex, "ERROR"); return; } document.getElementById("ToggleConnection").innerHTML = "斷開"; ws.onopen = WSonOpen; ws.onmessage = WSonMessage; ws.onclose = WSonClose; ws.onerror = WSonError; } }; function WSonOpen() { lockOff(); Log("鏈接已經創建。", "OK"); $("#SendDataContainer").show(); var msg = JSON.stringify({'command':'enter', 'roomId':groom , 'name': "all", 'info': gname + "加入聊天室"}) ws.send(msg); }; </html> 複製代碼

要點解析:

  • 發起服務端鏈接時,注意地址信息:'ws://localhost:8080/webSocket/INFO={"command":"enter","name":"'+ gname + '","roomId":"' + groom + '"}',這裏咱們在INFO後加入了用戶我的信息,服務端收到後,便可根據這個信息標記此會話。
  • 鏈接創建後,發送給房間內其餘人一條加入信息。經過ws.send()方法實現。

至此代碼部分就介紹完了,過多的代碼就再也不堆疊了,更詳細的代碼,請參見後面的Github地址。

本篇總結

經過一個相對完整的網頁聊天室例子,介紹了咱們本身使用WebSocket時的幾個細節:

  • 服務端想在創建鏈接,即握手階段搞事情,實現HandshakeInterceptor
  • 服務端想在創建鏈接以後和處理客戶端發來的消息,實現WebSocketHandler
  • 服務端經過WebSocketSession便可向客戶端發送消息,經過用戶和Session的綁定,實現對應關係。

想加深理解的同窗,仍是要深刻到代碼中仔細體會。限於篇幅,並且在文章中加入大量代碼自己也不容易讀下去。因此你們仍是要實際對着代碼理解比較好。

本篇涉及到的代碼

完整代碼實現-小小網頁聊天室

歡迎持續關注原創,喜歡的別忘了收藏關注,碼字實在太累,大家的鼓勵就是我堅持的動力!

小銘出品,必屬精品

歡迎關注xNPE技術論壇,更多原創乾貨每日推送。

相關文章
相關標籤/搜索