Spring Boot 整合 WebSocket 实现单聊+广播

Spring Boot 的 WebSocket + STOMP 把这一切都封装好了:

  • 用@MessageMapping定义接收地址
  • 单聊:@SendToUser 或 convertAndSendToUser 自动点对点发送, 一行代码搞定
  • 广播:convertAndSend+/topic自动群发
  • 内置心跳和会话管理

1. 引入依赖

xml 复制代码
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-websocket</artifactId>
</dependency>
<!-- 如果你需要前端页面,可以加一个 thymeleaf 方便演示(非必须) -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-thymeleaf</artifactId>
</dependency>

2. CustomHandshakeHandler

java 复制代码
/**
 * 因为 WebSocket 连接本身不带用户 ID
 * 这个类的作用:在 WebSocket 握手时,告诉 Spring "当前连接的用户是谁"。
 * 之后convertAndSendToUser才能根据名字找到对应的会话。
 * Spring 会根据这个信息自动管理用户与会话的映射。
 */
public class CustomHandshakeHandler extends DefaultHandshakeHandler {

    @Override
    protected Principal determineUser(ServerHttpRequest request, @NonNull WebSocketHandler wsHandler,
                                      @NonNull Map<String, Object> attributes) {
        // 从 URL 参数中提取用户名,例如 /ws?username=111
        String query = request.getURI().getQuery();
        String username = "unknown";
        if (query != null && query.startsWith("username=")) {
            username = query.substring(9);  // 截取 "username=" 后面的部分
        }

        // 返回一个 Principal 对象(就是用户身份标识)
        // 这里用匿名内部类实现,核心是 getName() 返回用户名
        final String finalUsername = username;
        return () -> finalUsername;
    }
}

3. WebSocketConfig配置类

开启WebSocket支持,注册连接端点,配置消息代理。

java 复制代码
import lombok.extern.slf4j.Slf4j;
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   // 开启 WebSocket 消息代理
@Slf4j
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {

    /**
     * 注册WebSocket连接端点(客户端连接的地址)
     * @param registry 端点注册器
     */
    @Override
    public void registerStompEndpoints(StompEndpointRegistry registry) {
        registry.addEndpoint("/ws")                  // 前端连接地址
                .setHandshakeHandler(new CustomHandshakeHandler()) // 关键!绑定身份处理器
                .setAllowedOriginPatterns("*")      // 允许跨域
                .withSockJS();                      // 支持SockJS降级,浏览器不支持WebSocket时自动切换为HTTP长轮询,提升兼容性
    }

    /**
     * 配置消息代理(用于转发消息,实现单聊/群聊)
     * @param registry 消息代理注册器
     */
    @Override
    public void configureMessageBroker(MessageBrokerRegistry registry) {
        // 客户端订阅消息的前缀(接收消息)
        // 1. 启用内存消息代理(单体应用够用,集群应用可替换为RabbitMQ/ActiveMQ)
        // 2. "/queue":用于单聊(点对点消息),"/topic":用于群聊(广播消息)
        registry.enableSimpleBroker("/topic", "/queue");

        // 3. 配置应用前缀:客户端发送消息的路径必须以"/app"开头,否则会被消息代理拦截
        registry.setApplicationDestinationPrefixes("/app");

        // 4. 配置用户前缀:用于指定接收消息的用户,默认是"/user",可自定义,这里用默认值
        // 发送消息时,路径会自动拼接为:/user/{接收者ID}/queue/chat
        registry.setUserDestinationPrefix("/user");

        log.info("已注册消息代理配置:广播前缀 /topic,单聊前缀 /queue,应用前缀 /app,用户前缀 /user");
    }
}
  • 常用配置说明:
    • 端点地址:/ws可自定义,比如改为 /ws/single,客户端连接时对应修改即可;
    • **跨域配置:**setAllowedOriginPatterns("*") 仅用于开发环境,生产环境需改为具体域名(如 setAllowedOriginPatterns("https://xxx.com")),避免跨域安全问题;
    • **消息代理:**enableSimpleBroker 是Spring提供的内存代理,适合单体应用;如果是集群部署,需替换为外部消息代理(如RabbitMQ),只需修改这一行配置即可;
    • 应用前缀:/app 是固定前缀,客户端发送消息时,路径必须以 /app 开头,否则消息无法被服务端接收。

4. 实体类

java 复制代码
@Data
public class ChatMessage {
    private String sender;    // 发送人
    private String receiver;  // 接收人
    private String content;   // 消息内容
    private String type;      // 如 CHAT, JOIN, LEAVE
}

5. Controller 来访问页面

**注意:**不是@RestController,没有@ResponseBody

java 复制代码
// 不是@RestController
@Controller
public class ChatController {

    @Autowired
    private SimpMessagingTemplate messagingTemplate;

    @GetMapping("/chat")
    // 返回的是页面,所以不能加@ResponseBody
    public String chat() {
        return "demo";
    }

    /**
     * 处理单聊消息
     * 前端发送地址:/app/chat
     */
    @MessageMapping("/chat")
    public void processSingleChat(@Payload ChatMessage message) {
        // 接收人标识
        String receiver = message.getReceiver();

        // 核心:convertAndSendToUser 会根据 receiver 找到对应的 WebSocket 会话
        // 参数1:接收人的用户名(必须和握手时的 Principal.getName() 一致)
        // 参数2:客户端订阅的路径(前端订阅的是 /user/queue/messages,这里写 /queue/messages 即可)
        // 参数3:消息体
        // 构造发送给特定用户的地址:/user/{receiver}/queue/messages
        messagingTemplate.convertAndSendToUser(
                receiver,
                "/queue/messages",
                message
        );

        // 注意:这里不需要自己维护 Map,Spring 已经自动维护了用户与会话的关系
    }

    /**
     * 广播消息:所有在线用户都能收到
     * 前端发送地址:/app/broadcast
     */
    @MessageMapping("/broadcast")
    @SendTo("/topic/public")   // 自动将返回值发送到 /topic/public
    public ChatMessage broadcast(ChatMessage message) {
        // 可以在这里做日志、敏感词过滤等
        System.out.println("广播消息: " + message.getContent());
        // 返回的消息会发给所有订阅了 /topic/public 的客户端
        return message;
    }
}

关键说明:

  • convertAndSendToUser会自动拼接目标地址为/user/{receiver}/queue/messages。
  • 前端订阅时,每个用户应订阅/user/queue/messages(注意没有{receiver},框架会自动识别)
  • 业务扩展:如果需要将消息存入数据库(持久化),可在这个方法中添加数据库操作(如调用Service层),新手可先不扩展,先实现基础单聊功能。
方法 后端路径 前端订阅路径 Spring 自动转换 说明
单聊 /queue/messages /user/queue/messages ✅ 自动加 /user/{username} 只发给指定用户
广播 /topic/public /topic/public ❌ 不转换 发给所有人

6. 前端页面

可直接复制到 src/main/resources/templates/demo.html

html 复制代码
<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>WebSocket 单聊</title>
    <script src="https://cdn.jsdelivr.net/npm/sockjs-client@1/dist/sockjs.min.js"></script>
    <script src="https://cdn.jsdelivr.net/npm/stompjs@2.3.3/lib/stomp.min.js"></script>
    <style>
        * {
            margin: 0;
            padding: 0;
            box-sizing: border-box;
        }

        body {
            font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
            background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
            min-height: 100vh;
            display: flex;
            justify-content: center;
            align-items: center;
            padding: 20px;
        }

        .chat-container {
            background: white;
            border-radius: 12px;
            box-shadow: 0 10px 40px rgba(0, 0, 0, 0.2);
            width: 100%;
            max-width: 900px;
            overflow: hidden;
        }

        .chat-header {
            background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
            color: white;
            padding: 20px;
            text-align: center;
        }

        .chat-header h2 {
            font-size: 24px;
            font-weight: 600;
        }

        .connection-panel {
            padding: 20px;
            background: #f8f9fa;
            border-bottom: 1px solid #e9ecef;
            display: flex;
            gap: 10px;
            align-items: center;
            flex-wrap: wrap;
        }

        .connection-panel label {
            font-weight: 500;
            color: #495057;
        }

        .connection-panel input {
            flex: 1;
            min-width: 150px;
            padding: 10px 15px;
            border: 2px solid #dee2e6;
            border-radius: 6px;
            font-size: 14px;
            transition: all 0.3s ease;
        }

        .connection-panel input:focus {
            outline: none;
            border-color: #667eea;
            box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
        }

        .btn {
            padding: 10px 20px;
            border: none;
            border-radius: 6px;
            font-size: 14px;
            font-weight: 500;
            cursor: pointer;
            transition: all 0.3s ease;
            white-space: nowrap;
        }

        .btn-primary {
            background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
            color: white;
        }

        .btn-primary:hover {
            transform: translateY(-2px);
            box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4);
        }

        .btn-danger {
            background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
            color: white;
        }

        .btn-danger:hover {
            transform: translateY(-2px);
            box-shadow: 0 4px 12px rgba(245, 87, 108, 0.4);
        }

        .btn-success {
            background: linear-gradient(135deg, #4facfe 0%, #00f2fe 100%);
            color: white;
        }

        .btn-success:hover {
            transform: translateY(-2px);
            box-shadow: 0 4px 12px rgba(79, 172, 254, 0.4);
        }

        .btn-warning {
            background: linear-gradient(135deg, #fa709a 0%, #fee140 100%);
            color: white;
        }

        .btn-warning:hover {
            transform: translateY(-2px);
            box-shadow: 0 4px 12px rgba(250, 112, 154, 0.4);
        }

        .message-panel {
            padding: 20px;
        }

        .input-section {
            margin-bottom: 20px;
        }

        .input-section-title {
            font-size: 14px;
            font-weight: 600;
            color: #495057;
            margin-bottom: 10px;
            display: flex;
            align-items: center;
            gap: 8px;
        }

        .input-group {
            display: flex;
            gap: 10px;
            margin-bottom: 10px;
            flex-wrap: wrap;
        }

        .input-group input {
            flex: 1;
            min-width: 150px;
            padding: 12px 15px;
            border: 2px solid #dee2e6;
            border-radius: 6px;
            font-size: 14px;
            transition: all 0.3s ease;
        }

        .input-group input:focus {
            outline: none;
            border-color: #667eea;
            box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
        }

        .message-area {
            border: 2px solid #e9ecef;
            border-radius: 8px;
            height: 400px;
            overflow-y: auto;
            padding: 15px;
            background: #fafbfc;
        }

        .message-item {
            margin-bottom: 12px;
            padding: 10px 15px;
            border-radius: 8px;
            animation: slideIn 0.3s ease;
        }

        @keyframes slideIn {
            from {
                opacity: 0;
                transform: translateX(-10px);
            }
            to {
                opacity: 1;
                transform: translateX(0);
            }
        }

        .message-system {
            background: #fff3cd;
            border-left: 4px solid #ffc107;
            color: #856404;
        }

        .message-received {
            background: #d1ecf1;
            border-left: 4px solid #17a2b8;
            color: #0c5460;
        }

        .message-sent {
            background: #d4edda;
            border-left: 4px solid #28a745;
            color: #155724;
            text-align: right;
        }

        .message-broadcast {
            background: #e2e3e5;
            border-left: 4px solid #6c757d;
            color: #383d41;
        }

        .message-error {
            background: #f8d7da;
            border-left: 4px solid #dc3545;
            color: #721c24;
        }

        .message-from {
            font-weight: 600;
            margin-bottom: 4px;
            font-size: 12px;
        }

        .message-content {
            word-wrap: break-word;
        }

        .status-indicator {
            display: inline-block;
            width: 8px;
            height: 8px;
            border-radius: 50%;
            margin-right: 8px;
            background: #dc3545;
        }

        .status-indicator.connected {
            background: #28a745;
            box-shadow: 0 0 8px rgba(40, 167, 69, 0.6);
        }

        .empty-state {
            text-align: center;
            color: #6c757d;
            padding: 40px 20px;
        }

        .empty-state svg {
            width: 64px;
            height: 64px;
            margin-bottom: 16px;
            opacity: 0.3;
        }

        .divider {
            height: 1px;
            background: #e9ecef;
            margin: 15px 0;
        }
    </style>
</head>
<body>
<div class="chat-container">
    <div class="chat-header">
        <h2>💬 WebSocket 单聊测试</h2>
    </div>

    <div class="connection-panel">
        <label><span class="status-indicator" id="statusIndicator"></span>你的名字:</label>
        <input type="text" id="username" placeholder="例如: 111">
        <button class="btn btn-primary" onclick="connect()">连接</button>
        <button class="btn btn-danger" onclick="disconnect()">断开</button>
    </div>

    <div class="message-panel">
        <div class="input-section">
            <div class="input-section-title">📤 发送私聊消息</div>
            <div class="input-group">
                <input type="text" id="receiver" placeholder="接收人 (例如: 222)">
                <input type="text" id="message" placeholder="输入消息..." onkeypress="handleKeyPress(event)">
                <button class="btn btn-success" onclick="sendMsg()">发送</button>
            </div>
        </div>

        <div class="divider"></div>

        <div class="input-section">
            <div class="input-section-title">📢 广播消息</div>
            <div class="input-group">
                <input type="text" id="broadcastContent" placeholder="输入要广播的内容" onkeypress="handleBroadcastKeyPress(event)">
                <button class="btn btn-warning" onclick="sendBroadcast()">广播给所有人</button>
            </div>
        </div>

        <div class="divider"></div>

        <div class="message-area" id="chat">
            <div class="empty-state">
                <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
                    <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" />
                </svg>
                <p>暂无消息,请先连接WebSocket</p>
            </div>
        </div>
    </div>
</div>

<script>
    let stompClient = null;
    let currentUser = "";

    function connect() {
        currentUser = document.getElementById("username").value.trim();
        if (!currentUser) {
            showMessage('系统', '请输入名字', 'error');
            return;
        }

        const socket = new SockJS("/ws?username=" + currentUser);
        stompClient = Stomp.over(socket);

        stompClient.connect({}, function (frame) {
            console.log("连接成功", frame);
            updateStatus(true);

            stompClient.subscribe("/user/queue/messages", function (response) {
                const msg = JSON.parse(response.body);
                appendMessage('来自 ' + msg.sender, msg.content, 'received');
            });

            stompClient.subscribe("/topic/public", function (response) {
                const msg = JSON.parse(response.body);
                appendMessage('[广播] ' + msg.sender, msg.content, 'broadcast');
            });

            appendMessage('系统', '你已上线(' + currentUser + ')', 'system');
        }, function(error) {
            console.error("连接失败", error);
            showMessage('系统', '连接失败', 'error');
        });
    }

    function disconnect() {
        if (stompClient) {
            stompClient.disconnect();
            stompClient = null;
            updateStatus(false);
            appendMessage('系统', '已断开', 'system');
        }
    }

    function sendMsg() {
        const receiver = document.getElementById("receiver").value.trim();
        const content = document.getElementById("message").value.trim();
        if (!receiver || !content) {
            showMessage('系统', '接收人和消息不能为空', 'error');
            return;
        }

        const msg = {
            sender: currentUser,
            receiver: receiver,
            content: content
        };

        stompClient.send("/app/chat", {}, JSON.stringify(msg));
        appendMessage('我 (对 ' + receiver + ')', content, 'sent');
        document.getElementById("message").value = "";
    }

    function sendBroadcast() {
        const content = document.getElementById("broadcastContent").value.trim();
        if (!content) {
            showMessage('系统', '请输入广播内容', 'error');
            return;
        }

        const msg = {
            sender: currentUser,
            receiver: "所有人",
            content: content,
            type: "BROADCAST"
        };

        stompClient.send("/app/broadcast", {}, JSON.stringify(msg));
        appendMessage('我 [广播]', content, 'sent');
        document.getElementById("broadcastContent").value = "";
    }

    function handleKeyPress(event) {
        if (event.key === 'Enter') {
            sendMsg();
        }
    }

    function handleBroadcastKeyPress(event) {
        if (event.key === 'Enter') {
            sendBroadcast();
        }
    }

    function appendMessage(from, content, type) {
        const chatDiv = document.getElementById("chat");

        const emptyState = chatDiv.querySelector('.empty-state');
        if (emptyState) {
            emptyState.remove();
        }

        const messageDiv = document.createElement('div');
        messageDiv.className = `message-item message-${type}`;

        const fromDiv = document.createElement('div');
        fromDiv.className = 'message-from';
        fromDiv.textContent = from;

        const contentDiv = document.createElement('div');
        contentDiv.className = 'message-content';
        contentDiv.textContent = content;

        messageDiv.appendChild(fromDiv);
        messageDiv.appendChild(contentDiv);
        chatDiv.appendChild(messageDiv);
        chatDiv.scrollTop = chatDiv.scrollHeight;
    }

    function updateStatus(connected) {
        const indicator = document.getElementById('statusIndicator');
        if (connected) {
            indicator.classList.add('connected');
        } else {
            indicator.classList.remove('connected');
        }
    }

    function showMessage(from, content, type) {
        alert(`${from}: ${content}`);
    }
</script>
</body>
</html>

7.常见问题排查

7.1 . 发送消息后,接收者收不到消息

解决方案:

  • 核对订阅路径,确保是/user/{接收者ID}/queue/messages;
  • 核对发送路径,确保包含/app前缀;
  • 确保消息体中的receiver和订阅路径中的接收者ID一致。

7.2. 广播消息收不到

解决方案:

  • 前端是否订阅了/topic/public(注意前面没有/user)。
  • 后端是否使用了@SendTo("/topic/public")或convertAndSend("/topic/public", msg)。
  • 检查配置中enableSimpleBroker是否包含/topic。

8. 高频面试题

8.1. STOMP 是什么?为什么 WebSocket 还要用它?

  • STOMP 是一个简单的文本导向的消息协议,它定义了消息的格式(如帧 header、body)。
  • 原生 WebSocket 只是传输原始数据,你需要自己定义消息格式和路由规则;STOMP 提供了类似 HTTP 的"请求-订阅-发布"模式,开发更高效。

8.2. STOMP 协议的作用?

STOMP 定义了一套消息帧格式(类似 HTTP 的 header+body),让 WebSocket 编程更规范,支持订阅/发布模式,避免直接操作原始 WebSocket 帧。

8.3. convertAndSendToUser底层原理?

它会将目标地址转为/user/{username}/queue/messages,然后UserDestinationMessageHandler根据username找到对应的Principal和会话,最终把消息推送给客户端订阅的/user/queue/messages。

8.4. 广播性能如何优化?

当在线人数很多(比如>5000)时,内存代理(enableSimpleBroker)会成为瓶颈。可以改用外部消息代理如 RabbitMQ:enableStompBrokerRelay("/topic","/queue"),把广播压力交给消息中间件。

8.5. 如何实现离线消息?

STOMP 本身不存储离线消息。需要在业务层实现:收到消息时如果目标用户不在线,存入数据库;用户上线后(监听SessionConnectedEvent)从数据库拉取未读消息。

8.6. 如何保证消息的可靠性(消息不丢)?

开启 STOMP 的 broker 消息持久化(如使用 RabbitMQ 等外部 broker)。客户端实现消息 ACK 机制(STOMP 支持 client-individual ack)。业务层自己保存离线消息,用户上线后拉取。

相关推荐
武子康2 小时前
大数据-275 Spark MLib-集成学习:从Bagging到Boosting的群体智慧
大数据·后端·spark
SimonKing2 小时前
国产开源富文本编辑器 wangEditor,本姓编辑器
java·后端·程序员
Moment2 小时前
面试官:LangChain中 TS 和 Python 版本有什么差别,什么时候选TS ❓❓❓
前端·javascript·后端
ATCH IERV2 小时前
如何在 Spring Boot 中配置数据库?
数据库·spring boot·后端
阿丰资源2 小时前
基于SpringBoot+MySQL的时装购物系统(附源码)
java·spring boot·mysql
IT_陈寒2 小时前
React状态管理这个坑,我终于爬出来了
前端·人工智能·后端
Mr -老鬼2 小时前
Salvo Web框架专属AI智能体 - 让Rust开发效率翻倍
人工智能·后端·rust·智能体·salvo
AI人工智能+电脑小能手3 小时前
【大白话说Java面试题】【Java基础篇】第5题:HashMap的底层原理是什么
java·开发语言·数据结构·后端·面试·hash-index·hash
_Evan_Yao3 小时前
软件工程就是一场“抽象”游戏:从 abstract 关键字到架构设计的认知跃迁
java·后端·游戏·状态模式·软件工程