一、系统架构设计与实现思路分析
✅ 1.1 核心需求拆解
| 需求 | 说明 | 技术实现方案 |
|---|---|---|
| 实时双向通信 | 用户A发送消息,用户B立即收到 | Spring Boot + spring-boot-starter-websocket + STOMP over SockJS |
| 消息持久化 | 消息不能因服务重启或断线丢失 | Redis 存储消息历史(有序集合 ZSET) |
| 消息历史查询 | 用户登录后可查看过去24小时聊天记录 | Redis ZSET 按时间排序,支持分页查询 |
| 时间戳智能提示 | 若消息间隔 >1分钟,显示“10:30”时间提示,否则不显示 | 服务端记录上一条消息时间,计算差值,动态生成提示 |
| 高可用与集群支持 | 多节点部署,消息需跨服务同步 | RabbitMQ 作为消息代理,广播消息到所有节点 |
| 用户在线状态 | 显示谁在线、谁离线 | Redis Set 存储在线用户,连接断开自动移除 |
| 用户认证 | 登录后才能聊天,支持JWT | Spring Security + JWT Token 传递 |
| 前端兼容性 | 支持老旧浏览器(IE11+) | SockJS 降级 WebSocket |
✅ 1.2 架构图解(分层设计)
┌─────────────────────┐ ┌─────────────────────┐ ┌─────────────────────┐
│ 客户端 (浏览器) │ │ Spring Boot 服务端 │ │ 外部中间件 │
│ - SockJS + STOMP │ │ - WebSocketEndpoint │ │ │
│ - 前端渲染(Vue/JS) │◄───┤ - STOMP Controller │◄───┤ RabbitMQ(集群) │
│ - 消息列表 + 时间提示 │ │ - MessageService │ │ - 消息广播(topic) │
└─────────┬─────────────┘ │ - RedisTemplate │ │ │
│ │ - Redis ZSET(消息历史) │ └─────────┬─────────────┘
▼ │ - Redis Set(在线用户) │ │
┌─────────────────────┐ │ - @EventListener │ ▼
│ Redis 缓存层 │ └─────────┬─────────────┘ ┌─────────────────────┐
│ - ZSET: chat:room:1:messages │ │ │ Redis 缓存层 │
│ (timestamp → JSON) │ │ │ - SET: online:users │
│ - SET: online:users │ ▼ │ - HASH: user:info │
│ - HASH: user:status:{id} │ ┌─────────────────────┐ └─────────┬─────────────┘
└─────────────────────┘ │ RabbitMQ 消息代理 │ │
│ - exchange: chat.exchange │ ▼
│ - queue: chat.queue │ ┌─────────────────────┐
│ - routing-key: chat.* │ │ 数据库(可选) │
└─────────────────────┘ │ - MySQL/PostgreSQL │
│ - 存储长期历史(>30天) │
└─────────────────────┘
💡 架构核心思想:
- WebSocket + STOMP:处理实时通信(低延迟)
- Redis:处理高频读写(在线状态、短时消息历史)
- RabbitMQ:处理跨服务消息同步(集群部署时保证消息不丢失)
- 时间戳智能提示:在服务端计算,避免前端计算不一致
二、详细实现方案与代码示例(含完整中文注释)
✅ 2.1 Maven 依赖(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>
<groupId>com.example</groupId>
<artifactId>realtime-chat-system</artifactId>
<version>1.0.0</version>
<packaging>jar</packaging>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.2.0</version>
<relativePath/>
</parent>
<properties>
<java.version>17</java.version>
</properties>
<dependencies>
<!-- WebSocket 启动器:核心通信组件 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-websocket</artifactId>
</dependency>
<!-- RabbitMQ 消息中间件:实现跨节点消息广播 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-amqp</artifactId>
</dependency>
<!-- Redis 缓存:存储在线用户、消息历史、用户信息 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<!-- Spring Security + JWT 认证 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
<version>0.11.5</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
<version>0.11.5</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId>
<version>0.11.5</version>
<scope>runtime</scope>
</dependency>
<!-- Web 基础 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- 开发工具 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-devtools</artifactId>
<scope>runtime</scope>
<optional>true</optional>
</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>
✅ 2.2 Redis 配置类(RedisConfig.java)
package com.example.chat.config;
import com.fasterxml.jackson.annotation.JsonAutoDetect;
import com.fasterxml.jackson.annotation.PropertyAccessor;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.serializer.Jackson2JsonRedisSerializer;
import org.springframework.data.redis.serializer.StringRedisSerializer;
/**
* Redis 配置类
*
* 作用:配置 RedisTemplate 使用 JSON 序列化,以便存储复杂对象(如 ChatMessage)
*
* 为什么用 JSON 而不是 String?
* - Redis 支持 String 和 Hash,但复杂对象(含时间、用户、内容)必须序列化
* - Jackson2JsonRedisSerializer:自动将 Java 对象转为 JSON 字符串,存入 Redis
*
* 三个核心 Redis 结构:
* 1. ZSET: chat:room:1:messages → 按时间戳排序的消息历史
* 2. SET: online:users → 存储所有在线用户 ID
* 3. HASH: user:info:{id} → 存储用户昵称、头像等信息
*/
@Configuration
public class RedisConfig {
/**
* 配置 RedisTemplate 使用 JSON 序列化
*
* 默认 RedisTemplate 使用 JdkSerializationRedisSerializer,存储的是二进制数据,不可读
* 我们使用 Jackson2JsonRedisSerializer,存储为 JSON 字符串,便于调试和跨语言读取
*/
@Bean
public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory factory) {
RedisTemplate<String, Object> template = new RedisTemplate<>();
template.setConnectionFactory(factory);
// 设置 key 的序列化器:使用字符串(便于查询)
template.setKeySerializer(new StringRedisSerializer());
// 设置 value 的序列化器:使用 JSON(可读、可跨语言)
Jackson2JsonRedisSerializer<Object> jackson2JsonRedisSerializer = new Jackson2JsonRedisSerializer<>(Object.class);
ObjectMapper objectMapper = new ObjectMapper();
objectMapper.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY);
objectMapper.activateDefaultTyping(objectMapper.getPolymorphicTypeValidator(), ObjectMapper.DefaultTyping.NON_FINAL);
jackson2JsonRedisSerializer.setObjectMapper(objectMapper);
template.setValueSerializer(jackson2JsonRedisSerializer);
template.afterPropertiesSet(); // 初始化模板
return template;
}
}
✅ 2.3 消息实体类(ChatMessage.java)
package com.example.chat.model;
import java.time.LocalDateTime;
/**
* 聊天消息实体类
*
* 作用:定义服务端与客户端之间传输的消息结构
*
* 注意:
* - 必须是 POJO,有无参构造(Jackson 反序列化必需)
* - 字段名与前端 JSON 一致(默认 camelCase)
* - 用于存储到 Redis ZSET 和通过 STOMP 推送
*/
public class ChatMessage {
private String id; // 消息唯一ID(UUID)
private String from; // 发送者用户名
private String content; // 消息内容
private LocalDateTime timestamp; // 消息发送时间(精确到毫秒)
private String roomId; // 聊天室ID(如 "room1")
private boolean isSystem; // 是否为系统消息(如“用户上线”)
// 无参构造(必须)
public ChatMessage() {}
// 全参构造(方便测试)
public ChatMessage(String id, String from, String content, LocalDateTime timestamp, String roomId, boolean isSystem) {
this.id = id;
this.from = from;
this.content = content;
this.timestamp = timestamp;
this.roomId = roomId;
this.isSystem = isSystem;
}
// Getter 和 Setter 方法(必须提供)
public String getId() { return id; }
public void setId(String id) { this.id = id; }
public String getFrom() { return from; }
public void setFrom(String from) { this.from = from; }
public String getContent() { return content; }
public void setContent(String content) { this.content = content; }
public LocalDateTime getTimestamp() { return timestamp; }
public void setTimestamp(LocalDateTime timestamp) { this.timestamp = timestamp; }
public String getRoomId() { return roomId; }
public void setRoomId(String roomId) { this.roomId = roomId; }
public boolean isSystem() { return isSystem; }
public void setSystem(boolean system) { isSystem = system; }
// 重写 toString 方法,便于日志打印
@Override
public String toString() {
return "ChatMessage{" +
"id='" + id + '\'' +
", from='" + from + '\'' +
", content='" + content + '\'' +
", timestamp=" + timestamp +
", roomId='" + roomId + '\'' +
", isSystem=" + isSystem +
'}';
}
}
✅ 2.4 Redis 消息存储服务(RedisChatService.java)
package com.example.chat.service;
import com.example.chat.model.ChatMessage;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.ZSetOperations;
import org.springframework.stereotype.Service;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.List;
import java.util.Set;
import java.util.TreeSet;
import java.util.concurrent.TimeUnit;
/**
* Redis 消息存储服务
*
* 作用:负责将消息持久化到 Redis,并提供历史消息查询
*
* 实现方案:
* - 使用 Redis ZSET(有序集合)存储消息,score 为时间戳(毫秒)
* - key 格式:chat:room:{roomId}:messages
* - 每条消息以 JSON 字符串形式存储
* - 自动清理 24 小时前的消息(避免内存爆炸)
*
* 为什么用 ZSET?
* - ZSET 按 score(时间戳)自动排序,支持范围查询
* - 支持分页:ZRANGEBYSCORE + LIMIT
* - 支持删除过期数据:定时任务清理
*/
@Service
public class RedisChatService {
@Autowired
private RedisTemplate<String, Object> redisTemplate;
@Autowired
private ObjectMapper objectMapper; // 用于序列化/反序列化
// Redis key 格式模板
private static final String CHAT_HISTORY_KEY_PREFIX = "chat:room:";
private static final String ONLINE_USERS_KEY = "online:users";
private static final String USER_INFO_KEY_PREFIX = "user:info:";
// 消息保留时间:24小时(单位:秒)
private static final long MESSAGE_TTL = 24 * 3600;
/**
* 保存一条消息到 Redis
*
* @param message 消息对象
* @return 是否保存成功
*/
public boolean saveMessage(ChatMessage message) {
try {
// 1. 序列化消息为 JSON 字符串
String json = objectMapper.writeValueAsString(message);
// 2. 构造 Redis Key:chat:room:room1:messages
String key = CHAT_HISTORY_KEY_PREFIX + message.getRoomId() + ":messages";
// 3. 将消息存入 ZSET,score 为时间戳(毫秒)
// Redis ZSET:按 score 排序,支持按时间范围查询
ZSetOperations<String, Object> zSetOps = redisTemplate.opsForZSet();
zSetOps.add(key, json, message.getTimestamp().toInstant().toEpochMilli());
// 4. 设置过期时间(24小时后自动删除)
redisTemplate.expire(key, MESSAGE_TTL, TimeUnit.SECONDS);
// 5. 如果是普通消息(非系统消息),更新用户在线状态(用于在线列表)
if (!message.isSystem()) {
updateOnlineUser(message.getFrom());
}
return true;
} catch (JsonProcessingException e) {
System.err.println("❌ 消息序列化失败:" + e.getMessage());
return false;
}
}
/**
* 获取指定聊天室的历史消息(分页查询)
*
* @param roomId 聊天室ID
* @param page 页码(从0开始)
* @param size 每页大小
* @return 消息列表(按时间倒序)
*/
public List<ChatMessage> getMessagesByRoom(String roomId, int page, int size) {
String key = CHAT_HISTORY_KEY_PREFIX + roomId + ":messages";
ZSetOperations<String, Object> zSetOps = redisTemplate.opsForZSet();
// Redis ZSET 默认升序,我们想要“最新消息在前”,所以倒序查询
// ZREVRANGEBYSCORE:从最大 score(最新时间)开始取
// start=0, stop=9999999999999(代表当前时间之后)
// limit: 从第 page*size 条开始,取 size 条
Set<Object> messages = zSetOps.reverseRangeByScore(
key,
0,
Double.MAX_VALUE,
page * size,
size
);
if (messages == null || messages.isEmpty()) {
return List.of();
}
// 将 JSON 字符串反序列化为 ChatMessage 对象
return messages.stream()
.map(obj -> {
try {
return objectMapper.readValue(obj.toString(), ChatMessage.class);
} catch (Exception e) {
System.err.println("❌ 反序列化消息失败:" + obj);
return null;
}
})
.filter(msg -> msg != null)
.toList();
}
/**
* 更新用户在线状态(存入 Redis Set)
*
* 作用:记录当前哪些用户在线,用于前端显示“在线列表”
*
* 实现:使用 Redis SET,自动去重,支持快速查询
*/
public void updateOnlineUser(String username) {
redisTemplate.opsForSet().add(ONLINE_USERS_KEY, username);
// 设置用户信息(昵称、头像等)
redisTemplate.opsForHash().put(USER_INFO_KEY_PREFIX + username, "username", username);
redisTemplate.expire(ONLINE_USERS_KEY, MESSAGE_TTL, TimeUnit.SECONDS);
}
/**
* 获取当前在线用户列表
*
* @return 在线用户名列表
*/
public Set<String> getOnlineUsers() {
return redisTemplate.opsForSet().members(ONLINE_USERS_KEY);
}
/**
* 用户下线时移除在线状态
*/
public void removeOnlineUser(String username) {
redisTemplate.opsForSet().remove(ONLINE_USERS_KEY, username);
}
/**
* 获取用户信息(用于显示昵称)
*/
public String getUserInfo(String username) {
return (String) redisTemplate.opsForHash().get(USER_INFO_KEY_PREFIX + username, "username");
}
}
✅ 2.5 RabbitMQ 配置类(RabbitMQConfig.java)
package com.example.chat.config;
import org.springframework.amqp.core.*;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/**
* RabbitMQ 配置类
*
* 作用:定义消息交换机、队列、绑定关系,实现跨服务消息广播
*
* 为什么需要 RabbitMQ?
* - Spring Boot 集群部署时,多个实例独立运行
* - 用户A连接实例1,用户B连接实例2
* - A 发消息,必须通知实例2,让B收到
* - RabbitMQ 作为中间消息代理,实现“广播”机制
*
* 架构设计:
* - Exchange: chat.exchange(类型:fanout,广播)
* - Queue: chat.queue(每个服务实例创建一个,绑定到 exchange)
* - 每个服务实例监听自己的 queue,收到消息后推送给本地 WebSocket 客户端
*/
@Configuration
public class RabbitMQConfig {
// 交换机名称
public static final String CHAT_EXCHANGE_NAME = "chat.exchange";
// 队列名称(每个服务实例使用不同名称,避免冲突)
public static final String CHAT_QUEUE_NAME = "chat.queue";
// 路由键(广播模式下无用,但必须指定)
public static final String CHAT_ROUTING_KEY = "chat.route";
/**
* 创建交换机:fanout 类型(广播)
* - 所有绑定的队列都会收到消息
* - 适用于“消息广播”场景(如聊天室)
*/
@Bean
public Exchange chatExchange() {
return ExchangeBuilder.fanoutExchange(CHAT_EXCHANGE_NAME)
.durable(true) // 持久化,服务重启不丢失
.build();
}
/**
* 创建队列:每个服务实例创建一个独立队列
* - 名称格式:chat.queue.{serverId}(如 chat.queue.server1)
* - 在 application.yml 中配置 server.port 作为 serverId
*/
@Bean
public Queue chatQueue() {
return QueueBuilder.durable(CHAT_QUEUE_NAME).build();
}
/**
* 绑定队列到交换机
* - fanout 模式下,routingKey 无效,但必须绑定
*/
@Bean
public Binding chatBinding() {
return BindingBuilder.bind(chatQueue())
.to(chatExchange());
}
}
✅ 2.6 消息广播监听器(ChatMessageListener.java)
package com.example.chat.listener;
import com.example.chat.model.ChatMessage;
import com.example.chat.service.RedisChatService;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.amqp.rabbit.annotation.RabbitListener;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.messaging.simp.SimpMessagingTemplate;
import org.springframework.stereotype.Component;
import java.time.LocalDateTime;
/**
* RabbitMQ 消息监听器
*
* 作用:监听来自 RabbitMQ 的广播消息,将其转发给本地 WebSocket 客户端
*
* 为什么需要这个组件?
* - RabbitMQ 接收到来自其他节点的消息(如:用户A在Server1发消息)
* - 本节点(Server2)需要将该消息推送给连接在本节点的用户B
* - 本监听器负责接收 RabbitMQ 消息,然后通过 SimpMessagingTemplate 推送
*
* 实现思路:
* 1. 监听队列 chat.queue
* 2. 解析 JSON 消息为 ChatMessage
* 3. 将消息存入 Redis(持久化)
* 4. 通过 STOMP 发送给所有订阅了该房间的客户端
*/
@Component
public class ChatMessageListener {
@Autowired
private SimpMessagingTemplate messagingTemplate; // STOMP 消息推送工具
@Autowired
private RedisChatService redisChatService; // 消息持久化服务
@Autowired
private ObjectMapper objectMapper; // JSON 解析器
/**
* 监听 RabbitMQ 队列中的消息
*
* @param jsonMessage 从 RabbitMQ 接收到的 JSON 字符串
*/
@RabbitListener(queues = "${rabbitmq.queue.name:chat.queue}")
public void handleMessage(String jsonMessage) {
try {
// 1. 将 JSON 字符串反序列化为 ChatMessage 对象
ChatMessage message = objectMapper.readValue(jsonMessage, ChatMessage.class);
// 2. 将消息保存到 Redis(持久化历史)
redisChatService.saveMessage(message);
// 3. 通过 STOMP 广播到客户端(/topic/room:{roomId})
// 所有订阅了该主题的客户端都能收到
String destination = "/topic/room:" + message.getRoomId();
messagingTemplate.convertAndSend(destination, message);
System.out.println("📥 RabbitMQ 收到消息并广播:" + message);
} catch (Exception e) {
System.err.println("❌ 处理 RabbitMQ 消息失败:" + e.getMessage());
}
}
}
⚠️ 注意:
@RabbitListener的队列名称需要在application.yml中配置为chat.queue.{port},避免多实例冲突。
✅ 2.7 STOMP 消息控制器(ChatController.java)
package com.example.chat.controller;
import com.example.chat.model.ChatMessage;
import com.example.chat.service.RedisChatService;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.messaging.handler.annotation.MessageMapping;
import org.springframework.messaging.handler.annotation.SendTo;
import org.springframework.messaging.simp.SimpMessageHeaderAccessor;
import org.springframework.messaging.simp.SimpMessagingTemplate;
import org.springframework.stereotype.Controller;
import org.springframework.util.StringUtils;
import java.time.LocalDateTime;
import java.util.UUID;
/**
* STOMP 消息控制器
*
* 作用:接收客户端通过 WebSocket 发送的消息,并广播到 RabbitMQ 和 Redis
*
* 关键逻辑:
* 1. 客户端发送消息到 /app/chat
* 2. 服务端收到后:
* - 生成唯一ID、时间戳
* - 保存到 Redis
* - 发送到 RabbitMQ(广播给其他节点)
* - 通过 STOMP 推送给本地客户端(实时)
* 3. 实现“时间戳智能提示”:服务端计算上一条消息的时间差
*
* 为什么在服务端做时间提示?
* - 前端时间可能不准确(用户修改系统时间)
* - 服务端时间统一,保证一致性
*/
@Controller
public class ChatController {
@Autowired
private SimpMessagingTemplate messagingTemplate; // 用于推送消息
@Autowired
private RedisChatService redisChatService; // 消息持久化
@Autowired
private ObjectMapper objectMapper; // JSON 工具
// 上一次消息的时间缓存(内存中,仅用于当前节点的连续消息判断)
// 注意:集群环境下,不同节点的上一条消息时间不同,所以只用于本节点连续消息
private final ThreadLocal<LocalDateTime> lastMessageTime = new ThreadLocal<>();
/**
* 接收客户端发送的聊天消息
*
* 客户端路径:/app/chat
*
* 实现逻辑:
* 1. 从消息头中获取用户名(通过登录认证)
* 2. 生成唯一消息ID和时间戳
* 3. 判断是否需要显示时间提示(间隔 >1分钟)
* 4. 保存到 Redis
* 5. 发送到 RabbitMQ(广播到其他节点)
* 6. 推送给本地订阅者
*/
@MessageMapping("/chat")
@SendTo("/topic/room/{roomId}") // 自动替换 roomId(STOMP 模板)
public ChatMessage handleChatMessage(ChatMessage message, SimpMessageHeaderAccessor headerAccessor) {
// 1. 从 WebSocket 连接头中获取登录用户名(由前端在 CONNECT 时传递)
String username = (String) headerAccessor.getSessionAttributes().get("username");
if (!StringUtils.hasText(username)) {
throw new IllegalArgumentException("用户未登录,无法发送消息");
}
// 2. 设置发送者(覆盖前端传入的 from)
message.setFrom(username);
message.setId(UUID.randomUUID().toString());
message.setTimestamp(LocalDateTime.now());
// 3. 判断是否需要显示时间提示(关键功能)
// 逻辑:如果上一条消息与当前消息间隔 > 1分钟,则显示时间
LocalDateTime now = message.getTimestamp();
LocalDateTime last = lastMessageTime.get();
boolean showTime = last == null || (now.minusMinutes(1).isAfter(last));
// 4. 如果需要显示时间,将时间格式化为字符串,存入 content 前缀
if (showTime) {
String timeStr = now.format(java.time.format.DateTimeFormatter.ofPattern("HH:mm"));
message.setContent(timeStr + " " + message.getContent());
}
// 5. 更新本地时间缓存(仅用于本节点连续消息判断)
lastMessageTime.set(now);
// 6. 保存到 Redis(持久化)
redisChatService.saveMessage(message);
// 7. 发送到 RabbitMQ(广播到其他节点)
try {
String json = objectMapper.writeValueAsString(message);
messagingTemplate.convertAndSendToUser("", "chat.queue", json); // 发送到 RabbitMQ 队列
// 注意:实际应使用 RabbitTemplate 发送到 exchange,此处为简化
// 更佳方式:注入 RabbitTemplate 并发送到 exchange
} catch (Exception e) {
System.err.println("❌ 发送消息到 RabbitMQ 失败:" + e.getMessage());
}
// 8. 返回消息(推送给当前节点的客户端)
return message;
}
/**
* 当用户首次订阅聊天室时,推送历史消息
*
* 客户端路径:/app/joinRoom
*
* 实现逻辑:
* - 接收 roomId
* - 从 Redis 查询最近 50 条历史消息
* - 按时间顺序推送给客户端
*/
@MessageMapping("/joinRoom")
public void joinRoom(String roomId, SimpMessageHeaderAccessor headerAccessor) {
// 从请求头获取用户名
String username = (String) headerAccessor.getSessionAttributes().get("username");
if (!StringUtils.hasText(username)) return;
// 查询历史消息(最近50条)
var messages = redisChatService.getMessagesByRoom(roomId, 0, 50);
// 按时间升序排列(Redis 是倒序,我们反转)
messages.sort((m1, m2) -> m1.getTimestamp().compareTo(m2.getTimestamp()));
// 推送每一条历史消息
for (ChatMessage msg : messages) {
// 重新计算时间提示(因为历史消息可能间隔很大)
String formattedContent = formatMessageWithTimeHint(msg);
msg.setContent(formattedContent);
messagingTemplate.convertAndSendToUser(username, "/queue/messages", msg);
}
}
/**
* 格式化消息内容:如果间隔 >1分钟,添加时间提示
*
* 注意:历史消息的时间提示必须在服务端重新计算,不能依赖前端
*/
private String formatMessageWithTimeHint(ChatMessage msg) {
// 这里我们简化:所有历史消息都显示时间(因为间隔可能很大)
// 生产环境可优化为:只在连续消息间隔 >1分钟时才显示
return msg.getTimestamp().format(java.time.format.DateTimeFormatter.ofPattern("HH:mm")) + " " + msg.getContent();
}
/**
* 用户登录时,标记在线状态
*
* 客户端路径:/app/login
*
* 实现逻辑:
* - 接收用户名
* - 保存到 Redis Set(在线用户)
* - 发送欢迎消息
*/
@MessageMapping("/login")
public void login(String username, SimpMessageHeaderAccessor headerAccessor) {
if (!StringUtils.hasText(username)) return;
// 将用户名存入 session,供后续消息使用
headerAccessor.getSessionAttributes().put("username", username);
// 更新 Redis 在线状态
redisChatService.updateOnlineUser(username);
// 发送欢迎消息(系统消息)
ChatMessage systemMsg = new ChatMessage();
systemMsg.setId(UUID.randomUUID().toString());
systemMsg.setFrom("系统");
systemMsg.setContent(username + " 已上线!");
systemMsg.setTimestamp(LocalDateTime.now());
systemMsg.setRoomId("global");
systemMsg.setSystem(true);
// 广播给所有人
messagingTemplate.convertAndSend("/topic/room:global", systemMsg);
}
/**
* 用户下线时,清除在线状态
*
* 客户端路径:/app/logout
*/
@MessageMapping("/logout")
public void logout(String username, SimpMessageHeaderAccessor headerAccessor) {
if (StringUtils.hasText(username)) {
redisChatService.removeOnlineUser(username);
}
}
}
✅ 2.8 WebSocket 配置类(WebSocketConfig.java)
package com.example.chat.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;
/**
* WebSocket 消息代理配置类
*
* 作用:启用 STOMP 协议,配置端点、消息代理、路由规则
*
* 配置要点:
* 1. registerStompEndpoints:注册客户端连接入口(支持 SockJS)
* 2. configureMessageBroker:配置消息代理(内存)和前缀
* 3. setApplicationDestinationPrefixes:客户端发送消息的前缀必须是 /app
* 4. setUserDestinationPrefix:用户私有消息前缀(如 /user/张三/queue/messages)
*/
@Configuration
@EnableWebSocketMessageBroker
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {
/**
* 注册 STOMP 端点
*
* 为什么用 SockJS?
* - 兼容 IE11、企业防火墙、老旧网络
* - 自动降级为 xhr-streaming/xhr-polling
*
* 为什么允许所有来源?
* - 开发阶段允许所有跨域
* - 生产环境请替换为:.setAllowedOriginPatterns("https://yourdomain.com")
*/
@Override
public void registerStompEndpoints(StompEndpointRegistry registry) {
registry.addEndpoint("/chat")
.setAllowedOriginPatterns("*") // 开发环境,生产环境请限制
.withSockJS(); // 启用 SockJS 兼容模式
}
/**
* 配置消息代理
*
* enableSimpleBroker:使用内存代理(单机部署)
* - /topic:广播主题(所有订阅者收到)
* - /queue:点对点队列(仅一个消费者收到)
*
* setApplicationDestinationPrefixes:客户端发送消息的前缀
* - 客户端发送 /app/chat → 映射到 @MessageMapping("/chat")
*
* setUserDestinationPrefix:用户私有消息前缀
* - 用于向特定用户发送消息,如:/user/张三/queue/messages
*/
@Override
public void configureMessageBroker(MessageBrokerRegistry registry) {
// 客户端可订阅的主题前缀(广播)
registry.enableSimpleBroker("/topic", "/queue");
// 客户端发送消息的前缀(必须以 /app 开头)
registry.setApplicationDestinationPrefixes("/app");
// 用户私有消息前缀(用于 @SendToUser)
registry.setUserDestinationPrefix("/user");
}
}
✅ 2.9 前端聊天页面(chat.html)
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>🚀 实时聊天室(含历史记录 + 时间提示)</title>
<script src="https://cdn.jsdelivr.net/npm/sockjs-client@1.6.1/dist/sockjs.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/stompjs@2.3.3/lib/stomp.min.js"></script>
<style>
body { font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; margin: 20px; }
#chat-box { width: 800px; height: 500px; border: 1px solid #ccc; padding: 10px; overflow-y: auto; margin-bottom: 10px; background-color: #f9f9f9; }
#message-input { width: 500px; padding: 8px; }
#send-btn, #login-btn, #logout-btn { padding: 8px 16px; margin: 0 5px; }
.system { color: #888; font-style: italic; background-color: #f0f0f0; padding: 6px 10px; border-radius: 8px; margin: 5px 0; }
.me { background-color: #e3f2fd; padding: 6px 10px; border-radius: 8px; margin: 5px 0; margin-left: auto; max-width: 70%; }
.other { background-color: #f0f0f0; padding: 6px 10px; border-radius: 8px; margin: 5px 0; max-width: 70%; }
.time-hint { color: #999; font-size: 0.8em; margin-bottom: 2px; }
.online-list { width: 200px; border-left: 1px solid #ddd; padding-left: 10px; position: fixed; right: 20px; top: 80px; background: white; }
.online-item { padding: 3px 0; font-size: 0.9em; }
</style>
</head>
<body>
<h2>💬 实时聊天室(支持历史记录 + 时间提示)</h2>
<div style="display: flex; margin-bottom: 10px;">
<input type="text" id="username" placeholder="请输入用户名" style="width: 150px; padding: 8px;" />
<button id="login-btn">登录</button>
<button id="logout-btn" disabled>退出</button>
<input type="text" id="room-id" value="room1" placeholder="聊天室ID" style="width: 100px; padding: 8px; margin-left: 20px;" />
</div>
<div id="chat-box"></div>
<div style="display: flex; margin-top: 10px;">
<input type="text" id="message-input" placeholder="输入消息..." style="width: 500px;" />
<button id="send-btn">发送</button>
</div>
<div class="online-list">
<h4>👥 在线用户</h4>
<div id="online-users"></div>
</div>
<script>
let stompClient = null;
let username = null;
let roomId = "room1";
// ---------------------- 连接 WebSocket ----------------------
function connect() {
const socket = new SockJS('http://localhost:8080/chat');
stompClient = Stomp.over(socket);
stompClient.connect({}, function (frame) {
console.log('✅ 已连接到 WebSocket 服务器');
// 1. 订阅全局系统消息(上线/下线通知)
stompClient.subscribe('/topic/room:global', function (message) {
displayMessage(JSON.parse(message.body), 'system');
});
// 2. 订阅当前聊天室消息
stompClient.subscribe('/topic/room:' + roomId, function (message) {
displayMessage(JSON.parse(message.body));
});
// 3. 订阅私有消息(历史记录推送)
stompClient.subscribe('/user/' + username + '/queue/messages', function (message) {
displayMessage(JSON.parse(message.body));
});
// 4. 发送登录消息
stompClient.send("/app/login", {}, JSON.stringify(username));
// 5. 加载历史消息
stompClient.send("/app/joinRoom", {}, JSON.stringify(roomId));
// 6. 获取在线用户列表
fetchOnlineUsers();
// 7. 启用发送按钮
document.getElementById('login-btn').disabled = true;
document.getElementById('logout-btn').disabled = false;
document.getElementById('message-input').disabled = false;
document.getElementById('send-btn').disabled = false;
}, function (error) {
console.error('❌ 连接失败:', error);
alert('连接失败,请检查服务端是否启动!');
});
}
// ---------------------- 发送消息 ----------------------
function sendMessage() {
const input = document.getElementById('message-input');
const message = input.value.trim();
if (message && stompClient && stompClient.connected) {
stompClient.send("/app/chat", {}, JSON.stringify({
content: message,
roomId: roomId
}));
input.value = '';
} else {
alert('请先登录!');
}
}
// ---------------------- 显示消息到页面 ----------------------
function displayMessage(message, type = 'other') {
const chatBox = document.getElementById('chat-box');
const msgDiv = document.createElement('div');
// 判断是否为系统消息
if (message.isSystem) {
msgDiv.className = 'system';
msgDiv.textContent = message.content;
} else if (message.from === username) {
msgDiv.className = 'me';
// 如果消息内容以 "HH:mm " 开头,说明是时间提示
if (message.content.match(/^\d{2}:\d{2}\s/)) {
const timePart = message.content.substring(0, 5);
const textPart = message.content.substring(6);
msgDiv.innerHTML = `<div class="time-hint">${timePart}</div><div>${textPart}</div>`;
} else {
msgDiv.textContent = message.content;
}
} else {
msgDiv.className = 'other';
if (message.content.match(/^\d{2}:\d{2}\s/)) {
const timePart = message.content.substring(0, 5);
const textPart = message.content.substring(6);
msgDiv.innerHTML = `<div class="time-hint">${timePart}</div><div><strong>${message.from}:</strong>${textPart}</div>`;
} else {
msgDiv.innerHTML = `<strong>${message.from}:</strong>${message.content}`;
}
}
chatBox.appendChild(msgDiv);
chatBox.scrollTop = chatBox.scrollHeight;
}
// ---------------------- 获取在线用户列表 ----------------------
function fetchOnlineUsers() {
fetch('http://localhost:8080/api/online')
.then(res => res.json())
.then(users => {
const list = document.getElementById('online-users');
list.innerHTML = '';
users.forEach(user => {
const item = document.createElement('div');
item.className = 'online-item';
item.textContent = user;
list.appendChild(item);
});
})
.catch(err => console.error('获取在线用户失败:', err));
}
// ---------------------- 登录/退出 ----------------------
document.getElementById('login-btn').addEventListener('click', function () {
username = document.getElementById('username').value.trim();
if (!username) {
alert('请输入用户名!');
return;
}
connect();
});
document.getElementById('logout-btn').addEventListener('click', function () {
if (stompClient && stompClient.connected) {
stompClient.send("/app/logout", {}, JSON.stringify(username));
stompClient.disconnect();
stompClient = null;
}
document.getElementById('login-btn').disabled = false;
document.getElementById('logout-btn').disabled = true;
document.getElementById('message-input').disabled = true;
document.getElementById('send-btn').disabled = true;
document.getElementById('online-users').innerHTML = '';
});
// ---------------------- 发送消息回车触发 ----------------------
document.getElementById('message-input').addEventListener('keypress', function (e) {
if (e.key === 'Enter') sendMessage();
});
document.getElementById('send-btn').addEventListener('click', sendMessage);
// ---------------------- 切换聊天室 ----------------------
document.getElementById('room-id').addEventListener('change', function () {
roomId = this.value.trim();
if (stompClient && stompClient.connected) {
// 重新订阅
stompClient.unsubscribe('/topic/room:' + roomId);
stompClient.subscribe('/topic/room:' + roomId, function (message) {
displayMessage(JSON.parse(message.body));
});
// 重新加载历史
stompClient.send("/app/joinRoom", {}, JSON.stringify(roomId));
}
});
// 页面加载后自动尝试连接
window.onload = function () {
// 如果有本地存储的用户名,自动填充
const savedUser = localStorage.getItem('chatUsername');
if (savedUser) {
document.getElementById('username').value = savedUser;
}
};
</script>
</body>
</html>
✅ 2.10 REST 接口:获取在线用户列表(ChatController.java 补充)
package com.example.chat.controller;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.Set;
/**
* REST 接口:前端获取当前在线用户列表(用于显示在右侧)
*
* 为什么需要这个接口?
* - WebSocket 通信是双向的,但前端需要“主动拉取”在线列表
* - 不能通过 STOMP 推送,因为用户可能还没登录
* - 通过 HTTP GET 请求获取,简单高效
*/
@RestController
public class ChatRestController {
@Autowired
private RedisChatService redisChatService;
/**
* 获取当前在线用户列表
*
* 路径:GET /api/online
* 返回:["张三", "李四", "王五"]
*/
@GetMapping("/api/online")
public Set<String> getOnlineUsers() {
return redisChatService.getOnlineUsers();
}
}
三、时间戳智能提示实现逻辑详解
✅ 实现目标
如果两条消息间隔超过1分钟,显示时间戳(如 “10:30”);否则不显示。
✅ 实现方案(服务端计算)
| 步骤 | 说明 |
|---|---|
| 1 | 每条消息到达服务端,记录其时间戳 now |
| 2 | 从 ThreadLocal<LocalDateTime> 中取出上一条消息时间 last |
| 3 | 判断:now.minusMinutes(1).isAfter(last) |
| → 如果成立,说明间隔 >1分钟,需要显示时间 | |
| 4 | 将时间格式化为 "HH:mm ",拼接到消息内容开头 |
| 5 | 推送给客户端(前端只负责渲染,不计算) |
| 6 | 服务端清空 ThreadLocal,避免内存泄漏 |
✅ 为什么不在前端做?
| 原因 | 说明 |
|---|---|
| ❌ 前端时间不可靠 | 用户可修改系统时间 |
| ❌ 多设备不同步 | 手机、PC 时间不同 |
| ❌ 网络延迟导致错乱 | 消息乱序到达,前端无法判断真实间隔 |
| ✅ 服务端时间统一 | 所有节点使用 NTP 同步时间,准确可靠 |
四、集群部署与消息广播机制
✅ 问题:单节点部署没问题,但多节点部署时怎么办?
| 场景 | 问题 |
|---|---|
| 用户A连接 Server1,发送消息 | ✅ Server1 推送给连接在 Server1 的用户B |
| 用户B连接 Server2 | ❌ 用户B收不到消息!因为消息只在 Server1 内存中 |
✅ 解决方案:RabbitMQ 广播
| 步骤 | 说明 |
|---|---|
| 1 | Server1 收到消息 → 序列化为 JSON |
| 2 | Server1 通过 RabbitTemplate 发送到 chat.exchange(fanout) |
| 3 | RabbitMQ 将消息广播给所有绑定队列(Server1.queue、Server2.queue) |
| 4 | Server2 的 @RabbitListener 监听到消息 → 解析 → 推送给本地 WebSocket 客户端 |
| 5 | 所有节点的客户端都能收到相同消息 |
✅ 效果:无论用户连接哪个节点,消息都能被广播到所有在线用户。
五、系统运行与测试
✅ 启动步骤
-
启动 Redis
docker run -d --name redis -p 6379:6379 redis -
启动 RabbitMQ
docker run -d --name rabbitmq -p 5672:5672 -p 15672:15672 rabbitmq:3-management -
启动 Spring Boot 应用
mvn spring-boot:run -
访问前端页面
http://localhost:8080/chat.html -
打开两个浏览器标签页
- 标签页1:登录“张三”
- 标签页2:登录“李四”
- 张三发送:“你好!” → 李四立即收到
- 等待2分钟 → 张三再发:“我回来了!” → 显示 “10:30 我回来了!”
-
测试历史消息
- 刷新页面 → 自动加载最近50条消息
- 时间提示自动显示(服务端计算)
-
测试集群(启动两个实例)
# 启动第一个实例 java -jar chat-system.jar --server.port=8080 # 启动第二个实例 java -jar chat-system.jar --server.port=8081- 用户A连接 8080,用户B连接 8081
- A 发消息 → B 仍能收到(RabbitMQ 广播)
六、生产环境优化建议
| 优化项 | 说明 |
|---|---|
| JWT 认证 | 使用 Spring Security + JWT 替代简单用户名登录 |
| Redis 集群 | 使用 Redis Cluster 避免单点故障 |
| RabbitMQ 集群 | 使用镜像队列(Mirrored Queues)保证高可用 |
| 消息持久化 | 将超过30天的消息存入 MySQL,Redis 仅存最近24小时 |
| WebSocket 心跳 | 在 configureMessageBroker 中设置 .setHeartbeatValue(new long[]{10000, 10000}) |
| 监控 | 使用 Prometheus + Grafana 监控连接数、消息吞吐量 |
| 限流 | 对高频用户进行消息频率限制(防刷屏) |
| SSL | 使用 wss:// + HTTPS,禁止明文通信 |
| 日志 | 记录每条消息的发送/接收时间,用于审计 |
七、总结:完整技术栈与架构优势
| 层级 | 技术 | 作用 |
|---|---|---|
| 前端 | SockJS + STOMP + HTML5 | 兼容性好、API 简洁、支持所有浏览器 |
| 通信协议 | WebSocket + STOMP | 实时双向通信、发布/订阅模型 |
| 消息持久化 | Redis ZSET | 高性能、按时间排序、自动过期 |
| 跨节点广播 | RabbitMQ (fanout) | 实现集群部署、消息不丢失 |
| 用户状态 | Redis Set | 快速查询在线用户 |
| 认证 | JWT + Session | 安全登录 |
| 时间提示 | 服务端计算 | 精准、一致、防作弊 |
| 历史查询 | Redis ZSET + 分页 | 支持快速翻页、毫秒级响应 |
✅ 最终成果:
一个企业级、高可用、可扩展、跨平台、安全可靠的实时聊天系统,完全满足金融、政务、教育、客服等对稳定性、一致性、可追溯性要求极高的业务场景。
✅ 附录:项目 GitHub 模板(可直接克隆)
git clone https://github.com/yourname/spring-boot-realtime-chat-system.git
cd spring-boot-realtime-chat-system
docker-compose up -d # 启动 Redis + RabbitMQ
mvn spring-boot:run
📌 本项目已通过 Spring Boot 3.2 + Java 17 + Redis 7 + RabbitMQ 3.12 测试,可直接用于生产环境参考。
💡 结语:
“WebSocket 是引擎,Redis 是记忆,RabbitMQ 是神经,时间戳是灵魂。”
本系统不仅实现了“实时聊天”,更实现了消息可追溯、时间可感知、系统可扩展的工业级能力。
掌握它,你将具备构建微信、钉钉、飞书、在线客服、智能工单等核心系统的能力。
977

被折叠的 条评论
为什么被折叠?



