原文链接:Building a chat application with Spring Boot and WebSocket - 原文作者:Ramesh Fadatare

本文采用意译的方式

本文,我们将学习如何使用 WebSocket APISpring Boot ,并在最后构建个简单的群聊应用。

以下是我们将在教程中构建的聊天应用的截图 -

Spring Boot 和 WebSocket 构建一个聊天室应用_客户端

WebSocket 是一个通信协议,在服务端和客户端之间建立双向沟通渠道。

WebSocket 工作原理是首先和服务端建立一个常规的 HTTP 链接,然后通过发送一个 Upgrade 头来升级为双向 WebSocket 连接。

现在大多数的现代浏览器都支持 WebSocket,对于那些不支持的浏览器,有相关的库等技术作为后备,比如 comet 和 long-polling。

嗯,现在我们知道 WebSocket 是什么了,并且了解了它怎么工作。那么,我们来实现聊天程序。

开发环境

本小节为译者加

本案例在以下开发环境进行复现:

  • macOs Monterey 12.4 (Apple M1)
  • IntelliJ IDEA 2021.2.2(Ultimate Edition)
  • java --version (17.0.7)
  • maven version 3.9.2
  • Google Chrome 版本 119.0.6045.123(正式版本)(arm64)

浮现效果如下👇

Spring Boot 和 WebSocket 构建一个聊天室应用_spring_02

👌,我们下面进入案例环节。

假设我们通过 $ spring init --name=websocket-demo -dependencies=websocket websocket-demo 创建了一个 websocket-demo 项目。

WebSocket 配置

首先,我们配置 websocket 断点和信息代理(message broker)。我们在 com.example.websocketdemo 中创建一个新的包 config,然后在该包内创建 WebSocketConfig 类。

package com.example.websocketdemo.config;

import org.springframework.context.annotation.Configuration;
import org.springframework.messaging.simp.config.MessageBrokerRegistry;
import org.springframework.web.socket.config.annotation.*;

@Configuration
@EnableWebSocketMessageBroker
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {

    @Override
    public void registerStompEndpoints(StompEndpointRegistry registry) {
        registry.addEndpoint("/ws").withSockJS();
    }

    @Override
    public void configureMessageBroker(MessageBrokerRegistry registry) {
        registry.setApplicationDestinationPrefixes("/app");
        registry.enableSimpleBroker("/topic");
    }
}

@EnableWebSocketMessageBroker 注解是用来开启 WebSocket 服务。我们实现了 WebSocketMessageBrokerConfigurer 接口,并为其中的一些方法提供了实现,以配置 websocket 连接。

在第一个方法中,我们注册了一个 websocket 端点,客户端将用它来连接 websocket 服务。

请注意 withSocketJS() 和端点配置的使用。SockJS 用于为不支持 websocket 服务的后备选项。

你可能会留意到方法名中的 STOMP 单词。这个方法来自 Spring 框架 STOMP 实现。STOMP 全称 Simple Text Oriented Messaging Protocol (简单文本导向信息协议)。它是一个信息协议,定义了数据交流的格式和规则。

为什么我们需要 STOMP 呢? 嗯~ WebSocket 只是一个通信协议。它并没有定义一些事情,比如 - 怎么给订阅特定主题的用户发送消息,或者说怎么给特定的用户发送消息。所以,我们需要 STOMP 来实现这些功能。

在第二个方法中,我们配置了一个信息代理,用于将信息从一个客户端路由到另一个客户端。

第一行代码中,我们定义目的地以 /app 开头的消息应路由到消息处理方法(我们后面很快会定义这些方法)。

第二行代码中,我们定义目的地以 /topic 开头的消息路由到消息代理。信息代理将向所有连接并订阅特定主题的客户端广播信息。

在上面这个例子中,我们开启了一个简单的基于内存的信息代理。但是,我们可以免费使用任意全功能信息代理,比如 RabbitMQActiveMQ

创建 ChatMessage model

ChatMessage 模型是用于在客户端和服务端交流的信息载体。我们在 com.example.websocketdemo 包中创建一个新的包 model,然后创建名为 ChatMessage 的类:

package com.example.websocketdemo.model;

public class ChatMessage {
    private MessageType type;
    private String content;
    private String sender;

    public enum MessageType {
        CHAT,
        JOIN,
        LEAVE
    }

    public MessageType getType() {
        return type;
    }

    public void setType(MessageType type) {
        this.type = type;
    }

    public String getContent() {
        return content;
    }

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

    public String getSender() {
        return sender;
    }

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

创建 Controller 用来发送和接收信息

我们将在 controller 里面定义处理的方法。这些方法的职责是从另外客户端接收信息,然后将其广播出去。

我们创建一个新包 controller,然后常见 ChatController 类:

package com.example.websocketdemo.controller;

import com.example.websocketdemo.model.ChatMessage;
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;

@Controller
public class ChatController {

    @MessageMapping("/chat.sendMessage")
    @SendTo("/topic/public")
    public ChatMessage sendMessage(@Payload ChatMessage chatMessage) {
        return chatMessage;
    }

    @MessageMapping("/chat.addUser")
    @SendTo("/topic/public")
    public ChatMessage addUser(@Payload ChatMessage chatMessage, 
                               SimpMessageHeaderAccessor headerAccessor) {
        // Add username in web socket session
        headerAccessor.getSessionAttributes().put("username", chatMessage.getSender());
        return chatMessage;
    }

}

如果你还记得 websocket 配置,所有客户端目的地以 /app 开头的信息将被路由到以 @MessageMapping 注解处理的方法。

比如,目的地为 /app/chat.sendMessage 的信息将会路由到 sendMessage() 方法,目的地为 /app/chat.addUser 的信息将会路由到 addUser() 方法。

添加 WebSocket 事件监听器

我们将使用事件监听器来监听 socket 连接和断开,以便我们可以记录这些事件并在用户加入或者离开聊天室时候对他们广播。

package com.example.websocketdemo.controller;

import com.example.websocketdemo.model.ChatMessage;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
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;

@Component
public class WebSocketEventListener {

    private static final Logger logger = LoggerFactory.getLogger(WebSocketEventListener.class);

    @Autowired
    private SimpMessageSendingOperations messagingTemplate;

    @EventListener
    public void handleWebSocketConnectListener(SessionConnectedEvent event) {
        logger.info("Received a new web socket connection");
    }

    @EventListener
    public void handleWebSocketDisconnectListener(SessionDisconnectEvent event) {
        StompHeaderAccessor headerAccessor = StompHeaderAccessor.wrap(event.getMessage());

        String username = (String) headerAccessor.getSessionAttributes().get("username");
        if(username != null) {
            logger.info("User Disconnected : " + username);

            ChatMessage chatMessage = new ChatMessage();
            chatMessage.setType(ChatMessage.MessageType.LEAVE);
            chatMessage.setSender(username);

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

ChatController 内,我们在 addUser() 方法中广播了用户加入的事件。所以我们并不需要在 SessionConnected 事件中做其他事情。

SessionDisconnect 事件,我们从 websocket session 中提取用户名字,然后给所有的连接客户端广播用户离开事件。

创建前端

srx/main/resources 文件夹中,创建下面的文件:

static
  └── css
       └── main.css
  └── js
       └── main.js
  └── index.html

src/main/resources/static 文件夹是 Spring Boot 中默认的静态文件存放的位置。

1. 创建 HTML - index.html

HTML 文件包含用户界面,展示聊天信息。它应该可以包含 sockjsstomp 这两个 javascript 库。

SockJS 是一个 WebSocket 客户端,尝试使用本地的 WebSockets,并且为不支持 WebSocket 老浏览器提供备选。STOMP JSjavascript 版的 stomp 客户端。

下面是 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 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">Type your username</h1>
            <form id="usernameForm" name="usernameForm">
                <div class="form-group">
                    <input type="text" id="name" placeholder="Username" autocomplete="off" class="form-control" />
                </div>
                <div class="form-group">
                    <button type="submit" class="accent username-submit">Start Chatting</button>
                </div>
            </form>
        </div>
    </div>

    <div id="chat-page" class="hidden">
        <div class="chat-container">
            <div class="chat-header">
                <h2>Spring 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="Type a message..." autocomplete="off" class="form-control"/>
                        <button type="submit" class="primary">Send</button>
                    </div>
                </div>
            </form>
        </div>
    </div>

    <script src="https://cdnjs.cloudflare.com/ajax/libs/sockjs-client/1.1.4/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>

2. JavaScript - main.js

现在,我们使用 javascript 来连接 websocket 端点并发送&接收信息。首先,在 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 stompClient = null;
var username = null;

var colors = [
    '#2196F3', '#32c787', '#00BCD4', '#ff5652',
    '#ffc107', '#ff85af', '#FF9800', '#39bbb0'
];

function connect(event) {
    username = document.querySelector('#name').value.trim();

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

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

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


function onConnected() {
    // Subscribe to the Public Topic
    stompClient.subscribe('/topic/public', onMessageReceived);

    // Tell your username to the server
    stompClient.send("/app/chat.addUser",
        {},
        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';
}


function sendMessage(event) {
    var messageContent = messageInput.value.trim();
    if(messageContent && stompClient) {
        var chatMessage = {
            sender: username,
            content: messageInput.value,
            type: 'CHAT'
        };
        stompClient.send("/app/chat.sendMessage", {}, JSON.stringify(chatMessage));
        messageInput.value = '';
    }
    event.preventDefault();
}


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 + ' joined!';
    } else if (message.type === 'LEAVE') {
        messageElement.classList.add('event-message');
        message.content = message.sender + ' left!';
    } else {
        messageElement.classList.add('chat-message');

        var avatarElement = document.createElement('i');
        var avatarText = document.createTextNode(message.sender[0]);
        avatarElement.appendChild(avatarText);
        avatarElement.style['background-color'] = getAvatarColor(message.sender);

        messageElement.appendChild(avatarElement);

        var usernameElement = document.createElement('span');
        var usernameText = document.createTextNode(message.sender);
        usernameElement.appendChild(usernameText);
        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;
}


function getAvatarColor(messageSender) {
    var hash = 0;
    for (var i = 0; i < messageSender.length; i++) {
        hash = 31 * hash + messageSender.charCodeAt(i);
    }
    var index = Math.abs(hash % colors.length);
    return colors[index];
}

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

connect() 方法使用 SockJSstomp 客户端来连接在 Spring Boot 中配置的 /ws 端点。

在成功连接后,客户端订阅了目的地路径 /topic/public,然后通过目的地路径 /app/chat.addUser 发送消息将用户名发送给服务端。

stompClient.subscribe() 方法的回调函数在信息发送到订阅的主题的时候被调用。

文件中剩下的代码就是用来展示和格式化屏幕上的信息。

3. 添加 CSS - main.css

最后,我们在 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: #128ff2;
    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;
    }
}

译者加:样式无需多说了吧。样式只是让页面看起来更加清爽,我们可以不加。

运行应用

然后我们可以在控制台上运行下面的命令行开启服务 mvn spring-boot:run

当然,我们可以通过 IntelliJ IDEA 开启

应用将会默认开启在 Spring Boot 默认的端口号 8080 上。我们直接在浏览器上打开 http://localhost:8080 即可。

使用 ReabbitMQ 作为信息中间件

读者自行验证

如果我们想使用全信息的消息中间件,比如 RabbitMQ 来代替简单的内存消息中间件,那么我们需要在 pom.xml 内添加下面的依赖 -

<!-- RabbitMQ Starter Dependency -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-amqp</artifactId>
</dependency>

<!-- Following additional dependency is required for Full Featured STOMP Broker Relay -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-reactor-netty</artifactId>
</dependency>

一旦添加了上面的依赖,我们可以在文件 WebSocketConfig.java 文件中开启 RabbitMQ 消息中间件,如下 -

public void configureMessageBroker(MessageBrokerRegistry registry) {
    registry.setApplicationDestinationPrefixes("/app");

    // Use this for enabling a Full featured broker like RabbitMQ
    registry.enableStompBrokerRelay("/topic")
            .setRelayHost("localhost")
            .setRelayPort(61613)
            .setClientLogin("guest")
            .setClientPasscode("guest");
}

总结

自此,我们使用 Spring BootWebSocket 从头开始构建了一个成熟的聊天应用程序。