網頁

2019/4/27

Spring Boot WebSocket 建立一個簡單的網頁聊天室

本篇介紹如何用Spring Boot WebSocket實作一個簡單的網頁聊天室(Chat room)。

本篇內容參考於Building a chat application with Spring Boot and WebSocket

Spring Tool Suit(STS)建立一個Spring Boot專案,勾選[WebSocket]



專案剛建立好的目錄結構如下:


專案剛建立好的pom.xml內容:

pom.xml

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
    xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>
    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>2.1.4.RELEASE</version>
        <relativePath/> <!-- lookup parent from repository -->
    </parent>
    <groupId>idv.matt</groupId>
    <artifactId>spring-boot-websocket-demo</artifactId>
    <version>0.0.1-SNAPSHOT</version>
    <name>spring-boot-websocket-demo</name>
    <description>Demo project for Spring Boot WebSocket</description>

    <properties>
        <java.version>1.8</java.version>
    </properties>

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

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </dependency>
    </dependencies>

    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
            </plugin>
        </plugins>
    </build>

</project>

本範例一共用要建立以下檔案

後端:

  • WebSocketConfig.java
  • ChatMessage.java
  • ChatController.java
  • WebSocketEventListener.java

前端:

  • index.html
  • main.css
  • main.js

WebSocketConfig是用來配置訊息代理(MessageBroker)的類別,在此要註冊WebSocket的endpoint及設定訊息傳送路徑的prefix。

WebSocketConfig

package idv.matt.demo.config;

import org.springframework.context.annotation.Configuration;
import org.springframework.messaging.simp.config.MessageBrokerRegistry;
import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker;
import org.springframework.web.socket.config.annotation.StompEndpointRegistry;
import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerConfigurer;

@Configuration
@EnableWebSocketMessageBroker
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {
   
    @Override
    public void registerStompEndpoints(StompEndpointRegistry endpointRegistry) {
        // 註冊一個給Client連至WebSocket Server的節點(websocket endpoint)
        endpointRegistry.addEndpoint("/chatroom").withSockJS(); 
    }

    @Override
    public void configureMessageBroker(MessageBrokerRegistry brokerRegister) {
        
        // 啟用一個訊息代理並設定訊息發送目地的前綴路徑
        brokerRegister.enableSimpleBroker("/topic");
        
        // 設定導向至訊息處理器的前綴路徑
        brokerRegister.setApplicationDestinationPrefixes("/app");
    }

}

ChatMessage為包裝傳輸訊息的容器。

ChatMessage

package idv.matt.demo.message;

/**
 * 傳輸的訊息
 */
public class ChatMessage {
    /** 訊息種類 */
    private ChatType type;
    /** 訊息發送者的名稱 */
    private String sender;
    /** 訊息內容 */
    private String content;

    /**
     * 訊息種類Enum
     */
    public enum ChatType {
        CHAT,
        JOIN,
        LEAVE
    }
    
    // 省略getter setter 
}

ChatController用來接收及發送訊息。@MessageMapping設定前端送來訊息的路徑。@SendTo設定發送訊息給前端的路徑。


ChatController

package idv.matt.demo.controller;

import org.springframework.messaging.handler.annotation.MessageMapping;
import org.springframework.messaging.handler.annotation.Payload;
import org.springframework.messaging.handler.annotation.SendTo;
import org.springframework.messaging.simp.SimpMessageHeaderAccessor;
import org.springframework.stereotype.Controller;

import idv.matt.demo.message.ChatMessage;

/**
 * 接收Client送來的WebSocket訊息及推送給前端的訊息
 */
@Controller
public class ChatController {
    
    /**
     * 處理前端加入聊天室的訊息,並把訊息推送給前端
     */
    @MessageMapping("/join")
    @SendTo("/topic/public")
    public ChatMessage addUser(@Payload ChatMessage chatMessage, 
            SimpMessageHeaderAccessor headerAccessor) {
        // 把username加入WebSocket的Session
        headerAccessor.getSessionAttributes().put("username", chatMessage.getSender());
        return chatMessage; // 返回時會將訊息送至/topic/public
    }
    
    /**
     * 處理前端送來的聊天訊息,並把訊息推送給前端
     */
    @MessageMapping("/chat")
    @SendTo("/topic/public")
    public ChatMessage sendMessage(@Payload ChatMessage chatMessage) {
        return chatMessage; // 返回時會將訊息送至/topic/public
    }

}

WebSocketEventListener負責監聽WebSocket的連線。


WebSocketEventListener

package idv.matt.demo.listener;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.event.EventListener;
import org.springframework.messaging.simp.SimpMessageSendingOperations;
import org.springframework.messaging.simp.stomp.StompHeaderAccessor;
import org.springframework.stereotype.Component;
import org.springframework.web.socket.messaging.SessionConnectedEvent;
import org.springframework.web.socket.messaging.SessionDisconnectEvent;

import idv.matt.demo.message.ChatMessage;

/**
 * WebSocket連線事件監聽器
 */
@Component
public class WebSocketEventListener {
    
    /** STOMP 訊息發送器 */
    @Autowired
    private SimpMessageSendingOperations messagingTemplate;

    /**
     * 連線時的處理
     */
    @EventListener
    public void handleWebSocketConnectListener(SessionConnectedEvent event) {
        System.out.println("收到一個新的WebSocket連線");
    }
    
    /**
     * 離線時的處理
     */
    @EventListener
    public void handleWebSocketDisconnectListener(SessionDisconnectEvent event) {
        StompHeaderAccessor headerAccessor = StompHeaderAccessor.wrap(event.getMessage());

        // 從WebSocket Session中取得使用者名稱
        String username = (String) headerAccessor.getSessionAttributes().get("username");
        if(username != null) {
            System.out.println("使用者" + username + "已離線");

            // 建立一個離線訊息送給前端
            ChatMessage chatMessage = new ChatMessage();
            chatMessage.setType(ChatMessage.ChatType.LEAVE);
            chatMessage.setSender(username);

            messagingTemplate.convertAndSend("/topic/public", chatMessage);
        }
    }
}


下面建立前端頁面所需的檔案,先在專案的src/main/resources下建立public資料夾,然後在public資料夾中再建立cssjs資料夾

index.html放在public資料夾;
main.css放在css資料夾;
main.js放在js資料夾。


index.html

<!DOCTYPE html>
<html>
<head>
<meta name="viewport" content="width=device-width, initial-scale=1.0, minimum-scale=1.0">
<meta http-equiv="Content-Type" content="text/html; charset=utf-8">
<title>Spring Boot WebSocket Chat Application</title>
<link rel="stylesheet" href="/css/main.css" />
</head>
<body>
    <noscript>
        <h2>Sorry! Your browser doesn't support Javascript</h2>
    </noscript>

    <!-- 進入頁面 -->
    <div id="username-page">
        <div class="username-page-container">
            <h1 class="title">輸入名稱</h1>
            
            <form id="usernameForm" name="usernameForm">
                <div class="form-group popup">
                    <input type="text" id="name" placeholder="輸入名稱..."
                        autocomplete="off" class="form-control popup" />
                    <span class="popuptext" id="hint">請輸入名稱</span>
                </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 Chat Demo</h2>
            </div>
            <div class="connecting">Connecting...</div>
            <ul id="messageArea">

            </ul>
            <form id="messageForm" name="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 type="text/javascript" src="https://cdnjs.cloudflare.com/ajax/libs/sockjs-client/1.1.4/sockjs.min.js"></script>
    <script type="text/javascript" src="https://cdnjs.cloudflare.com/ajax/libs/stomp.js/2.3.3/stomp.min.js"></script>
    <script type="text/javascript" charset="UTF-8" src="/js/main.js?version=1"></script>
</body>
</html>


main.css

* {
    -webkit-box-sizing: border-box;
    -moz-box-sizing: border-box;
    box-sizing: border-box;
}

html, body {
    height: 100%;
    overflow: hidden;
}

body {
    margin: 0;
    padding: 0;
    font-weight: 400;
    font-family: "Helvetica Neue", Helvetica, Arial, sans-serif;
    font-size: 1rem;
    line-height: 1.58;
    color: #333;
    background-color: #f4f4f4;
    height: 100%;
}

body:before {
    height: 50%;
    width: 100%;
    position: absolute;
    top: 0;
    left: 0;
    background: #008080;
    content: "";
    z-index: 0;
}

.clearfix:after {
    display: block;
    content: "";
    clear: both;
}

.hidden {
    display: none;
}

.form-control {
    width: 100%;
    min-height: 38px;
    font-size: 15px;
    border: 1px solid #c8c8c8;
}

.form-group {
    margin-bottom: 15px;
}

input {
    padding-left: 10px;
    outline: none;
}

h1, h2, h3, h4, h5, h6 {
    margin-top: 20px;
    margin-bottom: 20px;
}

h1 {
    font-size: 1.7em;
}

a {
    color: #128ff2;
}

button {
    box-shadow: none;
    border: 1px solid transparent;
    font-size: 14px;
    outline: none;
    line-height: 100%;
    white-space: nowrap;
    vertical-align: middle;
    padding: 0.6rem 1rem;
    border-radius: 2px;
    transition: all 0.2s ease-in-out;
    cursor: pointer;
    min-height: 38px;
}

button.default {
    background-color: #e8e8e8;
    color: #333;
    box-shadow: 0 2px 2px 0 rgba(0, 0, 0, 0.12);
}

button.primary {
    background-color: #128ff2;
    box-shadow: 0 2px 2px 0 rgba(0, 0, 0, 0.12);
    color: #fff;
}

button.accent {
    background-color: #ff4743;
    box-shadow: 0 2px 2px 0 rgba(0, 0, 0, 0.12);
    color: #fff;
}

#username-page {
    text-align: center;
}

.username-page-container {
    background: #fff;
    box-shadow: 0 1px 11px rgba(0, 0, 0, 0.27);
    border-radius: 2px;
    width: 100%;
    max-width: 500px;
    display: inline-block;
    margin-top: 42px;
    vertical-align: middle;
    position: relative;
    padding: 35px 55px 35px;
    min-height: 250px;
    position: absolute;
    top: 50%;
    left: 0;
    right: 0;
    margin: 0 auto;
    margin-top: -160px;
}

.username-page-container .username-submit {
    margin-top: 10px;
}

#chat-page {
    position: relative;
    height: 100%;
}

.chat-container {
    max-width: 700px;
    margin-left: auto;
    margin-right: auto;
    background-color: #fff;
    box-shadow: 0 1px 11px rgba(0, 0, 0, 0.27);
    margin-top: 30px;
    height: calc(100% - 60px);
    max-height: 600px;
    position: relative;
}

#chat-page ul {
    list-style-type: none;
    background-color: #FFF;
    margin: 0;
    overflow: auto;
    overflow-y: scroll;
    padding: 0 20px 0px 20px;
    height: calc(100% - 150px);
}

#chat-page #messageForm {
    padding: 20px;
}

#chat-page ul li {
    line-height: 1.5rem;
    padding: 10px 20px;
    margin: 0;
    border-bottom: 1px solid #f4f4f4;
}

#chat-page ul li p {
    margin: 0;
}

#chat-page .event-message {
    width: 100%;
    text-align: center;
    clear: both;
}

#chat-page .event-message p {
    color: #777;
    font-size: 14px;
    word-wrap: break-word;
}

#chat-page .chat-message {
    padding-left: 68px;
    position: relative;
}

#chat-page .chat-message i {
    position: absolute;
    width: 42px;
    height: 42px;
    overflow: hidden;
    left: 10px;
    display: inline-block;
    vertical-align: middle;
    font-size: 18px;
    line-height: 42px;
    color: #fff;
    text-align: center;
    border-radius: 50%;
    font-style: normal;
    text-transform: uppercase;
}

#chat-page .chat-message span {
    color: #333;
    font-weight: 600;
}

#chat-page .chat-message p {
    color: #43464b;
}

#messageForm .input-group input {
    float: left;
    width: calc(100% - 85px);
}

#messageForm .input-group button {
    float: left;
    width: 80px;
    height: 38px;
    margin-left: 5px;
}

.chat-header {
    text-align: center;
    padding: 15px;
    border-bottom: 1px solid #ececec;
}

.chat-header h2 {
    margin: 0;
    font-weight: 500;
}

.connecting {
    padding-top: 5px;
    text-align: center;
    color: #777;
    position: absolute;
    top: 65px;
    width: 100%;
}

@media screen and (max-width: 730px) {
    .chat-container {
        margin-left: 10px;
        margin-right: 10px;
        margin-top: 10px;
    }
}

@media screen and (max-width: 480px) {
    .chat-container {
        height: calc(100% - 30px);
    }
    .username-page-container {
        width: auto;
        margin-left: 15px;
        margin-right: 15px;
        padding: 25px;
    }
    #chat-page ul {
        height: calc(100% - 120px);
    }
    #messageForm .input-group button {
        width: 65px;
    }
    #messageForm .input-group input {
        width: calc(100% - 70px);
    }
    .chat-header {
        padding: 10px;
    }
    .connecting {
        top: 60px;
    }
    .chat-header h2 {
        font-size: 1.1em;
    }
}

/* Popup container */
.popup {
  position: relative;
}
/* The actual popup (appears on top) */
.popup .popuptext {
    visibility: hidden;
    width: 160px;
    background-color: #ff8080;
    color: #fff;
    text-align: center;
    border-radius: 6px;
    padding: 8px 0;
    position: absolute;
    z-index: 1;
    bottom: 125%;
    left: 10%;
    margin-left: -80px;
}

/* Popup arrow */
.popup .popuptext::after {
    content: "";
    position: absolute;
    top: 100%;
    left: 50%;
    margin-left: -5px;
    border-width: 5px;
    border-style: solid;
    border-color: #ff8080 transparent transparent transparent;
}

/* Toggle this class when clicking on the popup container (hide and show the popup) */
.popup .show {
    visibility: visible;
    -webkit-animation: fadeIn 0.5s;
    animation: fadeIn 0.5s
}

/* Add animation (fade in the popup) */
@-webkit-keyframes fadeIn {
    from {opacity: 0;} 
    to {opacity: 1;}
}

@keyframes fadeIn {
    from {opacity: 0;}
    to {opacity:1 ;}
}

main.js

'use strict';

var usernamePage = document.querySelector('#username-page');
var chatPage = document.querySelector('#chat-page');
var usernameForm = document.querySelector('#usernameForm');
var messageForm = document.querySelector('#messageForm');
var messageInput = document.querySelector('#message');
var messageArea = document.querySelector('#messageArea');
var connectingElement = document.querySelector('.connecting');
var nameInput = document.querySelector('#name');
var popup = document.querySelector('#hint');

var stompClient = null;
var username = null;

/**
 * 頭像的顏色
 */
var colors = [ '#2196F3', '#32c787', '#00bcd4','#4dbb00', '#ff5652', '#ffc107',
        '#ff85af', '#ff9800', '#39bbb0', '#b0c503' ];

/**
 * 連線
 * @param event
 * @returns
 */
function connect(event) {
    username = document.querySelector('#name').value.trim();

    if (username) {
        usernamePage.classList.add('hidden');
        chatPage.classList.remove('hidden');

        var socket = new SockJS('/chatroom');
        stompClient = Stomp.over(socket);

        stompClient.connect({}, onConnected, onError);
    } else {
        popHint();
    }
    event.preventDefault();
}

/**
 * 連線建立後要處理的邏輯
 * @returns
 */
function onConnected() {
    // 訂閱/topic/public
    stompClient.subscribe('/topic/public', onMessageReceived); // 當後端送訊息至/topic/public時,會執行onMessageReceived()。

    // 發送訊息至/app/join,也就是送到ChatController.addUser()
    stompClient.send("/app/join", {}, JSON.stringify({
        sender : username,
        type : 'JOIN'
    }))

    connectingElement.classList.add('hidden');
}

function onError(error) {
    connectingElement.textContent = 'Could not connect to WebSocket server. Please refresh this page to try again!';
    connectingElement.style.color = 'red';
}

/**
 * 彈出提示
 * @returns
 */
function popHint() {
    popup.classList.toggle("show");
}

/**
 * 發送訊息
 * @param event 發送訊息事件
 * @returns
 */
function sendMessage(event) {
    var messageContent = messageInput.value.trim();
    if (messageContent && stompClient) {
        var chatMessage = {
            sender : username,
            content : messageInput.value,
            type : 'CHAT'
        };
        // 發送訊息至/app/chat,也就是送到ChatController.sendMessage()
        stompClient.send("/app/chat", {}, JSON.stringify(chatMessage));
        messageInput.value = '';
    }
    event.preventDefault();
}

/**
 * 從後端接受訊息後要進行的處理
 * @param payload 後端送來的訊息
 * @returns
 */
function onMessageReceived(payload) {
    var message = JSON.parse(payload.body);

    var messageElement = document.createElement('li');

    if (message.type === 'JOIN') {
        messageElement.classList.add('event-message');
        message.content = message.sender + ' 加入聊天室';
    } else if (message.type === 'LEAVE') {
        messageElement.classList.add('event-message');
        message.content = message.sender + ' 離開聊天室';
    } else {
        messageElement.classList.add('chat-message');

        var avatarElement = getAvatarElement(message.sender);
        messageElement.appendChild(avatarElement);

        var usernameElement = getUsernameElement(message.sender);
        messageElement.appendChild(usernameElement);
    }

    var textElement = document.createElement('p');
    var messageText = document.createTextNode(message.content);
    textElement.appendChild(messageText);

    messageElement.appendChild(textElement);

    messageArea.appendChild(messageElement);
    messageArea.scrollTop = messageArea.scrollHeight;
}

/**
 * 取得頭像元素
 * @param sender 訊息發送者名稱
 * @returns
 */
function getAvatarElement(sender) {
    var avatarElement = document.createElement('i');
    var avatarText = document.createTextNode(sender[0]);
    avatarElement.appendChild(avatarText);
    avatarElement.style['background-color'] = getAvatarColor(sender);
    return avatarElement;
}

/**
 * 取得頭像顏色
 * @param sender 訊息發送者名稱
 * @returns
 */
function getAvatarColor(sender) {
    var hash = 0;
    for (var i = 0; i < sender.length; i++) {
        hash = 31 * hash + sender.charCodeAt(i);
    }
    var index = Math.abs(hash % colors.length);
    return colors[index];
}

/**
 * 取得使用者名稱的元素
 * @param sender 使用者名稱
 * @returns
 */
function getUsernameElement(sender) {
    var usernameElement = document.createElement('span');
    var usernameText = document.createTextNode(sender);
    usernameElement.appendChild(usernameText);
    return usernameElement;
}

/**
 * 移除彈出的提示
 * @param event
 * @returns
 */
function removePopup (event) {
    popup.classList.remove("show");
}

nameInput.addEventListener('focus', removePopup, true)
usernameForm.addEventListener('submit', connect, true)
messageForm.addEventListener('submit', sendMessage, true)

完成後的目錄結構如下:


然後啟動Spring Boot專案,在瀏覽器輸入http://localhost:8080/即可進入畫面。


如果本篇有幫助到您,幫忙點一下廣告支持,感恩。



15 則留言:


  1. 你好,感謝你po出這麼精采的程式,以下是我在實作上有遇到一些問題
    下面三行程式碼中的getSender() setType 以及 setSender() 在我的實作中市發生錯誤的
    原因是系統說這三個類別並不屬於charMassage裡面,請問該如何解決呢? 謝謝

    headerAccessor.getSessionAttributes().put("username",chatMessage.getSender());
    chatMessage.setType(ChatMessage.ChatType.LEAVE);
    chatMessage.setSender(username);

    回覆刪除
  2. ChatMessage為文中自訂的類別,裡面還有個inner enum,你可能沒有建立到這個類別

    回覆刪除
  3. 這樣導致最後一張圖的結果無法呈現

    回覆刪除
  4. 那請問該怎麼建立 inner enum 呢

    回覆刪除
  5. 文中的ChatMessage類別的程式碼中已經有寫囉,你可以直接複製貼上就有裡面的enum CharType了

    回覆刪除
  6. package com.example.demo.message;

    public class ChatMessage {
    /** 訊息種類 */
    private ChatType type;
    /** 訊息發送者的名稱 */
    private String sender;
    /** 訊息內容 */
    private String content;




    /**
    * 訊息種類Enum
    */
    public enum ChatType {
    CHAT,
    JOIN,
    LEAVE
    }


    public Object getSender() {
    // TODO Auto-generated method stub
    return null;
    }


    public void setType(ChatType leave) {
    // TODO Auto-generated method stub

    }


    public void setSender(String username) {
    // TODO Auto-generated method stub

    }

    chatMessage中實在沒有getSender() setType setSender這三樣東西,就算有enum類別仍然沒用,所以我降這三個詞創了方法到chatMessage裡面,依然還是輸入姓名後沒有顯示已進入聊天室,還有打訊息不會出現在聊天室窗裡

    回覆刪除
  7. @晶晶 我弄清楚你的問題了,我的很多範例都不會特別寫類別成員的getter setter,我通常會以 // 省略getters setters... 或 // getters setters... 來表示,所以這邊你要自行加上去。

    回覆刪除
  8. 那請問一下自行加上去的寫法是什麼(EX:getter setter;),還有應該加在哪個或哪些java檔 css檔 js檔 html檔的什麼地方? 謝謝

    回覆刪除
  9. 在ChatMessage類別新增以下成員變數的getters setters

    public void setCharType(CharType type) {
    this.type = type;
    }

    public CharType getCharType() {
    return this.type;
    }

    public void setSender(String sender) {
    this.sender = sender;
    }

    public String getSender() {
    return this.sender;
    }

    public void setContent(String content) {
    this.content = content;
    }

    public String getContent() {
    return this.content;
    }

    回覆刪除
  10. public void setCharType(CharType type) {
    this.type = type;
    }

    public CharType getCharType()
    {return this.type;
    }

    版大我在輸入你po的東西時,這兩行是出現錯誤的,而我提出的三個問題裡面只剩setType還未解決

    後來我嘗試用了source裡面的自動getter and setter變多出了

    public void setType(ChatType type) {
    this.type = type;
    }
    public ChatType getType() {
    return type;
    }
    這兩行,此時setType已經沒有問題,但在run專案的時候仍然失敗,可以輸入名字但不會顯示某某某進入聊天室以及打訊息時按送出也並無跳至聊天室窗裡

    回覆刪除
  11. 問題已解決,聊天室可以使用

    回覆刪除
  12. 在Intellij run這個專案可以成功進入聊天室,但包成war部署到local tomcat卻報錯。
    錯誤訊息:
    Could not connect to WebSocket server. Please refresh this page to try again!

    有什麼地方需要特別注意的嗎?

    回覆刪除
  13. 樓上您好,可能是Socket路徑有錯,在IntelliJ都是在本機跑沒問題,但部署到Tomcat那麼main.js的Socket位置可能要做調整。

    回覆刪除
  14. 嘗試了一下,包成war部署到Tomcat上,main.js中的new SockJS('/chatroom'),要加上war包的名字在url中,
    變成new SockJS('/{war_name}/chatroom')

    回覆刪除