【Java项目-轻聊】13-实现消息管理模块-消息的接收与转发

✨✨✨✨✨✨✨✨✨✨✨✨✨✨✨✨✨✨✨✨

🎯 你正在阅读「Java项目-轻聊」系列文章 🎯

✨✨✨✨✨✨✨✨✨✨✨✨✨✨✨✨✨✨✨✨

🔥 弹简特 个人主页

❄️ 个人专栏直通车:

靠热爱去书写自己,靠勇敢去书写生活!

✨✨✨✨✨✨✨✨✨✨✨✨✨✨✨✨✨✨✨✨


🌟 博主简介:


文章目录:


一、前言

前面几期,我们已经能用 HTTP 点开会话、拉历史消息的功能了。

那么本期要做的是:不用刷新页面,对方一发消息,你这边马上能看见

整体还是老规矩,分五块来写:

  1. 约定前后端传什么
  2. 想清楚消息怎么转发(业务逻辑)
  3. 后端代码
  4. 前端代码
  5. 自己测一遍

二、前置:搭建WebSocket

1、搭建 WebSocket 基础代码

上一期期我们已经有一个 测试用TestWebSocketController,挂在 /chat 上,发什么给你回什么,用来确认长连接能通。

本期要再建一个 真正干聊天活的 控制器。

controller 包下新建 WebSocketController,继承 TextWebSocketHandler(Spring 里专门处理 文字长连接 的父类):

代码:

java 复制代码
import org.springframework.stereotype.Component;
import org.springframework.web.socket.CloseStatus;
import org.springframework.web.socket.TextMessage;
import org.springframework.web.socket.WebSocketSession;
import org.springframework.web.socket.handler.TextWebSocketHandler;

/**
 * 正式聊天的 WebSocket 控制器
 *
 * 【核心作用】
 * 这个类负责处理所有WebSocket连接相关的业务逻辑,是轻聊的后端核心。
 * 每个浏览器客户端连接时,都会建立一个WebSocketSession对象,
 * 这个Controller负责管理这些会话,并处理消息的收发。
 *
 * 【工作流程】
 * 1. 用户登录后,前端发起WebSocket连接(ws://域名/路径)
 * 2. 连接成功 -> afterConnectionEstablished() 被调用
 * 3. 用户发送消息 -> handleTextMessage() 被调用
 * 4. 连接异常 -> handleTransportError() 被调用
 * 5. 用户关闭页面/主动断开 -> afterConnectionClosed() 被调用
 *
 * 【Spring注解说明】
 * @Component 将该类交给Spring容器管理,使其成为一个Bean。
 * 继承 TextWebSocketHandler 表示这是一个处理文本消息的处理器。
 */
@Component
public class WebSocketController extends TextWebSocketHandler {

    /**
     * 【连接建立成功回调】
     *
     * 触发时机:当浏览器通过WebSocket协议成功连接到服务器时,Spring框架会自动调用此方法
     *
     * 参数说明:
     * @param session WebSocket会话对象,代表当前这个客户端连接
     *                 可以通过它来:
     *                 - session.getId() 获取会话唯一标识
     *                 - session.getAttributes() 获取附加属性(如用户信息)
     *                 - session.sendMessage() 主动向客户端推送消息
     *                 - session.isOpen() 检查连接是否还活着
     *
     * 后续需要做的事情(TODO清单):
     * 1. 获取当前登录用户:
     *    - 从session.getAttributes()中取出之前拦截器存入的用户信息
     *    - 或者从Spring Security的上下文获取
     * 2. 登记在线用户:
     *    - 将用户信息存入一个全局的在线用户Map中(key: userId, value: session)
     *    - 广播"xxx进入了聊天室"的系统消息给所有人
     * 3. 推送历史消息:
     *    - 查询最近的聊天记录发送给刚进入的用户
     * 4. 更新在线人数:
     *    - 维护一个在线人数计数器,并广播给所有人
     */
    @Override
    public void afterConnectionEstablished(WebSocketSession session) throws Exception {
        System.out.println("[WebSocketController] 连接成功~");
        System.out.println("会话ID:" + session.getId());
        System.out.println("远程地址:" + session.getRemoteAddress());
        // TODO 后面几步再补:取当前登录用户、登记在线
    }

    /**
     * 【接收客户端消息的回调】
     *
     * 触发时机:当浏览器通过WebSocket发送文本消息时,Spring框架会自动调用此方法
     *
     * 参数说明:
     * @param session 发送消息的那个客户端的会话对象
     * @param message 客户端发送的消息内容(TextMessage类型)
     *                 可以通过 message.getPayload() 获取消息的字符串内容
     *                 通常我们会将消息内容设计为JSON格式,便于解析
     *
     *
     * 后续需要做的事情(TODO清单):
     * 1. 解析消息内容:
     *    - 使用Jackson等工具将JSON字符串解析为Java对象
     *    - 校验消息格式是否正确
     * 2. 判断消息类型:
     *    - 群聊消息:转发给所有在线的用户
     *    - 私聊消息:只转发给目标用户
     *    - 系统消息:特殊处理
     * 3. 保存到数据库:
     *    - 调用Service层保存聊天记录
     *    - 异步处理避免阻塞WebSocket线程
     * 4. 消息转发:
     *    - 遍历在线用户列表,发送消息
     *    - 注意消息发送失败时的异常处理
     * 5. 消息过滤:
     *    - 敏感词过滤
     *    - 消息长度限制
     * 6. 消息回执:
     *    - 给发送者一个"消息已送达"的确认
     *
     * 注意事项:
     * - 不要在此方法中进行耗时操作,否则会阻塞其他消息的处理
     * - 如果消息需要保存数据库,建议使用@Async异步执行
     * - 要捕获异常,避免单个消息处理失败导致连接断开
     */
    @Override
    protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception {
        System.out.println("[WebSocketController] 收到消息:" + message.toString());
        System.out.println("消息内容:" + message.getPayload());
        // TODO: 后面主要做:解析消息、转发、保存到数据库
    }

    /**
     * 【连接异常处理回调】
     *
     * 触发时机:当WebSocket连接出现异常时(如网络中断、连接超时等),Spring会调用此方法
     *
     * 参数说明:
     * @param session 发生异常的会话对象(可能已经关闭)
     * @param exception 异常对象,包含了具体的错误信息
     *                  常见的异常类型:
     *                  - IOException: 网络IO异常
     *                  - WebSocketHandshakeException: 握手失败
     *                  - 其他运行时异常
     *
     * 后续需要做的事情(TODO清单):
     * 1. 记录异常日志:
     *    - 使用Logback/Log4j记录详细的异常堆栈
     *    - 便于后期排查问题
     * 2. 清理资源:
     *    - 如果session仍然有效,尝试关闭
     *    - 从在线用户列表中移除该用户
     * 3. 通知相关人员:
     *    - 发送系统通知给管理员
     *    - 广播"xxx因网络异常离开"的消息
     * 4. 重连机制:
     *    - 可以在客户端实现自动重连逻辑
     *    - 服务端记录异常次数,防止恶意攻击
     *
     * 注意:异常发生后,连接可能会自动关闭,紧接着会调用 afterConnectionClosed()
     */
    @Override
    public void handleTransportError(WebSocketSession session, Throwable exception) throws Exception {
        System.out.println("[WebSocketController] 连接出错:" + exception.toString());
        exception.printStackTrace(); // 实际项目中应该使用日志框架
    }

    /**
     * 【连接关闭回调】
     *
     * 触发时机:当WebSocket连接正常关闭或异常关闭时,Spring会调用此方法
     *
     * 参数说明:
     * @param session 被关闭的会话对象(此时已经关闭,不能再发送消息)
     * @param status 关闭状态对象,包含了关闭原因:
     *                - status.getCode(): 关闭状态码
     *                  * 1000: 正常关闭
     *                  * 1001: 端点离开(如浏览器关闭)
     *                  * 1006: 异常关闭(如网络断开)
     *                - status.getReason(): 关闭原因描述
     *
     * 后续需要做的事情(TODO清单):
     * 1. 清理在线用户:
     *    - 从在线用户Map中移除该用户
     *    - 更新在线人数计数器
     * 2. 广播离开消息:
     *    - 通知其他用户"xxx离开了聊天室"
     *    - 如果是异常关闭,可以特殊提示
     * 3. 记录日志:
     *    - 记录用户退出时间
     *    - 记录会话持续时间
     * 4. 资源释放:
     *    - 释放该会话占用的资源
     *    - 如果使用了内存缓存,清理相关数据
     *
     * 注意:
     * - 此时的session已经关闭,不要再尝试发送消息
     * - 如果需要在关闭时发送通知,应该在关闭前完成
     * - 该方法会被正常关闭和异常关闭两种情况触发
     */
    @Override
    public void afterConnectionClosed(WebSocketSession session, CloseStatus status) throws Exception {
        System.out.println("[WebSocketController] 连接关闭:" + status.toString());
        System.out.println("关闭代码:" + status.getCode());
        System.out.println("关闭原因:" + status.getReason());
    }
}

这里的 WebSocketSession,你就理解成:浏览器和服务器之间的这一条长连接。后面发消息、记谁在线,咱都要围着它转。


2、注册WebSocketController这个类

光写好WebSocketController类还不行,得告诉 Spring:哪条网址 由这个类来处理。

打开已有的 WebSocketConfig,在原来 /chat 测试路径 旁边 再挂一条(前期只有 TestWebSocketController,本期追加):

代码:

java 复制代码
@Resource
private WebSocketController webSocketController;

@Override
public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
    // 测试回显,前期已有
    registry.addHandler(testWebSocketController, "/chat")
            .setAllowedOrigins("*");

    // 正式聊天:浏览器连 ws://ip:端口/WebSocketMessage 会进 WebSocketController
    registry.addHandler(webSocketController, "/WebSocketMessage")
            .setAllowedOrigins("*");
}
  • addHandler:哪个类、管哪条路径
  • setAllowedOrigins("*"):允许跨域(学习阶段先写 *,上线要改成你的前端域名)

3、在 client.js 里先把长连接搭起来

client.js 里加上 WebSocket 相关代码。文件最上面先声明:

javascript 复制代码
let websocket = null;   // 先不创建,等确认登录成功再说

那么我们上一期在介绍WebSocket知识的时候,也是介绍过前端该部分的代码编写方式👉WebSocket详解

那么现在我们前端对于WebSocket部分的代码的编写我们封装在一个方法中:

封装一个 initWebSocket注意地址要和当前页面一致,这样登录 Cookie 才能带上,后面踩坑还会讲):

javascript 复制代码
////////////////////////////////////////////////
// 操作 WebSocket(长连接)
////////////////////////////////////////////////

function initWebSocket() {
    let protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
    let host = window.location.host;
    websocket = new WebSocket(protocol + '//' + host + '/WebSocketMessage');

    websocket.onopen = function() {
        console.log("WebSocket 连接成功");
    };

    websocket.onmessage = function(e) {
        console.log("WebSocket 收到的消息:" + e.data);
        // TODO: 后面重点实现:收到消息后更新界面
    };

    websocket.onclose = function() {
        console.log("WebSocket 连接断开");
        // TODO: 重连模块预留,后续实现断线自动重连
    };

    websocket.onerror = function() {
        console.log("WebSocket 异常");
        alert("消息连接异常,请刷新页面重试");
    };
}

那么,我们这个方法什么时候调用呢?考虑一下

首先就拿着我们最基本的原则来说:你无论干什么,对于这种核心的接口我们都是必须登录过之后才能调用的,所以此处我们站在这个分析的角度我们就应该清楚,我们之前实现过的获取用户信息,如果用户登录成功了,那么我就可以得到用户信息,如果没有登录那么就跳到登录页面,所以此处根据我们的业务场景,我们就应该将这个方法的调用放到获取用户信息成功之后(当然具体到底放在哪里,我们不是固定的,我们看需求和业务)

getUserInfo 里,确认已经登录(userId > 0)之后再调用:

javascript 复制代码
function getUserInfo() {
    $.ajax({
        method: 'get',
        url: '/userInfo', // Session 中的用户信息
        success: function(body) {
            if (body.userId && body.userId > 0) {
                // TODO:后续建立实时连接(已实现)
                initWebSocket(); // 建立长连接
                //...
            }
 }

🚀注意:别页面一打开就 new WebSocket,那时可能还没登录,服务器不知道你是谁。


二、约定消息传输过程中前后端交互的接口

注意:这里 已经不是 HTTP 了,没有请求行、请求头那一套,直接传 JSON 字符串 就行。

1、请求(浏览器 → 服务器)

用户点「发送」时,大概长这样:

json 复制代码
{
  "type": "message",
  "sessionId": 1,
  "content": "你好"
}
字段 含义
type 消息类型。本期聊天写 "message",以后发图片可以换别的
sessionId 当前在和哪个会话聊(数据库里会话表的 id)
content 输入框里的文字

type 是为了以后扩展:长连接本身不管里面装的是聊天还是心跳,我们自己用 type 区分。

2、响应(服务器 → 浏览器)

服务器推回来的也是 JSON:

json 复制代码
{
  "type": "message",
  "fromId": 1,
  "fromName": "张三",
  "sessionId": 1,
  "content": "你好",
  "sessionName": "李四",
  "avatarPath": "/avatars/xxx.png"
}
字段 含义
fromId / fromName 谁发的
sessionName 左侧列表显示的名字(推给自己时显示对方名,推给对方时显示发送者名)
avatarPath 发送者头像,画气泡旁边小图

3、和 HTTP 不一样的地方

不是 平时那种「谁发请求谁拿响应」。

张三发的,李四也能收到 ------ 服务器会主动往李四那边推。

还有个小细节:张三自己界面上也要出现这条字。所以服务器转发时 也会给张三推一条 ;前端也可以在点发送时 先自己画一条 (后面 initSendButton 里会写)。


三、实现客户端发送消息的代码编写

发送按钮在聊天页右下角,输入框在它上面。我们要:找到这两个元素 → 点发送时拼 JSON → 用长连接发出去

那么接下里我们要实现前端发送消息,我们先看请求:

思路:

我们有三个字段:type、sessionid、content

其中type是固定的,content是我们输入框中获取的,唯一要自己考虑的就是sessionid,而这个sessionid我们在获取会话列表信息的时候,就已经将他存于我们的标签属性中,所以我们就直接从中获取即可,接下里我们一步一步都实现:

1、找到输入框和发送按钮

javascript 复制代码
////////////////////////////////////////////////
// 实现消息发送和接收
////////////////////////////////////////////////

function initSendButton() {
    let sendButton = document.querySelector('.right .ctrl button');
    let messageInput = document.querySelector('.right .message-input');

2、绑定点击事件

javascript 复制代码
    sendButton.onclick = function() {
        // 输入框空的,啥也不干
        if (!messageInput.value) {
            return;
        }

3、拿到当前选中的会话 id

左侧会话列表里,当前正在聊的那一行,前期渲染时写了自定义属性 message-session-id

javascript 复制代码
        let selectedLi = document.querySelector("#session-list .selected");

        // 刚刷新页面可能还没选会话
        if (!selectedLi) {
            return;
        }

        let sessionId = selectedLi.getAttribute('message-session-id');
        if (!sessionId) {
            alert('会话尚未就绪,请稍后再发');
            return;
        }

4、拼请求并发送

javascript 复制代码
        if (!websocket || websocket.readyState !== WebSocket.OPEN) {
            alert('消息连接未就绪,请刷新页面重试');
            return;
        }

        let req = {
            type: 'message',
            sessionId: parseInt(sessionId, 10),
            content: messageInput.value
        };
        websocket.send(JSON.stringify(req));   // 对象转成字符串再发
        messageInput.value = '';

5、自己这边先显示出来

不等服务器回推,发送方本地先画气泡(addMessagescrollBottom 是前期拉历史消息时写好的,直接复用):

javascript 复制代码
        if (selectedLi.classList.contains('selected')) {
            let messageShowDiv = document.querySelector('.message-show');
            let selfUser = getCurrentUsername();
            let selfUserId = document.querySelector('.left .user').getAttribute('user-id');
            let avatarPath = document.querySelector('#user-bar').getAttribute('data-avatar-path');
            addMessage(messageShowDiv, {
                fromId: parseInt(selfUserId, 10),
                fromName: selfUser,
                avatarPath: avatarPath,
                content: req.content
            });
            scrollBottom(messageShowDiv);
        }
    };
}

最后在 $(document).ready 里别忘了:

javascript 复制代码
initSendButton();

6、完整代码

此处我们的逻辑就是写一个前端js代码发送我们的数据而已。

js 复制代码
/**
 * ============================================================
 * 发送消息功能(核心交互)
 * ============================================================
 * 作用:用户点击发送按钮时,将输入框的内容通过 WebSocket 发送给服务器
 * 流程:校验 → 构造数据 → 发送 → 本地显示(秒显)
 * ============================================================
 */
function initSendButton() {
    // ----- 获取页面元素 -----
    // 发送按钮(右侧聊天区域底部的"发送"按钮)
    let sendButton = document.querySelector('.right .ctrl button');
    // 消息输入框(用户打字的地方)
    let messageInput = document.querySelector('.right .message-input');

    // ----- 绑定点击事件 -----
    sendButton.onclick = function() {

        // ============================================================
        // 第一步:校验输入内容
        // ============================================================
        // 如果输入框是空的(没有文字),直接返回,不发送
        if (!messageInput.value) {
            return;
        }

        // ============================================================
        // 第二步:校验是否选中了会话
        // ============================================================
        // 获取当前高亮的会话列表项(class="selected" 表示被选中)
        let selectedLi = document.querySelector('#session-list .selected');

        // 如果没有选中任何会话,或者选中的是"好友请求"这个特殊项,不能发消息
        // friend-request-item 是好友申请列表的特殊样式,选它不能发聊天消息
        if (!selectedLi || selectedLi.classList.contains('friend-request-item')) {
            return;
        }

        // ============================================================
        // 第三步:获取会话ID
        // ============================================================
        // 从 li 标签的自定义属性中取出会话ID(message-session-id)
        // 这个ID是后端生成的,用来标识"你和谁"的聊天通道
        let sessionId = selectedLi.getAttribute('message-session-id');

        // 如果拿不到会话ID,说明会话还没初始化好
        if (!sessionId) {
            alert('会话尚未就绪,请稍后再发');
            return;
        }

        // ============================================================
        // 第四步:检查 WebSocket 连接状态
        // ============================================================
        // websocket 是全局变量,在 initWebSocket() 中创建
        // readyState === WebSocket.OPEN (值为1) 表示连接正常
        if (!websocket || websocket.readyState !== WebSocket.OPEN) {
            alert('消息连接未就绪,请刷新页面重试');
            return;
        }

        // ============================================================
        // 第五步:构造发送数据(JSON格式)
        // ============================================================
        let content = messageInput.value; // 获取输入框的文字

        // 按照和后端约定的格式组装数据
        let req = {
            type: 'message',              // 固定值:表示这是条聊天消息
            sessionId: parseInt(sessionId, 10), // 会话ID(转成数字类型)
            content: content              // 消息正文
        };

        // ============================================================
        // 第六步:通过 WebSocket 发送到服务器
        // ============================================================
        // JSON.stringify() 把 JavaScript 对象转成 JSON 字符串
        // 服务器收到后,会解析这个 JSON,然后保存到数据库并广播给其他人
        websocket.send(JSON.stringify(req));

        // 清空输入框,方便用户输入下一条消息
        messageInput.value = '';

        // ============================================================
        // 第七步:本地立即显示("秒显"优化)
        // ============================================================
        // 正常情况下:发送 → 服务器收到 → 服务器广播 → 自己收到 → 显示
        // 这样会有网络延迟,用户会觉得消息"卡"了一下
        // 所以我们先在本地上显示,等服务器回传时再过滤掉,避免重复

        // 再次确认:当前选中的会话还是这个(防止发送过程中用户切换了会话)
        if (selectedLi.classList.contains('selected')) {

            // ----- 获取页面元素 -----
            // 消息展示区域(显示聊天记录的大框)
            let messageShowDiv = document.querySelector('.message-show');

            // ----- 获取当前用户信息 -----
            // 自己的昵称(从 getCurrentUsername() 函数获取)
            let selfUser = getCurrentUsername();

            // 自己的用户ID(从左侧用户栏的自定义属性中获取)
            let selfUserId = document.querySelector('.left .user').getAttribute('user-id');

            // 自己的头像路径(从顶部用户栏获取,用于显示头像)
            let avatarPath = document.querySelector('#user-bar').getAttribute('data-avatar-path');

            // ----- 调用添加消息函数,在界面上显示 -----
            // addMessage() 会在聊天框中追加一条消息
            // 传入参数:显示区域 + 消息数据对象
            addMessage(messageShowDiv, {
                fromId: parseInt(selfUserId, 10), // 发送者ID
                fromName: selfUser,                // 发送者昵称
                avatarPath: avatarPath,            // 发送者头像
                content: content                   // 消息内容
            });

            // ----- 滚动到底部,让用户看到最新消息 -----
            // 如果消息太多超出容器,自动滚动到最下面
            scrollBottom(messageShowDiv);

            // ----- 更新会话列表的预览文字 -----
            // 在左侧会话列表中,显示"最后一条消息内容"
            // 比如:张三的会话下面显示"你好",李四的会话下面显示"在吗"
            setSessionPreview(selectedLi, content);
        }
    };
}


// 更新会话列表项上的最后一条消息预览(最多 20 字)
function setSessionPreview(li, text) {
    // ----- 第一步:找到显示预览的位置 -----
    // 优先找 class="session-preview" 的元素(专门用来显示预览的)
    // 如果找不到,就找 <p> 标签(兼容旧版布局)
    let p = li.querySelector('.session-preview') || li.querySelector('p');

    // 如果连 <p> 标签都没有,说明这个 li 结构不完整,直接返回
    if (!p) return;

    // ----- 第二步:处理消息内容(截断) -----
    // 如果传入了空值或 undefined,显示空字符串
    let preview = text || '';

    // 如果长度超过20个字符,截取前20个,然后加"..."
    // 注意:中文字符算1个,英文字母也算1个
    if (preview.length > 20) {
        preview = preview.substring(0, 20) + '...';
    }

    // ----- 第三步:更新显示 -----
    // 把处理好的预览文字设置到 <p> 标签中
    p.textContent = preview;
}

四、业务逻辑梳理:消息转发思路

客户端能发出去了,服务器收到之后 怎么转给李四?我们先把思路理清楚代码后面再写。

1、得维护一张「用户 id → 长连接」的对照表

张三往服务器里面发的时候:服务器是知道咱们的会话id的,因为这个会话id是你张三带过去的

张三发的消息到了服务器,服务器要干的事可以想成两步:

  1. 消息里有 sessionId → 去数据库查:这个会话里还有哪些用户 (表 message_session_user,之前会话模块就有了)。 此时可以查询出来,这个表中有李四,此时我们就干第二件事
  2. 拿到李四的 用户 id 之后 → 去内存里查:李四现在哪条长连接在线

这里你想一下,为什么我们拿到李四的id之后,还有去维护内存中的一张表呢?

答:因为现在虽然我知道你张三是要给李四发消息了,但是你服务器怎么给这个李四的浏览器发送消息呢?此时这个动作我们的WebSocketSession方法已经给我们封装好了,我们只需要去找到这个长连接即可。

故为啥内存还要一张表?

因为光有用户 id 不够,往浏览器推消息,必须拿着 那条长连接对象 去调发送方法(代码里叫 WebSocketSession)。

来理清楚逻辑

第一步:你张三(userId=1)给李四(userId=2)发一个"你好",那么你张三的浏览器就会构造一个请求,给服务器发送消息👇

第二步:此时服务器根据你的会话id得出来,你这个会话中有两个用户(单聊只有两个用户),一个是你自己张三(userId=1),另一个是张三的好友李四(userId=2)

第三步:服务器需要将消息发送给userId=2的李四,那么得知道服务器和李四之间的WebSocket连接,并通过里面的WebSocketSession对象将消息发过去,所以我们会这样处理:

我们每一个客户端和服务器建立一个WebSocket连接,都会有对应的一个WebSocketSession对象👇

注意咯:这部分咱们在浏览器和服务器建立WebSocket连接的时候,就将这个用户id和对应的WebSocketSession的关系维护在一个内存中👇

第四步:有了上述的一个关系之后,我服务器根据你张三给的sessionid得出你要发的用户李四的userid=2,那么我服务器再根据userId=2去这个map集合中得出李四的WebSocketSession对象,最后通过这个对象将消息给发送过去👇

那么,上述这样的过程中影藏这很多的细节,这些细节我们后续会慢慢的描述。


故上述的流程告诉我们每个连上来的浏览器,都要在服务器里登记「用户 id → 这条连接」 。 这样的维护关系 咱本期用 OnlineUserManager 这个类来记,里面是一个 线程安全的 MapConcurrentHashMap,多人同时上线、下线、发消息时不能乱)。

2、映射什么时候建、什么时候删?

上述我们也提到过,你每个用户浏览器和服务器之间建立一个WebSocket连接之后,我们就得维护一个userId和WebSocketSession对象的映射关系,那么这个映射关系我们什么时候来维护?什么时候删除这个关系?

什么时候 干什么
WebSocket连接建立成功 登记:用户 id → 当前这条连接
连接关闭或出错 从表里删掉(删的时候要判断:关的是不是登记表里那条,别误删)
转发消息 根据对方用户 id 查连接,查到了就推

同一账号两个浏览器登录 (多开)怎么办?

完整项目里会踢掉一个或拒绝后登录。本期代码里先预留,我们后续会实现:

java 复制代码
// TODO: 重复登录模块预留,后续实现同一账号多开时的踢下线或拒绝新连接策略

3、转发消息的四步(transferMessage 方法)

我们上述都是在说逻辑,但是具体落实到代码中我们大概是怎么实现的呢?

我们后面会在服务层里面写一个 transferMessage 方法,专门干消息转发。可以记四步:

  1. 拼好要推出去的 JSON(谁发的、哪个会话、内容是啥)。
  2. 根据 sessionId 查会话里还有谁 。查询会把你本人排除,但转发时 还要把自己加回列表(自己也要收到一条,多端同步)。
  3. 对每个人:在线就推,不在线就跳过
  4. 写入 message ,和对方在不在线 没关系

李四离线了会丢吗?

不会。第 3 步推不到他,第 4 步照样存库。他下次登录点会话,还是用以前的 GET /message 拉历史消息记录。

4、怎么知道登录的人是谁?

我们启动项目之后,我们的设计是只要用户登录,那么就会自动调用前端的getUserInfo(),这个方法中会自动调用initWebSocket()建立浏览器和服务器之间的WebSocket连接,此时建立连接之后自动调用afterConnectionEstablished()方法

我们说了第一时间维护userid和WebSocketSession之间的关系,OK,那么问题来了:userId怎么获取?

答:我们用户登录成功之后,我们的用户信息是存在我们的HTTP会话中的,我们可以从里面获取。

可问题是:登录成功时,用户对象放在 HTTP 会话 里(带 JSESSIONID 那块)。

而WebSocket长连接建立时,默认 不会 自动带过来。你在afterConnectionEstablished()这个方法中获取不到HTTP会话中的消息。(注意:afterConnectionEstablished是我们上一篇博客中介绍的方法,忘记可回去先复习)

此时怎么办呢?

办法:注册WebSocket长连接时加一个 握手拦截器HttpSessionHandshakeInterceptor),在连接建立那一瞬间,把 HTTP 会话里的用户信息 复制到长连接会话里

所以只要注册了拦截器之后,后面就能写:

java 复制代码
User user = (User) session.getAttributes().get("user");

补充常见问题:

  • 离线咋办? ------ 实时推不到就存库,上线拉历史。
  • 为啥把自己加回转发列表? ------ 发送者也要收到回推。
  • 为啥 Map 要线程安全? ------ 很多人同时上线、发消息。

简单梳理一下:张三或者李四等等,他们登录成功之后,会将userId存在HTTP会话中,而且登录成功之后会自动与服务器建立WebSocket连接(我们是这样设计的),此时连接成功,那么必须维护userId和WebSocketSession映射关系,这个映射关系是在afterConnectionEstablished方法里面建立的,你在这个方法中代表你此时是WebSocket连接下干活,所以问题是在这个WebSocket下面,我们获取不到HTTP会话中的当前用户的userId,我们WebSocket连接中不会将HTTP会话中的userid带过来,所以办法就是加一个拦截器,将HTTP中的用户信息包括userId复制到WebSocket长连接会话WebSocketSession里

那么接下来我们就先加入一个拦截器👇

5、加入拦截器

为了能够维护刚才所讨论的键值对映射关系,就需要知道当前WebSocket连接是哪一个user ID进行的。

这个时候就涉及一个问题,user id这个信息保存在哪里呢?其实在我们的请求参数中是没有的,但是在我http session中,我们把它存在会话中了【是我们最初在用户登录的时候,给http session里存了当前的user对象】

问题:既然信息在httpsession中,那么在我们当下websocket的代码中怎么拿到httpsession呢?

其实这样的需求在我们大佬们设计webSocket的时候就考虑到了,所以他提供了一个拦截器HttpSessionHandshakeInterceptor握手时把 HttpSession 里的键值对拷到 WebSocketSession 里

能够把httpsession里面的信息给他放到webSocketSession中,那么我们的httpsession中存的无非就是键值对"user"==>User对象,此时我们通过上述特殊的手段,把HttpSession中的这些Attribute键值对拷贝到webSocketSession中,所以此时我们的webSocketSession也就可以拿到user的相关信息了。

那么具体这样的方法怎么实现的呢?

我们还是回到最初注册WebSocket Handler这里(WebSocketConfig),在注册的同时给他指定一个特殊的拦截器

回到 WebSocketConfig,给 /WebSocketMessage 加上:

java 复制代码
registry.addHandler(webSocketController, "/WebSocketMessage")
        .setAllowedOrigins("*")
        .addInterceptors(new HttpSessionHandshakeInterceptor());

加完之后,在 afterConnectionEstablished 里可以取用户了:

java 复制代码
    @Override
    public void afterConnectionEstablished(WebSocketSession session) throws Exception {
        System.out.println("[WebSocketController] 连接成功~"); //调试日志

        // TODO 后面几步再补:取当前登录用户、登记在线(正在实现)
        // 拦截器从 HttpSession 复制的用户
        User user = (User) session.getAttributes().get("user");
        if (user == null) { // 未携带登录用户(未登录或 Session 失效)
            System.out.println("[WebSocketController] user == null 未登录用户无法进行转发"); // 日志
            return;// 你没有登录就别谈发消息了
        }
        System.out.println("[WebSocketController] 用户:" + user.getUserId()); // 记录上线用户 ID
    }

OK,启动服务器,但是出现问题了,我们项目中由于jquery是cnd引入的,所以会受到网络限制,因此我们打算换成本地的(源码我的码云仓库中有)

解决:

下载到本地

引入本地的即可

html 复制代码
<script src="/js/jquery.min.js"></script>

重启之后,正常


五、实现用户在线管理

那么我们就开始完成userid和websocketsession之间的一个映射关系

后续我们对这个哈希表的操作是在多行程环境下的

因此我们在这里需要注意线程安全问题。而我们的hash不是线程安全的,所以在这里我们是用 ConcurrentHashMap

新建包 component,新建 OnlineUserManager专门记谁在线、对应哪条长连接

java 复制代码
package com.zhongge.web_chatroom.component;

import org.springframework.stereotype.Component;
import org.springframework.web.socket.WebSocketSession;

import java.util.concurrent.ConcurrentHashMap;

/**
 * 在线用户管理:维护 用户id 和 长连接 的对应关系
 */
@Component
public class OnlineUserManager {

    // 用 ConcurrentHashMap:多线程同时上线、下线、转发时更安全
    private final ConcurrentHashMap<Integer, WebSocketSession> sessions = new ConcurrentHashMap<>();

    // 1) 用户上线
    public boolean online(Integer userId, WebSocketSession session) {
        if (sessions.get(userId) != null) {
            // TODO: 重复登录模块预留,后续实现同一账号多开时的踢下线或拒绝新连接策略
            System.out.println("[ " + userId + " ] 已经登录了,本次连接不登记");
            return false;
        }
        sessions.put(userId, session);
        System.out.println("[ " + userId + " ] 上线了");
        return true;
    }

    // 2) 用户下线
    public void offline(Integer userId, WebSocketSession session) {
        WebSocketSession existSession = sessions.get(userId);
        // 只有关掉的正好是登记表里那条,才删(防止多开场景误删先登录的连接)
        if (existSession == session) {
            sessions.remove(userId);
            System.out.println("[ " + userId + " ] 下线了");
        }
    }

    // 3) 转发时根据用户 id 查长连接
    public WebSocketSession getWebSocketSession(Integer userId) {
        return sessions.get(userId);
    }
}

1、多开问题

用户上线的时候,还要考虑一种情况:两个不同的客户端,用同一个账号登录(俗称「多开」)。

常见产品策略有两种,你可以先建立直觉:

策略 什么意思
踢掉先登录的 后连上的把先连上的挤下去,只保留最新一个浏览器
拒绝后登录的 账号已经在线了,第二个浏览器 不能再登记成功

咱们 HTTP 登录那期,用的是 第二种 :已经登录了,再登录会提示失败。

长连接这边本期也按 「第二个连上来不登记」 来写(完整版还可以改成踢掉新连接,见 web_chatroom 和 TODO)。

上线时:第二个浏览器为啥登记不进去?

online 方法:

java 复制代码
if (sessions.get(userId) != null) {
    // 已经有人在线了
    return false;   // 本次连接不放进 Map
}

后果 :第二个浏览器虽然 长连接握手可能成功 ,但 Map 里没有它 → 收不到任何转发消息(转发全靠这张表找连接)。

本期先 return false 并打日志;后面要做「踢下线」或「给前端弹窗提示」,在 TODO 里实现:

java 复制代码
// TODO: 重复登录模块预留,后续实现同一账号多开时的踢下线或拒绝新连接策略

下线时:为啥要判断 existSession == session

这个判断 主要是为多开准备的。举例以下场景你就懂了:

复制代码
【客户端 1】张三先登录,userId = 1
    → online 成功
    → Map 里:1 → session1(客户端1 的长连接)

【客户端 2】张三又用同一账号打开一个浏览器,userId 还是 1
    → online 时发现 Map 里已有 1
    → 触发防多开:return false,**session2 没有写进 Map**
    → Map 里仍然是:1 → session1

【客户端 2】关网页 / 断网
    → 也会走到 offline(userId=1, session2)

如果 offline 不判断,直接 remove(1):
    → 会把 session1 也删掉!
    → 客户端 1 明明还在线,却从表里消失了,再也收不到消息 ❌

正确做法:
    existSession = sessions.get(1)   → 拿到的是 session1
    existSession == session          → session1 == session2? 不相等
    → 什么都不删,客户端 1 继续在线 ✅

对应代码:

java 复制代码
public void offline(Integer userId, WebSocketSession session) {
    WebSocketSession existSession = sessions.get(userId);
  // 删除的是不是「登记表里当前那条连接」?
    if (existSession == session) {
        sessions.remove(userId);
        System.out.println("[ " + userId + " ] 下线了");
    }
}

正常单开用户 :关的就是自己那条连接,existSession == session 成立,正常下线。

多开里没登记成功的那个浏览器 :关的时候 不会 误删先登录的连接。

问「offline 为啥要比对 session?」------ 答:防止多开时,没登记成功的连接断开,把真正在线的那条映射误删掉


2、在 WebSocketController 里维护在线状态

我们在线表ConcurrentHashMap的增删,放在连接建立、关闭、出错这几个回调里做。

对于ConcurrentHashMap这个数据结构的增删我们是在下面的回调函数里做的👇

而对于具体逻辑我们放在服务层的 ChatMessageService 里,控制器只负责调用:

控制层代码

java 复制代码
/**
 * WebSocket 聊天处理器
 * 注册路径:/WebSocketMessage,负责全生命周期管理
 * 功能:建立连接鉴权、接收客户端聊天消息、异常/关闭时用户下线清理
 * 继承 TextWebSocketHandler:只处理文本类型WebSocket消息帧
 */
@Component // 交由Spring管理,由WebSocket配置类绑定访问路径 /WebSocketMessage
public class WebSocketController extends TextWebSocketHandler {

    /**
     * 聊天消息业务层
     * 能力:用户在线会话注册、消息转发推送、消息持久化、用户下线清理
     */
    @Autowired
    private ChatMessageService chatMessageService;

    /**
     * Jackson JSON工具
     * 用于将前端发来的JSON字符串反序列化为请求实体
     */
    private final ObjectMapper objectMapper = new ObjectMapper();

    /**
     * 客户端WebSocket握手成功、长连接建立完成回调
     * 执行登录校验 + 用户在线会话注册,支持单账号单设备登录互踢
     * @param session 当前客户端专属WebSocket会话
     * @throws Exception 会话操作异常
     */
    @Override
    public void afterConnectionEstablished(WebSocketSession session) throws Exception {
        System.out.println("[WebSocketController] 连接成功~");
        // 从会话属性中取出拦截器提前存入的登录用户信息
        User user = (User) session.getAttributes().get("user");
        // 用户未登录/登录态失效,直接放弃注册在线会话
        if (user == null) {
            System.out.println("[WebSocketController] user == null 未登录用户无法进行转发");
            return;
        }
        System.out.println("[WebSocketController] 用户:" + user.getUserId());
        // 注册在线会话;返回false代表该账号已有活跃连接,拒绝本次新连接
        boolean accepted = chatMessageService.registerWebSocket(user, session);
        if (!accepted) {
            System.out.println("[WebSocketController] 重复连接已拒绝 userId=" + user.getUserId());
        }
    }

    /**
     * 接收客户端发送的文本消息帧
     * 流程:登录校验 → JSON解析 → 根据消息类型分发业务逻辑
     * @param session 当前客户端WebSocket会话
     * @param message 前端发送的文本消息载体
     * @throws Exception JSON反序列化、消息转发IO异常
     */
    @Override
    protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception {
        System.out.println("[WebSocketController] 收到消息:" + message.toString());
        // 获取当前连接登录用户
        User user = (User) session.getAttributes().get("user");
        // 未登录直接丢弃消息,不做任何处理
        if (user == null) {
            System.out.println("[WebSocketController] user == null 未登录用户无法进行转发");
            return;
        }
        // 取出消息内原始JSON字符串
        String payload = message.getPayload();
        // JSON转为前端请求参数实体
        MessageRequest req = objectMapper.readValue(payload, MessageRequest.class);
        // 普通聊天文本消息,调用业务层完成推送+落库
        if ("message".equals(req.getType())) {
            chatMessageService.transferMessage(user, req);
        } else {
            // 不识别的消息类型,打印原始报文用于问题排查
            System.out.println("[WebSocketController] req.type 消息类型不正确" + message.getPayload());
        }
    }

    /**
     * WebSocket传输异常回调(网络断开、IO错误等异常断开场景)
     * 统一执行用户下线清理,移除在线会话映射,防止脏数据
     * @param session 异常断开的客户端会话
     * @param exception 异常堆栈信息
     * @throws Exception 下线清理过程抛出的异常
     */
    @Override
    public void handleTransportError(WebSocketSession session, Throwable exception) throws Exception {
        System.out.println("[WebSocketController] 连接出错:" + exception.toString());
        User user = (User) session.getAttributes().get("user");
        // 业务层校验会话匹配后,清除该用户在线记录
        chatMessageService.offline(user, session);
    }

    /**
     * WebSocket连接正常关闭回调(关闭页面、主动退出、账号互踢)
     * 正常关闭也执行下线逻辑,释放在线会话资源
     * @param session 待关闭的WebSocket会话
     * @param status 关闭状态码与关闭原因
     * @throws Exception 下线清理异常
     */
    @Override
    public void afterConnectionClosed(WebSocketSession session, CloseStatus status) throws Exception {
        System.out.println("[WebSocketController] 连接关闭:" + status.toString());
        User user = (User) session.getAttributes().get("user");
        chatMessageService.offline(user, session);
    }
}

服务层代码

ChatMessageService 接口:

java 复制代码
public interface ChatMessageService {
    boolean registerWebSocket(User user, WebSocketSession session);
    void transferMessage(User fromUser, MessageRequest req) throws IOException;
    void offline(User user, WebSocketSession session);
}

他的实现类,我们后面再做。


六、后端代码实现:消息接收与解析

1、建两个类实体类表示 JSON

回顾我们的前后端交互接口

那么此处我们得构建出对应的类能够把请求和响应进行表示出来。

MessageRequest.java (浏览器发来的):

java 复制代码
import lombok.Data;

@Data
public class MessageRequest {
    private String type = "message";
    private Integer sessionId;
    private String content;
}

MessageResponse.java(服务器推出去的):

java 复制代码
package com.zhongge.web_chatroom.controller.param;

import lombok.Data;

@Data
public class MessageResponse {
    private String type = "message";
    private Integer fromId;
    private String fromName;
    private Integer sessionId;
    private String content;
    private String sessionName;
    private String avatarPath;
}

2、在 handleTextMessage 里解析

我们在控制层WebSocketController里面的回调函数:handleTextMessage

加上 Jackson 的 ObjectMapperJSON 字符串和 Java 对象互转):

java 复制代码
/**
 * Jackson JSON序列化/反序列化工具实例
 * 用于WebSocket消息JSON字符串与Java实体对象之间互相转换
 */
private final ObjectMapper objectMapper = new ObjectMapper();

@Override
protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception {
    System.out.println("[WebSocketController] 收到消息:" + message.toString());

    User user = (User) session.getAttributes().get("user");
    if (user == null) {
        System.out.println("[WebSocketController] user == null 未登录用户无法进行转发");
        return;
    }

    String payload = message.getPayload();   // 拿到 JSON 字符串
    MessageRequest req = objectMapper.readValue(payload, MessageRequest.class);

    if ("message".equals(req.getType())) {
        chatMessageService.transferMessage(user, req);
    } else {
        System.out.println("[WebSocketController] req.type 消息类型不正确:" + message.getPayload());
    }
}

此处chatMessageService.transferMessage(user, req);这个方法的实现我们继续向下看👇


七、消息转发逻辑

图解

解释

1.先构造一个待转发的响应对象MessageResponse

2.根据请求中的sessionId获取到这个MessageSession里面都有哪些用户【查询数据库就知道了】

3.遍历这个MessageSession里面的所有用户,给列表中的每个用户都发一份响应消息

如何发送:知道了每个用户的userId之后 进一步查询刚才准备好的OnlineUserMessage就知道了对应的WebSocketSession

注意1:这里除了给自己查询到的好友发,同时也要给自己发一份 方便实现给自己的客户端显示上自己发的消息

注意2:一个会话中可能有多个用户,虽然我们的客户端没有支持群聊【前端的代码写起来比较复杂,所以群聊没有实现,

那么后续就需要我们自己借助ai实现群聊】 但是我们后端无论是api还是数据库都是支持群聊的 所以此处的转发逻辑也要支持群聊

4.转发的这个消息需要还需要给他"放到数据库"里这样的话后续用户如果下线之后重新上线还可以通过历史消息的方式拿到之前自己的消息

实现ChatMessageService实现类ChatMessageServiceImpl 中的 transferMessage() 之前,先把注释里的步骤写清楚:

java 复制代码
/**
 * 完成消息实际的转发
 * @param fromUser 谁发的
 * @param req      浏览器发来的 JSON 对应的对象
 */
@Override
public void transferMessage(User fromUser, MessageRequest req) throws IOException {
    // 1. 构造待转发的 MessageResponse
    // 2. 根据 sessionId 查会话里有哪些用户(查库)
    // 3. 遍历,在线就推;注意:查库时会排除自己,转发时要把自己加回列表
    // 4. 写入 message 表,离线用户以后靠 GET /message 拉
}

李四不在线怎么办?

那么考虑一下:当下设定的规则中,如果张三给李四发了个消息,但是如果李四不在线的话,该怎么办呢?

首先如果李四在线,就应该立即收到消息。

而如果李四不在线消息就应该丢了吗?其实不会。

首先如果你是不在线,那我们在第二步是能够获取到李四的,但是在第三步中,根据我们准备好的哈希表找到websocketsession,但是因为李四不在线,此时这里边这个web socket session就是找不到的,因此消息就不能直接发送给李四。
不过没关系,我们还有第四部操作就是我们会将这个消息存着的数据库里面,到时候李四那边登录之后查询的历史消息就会看到张三发的消息。

第 3 步查不到他的长连接,就不推;第 4 步 照样存库,他上线后拉历史能看见。


OK,到这里我们梳理好逻辑之后我们写下来就去一步一步的实现每一个步骤


1、消息转发逻辑实现 --- 第 1、2、3 步

ChatMessageServiceImpl 里写:

java 复制代码
    private final ObjectMapper objectMapper = new ObjectMapper(); // JSON 序列化器,用于 Web

    @Autowired
    private UserMapper userMapper; // 用户数据访问层

    @Autowired
    private MessageSessionMapper messageSessionMapper; // 会话数据访问层

    @Autowired
    private OnlineUserManager onlineUserManager; // 在线用户与 WebSocket 会话管理(userid和WebSocketSession的映射关系)

@Override
public void transferMessage(User fromUser, MessageRequest req) throws IOException {
    // 校验:未传入目标会话ID,直接终止转发流程
    if (req.getSessionId() == null) {
        return;
    }

    // 第 2 步:查询当前会话下除自己以外的好友会话成员
    List<Friend> peers = messageSessionMapper.getFriendsBySessionId(
            req.getSessionId(), fromUser.getUserId());
    // 无会话好友,无需推送,直接返回
    if (peers.isEmpty()) {
        return;
    }

    // 双人私聊会话归一化:统一双方共用唯一sessionId,避免双向创建两条会话
    Integer canonicalSessionId = messageSessionMapper.getSessionIdByUserPair(
            fromUser.getUserId(), peers.get(0).getFriendId());
    if (canonicalSessionId != null) {
        req.setSessionId(canonicalSessionId);
    }
    // 缓存对方昵称,用于发送者自己侧聊天列表展示
    String peerNameForSender = peers.get(0).getFriendName();

    // 查询发送者数据库信息,获取头像地址
    String senderAvatarPath = null;
    User senderDb = userMapper.selectById(fromUser.getUserId());
    if (senderDb != null) {
        senderAvatarPath = senderDb.getAvatarPath();
    }

    // 推送目标列表 = 对方好友 + 发送者自己(自己也要收到消息回显)
    List<Friend> targets = new ArrayList<>(peers);
    // 构造当前发送者自身Friend对象,加入推送列表
    Friend mySelf = new Friend();
    mySelf.setFriendId(fromUser.getUserId());
    mySelf.setFriendName(fromUser.getUsername());
    targets.add(mySelf);

    // 第 3 步:遍历所有推送目标,逐个推送消息
    for (Friend target : targets) {
        // 从在线用户管理器获取目标用户的WebSocket连接会话
        WebSocketSession wsSession = onlineUserManager.getWebSocketSession(target.getFriendId());
        // 用户当前不在线,跳过推送
        if (wsSession == null) {
            continue;
        }

        // 组装推送给当前用户的消息响应实体,不同用户sessionName展示文案不同
        MessageResponse resp = new MessageResponse();
        resp.setType("message");
        resp.setFromId(fromUser.getUserId());
        resp.setFromName(fromUser.getUsername());
        resp.setSessionId(req.getSessionId());
        resp.setContent(req.getContent());
        resp.setAvatarPath(senderAvatarPath);
        // 如果当前推送对象是发送者本人,会话名称显示对方昵称
        if (target.getFriendId().equals(fromUser.getUserId())) {
            resp.setSessionName(peerNameForSender);
        } else {
            // 推送给对方时,会话名称显示发送者昵称
            resp.setSessionName(fromUser.getUsername());
        }

        // 将响应实体序列化为JSON字符串
        String respJson = objectMapper.writeValueAsString(resp);
        System.out.println("[ChatMessageService.transferMessage] respJson:" + respJson);
        // WebSocket发送文本消息,必须封装为TextMessage对象传输
        wsSession.sendMessage(new TextMessage(respJson));
    }

    // 第 4 步见下一节
}

注意 1 :除了给好友推,也要给自己推一份(列表里已 add(mySelf))。

注意 2 :一个会话可能多人,后端按循环推,支持群聊;我们前端界面本期还是单聊样子。


2、消息转发逻辑实现 --- 第 4 步(落库)

for 循环 后面 加上:

java 复制代码
    @Autowired
    private MessageMapper messageMapper; // 消息数据访问层

        // 第 4 步:持久化聊天消息到数据库(落库)
        Message message = new Message();
        // 设置消息发送人ID
        message.setFromId(fromUser.getUserId());
        // 设置归属私聊会话唯一ID
        message.setSessionId(req.getSessionId());
        // 设置聊天文本内容
        message.setContent(req.getContent());
        // 插入消息记录,实现消息持久化,支持历史聊天记录查询
        messageMapper.add(message);

MessageMapper 接口增加:

java 复制代码
/**
 * 新增聊天消息记录
 * @param message 待入库的聊天消息实体
 */
void add(Message message);

MessageMapper.xml

xml 复制代码
<!-- 新增聊天消息SQL -->
<insert id="add">
    <!--
        字段顺序说明:id(自增主键)、发送人ID、会话ID、消息内容、发送时间
        null 交由数据库自增主键自动填充;now() 获取数据库当前时间作为消息发送时间
    -->
    insert into message values (null, #{fromId}, #{sessionId}, #{content}, now())
</insert>

踩坑 :时间用 now() ,如果写成 new() 会 SQL 报错。

八、控制层服务层完整代码

1、控制层

java 复制代码
/**
 * WebSocket 聊天处理器
 * 注册路径:/WebSocketMessage,负责全生命周期管理
 * 功能:建立连接鉴权、接收客户端聊天消息、异常/关闭时用户下线清理
 * 继承 TextWebSocketHandler:只处理文本类型WebSocket消息帧
 */
@Component // 交由Spring管理,由WebSocket配置类绑定访问路径 /WebSocketMessage
public class WebSocketController extends TextWebSocketHandler {

    /**
     * 聊天消息业务层
     * 能力:用户在线会话注册、消息转发推送、消息持久化、用户下线清理
     */
    @Autowired
    private ChatMessageService chatMessageService;

    /**
     * Jackson JSON工具
     * 用于将前端发来的JSON字符串反序列化为请求实体
     */
    private final ObjectMapper objectMapper = new ObjectMapper();

    /**
     * 客户端WebSocket握手成功、长连接建立完成回调
     * 执行登录校验 + 用户在线会话注册,支持单账号单设备登录互踢
     * @param session 当前客户端专属WebSocket会话
     * @throws Exception 会话操作异常
     */
    @Override
    public void afterConnectionEstablished(WebSocketSession session) throws Exception {
        System.out.println("[WebSocketController] 连接成功~");
        // 从会话属性中取出拦截器提前存入的登录用户信息
        User user = (User) session.getAttributes().get("user");
        // 用户未登录/登录态失效,直接放弃注册在线会话
        if (user == null) {
            System.out.println("[WebSocketController] user == null 未登录用户无法进行转发");
            return;
        }
        System.out.println("[WebSocketController] 用户:" + user.getUserId());
        // 注册在线会话;返回false代表该账号已有活跃连接,拒绝本次新连接
        boolean accepted = chatMessageService.registerWebSocket(user, session);
        if (!accepted) {
            System.out.println("[WebSocketController] 重复连接已拒绝 userId=" + user.getUserId());
        }
    }

    /**
     * 接收客户端发送的文本消息帧
     * 流程:登录校验 → JSON解析 → 根据消息类型分发业务逻辑
     * @param session 当前客户端WebSocket会话
     * @param message 前端发送的文本消息载体
     * @throws Exception JSON反序列化、消息转发IO异常
     */
    @Override
    protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception {
        System.out.println("[WebSocketController] 收到消息:" + message.toString());
        // 获取当前连接登录用户
        User user = (User) session.getAttributes().get("user");
        // 未登录直接丢弃消息,不做任何处理
        if (user == null) {
            System.out.println("[WebSocketController] user == null 未登录用户无法进行转发");
            return;
        }
        // 取出消息内原始JSON字符串
        String payload = message.getPayload();
        // JSON转为前端请求参数实体
        MessageRequest req = objectMapper.readValue(payload, MessageRequest.class);
        // 普通聊天文本消息,调用业务层完成推送+落库
        if ("message".equals(req.getType())) {
            chatMessageService.transferMessage(user, req);
        } else {
            // 不识别的消息类型,打印原始报文用于问题排查
            System.out.println("[WebSocketController] req.type 消息类型不正确" + message.getPayload());
        }
    }

    /**
     * WebSocket传输异常回调(网络断开、IO错误等异常断开场景)
     * 统一执行用户下线清理,移除在线会话映射,防止脏数据
     * @param session 异常断开的客户端会话
     * @param exception 异常堆栈信息
     * @throws Exception 下线清理过程抛出的异常
     */
    @Override
    public void handleTransportError(WebSocketSession session, Throwable exception) throws Exception {
        System.out.println("[WebSocketController] 连接出错:" + exception.toString());
        User user = (User) session.getAttributes().get("user");
        // 业务层校验会话匹配后,清除该用户在线记录
        chatMessageService.offline(user, session);
    }

    /**
     * WebSocket连接正常关闭回调(关闭页面、主动退出、账号互踢)
     * 正常关闭也执行下线逻辑,释放在线会话资源
     * @param session 待关闭的WebSocket会话
     * @param status 关闭状态码与关闭原因
     * @throws Exception 下线清理异常
     */
    @Override
    public void afterConnectionClosed(WebSocketSession session, CloseStatus status) throws Exception {
        System.out.println("[WebSocketController] 连接关闭:" + status.toString());
        User user = (User) session.getAttributes().get("user");
        chatMessageService.offline(user, session);
    }
}

2、服务层

接口

java 复制代码
package com.zhongge.web_chatroom.service;

import com.zhongge.web_chatroom.controller.param.MessageRequest;
import com.zhongge.web_chatroom.dao.dataobject.User;
import org.springframework.web.socket.WebSocketSession;

import java.io.IOException;

/**
 * @InterfaceName ChatMessageService
 * @Description TODO WebSocket 实时聊天业务服务接口:连接注册、消息转发、下线处理
 * @Author zhongge
 * @Version 1.0
 */
public interface ChatMessageService {
    /**
     * 用户建立WebSocket连接时注册会话
     * @param user 当前登录用户信息
     * @param session WebSocket会话对象,持有客户端连接通道
     * @return 注册成功返回true,失败返回false
     */
    boolean registerWebSocket(User user, WebSocketSession session);

    /**
     * 转发用户发送的聊天消息给目标接收方
     * @param fromUser 消息发送人
     * @param req 前端传递的消息请求体(包含接收人、消息内容等)
     * @throws IOException 消息发送IO异常(如连接断开、写入失败)
     */
    void transferMessage(User fromUser, MessageRequest req) throws IOException;

    /**
     * 用户断开WebSocket连接,执行下线清理逻辑
     * @param user 下线用户
     * @param session 待销毁的WebSocket会话
     */
    void offline(User user, WebSocketSession session);
}

实现类

java 复制代码
package com.zhongge.web_chatroom.service.impl;

import com.fasterxml.jackson.databind.ObjectMapper;
import com.zhongge.web_chatroom.component.OnlineUserManager;
import com.zhongge.web_chatroom.controller.param.MessageRequest;
import com.zhongge.web_chatroom.controller.param.MessageResponse;
import com.zhongge.web_chatroom.dao.dataobject.Friend;
import com.zhongge.web_chatroom.dao.dataobject.Message;
import com.zhongge.web_chatroom.dao.dataobject.User;
import com.zhongge.web_chatroom.dao.mapper.MessageMapper;
import com.zhongge.web_chatroom.dao.mapper.MessageSessionMapper;
import com.zhongge.web_chatroom.dao.mapper.UserMapper;
import com.zhongge.web_chatroom.service.ChatMessageService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.web.socket.TextMessage;
import org.springframework.web.socket.WebSocketSession;

import java.io.IOException;
import java.util.ArrayList;
import java.util.List;

/**
 * @ClassName ChatMessageServiceImpl
 * @Description TODO
 * @Author zhongge
 * @Version 1.0
 */
@Service
public class ChatMessageServiceImpl implements ChatMessageService {

    private final ObjectMapper objectMapper = new ObjectMapper(); // JSON 序列化器,用于 Web

    @Autowired
    private UserMapper userMapper; // 用户数据访问层

    @Autowired
    private MessageSessionMapper messageSessionMapper; // 会话数据访问层

    @Autowired
    private MessageMapper messageMapper; // 消息数据访问层
    @Autowired
    private OnlineUserManager onlineUserManager; // 在线用户与 WebSocket 会话管理(userid和WebSocketSession的映射关系)

    @Override
    public boolean registerWebSocket(User user, WebSocketSession session) {
        if (user == null) { // 用户未登录或无效
            return false; // 注册失败
        }
        return onlineUserManager.online(user.getUserId(), session); // 标记用户在线并绑定 WebSocket 会话
    }

    /**
     * 完成消息实际的转发
     * @param fromUser 谁发的
     * @param req      浏览器发来的 JSON 对应的对象
     */
    @Override
    public void transferMessage(User fromUser, MessageRequest req) throws IOException {
        // 校验参数(一般我们都会这么做):未传入目标会话ID,直接终止转发流程
        if (req.getSessionId() == null) {
            return;
        }

        // 第 2 步:查询当前会话下除自己以外的好友会话成员
        List<Friend> peers = messageSessionMapper.getFriendsBySessionId(
                req.getSessionId(), fromUser.getUserId());
        // 无会话好友,无需推送,直接返回
        if (peers.isEmpty()) {
            return;
        }

        // 保险操作:双人私聊会话归一化:统一双方共用唯一sessionId,避免双向创建两条会话
        Integer canonicalSessionId = messageSessionMapper.getSessionIdByUserPair(
                fromUser.getUserId(), peers.get(0).getFriendId());
        if (canonicalSessionId != null) {
            req.setSessionId(canonicalSessionId);
        }
        // 缓存对方昵称,用于发送者自己侧聊天列表展示
        String peerNameForSender = peers.get(0).getFriendName();

        // 查询发送者数据库信息,获取头像地址
        String senderAvatarPath = null;
        User senderDb = userMapper.selectById(fromUser.getUserId());
        if (senderDb != null) {
            senderAvatarPath = senderDb.getAvatarPath();
        }

        // 推送目标列表 = 对方好友 + 发送者自己(自己也要收到消息回显)
        List<Friend> targets = new ArrayList<>(peers);
        // 构造当前发送者自身Friend对象,加入推送列表
        Friend mySelf = new Friend();
        mySelf.setFriendId(fromUser.getUserId());
        mySelf.setFriendName(fromUser.getUsername());
        targets.add(mySelf);

        // 第 3 步:遍历所有推送目标,逐个推送消息
        for (Friend target : targets) {
            // 从在线用户管理器获取目标用户的WebSocket连接会话
            WebSocketSession wsSession = onlineUserManager.getWebSocketSession(target.getFriendId());
            // 用户当前不在线,跳过推送
            if (wsSession == null) {
                continue;
            }

            // 组装推送给当前用户的消息响应实体,不同用户sessionName展示文案不同
            MessageResponse resp = new MessageResponse();
            resp.setType("message");
            resp.setFromId(fromUser.getUserId());
            resp.setFromName(fromUser.getUsername());
            resp.setSessionId(req.getSessionId());
            resp.setContent(req.getContent());
            resp.setAvatarPath(senderAvatarPath);
            // 如果当前推送对象是发送者本人,会话名称显示对方昵称
            if (target.getFriendId().equals(fromUser.getUserId())) {
                resp.setSessionName(peerNameForSender);
            } else {
                // 推送给对方时,会话名称显示发送者昵称
                resp.setSessionName(fromUser.getUsername());
            }

            // 将响应实体序列化为JSON字符串
            String respJson = objectMapper.writeValueAsString(resp);
            System.out.println("[ChatMessageService.transferMessage] respJson:" + respJson);
            // WebSocket发送文本消息,必须封装为TextMessage对象传输
            wsSession.sendMessage(new TextMessage(respJson));
        }

        // 第 4 步:持久化聊天消息到数据库(落库)
        Message message = new Message();
        // 设置消息发送人ID
        message.setFromId(fromUser.getUserId());
        // 设置归属私聊会话唯一ID
        message.setSessionId(req.getSessionId());
        // 设置聊天文本内容
        message.setContent(req.getContent());
        // 插入消息记录,实现消息持久化,支持历史聊天记录查询
        messageMapper.add(message);
    }

    @Override
    public void offline(User user, WebSocketSession session) {
        if (user != null) { // 用户有效
            onlineUserManager.offline(user.getUserId(), session); // 从在线管理器移除该 WebSocket 连接
        }
    }
}

写到这里,客户端发送 + 服务器转发 的后端部分就通了。

接下来就来实现客户端接收消息👇


九、前端代码实现:客户端接收消息

当前已经实现了:发消息服务器转发 。下面实现 收消息、改界面

那么对于前端的实现我们可以借助AI来辅助实现自己想要的效果。

1、客户端接收消息 1 --- 梳理思路

图解:

开始实现:

对于前端我们主要靠 websocket.onmessage,收到后解析 JSON:

javascript 复制代码
    websocket.onmessage = function(e) {
        // 控制台打印原始推送报文,用于调试排查
        console.log("WebSocket 收到的消息:" + e.data);
        // TODO: 后面重点实现:收到消息后更新界面(已实现)
        // 将后端JSON字符串反序列化为前端消息响应对象
        let resp = JSON.parse(e.data);
        // 判断为普通聊天文本消息,执行消息渲染逻辑
        if (resp.type == 'message') {
            handleMessage(resp);
        } else {
            // TODO: 好友模块预留,后续实现好友申请等 WebSocket 推送类型
        }
    };

前端收到消息之后handleMessage()这个方法该做什么?:

  1. 右侧主消息区 --- 前提:当前正在看这个会话(左侧有 selected

  2. 左侧会话列表预览 --- 更新最后一句话;没有对应会话要新建一行

为了完成这两方面的显示工作,我们分因为以下几个步骤:

  1. 根据响应中的sessionId获取了当前会话对应的li标签。这个li标签怎么获取呢?首先我们之前已经都实现了,id在当时我们li标签里面有一个自定属性就是message session id.
    如果会话存在我们就可以通过这个message-session-id属性来进行查找看哪个li是对应的sessionId的标签。

但是也可能会话还不存在,比如之前张三和赵六完全没说过话,此时赵六说了第一句话,于是张三这边就需要能够创建出一个属于和赵六之间的会话(不过这一步的实现我们在我们的获取会话列表信息、创建会话中实现过了)

  1. 把新的消息显示到会话的预览区域,就是li标签里的p标签中。注意如果消息太长的话,就需要进行适当的截断,这件事情我们前面是做过的, substring

  2. 把收到消息的会话给放到会话列表的最上面。这样才比较显眼,才能让用户更好的知道,是当学会话里边有消息了。

  3. 如果当前收到的息的会话处于被选中状态,则把当前的消息给放到右侧的消息列表中。也就是在我们的右侧部分多加一条记录。新增消息的同时,注意调整滚动条的位置,保证新消息虽然在底部,但是能够被用户直接看到。

未读小红点之类:纯前端美化 ,本期不做,代码里 // TODO: 未读消息模块预留...

OK,那么接下里就开始实现handleMessage()方法👇


2、客户端接收消息 2 --- 编写前两步

javascript 复制代码
/**
 * 处理服务端推送过来的聊天消息
 * 1. 查找/创建左侧会话列表条目
 * 2. 更新会话最新消息预览文本
 * 3、4 步下一节(消息气泡渲染、会话置顶逻辑)
 * @param resp 后端返回的MessageResponse消息JSON对象
 */
function handleMessage(resp) {
    // 优先使用会话展示名,无则 备用方案 为发送者昵称
    let displayName = resp.sessionName || resp.fromName;

    // 1. 根据会话ID查找左侧聊天会话条目li,不存在则新建
    let curSessionLi = findSessionLi(resp.sessionId);

    // 兜底逻辑1:会话ID未匹配到,但存在发送人ID,按好友ID查找旧会话(修复双向会话ID不一致问题)
    if (curSessionLi == null && resp.fromId) {
        curSessionLi = findSessionByFriendId(resp.fromId);
        if (curSessionLi != null) {
            // 修正旧会话条目的sessionId为当前统一会话ID
            curSessionLi.setAttribute('message-session-id', resp.sessionId);
            // 清理列表中同名重复会话节点
            mergeDuplicateSessionByName(curSessionLi);
        }
    }

    // 兜底逻辑2:好友ID也未匹配,按展示昵称模糊匹配旧会话
    if (curSessionLi == null && displayName) {
        curSessionLi = findSessionByName(displayName);
        if (curSessionLi != null) {
            // 为匹配到的旧会话补全最新会话ID标识
            curSessionLi.setAttribute('message-session-id', resp.sessionId);
            mergeDuplicateSessionByName(curSessionLi);
        }
    }

    // 三重匹配均无结果:全新会话,创建左侧会话列表li节点
    if (curSessionLi == null) {
        curSessionLi = document.createElement('li');
        // 绑定当前标准会话ID
        curSessionLi.setAttribute('message-session-id', resp.sessionId);
        // 绑定好友ID,用于后续兜底匹配
        if (resp.fromId) {
            curSessionLi.setAttribute('data-friend-id', resp.fromId);
        }
        // 渲染会话条目基础HTML(头像+昵称)
        curSessionLi.innerHTML = buildSessionLiHtml(resp.fromId, displayName, '', resp.avatarPath);
        // 绑定点击事件:切换打开对应聊天窗口
        curSessionLi.onclick = function() {
            clickSession(curSessionLi);
        };
    } else {
        // 已存在会话条目,同步更新最新信息
        if (resp.fromId) {
            curSessionLi.setAttribute('data-friend-id', resp.fromId);
        }
        // 更新头像地址缓存
        if (resp.avatarPath) {
            curSessionLi.setAttribute('data-avatar-path', resp.avatarPath);
        }
        // 更新会话展示昵称
        let nameEl = curSessionLi.querySelector('.session-name');
        if (nameEl && displayName) {
            nameEl.textContent = displayName;
        }
    }


    // 2. 更新该会话的最新消息预览,超长内容做截断处理
    setSessionPreview(curSessionLi, resp.content);

    // 3、4 步下一节
}

findSessionLi方法:

javascript 复制代码
/**
 * 根据会话ID查找左侧会话列表对应的li元素
 * @param targetSessionId 目标会话唯一ID
 * @returns 匹配的li节点,无匹配返回null
 */
function findSessionLi(targetSessionId) {
    // 会话ID为空直接返回空
    if (targetSessionId == null || targetSessionId === '') {
        return null;
    }
    // 获取所有带会话ID标记的会话li
    let sessionLis = document.querySelectorAll("#session-list li[message-session-id]");
    // 遍历逐个对比会话ID(统一转字符串避免数字/字符不匹配)
    for (let li of sessionLis) {
        if (String(li.getAttribute("message-session-id")) === String(targetSessionId)) {
            return li;
        }
    }
    // 全部遍历完成无匹配再返回null,不可循环中途直接return
    return null;
}

/**
 * 设置会话条目内的最新消息预览文字
 * @param li 会话列表li节点
 * @param content 完整消息正文
 */
function setSessionPreview(li, content) {
    // 获取预览文本DOM节点
    let p = li.querySelector('.session-preview');
    // 无预览节点直接退出
    if (!p) return;
    let preview = content || '';
    // 超过10个字符截断并加省略号
    if (preview.length > 10) {
        preview = preview.substring(0, 10) + '...';
    }
    // 填充截断后的预览文字
    p.textContent = preview;
}

3、客户端接收消息 3 --- 编写后两步

javascript 复制代码
       // 3. 将当前会话条目置顶到会话列表最上方
    let sessionListUL = document.querySelector('#session-list');
    sessionListUL.insertBefore(curSessionLi, getFirstChatSessionLi(sessionListUL));

    // 4. 判断消息发送方、当前打开会话状态,区分未读角标/实时渲染气泡
    // 获取当前登录用户ID
    let selfUserId = document.querySelector('.left .user').getAttribute('user-id');
    // 标记消息是否来自其他人(非自己发送)
    let fromOthers = String(resp.fromId) !== String(selfUserId);
    // 判断当前打开的聊天窗口是否就是这条消息所属会话
    let viewingThisChat = isCurrentChatSession(resp.sessionId);

    // 他人发来消息,但当前没打开对应会话:预留未读消息角标逻辑
    if (fromOthers && !viewingThisChat) {
        // TODO: 未读消息模块预留,后续实现角标累计与清零
    }

    // 当前正打开该会话 + 消息是别人发送的:右侧聊天区实时渲染消息气泡
    if (viewingThisChat && fromOthers) {
        let messageShowDiv = document.querySelector('.message-show');
        // 拼接消息气泡DOM并插入聊天容器
        addMessage(messageShowDiv, resp);
        // 自动滚动到底部,展示最新消息
        scrollBottom(messageShowDiv);
    }
}

/**
 * 判断当前激活打开的聊天窗口是否为传入的会话ID
 * @param sessionId 待校验会话ID
 * @returns true=当前正在查看该会话;false=未打开/打开其他会话
 */
function isCurrentChatSession(sessionId) {
    // 获取右侧聊天面板容器
    let chatPanel = document.querySelector('.right .chat-panel');
    // 聊天面板隐藏,说明没有打开任何会话
    if (!chatPanel || chatPanel.classList.contains('hide')) {
        return false;
    }
    // 获取左侧列表被选中的会话li
    let selectedLi = document.querySelector('#session-list .selected');
    // 无选中会话直接返回false
    if (!selectedLi) {
        return false;
    }
    // 统一转为字符串对比会话ID,避免数字与字符串匹配异常
    return String(selectedLi.getAttribute('message-session-id')) === String(sessionId);
}

十、功能测试与验证

验证 1:接收方在线

两个浏览器分别登录张三、admin。张三发「你好」:

  • admin不用刷新就能看见
  • 张三自己这边也能看见
  • 数据库 message 表多一条

admin这边能实时更新

验证 2:接收方离线

只登录张三,给admin发消息。admin之后再登录,点进会话,历史里应该有这条(GET /message)。

登录admin


本期搞定【消息的接收与转发】🎉!🚀。

但是还有一个问题:我们第一个浏览器登录张三后端会添加张三和WebSocketSession的关系

第二个浏览器登录张三的时候,我们后端不会再添加

但是有个问题:你前端没实现重复登录第二次拒绝登录的问题,所以这部分我们下一期会解决,同时下一期我们会实现添加好友、消息未读显示、退出登录等等后端的项目的收尾工作~

干货持续更新,记得点赞👍关注🌟收藏⭐,追更不迷路~