【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数组),仅需新增群聊创建、群成员管理功能即可。
相关推荐
舒一笑2 小时前
🚀 我用一行命令,把 OSS 私有文件变成“可直接下载的公网链接”(很多人不会)
后端
yyt3630458412 小时前
spring单例bean线程安全问题讨论
java·spring
小兔崽子去哪了2 小时前
Docker 安装 PostgreSQL
数据库·后端·postgresql
Sweet锦2 小时前
SpringBoot 3.5 集成 InfluxDB 1.8
spring boot·时序数据库
野犬寒鸦2 小时前
Redis热点key问题解析与实战解决方案(附大厂实际方案讲解)
服务器·数据库·redis·后端·缓存·bootstrap
我是大猴子2 小时前
事务失效的几种情况以及是为什么(详解)
java·开发语言
snakeshe10103 小时前
深入理解 Java 注解:从原理到实战
后端
Lucaju3 小时前
吃透 Spring AI Alibaba 多智能体|四大协同模式+完整代码
后端
Nyarlathotep01133 小时前
Redis的对象(5):有序集合对象
redis·后端