iT邦幫忙

第 12 屆 iThome 鐵人賽

DAY 28
0
Modern Web

30天從零撰寫 Kotlin 語言並應用於 Spring Boot 開發系列 第 28

[Day 28] 遠征 Kotlin × Spring Boot 介紹 WebSocket 實作

先前我們設計的 API 其實都是利用 HTTP 協定進行傳輸,而 HTTP 只能利用 Client 端發送請求到 Service端,這類型屬於單向的,而 WebSocket 也是一種網路傳輸協定,它可以支援我們在 TCP 連接上進行全雙工通訊,使得 Client 端和 Server 端之間的資料交換可以變得更簡單,允許 Server 端主動向 Client 端傳輸資料。

早期,網站為了實現 Server 端向 Client 端傳輸資料,會使用到輪詢(Polling)技術,此技術主要就是在 Client端設計由瀏覽器每隔一段時間向 Server 端發送 HTTP 請求(Request),再由 Server 端回應最新的資料給 Client端,而此技術最大的缺點就是瀏覽器會不斷向 Server 端發送請求,可能會造成浪費許多頻寬資源。

目前較新的 Polling 技術是 Comet ,採用的方法是長時間輪詢(Long-Polling),設計概念則是讓 Server 在接收到瀏覽器所送出的 Http 請求後,Server 端會等待一段時間,若在這段時間內伺服器有新的資料,他就會把最新的資料傳給瀏覽器,倘若沒有新的資料,則會回應瀏覽器資料沒有更新。雖然 Long-Polling 可以減少原先 Polling 技術造成網路頻寬浪費的狀況,但如果專案功能是屬於資料更新頻率很高的狀況下,Long-Polling 其實不會比 Polling 還要有效率。

而此篇要介紹的 WebSocket 協定其實也是建立於 HTTP 架構之上,它背後基本上還是以 HTTP 作為傳輸層,與 HTTP 一樣使用 80、443 port(https),但 WebSocket 大幅改善了 Comet 缺點,連線數量減少為一條,當 Server 端有資料更新時,會自動傳送給 Client 端,進行即時更新(Realtime)的動作,所以WebSocket非常適用於即時系統上,例如聊天室、遊戲、證券交易系統、多人共同編輯工具等。

接下來,我們直接實作聊天室應用來深入感受 WebSocket 技術:

  1. 在 Gradle build.gradle.kts 加入 WebSocket 套件

    implementation("org.springframework.boot:spring-boot-starter-websocket")
    
  2. 建立 WebSocket 配置- WebSocketConfig

    @Configuration
    @EnableWebSocketMessageBroker
    class WebSocketConfig : WebSocketMessageBrokerConfigurer {
    
        override fun registerStompEndpoints(stompEndpointRegistry: StompEndpointRegistry) {
            stompEndpointRegistry.addEndpoint("/ws").setAllowedOrigins().withSockJS()
        }
    
        override fun configureMessageBroker(messageBrokerRegistry: MessageBrokerRegistry) {
            messageBrokerRegistry.setApplicationDestinationPrefixes("/app")
            messageBrokerRegistry.enableSimpleBroker("/topic")
        }
    }
    
  3. 建立 Data Class- ChatMessage

    enum class MessageType {
        CHAT,
        JOIN,
        LEAVE
    }
    
    data class ChatMessage(
            val type: MessageType,
            val content: String? = null,
            val sender: String
    )
    
  4. 建立 WebSocketConfig 監聽器- WebSocketConfig

    @Component
    class WebSocketEventListener(@Autowired val simpleMessageSendingOperations: SimpMessageSendingOperations) {
    
        val logger: Logger = LoggerFactory.getLogger(WebSocketEventListener::class.java)
    
        /**
         * WebSocket 連線監聽器
         */
        @EventListener
        fun handleWebSocketConnectListener(sessionConnectedEvent: SessionConnectedEvent) {
            logger.info("接收到新的連線");
        }
    
        /**
         * WebSocket 中斷連線監聽器
         */
        @EventListener
        fun handleWebSocketDisconnectListener(sessionDisconnectEvent: SessionDisconnectEvent) {
            val stompHeaderAccessor: StompHeaderAccessor = StompHeaderAccessor.wrap(sessionDisconnectEvent.message)
            val username: String? = stompHeaderAccessor.sessionAttributes?.get("username") as String?
    
            if (username != null) {
                logger.info("$username 使用者離開聊天室");
                val chatMessage = ChatMessage(
                        type = MessageType.LEAVE,
                        sender = username
                )
                simpleMessageSendingOperations.convertAndSend("/topic/public", chatMessage);
            }
        }
    }
    
  5. 建立 Controller 方法- ChatController

    @Controller
    class ChatController {
    
        /**
         * 新增聊天訊息
         */
        @MessageMapping("/sendMessage")
        @SendTo("/topic/public")
        fun sendMessage(@Payload chatMessage: ChatMessage): ChatMessage = chatMessage
    
        /**
         * 新增使用者
         */
        @MessageMapping("/addUser")
        @SendTo("/topic/public")
        fun addUser(@Payload chatMessage: ChatMessage, simpMessageHeaderAccessor: SimpMessageHeaderAccessor): ChatMessage {
            // 設定使用者姓名
            simpMessageHeaderAccessor.sessionAttributes?.put("username", chatMessage.sender)
            return chatMessage
        }
    }
    
  6. 完成後端的 WebSocket 後,接下來我們實作前端聊天室部份,前端聊天室部份會採用 Sockjs 套件簡化 WebSocket 呼叫,而一般使用 Sockjs 會搭配 Stomp 套件一起使用。

  7. resources/static 資料夾下新增 index.html,內容如下:

    <!DOCTYPE html>
    <html>
      <head>
          <meta name="viewport" content="width=device-width, initial-scale=1.0, minimum-scale=1.0">
          <title>Spring Boot WebSocket 聊天室應用</title>
          <meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
          <link rel="stylesheet" href="/css/main.css" />
      </head>
      <body>
        <noscript>
          <h2>Sorry! 您的瀏覽器不支援</h2>
        </noscript>
    
        <div id="username-page">
            <div class="username-page-container">
                <h1 class="title">輸入聊天室使用者名稱</h1>
                <form id="usernameForm" name="usernameForm">
                    <div class="form-group">
                        <input type="text" id="name" placeholder="使用者名稱" autocomplete="off" class="form-control" />
                    </div>
                    <div class="form-group">
                        <button type="submit" class="accent username-submit">開始聊天</button>
                    </div>
                </form>
            </div>
        </div>
    
        <div id="chat-page" class="hidden">
            <div class="chat-container">
                <div class="chat-header">
                    <h2>Spring Boot WebSocket 聊天室</h2>
                </div>
                <div class="connecting">
                    連線中...
                </div>
                <ul id="messageArea">
    
                </ul>
                <form id="messageForm" name="messageForm" nameForm="messageForm">
                    <div class="form-group">
                        <div class="input-group clearfix">
                            <input type="text" id="message" placeholder="輸入對話訊息" autocomplete="off" class="form-control"/>
                            <button type="submit" class="primary">送出</button>
                        </div>
                    </div>
                </form>
            </div>
        </div>
    
        <script src="https://cdn.jsdelivr.net/npm/sockjs-client@1/dist/sockjs.min.js"></script>
        <script src="https://cdnjs.cloudflare.com/ajax/libs/stomp.js/2.3.3/stomp.min.js"></script>
        <script src="/js/main.js"></script>
      </body>
    </html>
    
  8. resources/static/js 資料夾新增 main.js 檔案,內容如下:

    const usernamePage = document.querySelector('#username-page');
    const chatPage = document.querySelector('#chat-page');
    const usernameForm = document.querySelector('#usernameForm');
    const messageForm = document.querySelector('#messageForm');
    const messageInput = document.querySelector('#message');
    const messageArea = document.querySelector('#messageArea');
    const connectingElement = document.querySelector('.connecting');
    
    let stompClient = null;
    let username = null;
    let colors = [
        '#2196F3', '#32c787', '#00BCD4', '#ff5652',
        '#ffc107', '#ff85af', '#FF9800', '#39bbb0'
    ];
    
    // 設定WebSocket連線
    const connect = (event) => {
        username = document.querySelector('#name').value.trim();
        if(username) {
            usernamePage.classList.add('hidden');
            chatPage.classList.remove('hidden');
    
            let socket = new SockJS('/ws');
            stompClient = Stomp.over(socket);
            stompClient.connect({}, onConnected, onError);
        }
        event.preventDefault();
    };
    
    // 連線成功時發出 addUser 請求
    const onConnected = (options) => {
        stompClient.subscribe('/topic/public', onMessageReceived);
        stompClient.send("/app/addUser",
            {},
            JSON.stringify({sender: username, type: 'JOIN'})
        );
        connectingElement.classList.add('hidden');
    };
    
    // 無法連線到 WebSocket 時出現錯誤
    const onError = (error) => {
        connectingElement.textContent = '無法連到 WebSocket 伺服器';
        connectingElement.style.color = 'red';
    };
    
    // 發送對話訊息
    const sendMessage = (event) => {
        let messageContent = messageInput.value.trim();
    
        if(messageContent && stompClient) {
            let chatMessage = {
                sender: username,
                content: messageInput.value,
                type: 'CHAT'
            };
            stompClient.send("/app/sendMessage", {}, JSON.stringify(chatMessage));
            messageInput.value = '';
        }
        event.preventDefault();
    };
    
    // 接收 WebSocket 回應進行處理
    const onMessageReceived = (payload) => {
        let message = JSON.parse(payload.body);
        let messageElement = document.createElement('li');
    
        if(message.type === 'JOIN') {
            messageElement.classList.add('event-message');
            message.content = message.sender + ' joined!';
        }
        if (message.type === 'LEAVE') {
            messageElement.classList.add('event-message');
            message.content = message.sender + ' left!';
        }
        if (message.type === 'CHAT'){
            messageElement.classList.add('chat-message');
            let avatarElement = document.createElement('i');
            let avatarText = document.createTextNode(message.sender[0]);
            avatarElement.appendChild(avatarText);
            avatarElement.style['background-color'] = getHashBackgroundColor(message.sender);
            messageElement.appendChild(avatarElement);
    
            let usernameElement = document.createElement('span');
            let usernameText = document.createTextNode(message.sender);
            usernameElement.appendChild(usernameText);
            messageElement.appendChild(usernameElement);
        }
    
        let textElement = document.createElement('p');
        let messageText = document.createTextNode(message.content);
        textElement.appendChild(messageText);
        messageElement.appendChild(textElement);
    
        messageArea.appendChild(messageElement);
        messageArea.scrollTop = messageArea.scrollHeight;
    };
    
    // 取得姓名象徵顏色
    const getHashBackgroundColor = (messageSender) => {
        let hash = 0;
        for (let i = 0; i < messageSender.length; i++) {
            hash = 31 * hash + messageSender.charCodeAt(i);
        }
    
        let index = Math.abs(hash % colors.length);
        return colors[index];
    };
    
    // 設定 Submit 事件
    usernameForm.addEventListener('submit', connect, true);
    messageForm.addEventListener('submit', sendMessage, true);
    
  9. 最後完成結果
    https://ithelp.ithome.com.tw/upload/images/20201007/20121179mdbSLkn5lX.png

此文章有提供範例程式碼在 Github 供大家參考

Reference


上一篇
[Day 27] 遠征 Kotlin × Spring Boot 介紹 Spring AOP 機制
下一篇
[Day 29] 遠征 Kotlin × Spring Boot 介紹多資料庫連線配置
系列文
30天從零撰寫 Kotlin 語言並應用於 Spring Boot 開發30

尚未有邦友留言

立即登入留言