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)。业务层自己保存离线消息,用户上线后拉取。