【Java 实战项目】多用户网页版聊天室:消息传输模块 —— 基于 WebSocket 实现实时通信

文章目录

消息传输模块是多用户网页聊天室的核心功能支柱,直接决定了用户聊天体验的流畅度、实时性与可靠性。其核心目标是打通 "实时消息收发、历史消息回溯、消息持久化存储" 三大关键链路,彻底解决传统 HTTP 协议 "请求 - 响应" 模式无法满足实时通信的技术痛点,为用户打造接近原生聊天工具的交互体验。

本模块基于 WebSocket 协议实现全双工实时通信,搭配 MySQL 数据库完成消息持久化存储,既保证了在线用户之间的毫秒级消息传递,又支持离线用户上线后无缝查看历史消息,完全贴合网页版微信、QQ 等主流聊天工具的核心逻辑。以下将从设计思路、技术选型、实现细节、等多个维度,详细拆解模块开发的完整流程。

一、模块核心设计前提

1.1 技术选型依据

传统 Web 开发中,客户端与服务器通过 HTTP 协议通信,属于"请求-响应"模式,服务器只能被动接收客户端请求并返回结果,无法主动向客户端推送数据。若强行使用 HTTP 实现实时通信,只能通过以下方式迂回实现,但均存在明显缺陷:

  • 轮询(Polling):客户端每隔固定时间(如 1 秒)发起一次 HTTP 请求,查询是否有新消息。这种方式的问题在于:大部分请求都是 "无效请求"(无新消息),会造成服务器资源浪费、网络带宽占用,且消息延迟取决于轮询间隔(间隔越长延迟越高,间隔越短资源消耗越大)。

WebSocket 协议作为 HTML5 标准特性,专为实时通信场景设计,其核心优势完美匹配聊天系统需求:

  • 全双工通信: 连接建立后,客户端与服务器可双向主动发送数据,无需像 HTTP 那样每次通信都重新建立连接,实现 "一次握手,永久通信"。
  • 持久化连接: 连接建立后持续有效,直到客户端或服务器主动关闭,减少了连接建立 / 断开的开销,消息传递延迟可低至毫秒级。
  • 轻量级协议头: WebSocket 数据帧的协议头仅 2-10 字节,远小于 HTTP 协议的几十甚至几百字节,数据传输效率更高,尤其适合高频次、小体量的消息交互(如文字聊天)。
  • 浏览器原生支持: 主流浏览器(Chrome、Firefox、Edge 等)均原生支持 WebSocket API,无需额外安装插件,开发成本低、兼容性好。
  • 协议升级机制: WebSocket 基于 HTTP 协议完成握手升级,可复用 HTTP 的端口(80/443),避免防火墙拦截问题,部署更便捷。

基于以上优势,WebSocket 成为实时聊天场景的最优技术选型,也是本模块的核心通信协议。

1.2 依赖环境准备

项目已在初始化阶段引入 WebSocket 依赖(Spring 内置支持),无需额外新增依赖,仅需通过配置类启用 WebSocket 功能即可。

xml 复制代码
	<dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-websocket</artifactId>
    </dependency>

二、基础功能实现:历史消息查询

用户切换会话时,需加载该会话的所有历史消息,实现 "消息回溯" 功能。该功能基于 HTTP 接口实现(无需实时推送,属于 "按需查询" 场景),核心逻辑为 "按会话 ID 查询消息表 → 按时间排序 → 前端格式化渲染",具体实现如下:

3.1 前后端交互接口约定

  • 请求:GET /message?sessionId=1(sessionId 为目标会话ID)
  • 响应:HTTP/1.1 200 OK | Content-Type: application/json
json 复制代码
[
    {
        "messageId": 1,
        "fromId": 1,
        "fromName": "zhangsan",
        "sessionId": 1,
        "content": "今晚吃啥?",
        "postTime": "2038-01-01 00:00:00"
    },
    {
        "messageId": 2,
        "fromId": 2,
        "fromName": "lisi",
        "sessionId": 1,
        "content": "随便",
        "postTime": "2038-01-01 00:02:00"
    }
]

3.2 服务端实现

(1)创建消息实体类

封装消息查询结果,与接口响应字段一一对应:

java 复制代码
public class Message {
    private int messageId;
    private int fromId;
    private String fromName;  // 发送者昵称(关联user表查询得到)
    private int sessionId;
    private String content;
    private Timestamp postTime;  // 数据库存储为datetime类型

    // 处理时间格式:Spring默认返回CST格式,手动转换为"yyyy-MM-dd HH:mm:ss"
    public String getPostTime() {
        SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
        return sdf.format(postTime);
    }

    // 省略getter、setter方法
}
(2)创建MessageMapper接口

定义消息查询、插入的核心方法:

java 复制代码
@Mapper
public interface MessageMapper {
    // 根据会话ID查询历史消息(限制100条,避免数据量过大)
    List<Message> getMessagesBySessionId(int sessionId);
    // 新增消息(消息发送时调用,持久化到数据库)
    void add(Message message);
    // 查询会话的最后一条消息(用于会话列表消息预览)
    String getLastMessagesBySessionId(int sessionId);
}
(3)实现Mapper.xml映射文件

通过联合查询(message表 + user表)获取发送者昵称,按发送时间降序排列后取前100条:

xml 复制代码
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" 
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.example.chatroom.model.MessageMapper">
    <!-- 查询会话历史消息:联合user表获取发送者昵称 -->
    <select id="getMessagesBySessionId" resultType="com.example.chatroom.model.Message">
        select 
            messageId, 
            fromId, 
            user.username as fromName,  -- 关联user表查询昵称
            sessionId, 
            content, 
            postTime 
        from message, user
        where sessionId = #{sessionId} 
          and message.fromId = user.userId  -- 关联发送者ID与用户表
        order by postTime desc limit 100;  -- 按时间降序,最多返回100条
    </select>

    <!-- 新增消息:持久化到数据库 -->
    <insert id="add">
        insert into message values(null, #{fromId}, #{sessionId}, #{content}, #{postTime});
    </insert>

    <!-- 查询会话最后一条消息(用于会话列表预览) -->
    <select id="getLastMessagesBySessionId" resultType="java.lang.String">
        select content from message 
        where sessionId = #{sessionId} 
        order by postTime desc limit 1;
    </select>
</mapper>
(4)创建MessageAPI控制层

提供历史消息查询接口,接收会话ID参数,返回按时间升序排列的消息列表(数据库查询为降序,需手动反转):

java 复制代码
@RestController
public class MessageAPI {
    @Resource
    private MessageMapper messageMapper;

    @GetMapping("/message")
    public Object getMessage(int sessionId) {
        // 1. 查询该会话的历史消息(降序)
        List<Message> messages = messageMapper.getMessagesBySessionId(sessionId);
        // 2. 反转列表,转为升序(符合聊天消息展示逻辑:旧消息在前,新消息在后)
        Collections.reverse(messages);
        return messages;
    }
}

3.3 客户端实现

用户点击会话列表项时,触发历史消息查询,渲染到右侧消息区域,并自动滚动到最新消息位置:

javascript 复制代码
// 加载会话历史消息(在clickSession函数中调用)
function getHistoryMessage(sessionId) {
    console.log('获取历史消息: ' + sessionId);
    let titleDiv = document.querySelector('.right>.title');
    let messageShowDiv = document.querySelector('.message-show');
    // 清空原有消息内容
    titleDiv.innerHTML = '';
    messageShowDiv.innerHTML = '';

    // 设置会话标题(取自选中的会话项)
    let selectedH3 = document.querySelector('#session-list>.selected>h3');
    if (selectedH3) {
        titleDiv.innerHTML = selectedH3.innerHTML.trim();
    }

    // 新会话无sessionId,无需查询
    if (!sessionId) return;

    // 发起HTTP请求查询历史消息
    $.ajax({
        type: 'get',
        url: '/message?sessionId=' + sessionId,
        success: function(body) {
            // 获取当前登录用户名(用于区分自己和他人消息的显示位置)
            let selfUsername = document.querySelector('.user').innerHTML.trim();
            for (let message of body) {
                addMessage(messageShowDiv, message, selfUsername);
            }
            // 自动滚动到消息底部(显示最新消息)
            scrollBottom(messageShowDiv);
        },
        error: function() {
            alert('获取历史消息失败!');
        }
    });
}

三、核心功能实现:WebSocket实时消息收发

实时消息收发是模块的核心,也是用户最直观的使用场景。基于 WebSocket 实现 "客户端发送消息 → 服务器接收并转发 → 目标客户端接收并渲染" 的全流程,确保消息实时性、可靠性与安全性。

4.1 WebSocket初始化配置

(1)创建WebSocket配置类

启用WebSocket功能,映射请求路径(/WebSocketMessage),并通过拦截器将HTTP Session中的用户信息传递到WebSocket Session,因为Session 是在http中的,因此使用该拦截器将用户信息同样的拷贝到WebSocket中去:

java 复制代码
@Configuration
@EnableWebSocket
public class WebSocketConfig implements WebSocketConfigurer {
    @Autowired
    private WebSocketAPI webSocketAPI;  // 自定义WebSocket处理器

    @Override
    public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
        registry.addHandler(webSocketAPI, "/WebSocketMessage")
                // 传递HTTP Session中的用户信息到WebSocket Session
                .addInterceptors(new HttpSessionHandshakeInterceptor());
    }
}
(2)创建WebSocketAPI处理器

继承TextWebSocketHandler,重写连接建立、消息接收、连接关闭等核心方法,处理WebSocket全生命周期事件:

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

    @Autowired
    private OnlineUserManager onlineUserManager;

    private ObjectMapper objectMapper = new ObjectMapper();

    @Autowired
    private MessageSessionMapper messageSessionMapper;

    @Autowired
    private MessageMapper messageMapper;

    // 在websocket 连接出现异常时,被自动调用
    @Override
    public void handleTransportError(WebSocketSession session, Throwable exception) throws Exception {
        System.out.println("WebSocket 连接异常!" + exception.toString());

        User user = (User) session.getAttributes().get("user");
        if(user == null){
            return;
        }

        onlineUserManager.offline(user.getUserId(),session);
    }

    // 在websocket 连接建立成功后,被自动调用
    @Override
    public void afterConnectionEstablished(WebSocketSession session) throws Exception {
        System.out.println("WebSocket 连接成功!");

        User user = (User) session.getAttributes().get("user");
        if(user == null){
            return;
        }
        System.out.println("获取到的userId: " + user.getUserId());

        // 存储键值对
        onlineUserManager.online(user.getUserId(),session);
    }

    // 在连接正常关闭后,被自动调用
    @Override
    public void afterConnectionClosed(WebSocketSession session, CloseStatus status) throws Exception {
        System.out.println("WebSocket 连接关闭!" + status.toString());

        // 下线,移除映射
        User user = (User) session.getAttributes().get("user");
        if(user == null){
            return;
        }
        onlineUserManager.offline(user.getUserId(),session);
    }

    // 在 websocket收到小时后自动调用
    @Override
    protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception {
        System.out.println("WebSocket 收到消息!" + message.toString());

        // 处理消息接收,转发,保存记录
        // 获取到当前用户的信息
        User user = (User) session.getAttributes().get("user");
        if(user == null){
            log.info("未登录用户,无法进行消息转发!");
            return;
        }

        // 对请求解析
        MessageRequest req = objectMapper.readValue(message.getPayload(), MessageRequest.class);
        log.info("解析后的MessageRequest:{}",req);
        if(req.getType().equals("message")){
            transferMessage(user,req);
            messageSessionMapper.updateLastTime(req.getSessionId());
        }else {
            log.info("WebSocket Type 有误!" + message.getPayload());
        }
    }

    // 完成消息转发
    private void transferMessage(User fromUser, MessageRequest req) throws IOException {
        // 构造代转发的响应对象
        MessageResponse response = new MessageResponse();
        response.setFromId(fromUser.getUserId());
        response.setFromName(fromUser.getUsername());
        response.setSessionId(req.getSessionId());
        response.setContent(req.getContent());

        // 序列化为json
        String respjson = objectMapper.writeValueAsString(response);
        log.info("转发数据, {}",respjson);

        List<Friend> friendList = messageSessionMapper.getFriendsByUserId(req.getSessionId(),fromUser.getUserId());
        Friend myself = new Friend();
        myself.setFriendId(fromUser.getUserId());
        myself.setFriendName(fromUser.getUsername());
        friendList.add(myself);

        // 全部转发
        for (Friend friend : friendList){
            WebSocketSession webSocketSession = onlineUserManager.getWebSocketSession(friend.getFriendId());
            // 如果用户不在线,则不转发,用户上线后可从数据库中获取查看
            if(webSocketSession == null){
                continue;
            }
            webSocketSession.sendMessage(new TextMessage(respjson));
        }

        // 将消息添加到数据库中
        Message message = new Message();
        message.setFromId(fromUser.getUserId());
        message.setSessionId(response.getSessionId());
        message.setContent(response.getContent());

        messageMapper.add(message);
    }
}

4.2 在线用户管理组件

创建OnlineUserManager组件,维护userId → WebSocketSession的映射关系,使用哈希表存储用户与WebSocketSession 之间的映射关系,用于快速查找用户在线状态、转发消息:

java 复制代码
@Component
public class OnlineUserManager {
    // 线程安全的Map:存储在线用户的WebSocketSession
    private ConcurrentHashMap<Integer, WebSocketSession> sessions = new ConcurrentHashMap<>();

    // 用户上线:记录Session(禁止同一用户多端登录)
    public void online(int userId, WebSocketSession session) {
        if (sessions.get(userId) != null) {
            return;  // 已在线,直接返回
        }
        sessions.put(userId, session);
        System.out.println("[" + userId + "] 上线!");
    }

    // 用户离线:移除Session(需校验是否为当前会话)
    public void offline(int userId, WebSocketSession session) {
        WebSocketSession existSession = sessions.get(userId);
        if (existSession != session) {
            return;  // 非当前会话,不处理
        }
        sessions.remove(userId);
        System.out.println("[" + userId + "] 下线!");
    }

    // 根据用户ID获取WebSocketSession(用于消息转发)
    public WebSocketSession getSession(int userId) {
        return sessions.get(userId);
    }
}

4.3 消息请求/响应实体类

定义客户端与服务器之间的消息格式,统一JSON序列化规则,确保前后端数据交互的一致性:

java 复制代码
// 客户端发送消息的请求格式
public class MessageRequest {
    private String type;       // 消息类型:固定为"message"
    private int sessionId;     // 目标会话ID
    private String content;    // 消息正文

    // 省略getter、setter方法
}

// 服务器转发消息的响应格式
public class MessageResponse {
    private String type;       // 消息类型:固定为"message"
    private int fromId;        // 发送者ID
    private String fromName;   // 发送者昵称
    private int sessionId;     // 所属会话ID
    private String content;    // 消息正文
    private Timestamp postTime;// 发送时间(服务器时间)

    // 省略getter、setter方法
}

4.4 客户端WebSocket实现

客户端 WebSocket 实现分为 "连接初始化""消息发送""消息接收与渲染""连接异常处理" 四个核心部分,确保用户能快速发送消息、实时接收消息,且体验流畅。

(1)初始化WebSocket连接

client.js中初始化WebSocket连接,监听连接建立、消息接收、连接关闭等事件:

javascript 复制代码
// 初始化WebSocket连接(页面加载时执行)
let websocket;
function initWebSocket() {
    // 注意:路径为"/message",与服务器配置一致,末尾不带斜杠
    websocket = new WebSocket("ws://127.0.0.1:8080/message");

    // 1. 连接建立成功回调
    websocket.onopen = function (event) {
        console.log("websocket 连接成功!");
    };

    // 2. 接收服务器消息回调(核心:实时渲染消息)
    websocket.onmessage = function(event) {
        console.log('[收到消息] ' + event.data);
        let resp = JSON.parse(event.data);
        if (resp.type == 'message') {
            handlerMessage(resp);  // 处理实时消息
        }
    };

    // 3. 连接关闭回调
    websocket.onclose = function () {
        console.log("websocket 连接关闭!");
    };

    // 4. 连接异常回调
    websocket.onerror = function () {
        console.log("websocket 连接异常!");
    };

    // 5. 窗口关闭时主动关闭WebSocket连接
    window.onbeforeunload = function () {
        websocket.close();
    };
}

// 页面加载时初始化
initWebSocket();
(2)消息发送功能

绑定"发送"按钮点击事件,获取输入框内容,构造MessageRequest格式,通过WebSocket发送:

javascript 复制代码
// 初始化发送按钮事件
function initSendButton() {
    let sendBtn = document.querySelector('.ctrl>button');
    let messageInput = document.querySelector('.message-input>textarea');

    sendBtn.onclick = function() {
        // 1. 获取输入框内容(去空格)
        let content = messageInput.value.trim();
        if (!content) {
            alert("消息内容不能为空!");
            return;
        }

        // 2. 获取当前选中的会话ID
        let selectedSessionLi = document.querySelector('#session-list>.selected');
        if (!selectedSessionLi) {
            alert("请先选择一个会话!");
            return;
        }
        let sessionId = selectedSessionLi.getAttribute('message-session-id');

        // 3. 构造消息请求(与MessageRequest类对应)
        let req = {
            type: 'message',
            sessionId: parseInt(sessionId),
            content: content
        };

        // 4. 发送消息(JSON序列化)
        websocket.send(JSON.stringify(req));

        // 5. 清空输入框
        messageInput.value = '';
    };
}

// 页面加载时初始化发送按钮
initSendButton();
(3)实时消息接收与渲染

接收服务器转发的消息,更新会话列表消息预览,若当前会话为选中状态,则直接渲染到消息区域:

javascript 复制代码
// 处理实时接收的消息
function handlerMessage(resp) {
    let sessionListUL = document.querySelector('#session-list');
    let selfUsername = document.querySelector('.user').innerHTML.trim();

    // 1. 查找该会话对应的列表项,不存在则创建
    let curSessionLi = findSessionById(resp.sessionId);
    if (curSessionLi == null) {
        curSessionLi = document.createElement('li');
        curSessionLi.setAttribute('message-session-id', resp.sessionId);
        curSessionLi.innerHTML = `<h3>${resp.fromName}</h3><p></p>`;
        curSessionLi.onclick = function() {
            clickSession(curSessionLi);
        };
        sessionListUL.insertBefore(curSessionLi, sessionListUL.children[0]);
    }

    // 2. 更新会话列表的消息预览(超过10字截断)
    let previewP = curSessionLi.querySelector('p');
    let previewContent = resp.content.length > 10 ? resp.content.substring(0, 10) + '...' : resp.content;
    previewP.innerHTML = previewContent;

    // 3. 若当前会话为选中状态,直接渲染消息并滚动到底部
    if (curSessionLi.className == 'selected') {
        let messageShowDiv = document.querySelector('.message-show');
        // 构造消息对象(与历史消息格式一致,复用addMessage方法)
        let message = {
            fromName: resp.fromName,
            content: resp.content,
            postTime: resp.postTime
        };
        addMessage(messageShowDiv, message, selfUsername);
        scrollBottom(messageShowDiv);
    }
}

五、功能验证与注意事项

5.1 核心功能验证

  1. 实时消息收发:两个用户同时在线,发送消息后对方实时接收并渲染,消息位置正确(自己在右、他人在左);
  2. 历史消息加载:切换会话时,正确加载该会话的历史消息,时间格式显示为"yyyy-MM-dd HH:mm:ss";
  3. 消息持久化:发送消息后,数据库message表新增对应记录,刷新页面后历史消息不丢失;
  4. 离线消息支持:用户A离线时,用户B发送消息,A上线后切换会话可查看离线期间的消息;
  5. 多端登录限制:同一用户无法同时登录多个页面,避免消息转发异常。

验证示例:

5.2 关键注意事项

  1. WebSocket路径一致性 :客户端连接路径需与服务器WebSocketConfig配置的路径完全一致(本文为"/WebSocketMessage"),末尾不能带斜杠;
  2. 时间格式处理 :数据库存储为datetime类型,Spring默认返回CST格式,需通过SimpleDateFormat手动转换为标准格式;
  3. 线程安全OnlineUserManager使用ConcurrentHashMap存储在线用户,避免多线程环境下的并发问题;
  4. 连接关闭 :窗口关闭时主动调用websocket.close(),避免服务器抛出"连接重置"异常;
  5. 消息长度限制 :数据库content字段长度为2048,客户端可添加输入长度限制,避免消息过长导致插入失败。

六、后续扩展方向

  1. 消息类型扩展 :支持图片、表情消息,需在message表新增type字段(text/image/emoji),客户端新增图片上传组件;
  2. 未读消息提示 :在会话列表项添加未读消息数字提示,需设计unread_message表记录未读状态;
  3. 消息撤回 :支持2分钟内撤回消息,需在MessageResponse中新增isRecall字段,客户端隐藏撤回的消息;
  4. 消息搜索 :实现按关键词搜索历史消息,扩展MessageMapper接口,添加模糊查询方法;
  5. 群聊支持:当前会话设计已预留群聊扩展(friends数组),仅需新增群聊创建、群成员管理功能即可。
相关推荐
2401_8955213423 分钟前
【Spring Security系列】Spring Security 过滤器详解与基于JDBC的认证实现
java·后端·spring
皮卡蛋炒饭.1 小时前
线程的概念和控制
java·开发语言·jvm
一只大袋鼠1 小时前
MyBatis 入门详细实战教程(一):从环境搭建到查询运行
java·开发语言·数据库·mysql·mybatis
小码哥_常1 小时前
大文件上传不再卡顿:Spring Boot 分片上传、断点续传与进度条实现全解析
后端
nibabaoo1 小时前
前端开发攻略---H5页面手机获取摄像头权限回显出画面并且同步到PC页面
javascript·websocket·实时音视频·实时同步·录制
程序员老邢1 小时前
【人生底稿・番外篇 05】我的电影江湖:从录像带时代,到港片陪伴的青春岁月
java·程序人生·职场发展·娱乐
sonnet-10291 小时前
函数式接口和方法引用
java·开发语言·笔记
Bat U1 小时前
JavaEE|多线程(二)
java·开发语言
_Evan_Yao1 小时前
RAG中的“Chunk”艺术:我试过10种切分策略后总结的结论
java·人工智能·后端·python·软件工程
今天你TLE了吗1 小时前
LLM到Agent&RAG——AI概念概述 第二章:提示词
人工智能·笔记·后端·学习