Spring Boot 整合 WebSocket + Redis 实现离线消息(三)

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>
<!-- Redis -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>

<!-- 连接池依赖(提升Redis性能,可选但推荐) -->
<dependency>
    <groupId>org.apache.commons</groupId>
    <artifactId>commons-pool2</artifactId>
</dependency>

2. 配置文件

yaml 复制代码
spring:
  data:
    redis:
      host: localhost
      port: 6379
      password:
      # 若无密码则不填以下配置
      database: 0
      lettuce:
        pool:
          max-active: 8
          max-idle: 8
          max-wait: 1000ms
          min-idle: 0

3. Redis 配置类

java 复制代码
@Configuration
public class RedisConfig {

    @Bean
    public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory connectionFactory) {
        RedisTemplate<String, Object> template = new RedisTemplate<>();
        template.setConnectionFactory(connectionFactory);

        // 配置支持 LocalDateTime 的 ObjectMapper
        ObjectMapper objectMapper = new ObjectMapper();
        objectMapper.registerModule(new JavaTimeModule());
        objectMapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
        // 可选:允许序列化对象时包含类型信息(方便反序列化)
        objectMapper.activateDefaultTyping(objectMapper.getPolymorphicTypeValidator(), ObjectMapper.DefaultTyping.NON_FINAL);

        GenericJackson2JsonRedisSerializer serializer = new GenericJackson2JsonRedisSerializer(objectMapper);

        // key 使用 String 序列化
        template.setKeySerializer(new StringRedisSerializer());
        template.setHashKeySerializer(new StringRedisSerializer());
        // value 使用自定义的 JSON 序列化器
        template.setValueSerializer(serializer);
        template.setHashValueSerializer(serializer);

        template.afterPropertiesSet();
        return template;
    }
}

4. 消息实体

java 复制代码
@Data
public class Message implements Serializable {
    private static final long serialVersionUID = 1L;
    private String fromUserId;
    private String toUserId;
    private String content;
    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
    private LocalDateTime timestamp;
    private Boolean offline; // 是否为离线消息
}

5. 消息服务

java 复制代码
@Slf4j
@Service
@RequiredArgsConstructor
public class OfflineMessageService {

    private final RedisTemplate<String, Object> redisTemplate;

    // Redis key 前缀
    private static final String OFFLINE_KEY_PREFIX = "offline:msg:";
    // 离线消息保留 7 天
    private static final Duration MESSAGE_TTL = Duration.ofDays(7);

    /**
     * 保存离线消息(存到 Redis List 中)
     */
    public void saveOfflineMessage(String userId, Message message) {
        message.setOffline(true);
        String key = OFFLINE_KEY_PREFIX + userId;
        // 右侧推入
        redisTemplate.opsForList().rightPush(key, message);
        // 设置过期时间(如果 key 已存在会刷新 TTL)
        redisTemplate.expire(key, MESSAGE_TTL);
        log.info("已保存用户 {} 的离线消息: {}", userId, message.getContent());
    }

    /**
     * 获取并删除该用户的所有离线消息(原子操作)
     * 注意:range + delete 并非原子,但能满足大多数场景。如需严格原子可改用 Lua 脚本。
     */
    @SuppressWarnings("unchecked")
    public List<Message> getAndClearOfflineMessages(String userId) {
        String key = OFFLINE_KEY_PREFIX + userId;
        List<Message> messages = new ArrayList<>();

        // 获取所有消息
        List<Object> rawList = redisTemplate.opsForList().range(key, 0, -1);
        if (rawList != null && !rawList.isEmpty()) {
            for (Object obj : rawList) {
                if (obj instanceof Message) {
                    messages.add((Message) obj);
                }
            }
        }

        // 删除 key
        if (!messages.isEmpty()) {
            redisTemplate.delete(key);
            log.info("已拉取并删除用户 {} 的 {} 条离线消息", userId, messages.size());
        }
        return messages;
    }

    /**
     * 查询离线消息数量(可选,用于前端小红点)
     */
    public Long getOfflineMessageCount(String userId) {
        String key = OFFLINE_KEY_PREFIX + userId;
        Long size = redisTemplate.opsForList().size(key);
        return size != null ? size : 0L;
    }
}

6. WebSocket 核心处理器

java 复制代码
@Slf4j
@Component
@RequiredArgsConstructor
public class MyWebSocketHandler extends TextWebSocketHandler {

    private final OfflineMessageService offlineMessageService;
    private final ObjectMapper objectMapper;

    // 在线用户会话池(分布式环境下每个实例存自己的部分,但离线消息通过 Redis 共享)
    private final ConcurrentHashMap<String, WebSocketSession> sessionMap = new ConcurrentHashMap<>();

    /**
     * 连接建立后:记录会话 + 立即推送离线消息
     */
    @Override
    public void afterConnectionEstablished(WebSocketSession session) throws Exception {
        String userId = getUserIdFromSession(session);
        sessionMap.put(userId, session);
        log.info("用户 {} 上线,当前实例在线人数: {}", userId, sessionMap.size());

        // 关键:从 Redis 拉取离线消息并推送
        List<Message> offlineMessages = offlineMessageService.getAndClearOfflineMessages(userId);
        if (!offlineMessages.isEmpty()) {
            for (Message msg : offlineMessages) {
                String json = objectMapper.writeValueAsString(msg);
                session.sendMessage(new TextMessage(json));
                log.info("推送离线消息给 {}: {}", userId, msg.getContent());
            }
        }
    }

    /**
     * 处理客户端发送的消息:转发或存离线
     */
    @Override
    protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception {
        String fromUserId = getUserIdFromSession(session);
        String payload = message.getPayload();

        Message msg = objectMapper.readValue(payload, Message.class);
        msg.setFromUserId(fromUserId);
        msg.setTimestamp(LocalDateTime.now());

        String toUserId = msg.getToUserId();
        WebSocketSession toSession = sessionMap.get(toUserId);

        if (toSession != null && toSession.isOpen()) {
            // 目标用户在线,实时推送
            toSession.sendMessage(new TextMessage(objectMapper.writeValueAsString(msg)));
            log.info("实时消息 {} -> {}: {}", fromUserId, toUserId, msg.getContent());
        } else {
            // 目标用户离线,存入 Redis
            offlineMessageService.saveOfflineMessage(toUserId, msg);
            log.info("用户 {} 离线,消息已存入 Redis", toUserId);
        }
    }

    /**
     * 连接关闭:移除本地会话
     */
    @Override
    public void afterConnectionClosed(WebSocketSession session, CloseStatus status) {
        String userId = getUserIdFromSession(session);
        sessionMap.remove(userId);
        log.info("用户 {} 下线,当前实例在线人数: {}", userId, sessionMap.size());
    }

    /**
     * 从 URL 路径中提取 userId(例如 ws://localhost:8080/ws/1001)
     */
    private String getUserIdFromSession(WebSocketSession session) {
        String path = session.getUri().getPath();
        return path.substring(path.lastIndexOf('/') + 1);
    }
}

7. WebSocketConfig配置类

java 复制代码
@Configuration
@EnableWebSocket
@RequiredArgsConstructor
public class WebSocketConfig implements WebSocketConfigurer {

    private final MyWebSocketHandler myWebSocketHandler;

    @Override
    public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
        registry.addHandler(myWebSocketHandler, "/ws/{userId}")
                .setAllowedOriginPatterns("*");
    }
}

8. 前端页面

可直接复制到 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 离线消息 Demo</title>
    <style>
        * {
            box-sizing: border-box;
        }
        body {
            font-family: system-ui, -apple-system, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
            background: #f0f2f5;
            margin: 0;
            padding: 20px;
        }
        .container {
            max-width: 500px;
            margin: 0 auto;
            background: white;
            border-radius: 16px;
            box-shadow: 0 2px 10px rgba(0,0,0,0.1);
            overflow: hidden;
        }
        .header {
            background: #2c3e50;
            color: white;
            padding: 16px 20px;
            font-size: 18px;
            font-weight: 600;
            display: flex;
            justify-content: space-between;
            align-items: center;
        }
        .status {
            font-size: 12px;
            background: #e67e22;
            padding: 4px 8px;
            border-radius: 20px;
        }
        .status.connected {
            background: #27ae60;
        }
        .user-info {
            padding: 12px 20px;
            background: #ecf0f1;
            display: flex;
            gap: 12px;
            align-items: center;
            border-bottom: 1px solid #ddd;
        }
        .user-info label {
            font-weight: 600;
        }
        .user-info input {
            flex: 1;
            padding: 8px;
            border: 1px solid #ccc;
            border-radius: 8px;
        }
        .user-info button {
            background: #3498db;
            color: white;
            border: none;
            padding: 8px 16px;
            border-radius: 8px;
            cursor: pointer;
        }
        .chat-panel {
            display: flex;
            flex-direction: column;
            height: 500px;
        }
        .message-list {
            flex: 1;
            overflow-y: auto;
            padding: 16px;
            display: flex;
            flex-direction: column;
            gap: 8px;
        }
        .message {
            max-width: 80%;
            padding: 8px 12px;
            border-radius: 12px;
            font-size: 14px;
            word-wrap: break-word;
        }
        .message.sent {
            background: #3498db;
            color: white;
            align-self: flex-end;
            border-bottom-right-radius: 4px;
        }
        .message.received {
            background: #e9ecef;
            color: #2c3e50;
            align-self: flex-start;
            border-bottom-left-radius: 4px;
        }
        .message.offline-tag {
            font-size: 10px;
            opacity: 0.7;
            margin-top: 4px;
        }
        .input-area {
            display: flex;
            padding: 12px;
            border-top: 1px solid #ddd;
            background: white;
            gap: 8px;
        }
        .input-area input {
            flex: 1;
            padding: 10px;
            border: 1px solid #ccc;
            border-radius: 24px;
            outline: none;
        }
        .input-area button {
            background: #2ecc71;
            border: none;
            padding: 0 20px;
            border-radius: 24px;
            color: white;
            font-weight: bold;
            cursor: pointer;
        }
        .info {
            font-size: 12px;
            color: #7f8c8d;
            text-align: center;
            padding: 8px;
            border-top: 1px solid #eee;
        }
    </style>
</head>
<body>
<div class="container">
    <div class="header">
        <span>💬 离线消息 Demo</span>
        <span class="status" id="statusText">未连接</span>
    </div>
    <div class="user-info">
        <label>我的ID:</label>
        <input type="text" id="myUserId" placeholder="例如 1001" value="1001">
        <button id="connectBtn">连接/重连</button>
    </div>
    <div class="chat-panel">
        <div class="message-list" id="messageList">
            <div class="info">👈 连接后即可收发消息</div>
        </div>
        <div class="input-area">
            <input type="text" id="targetUserId" placeholder="对方ID" value="1002">
            <input type="text" id="messageInput" placeholder="输入消息..." onkeypress="if(event.key==='Enter') sendMessage()">
            <button id="sendBtn" disabled>发送</button>
        </div>
    </div>
    <div class="info">
        💡 提示:关闭页面 = 离线,再次打开自动拉取离线消息
    </div>
</div>

<script>
    let ws = null;
    let currentUserId = null;

    const messageList = document.getElementById('messageList');
    const statusSpan = document.getElementById('statusText');
    const sendBtn = document.getElementById('sendBtn');
    const connectBtn = document.getElementById('connectBtn');
    const myUserIdInput = document.getElementById('myUserId');
    const targetUserIdInput = document.getElementById('targetUserId');
    const messageInput = document.getElementById('messageInput');

    // 添加一条消息到界面
    function appendMessage(msg, isSent, isOffline = false) {
        const msgDiv = document.createElement('div');
        msgDiv.className = `message ${isSent ? 'sent' : 'received'}`;

        let content = `${msg.content}`;
        if (msg.fromUserId) {
            content = `[${msg.fromUserId}] ${content}`;
        }
        msgDiv.innerText = content;

        if (isOffline) {
            const tag = document.createElement('div');
            tag.className = 'offline-tag';
            tag.innerText = '📦 离线消息';
            msgDiv.appendChild(tag);
        }

        // 添加时间(可选)
        if (msg.timestamp) {
            const time = new Date(msg.timestamp).toLocaleTimeString();
            const timeSpan = document.createElement('div');
            timeSpan.style.fontSize = '10px';
            timeSpan.style.opacity = '0.6';
            timeSpan.innerText = time;
            msgDiv.appendChild(timeSpan);
        }

        messageList.appendChild(msgDiv);
        messageList.scrollTop = messageList.scrollHeight;
    }

    // 更新连接状态UI
    function setConnected(connected) {
        if (connected) {
            statusSpan.innerText = '已连接';
            statusSpan.classList.add('connected');
            sendBtn.disabled = false;
            connectBtn.innerText = '断开连接';
        } else {
            statusSpan.innerText = '未连接';
            statusSpan.classList.remove('connected');
            sendBtn.disabled = true;
            connectBtn.innerText = '连接';
        }
    }

    // 建立 WebSocket 连接
    function connect() {
        const userId = myUserIdInput.value.trim();
        if (!userId) {
            alert('请输入用户ID');
            return;
        }

        // 关闭旧连接
        if (ws && ws.readyState === WebSocket.OPEN) {
            ws.close();
        }

        currentUserId = userId;
        const wsUrl = `ws://localhost:8080/ws/${userId}`;
        ws = new WebSocket(wsUrl);

        ws.onopen = () => {
            console.log('WebSocket 已连接');
            setConnected(true);
            // 清空消息列表(可选,也可以保留历史)
            // messageList.innerHTML = '<div class="info">✅ 连接成功,开始聊天~</div>';
            appendMessage({ content: '✅ 连接成功,离线消息会自动拉取', fromUserId: '系统' }, false);
        };

        ws.onmessage = (event) => {
            const data = JSON.parse(event.data);
            console.log('收到消息:', data);
            // 判断是否是离线消息(后端已标记 offline 字段)
            const isOffline = data.offline === true;
            appendMessage(data, false, isOffline);
        };

        ws.onerror = (error) => {
            console.error('WebSocket 错误:', error);
            appendMessage({ content: '❌ 连接出错,请检查后端是否启动', fromUserId: '系统' }, false);
        };

        ws.onclose = () => {
            console.log('WebSocket 已关闭');
            setConnected(false);
        };
    }

    // 断开连接
    function disconnect() {
        if (ws && ws.readyState === WebSocket.OPEN) {
            ws.close();
        }
    }

    // 发送消息
    function sendMessage() {
        if (!ws || ws.readyState !== WebSocket.OPEN) {
            alert('未连接到服务器,请先连接');
            return;
        }

        const toUserId = targetUserIdInput.value.trim();
        const content = messageInput.value.trim();
        if (!toUserId || !content) {
            alert('请填写对方ID和消息内容');
            return;
        }

        const msg = {
            toUserId: toUserId,
            content: content
        };

        ws.send(JSON.stringify(msg));

        // 在本地立即显示"我"发送的消息(乐观更新)
        const displayMsg = {
            content: content,
            fromUserId: currentUserId,
            timestamp: new Date().toISOString()
        };
        appendMessage(displayMsg, true, false);

        messageInput.value = '';
    }

    // 绑定按钮事件
    connectBtn.onclick = () => {
        if (ws && ws.readyState === WebSocket.OPEN) {
            disconnect();
        } else {
            connect();
        }
    };

    sendBtn.onclick = sendMessage;

    // 页面关闭前主动断开连接(可选)
    window.addEventListener('beforeunload', () => {
        if (ws && ws.readyState === WebSocket.OPEN) {
            ws.close();
        }
    });
</script>
</body>
</html>

9. 涉及问题

  • 为什么离线消息要用 Redis 的 List 结构?

    • List 的rpush和lrange天然适合消息队列场景,按顺序存储,用户上线时一次性取出。而且可以用ltrim限制长度,避免单用户消息过多。
  • Redis 存储离线消息,如果用户永远不上线怎么办

    • 设置 TTL(上面代码设置了 7 天),过期自动删除。同时可以配合定时任务,扫描offline:msg:*前缀的 key,对超过 30 天未登录的用户进行归档或清理。
  • 分布式部署下,用户 A 连接实例 1,用户 B 连接实例 2,B 发送消息给 A 时,实例 2 的sessionMap中没有 A,会错误地存为离线消息。怎么解决?

    • **方案一:**使用 Redis Pub/Sub,每个实例订阅一个频道,当收到跨实例消息时,检查本地sessionMap,若有则推送。
    • **方案二:**使用 Spring 的@EnableWebSocketMessageBroker+ Redis 作为消息代理(Broker),这样所有会话和消息路由都由 Spring 处理。
    • **方案三:**通过一致性哈希,确保同一个用户的连接总是落到同一个实例(需要反向代理支持)。
  • WebSocket 心跳如何结合 Redis 做?

    • 在客户端定时发送 ping,服务端更新 Redis 中该用户的"最后活跃时间",并设置一个过期时间。若某个用户超过 N 秒未 ping,则认为离线,强制关闭会话。
相关推荐
程序员老邢2 小时前
【产品底稿 06】商助慧V1.2实战复盘:Milvus向量库重构+RAG仿写升级+前端SSE排版彻底修复
java·人工智能·经验分享·spring boot·ai·milvus
阿丰资源2 小时前
基于SpringBoot+MySQL的在线拍卖系统设计与实现(附源码)
spring boot·后端·mysql
Han.miracle2 小时前
Spring Cloud + Nacos 环境切换与配置管理最佳实践
数据库·spring boot·spring cloud·maven
budingxiaomoli2 小时前
SpringBoot快速上手
java·spring boot·后端
fengxin_rou2 小时前
黑马点评实战篇|第七篇:Redis消息队列
数据库·redis·缓存
我登哥MVP2 小时前
【SpringMVC笔记】 - 10 - 拦截器(Interceptor)
java·spring boot·spring·servlet·tomcat·maven
wljt2 小时前
Spring boot学习笔记六:SpringBoot实用技术整合
spring boot·笔记·学习
千月落2 小时前
Redis Cluster 集群部署
数据库·redis·缓存
卷毛的技术笔记3 小时前
从零到一:深入浅出分布式锁原理与Spring Boot实战(Redis + ZooKeeper)
java·spring boot·redis·分布式·后端·面试·java-zookeeper