☰
JavaWeb原生聊天系统:Servlet+WebSocket+MySQL工程实践
2026/10/7 21:58:16 网站建设 项目流程

简介:这是一套基于JavaWeb技术栈实现的一对一网页聊天系统,面向Java初学者与Web开发入门者,帮助理解AJAX异步通信、Servlet请求处理及前后端协同逻辑等核心知识点。资源共54个文件,包含12个Java源码、12个编译后Class文件、8个JSP页面(如chat.jsp、login.jsp等)、7个Jar依赖包(含数据库连接池c3p0)、4个XML配置文件(如web.xml、c3p0-config.xml)以及项目元数据文件,整体压缩包仅2.88MB,轻量易部署。已有347人学习下载,适合在Tomcat+MySQL本地环境中快速运行调试。读者可完整获得从用户登录、好友列表加载、消息实时收发到数据库持久化的全链路实现,尤其能深入体会每秒轮询获取新消息的AJAX设计思路、两个Servlet分工(TalkServlet处理发送、TalkFromServlet响应轮询)的架构意图,以及未加UI但功能完备的工程实践范式。

1. 为什么一个“javaweb一对一网页聊天系统”能成为新手跨入工程化开发的临门一脚?

不是所有 JavaWeb 项目都值得从头敲一遍。但这个「javaweb一对一网页聊天系统」是少有的、能在 3 天内跑通、7 天内调稳、14 天内改出自己风格的最小闭环工程样本——它不依赖 Spring Boot 自动装配黑盒,不绕开 Servlet 生命周期讲“原理”,更不把 WebSocket 封装成魔法 API 让你只写两行就上线。它用最原始的HttpServlet处理登录与消息路由,用HttpSession绑定用户身份,用ServletContext管理在线会话池,再硬刚一次WebSocket的@ServerEndpoint手动注册与Session池映射。你写的每一行request.getSession().setAttribute("user", user)都对应真实内存对象,每一条session.getBasicRemote().sendText()都能抓包看到帧结构。它解决的不是“怎么聊”,而是“谁在跟谁聊、消息怎么不丢、离线怎么兜底、刷新页面后对话状态还在不在”这些被多数“完整案例”悄悄跳过的工程细节。适合刚学完 Servlet/JSP、正卡在“学了但不会组织项目”的 Java 初学者;也适合想带新人做 Code Review 的一线工程师——因为它的边界清晰、无隐藏依赖、所有状态可打印、所有异常可复现。别被“网页聊天”四个字骗了:这本质是一个带状态管理、双通道通信、会话生命周期控制的 Web 实时应用最小原型。


2. 从零搭起骨架:用原生 JavaWeb 实现登录 + 用户在线列表 + 会话绑定

这个系统的第一道门槛,从来不是 WebSocket,而是如何让两个浏览器知道“他们正在和同一个人聊天”。很多教程直接甩出@ServerEndpoint("/chat"),却没告诉你:WebSocket 连接建立前,用户是谁?他的昵称存在哪?他上次发的消息有没有被对方收到?这些必须靠 HTTP 阶段打下地基。

2.1 登录 Servlet:用 HttpSession 建立可信身份锚点

我们不用框架的拦截器,也不用 Filter 做权限校验,就用最朴素的LoginServlet:

// src/main/java/com/example/chat/LoginServlet.java @WebServlet("/login") public class LoginServlet extends HttpServlet { @Override protected void doPost(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { String username = request.getParameter("username").trim(); if (username.isEmpty() || username.length() > 16) { request.setAttribute("error", "用户名不能为空或超长"); request.getRequestDispatcher("/login.jsp").forward(request, response); return; } // 模拟查库(实际应走 DAO) User user = new User(username, System.currentTimeMillis()); // 关键:将用户对象存入 session,且设置超时(防长期空闲占用) HttpSession session = request.getSession(true); session.setMaxInactiveInterval(30 * 60); // 30分钟无操作自动失效 session.setAttribute("user", user); // 为后续 WebSocket 识别做准备:把 session id 存入全局上下文(ServletContext) ServletContext context = getServletContext(); synchronized (context) { Map<String, User> onlineUsers = (Map<String, User>) context.getAttribute("onlineUsers"); if (onlineUsers == null) { onlineUsers = new ConcurrentHashMap<>(); context.setAttribute("onlineUsers", onlineUsers); } onlineUsers.put(session.getId(), user); } response.sendRedirect("/chat.jsp"); // 跳转到主聊天页 } }

逻辑说明:

  • request.getSession(true)强制创建新 session(避免未登录就访问/chat.jsp);
  • setMaxInactiveInterval(1800)是硬性要求:WebSocket 连接可能比 HTTP 请求长得多,若 session 过期而 WebSocket 还连着,后续session.getAttribute("user")就会返回 null —— 这是后期排查“用户身份丢失”的第一大坑;
  • ServletContext作为全局容器存放onlineUsers,是因为HttpSession是单个用户的,而“在线列表”需要跨 session 可见;用ConcurrentHashMap是因多线程并发读写(多个用户同时登录/登出)。

2.2 在线用户列表:用 ServletContext + JSTL 动态渲染

chat.jsp页面顶部需实时显示当前在线用户(不含自己),并支持点击发起私聊:

<%@ page contentType="text/html;charset=UTF-8" language="java" %> <%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %> <html> <head><title>JavaWeb 聊天室</title></head> <body> <div id="online-list"> <h3>在线用户(<span id="online-count">${fn:length(applicationScope.onlineUsers)}</span>人)</h3> <ul> <c:forEach items="${applicationScope.onlineUsers}" var="entry"> <c:if test="${not empty sessionScope.user and entry.key != session.id}"> <li> <a href="#" onclick="startChat('${entry.value.username}')"> ${entry.value.username} <small>[${entry.value.loginTimeStr}]</small> </a> </li> </c:if> </c:forEach> </ul> </div> <script> function startChat(targetUser) { // 前端记录当前聊天对象,用于后续消息路由 window.currentTarget = targetUser; document.getElementById('chat-area').innerHTML = ''; } </script> </body> </html>

参数说明:

  • ${applicationScope.onlineUsers}直接读取ServletContext中的 map,无需额外 Controller;
  • <c:if>过滤掉自己(session.id != entry.key),这是“一对一”的前提;
  • startChat()只设window.currentTarget,不立即建 WebSocket 连接——连接由后续消息发送触发,避免空闲连接堆积。

2.3 用户登出:显式清理 session 与全局映射

很多人忽略登出逻辑,导致onlineUsers越积越多,最终 OOM:

@WebServlet("/logout") public class LogoutServlet extends HttpServlet { @Override protected void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { HttpSession session = request.getSession(false); if (session != null) { User user = (User) session.getAttribute("user"); if (user != null) { // 从全局在线列表中移除 ServletContext context = getServletContext(); Map<String, User> onlineUsers = (Map<String, User>) context.getAttribute("onlineUsers"); if (onlineUsers != null) { onlineUsers.remove(session.getId()); } } session.invalidate(); // 彻底销毁 session } response.sendRedirect("/login.jsp"); } }

关键点:session.invalidate()不仅清除 session 数据,还会触发HttpSessionListener(如果配置了),是释放资源的唯一可靠方式;remove(session.getId())必须在invalidate()之前执行,否则session.getId()可能抛IllegalStateException。


3. 消息通道打通:手写 WebSocket Endpoint 实现点对点路由与消息广播

HTTP 解决身份,WebSocket 解决实时。但原生@ServerEndpoint默认是“广播模式”,而我们要的是“张三发给李四,王五收不到”。这就必须手动维护Session映射表,并在onMessage里做精准投递。

3.1 ChatEndpoint:用 static Map 建立 WebSocket Session 与用户身份的双向索引

// src/main/java/com/example/chat/ChatEndpoint.java @ServerEndpoint(value = "/ws/chat", configurator = ChatConfigurator.class) public class ChatEndpoint { // 【核心数据结构】WebSocket Session ↔ 用户名 ↔ HttpSession ID 三元映射 private static final Map<String, Session> userToSession = new ConcurrentHashMap<>(); private static final Map<String, String> sessionToUser = new ConcurrentHashMap<>(); private static final Map<String, String> userToHttpSessionId = new ConcurrentHashMap<>(); @OnOpen public void onOpen(Session session, EndpointConfig config) { // 从 HTTP Session 中提取用户信息(通过 ChatConfigurator 注入) HttpSession httpSession = (HttpSession) config.getUserProperties().get("httpSession"); if (httpSession == null) { try { session.close(new CloseReason(CloseReason.CloseCodes.VIOLATED_POLICY, "未登录")); } catch (IOException e) { e.printStackTrace(); } return; } User user = (User) httpSession.getAttribute("user"); if (user == null) { try { session.close(new CloseReason(CloseReason.CloseCodes.VIOLATED_POLICY, "Session 已失效")); } catch (IOException e) { e.printStackTrace(); } return; } // 建立三重映射 userToSession.put(user.getUsername(), session); sessionToUser.put(session.getId(), user.getUsername()); userToHttpSessionId.put(user.getUsername(), httpSession.getId()); // 广播“上线通知”(可选) broadcastOnlineStatus(user.getUsername(), true); } @OnMessage public void onMessage(String message, Session session) { try { // 解析 JSON 消息:{"to":"lisi","content":"hello"} JSONObject json = new JSONObject(message); String targetUser = json.optString("to"); String content = json.optString("content"); if (targetUser == null || content == null || content.trim().isEmpty()) { session.getBasicRemote().sendText("{\"type\":\"error\",\"msg\":\"消息格式错误\"}"); return; } // 查找目标用户对应的 WebSocket Session Session targetSession = userToSession.get(targetUser); if (targetSession == null || !targetSession.isOpen()) { session.getBasicRemote().sendText( String.format("{\"type\":\"offline\",\"to\":\"%s\",\"msg\":\"用户不在线\"}", targetUser) ); return; } // 构造转发消息:带上 sender 便于前端显示 String forwardMsg = String.format( "{\"type\":\"chat\",\"from\":\"%s\",\"to\":\"%s\",\"content\":\"%s\",\"time\":%d}", sessionToUser.get(session.getId()), targetUser, content, System.currentTimeMillis() ); // 精准投递(点对点) targetSession.getBasicRemote().sendText(forwardMsg); // 同时回执给发送方(已送达) session.getBasicRemote().sendText( String.format("{\"type\":\"sent\",\"to\":\"%s\",\"content\":\"%s\"}", targetUser, content) ); } catch (Exception e) { e.printStackTrace(); try { session.getBasicRemote().sendText("{\"type\":\"error\",\"msg\":\"消息处理失败\"}"); } catch (IOException ioException) { ioException.printStackTrace(); } } } @OnClose public void onClose(Session session) { String username = sessionToUser.remove(session.getId()); if (username != null) { userToSession.remove(username); userToHttpSessionId.remove(username); broadcastOnlineStatus(username, false); } } @OnError public void onError(Session session, Throwable error) { error.printStackTrace(); // 可记录日志,但不要在此 close session —— onClose 已保证 } // 辅助方法:广播上线/下线状态 private void broadcastOnlineStatus(String username, boolean isOnline) { String statusMsg = String.format( "{\"type\":\"status\",\"user\":\"%s\",\"online\":%s}", username, isOnline ); for (Session s : userToSession.values()) { if (s.isOpen()) { try { s.getBasicRemote().sendText(statusMsg); } catch (IOException e) { e.printStackTrace(); } } } } }

为什么用 static Map 而不是注入 Spring Bean?
因为@ServerEndpoint是由容器(Tomcat)直接管理的,不走 ServletContext 的 Bean 生命周期,无法被 Spring 托管。强行注入会导致NullPointerException。static是唯一可控的共享存储方式,配合ConcurrentHashMap保证线程安全。

3.2 ChatConfigurator:把 HTTP Session “偷渡”进 WebSocket

这是整个链路最关键的胶水代码。@ServerEndpoint默认拿不到HttpSession,必须通过自定义Configurator注入:

// src/main/java/com/example/chat/ChatConfigurator.java public class ChatConfigurator extends ServerEndpointConfig.Configurator { @Override public void modifyHandshake(ServerEndpointConfig sec, HandshakeRequest request, HandshakeResponse response) { // 从 Cookie 中提取 JSESSIONID Map<String, List<String>> cookies = request.getHeaders(); List<String> jsessionIds = cookies.get("Cookie"); String sessionId = null; if (jsessionIds != null && !jsessionIds.isEmpty()) { String cookieStr = jsessionIds.get(0); // 解析 Cookie 字符串,提取 JSESSIONID=xxx for (String part : cookieStr.split(";")) { if (part.trim().startsWith("JSESSIONID=")) { sessionId = part.trim().substring("JSESSIONID=".length()); break; } } } if (sessionId != null) { // 从 ServletContext 中根据 sessionId 获取 HttpSession ServletContext servletContext = (ServletContext) request.getHttpSession().getServletContext(); // 注意:此处不能直接用 request.getHttpSession(),因为它是 null // 正确做法是:通过 Tomcat 的内部机制获取,但更稳妥的是—— // 我们在 LoginServlet 中已将 user 存入 ServletContext,这里只需传 sessionId 即可 sec.getUserProperties().put("httpSessionId", sessionId); } } }

血泪经验:

  • modifyHandshake是唯一能拿到HandshakeRequest的地方,也是唯一能“偷渡” HTTP 上下文的窗口;
  • 不要试图在@OnOpen里用request.getSession()—— WebSocket 握手请求不是标准 HTTP Servlet Request,getSession()必然返回 null;
  • 实际生产中,更健壮的做法是:登录成功后,后端生成一个短期有效的token返回前端,前端在 WebSocket URL 中携带(如/ws/chat?token=abc123),服务端在modifyHandshake中解析 token 并查库验证。本例为简化,直接复用 JSESSIONID。

3.3 前端 JavaScript:用原生 WebSocket 发送结构化消息

chat.jsp中的脚本需完成三件事:连接 WebSocket、监听消息、发送消息(含目标用户):

<script> let ws; let currentTarget = null; // 当前聊天对象(由 startChat 设置) function connectWebSocket() { const wsUrl = "ws://" + window.location.host + "/javaweb-chat/ws/chat"; ws = new WebSocket(wsUrl); ws.onopen = function(event) { console.log("WebSocket 连接已建立"); document.getElementById('status').innerText = '在线'; document.getElementById('status').style.color = 'green'; }; ws.onmessage = function(event) { const data = JSON.parse(event.data); const chatArea = document.getElementById('chat-area'); if (data.type === 'chat') { // 收到他人发来的消息 const msgEl = document.createElement('div'); msgEl.className = 'received'; msgEl.innerHTML = `<strong>[${data.from}]:</strong> ${data.content} <small>${new Date(data.time).toLocaleTimeString()}</small>`; chatArea.appendChild(msgEl); } else if (data.type === 'sent') { // 自己发的消息已送达 const msgEl = document.createElement('div'); msgEl.className = 'sent'; msgEl.innerHTML = `<strong>我:</strong> ${data.content} <small>已送达</small>`; chatArea.appendChild(msgEl); } else if (data.type === 'status') { // 上线/下线通知 const statusEl = document.createElement('div'); statusEl.className = 'status'; statusEl.innerText = `${data.user} ${data.online ? '上线了' : '下线了'}`; chatArea.appendChild(statusEl); } else if (data.type === 'offline') { alert(`对方不在线:${data.msg}`); } }; ws.onclose = function(event) { console.log("WebSocket 连接已关闭", event.reason); document.getElementById('status').innerText = '离线'; document.getElementById('status').style.color = 'red'; }; ws.onerror = function(error) { console.error("WebSocket 错误", error); }; } function sendMessage() { const input = document.getElementById('msg-input'); const content = input.value.trim(); if (!content || !currentTarget) return; const msg = { to: currentTarget, content: content }; if (ws && ws.readyState === WebSocket.OPEN) { ws.send(JSON.stringify(msg)); input.value = ''; } } // 页面加载后自动连接 window.onload = function() { connectWebSocket(); }; </script>

注意:ws.send(JSON.stringify(msg))是强制要求。WebSocket 传输的是二进制或文本帧,不能直接 send 对象。JSON.stringify是最轻量、最兼容的序列化方式,比 XML 或自定义协议更易调试。


4. 避坑指南:5 个让 90% 新手卡住的真实问题与解法

这个系统看似简单,但每个环节都有“看似正常、实则埋雷”的设计点。以下是我带 12 届实习生踩过的真坑,按发生频率排序:

4.1 现象:WebSocket 连接成功,但onMessage完全不触发,控制台无报错

原因:@ServerEndpoint类没有被 Tomcat 扫描到。常见于:

  • Maven 项目未在pom.xml中声明javax.websocket-api依赖(注意:Tomcat 8+ 内置 WebSocket,但编译时仍需provided依赖);
  • web.xml中未配置metadata-complete="false"(Tomcat 7+ 默认为 true,会跳过@WebServlet/@ServerEndpoint扫描);
  • 类文件未编译到WEB-INF/classes下(IDEA 中常见于 Output path 配置错误)。

解决:

  1. 检查pom.xml是否有:
<dependency> <groupId>javax.websocket</groupId> <artifactId>javax.websocket-api</artifactId> <version>1.1</version> <scope>provided</scope> </dependency>
  1. 检查web.xml根节点是否含metadata-complete="false":
<web-app xmlns="http://xmlns.jcp.org/xml/ns/javaee" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://xmlns.jcp.org/xml/ns/javaee http://xmlns.jcp.org/xml/ns/javaee/web-app_4_0.xsd" version="4.0" metadata-complete="false">
  1. IDEA 中右键项目 →Reload project,确认target/classes下存在ChatEndpoint.class。

4.2 现象:用户 A 发消息给 B,B 收到了,但 A 的页面显示“已送达”,刷新页面后聊天记录消失

原因:消息只存在内存(userToSessionMap),未持久化。刷新页面 → WebSocket 断开 →onClose触发 →userToSession移除 → 消息彻底丢失。这不是 Bug,是设计选择。

解决:

  • 短期方案:前端用localStorage缓存聊天记录(仅限当前设备):
// 发送前存本地 localStorage.setItem('chat-history-' + currentTarget, JSON.stringify([...JSON.parse(localStorage.getItem('chat-history-' + currentTarget) || '[]'), {from:'me',content:content}])); // 收到消息后也存 localStorage.setItem('chat-history-' + data.from, JSON.stringify([...JSON.parse(localStorage.getItem('chat-history-' + data.from) || '[]'), data]));
  • 长期方案:引入 MySQL,建chat_message表,字段包括id,sender,receiver,content,send_time,status(0=已发送,1=已送达,2=已读)。onMessage中先 insert,再投递,onClose时更新 status。这就是“javaweb项目完整案例mysql”的落地起点。

4.3 现象:多人同时登录同一账号,后登录者挤掉前登录者,但前登录者的 WebSocket 连接未关闭,导致消息乱发

原因:userToSession是以username为 key 的 Map,第二次登录时put()覆盖了旧Session,但旧连接仍在运行。

解决:在onOpen中增加踢人逻辑:

Session oldSession = userToSession.get(user.getUsername()); if (oldSession != null && oldSession.isOpen()) { try { oldSession.close(new CloseReason(CloseReason.CloseCodes.GOING_AWAY, "账号已在其他地方登录")); } catch (IOException e) { e.printStackTrace(); } } // 再 put 新 session userToSession.put(user.getUsername(), session);

4.4 现象:Chrome 浏览器能连 WebSocket,Firefox 报SEC_ERROR_UNKNOWN_ISSUER或直接拒绝连接

原因:开发环境用了 HTTPS 代理(如 Charles/Fiddler),或本地证书不受 Firefox 信任。WebSocket 协议严格遵循 HTTP(S) 协议栈,ws://对应http://,wss://对应https://。若页面是http://localhost:8080,WebSocket 必须用ws://;若页面是https://localhost:8443,则必须用wss://且证书有效。

解决:

  • 开发阶段统一用http://localhost:8080+ws://localhost:8080;
  • 如需 HTTPS,用keytool生成自签名证书并导入浏览器信任库,或使用mkcert工具(推荐)。

4.5 现象:Tomcat 启动时报java.lang.NoClassDefFoundError: javax/websocket/Endpoint

原因:javax.websocket-api依赖 scope 错误。若设为compile,会与 Tomcat 自带的tomcat-websocket.jar冲突;若设为runtime,编译时又找不到类。

解决:必须设为provided:

<scope>provided</scope>

且确保 Tomcat 版本 ≥ 7.0.47(WebSocket 1.0)或 ≥ 8.0.0(WebSocket 1.1)。IDEA 中检查Project Structure → Modules → Dependencies,确认该 jar 的 Scope 是Provided。


5. 消息可靠性加固:用数据库落库 + 离线消息队列实现“不丢消息”

做到上一章,你已经有了一个能跑、能聊、能看在线列表的系统。但它还是“玩具级”——关掉页面,消息就没了;对方不在线,消息就丢了;网络抖动,sendText()抛异常,你甚至不知道消息发没发出。真正的工程化,从加一层“消息可靠性”开始。

5.1 数据库设计:最小可行的聊天消息表

我们不搞复杂分库分表,一张表搞定核心需求:

字段名类型说明
idBIGINT PK AUTO_INCREMENT主键
senderVARCHAR(32) NOT NULL发送者用户名
receiverVARCHAR(32) NOT NULL接收者用户名
contentTEXT NOT NULL消息内容(UTF-8)
send_timeDATETIME DEFAULT CURRENT_TIMESTAMP发送时间
statusTINYINT DEFAULT 0消息状态:0=待投递,1=已投递,2=已读,3=发送失败
ws_session_idVARCHAR(100)WebSocket Session ID(用于重试)

建表 SQL(MySQL):

CREATE TABLE chat_message ( id BIGINT PRIMARY KEY AUTO_INCREMENT, sender VARCHAR(32) NOT NULL, receiver VARCHAR(32) NOT NULL, content TEXT NOT NULL, send_time DATETIME DEFAULT CURRENT_TIMESTAMP, status TINYINT DEFAULT 0, ws_session_id VARCHAR(100), INDEX idx_sender (sender), INDEX idx_receiver_status (receiver, status) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

为什么用utf8mb4?
因为用户可能发 emoji(如 👍),utf8在 MySQL 中实际是utf8mb3,不支持 4 字节 Unicode。utf8mb4是唯一正确选择。

5.2 消息发送流程改造:从“直发”到“先存后发”

修改ChatEndpoint.onMessage(),把“发送即投递”改为“存库 + 异步投递”:

@OnMessage public void onMessage(String message, Session session) { try { JSONObject json = new JSONObject(message); String targetUser = json.optString("to"); String content = json.optString("content"); // 1. 先存库(关键!) long msgId = saveMessageToDB(sessionToUser.get(session.getId()), targetUser, content); // 2. 尝试投递 Session targetSession = userToSession.get(targetUser); if (targetSession != null && targetSession.isOpen()) { String forwardMsg = buildForwardMessage(sessionToUser.get(session.getId()), targetUser, content, msgId); targetSession.getBasicRemote().sendText(forwardMsg); // 更新状态为已投递 updateMessageStatus(msgId, 1); } else { // 对方不在线,状态保持 0(待投递),等待其上线时拉取 // 可在此处触发推送通知(如邮件、短信),本例略 } // 3. 给发送方回执(含 msgId,便于前端做消息状态同步) session.getBasicRemote().sendText( String.format("{\"type\":\"sent\",\"to\":\"%s\",\"msg_id\":%d}", targetUser, msgId) ); } catch (Exception e) { e.printStackTrace(); try { session.getBasicRemote().sendText("{\"type\":\"error\",\"msg\":\"消息处理失败\"}"); } catch (IOException ioException) { ioException.printStackTrace(); } } } private long saveMessageToDB(String sender, String receiver, String content) { String sql = "INSERT INTO chat_message (sender, receiver, content) VALUES (?, ?, ?)"; try (Connection conn = DataSourceUtil.getConnection(); PreparedStatement ps = conn.prepareStatement(sql, Statement.RETURN_GENERATED_KEYS)) { ps.setString(1, sender); ps.setString(2, receiver); ps.setString(3, content); ps.executeUpdate(); try (ResultSet rs = ps.getGeneratedKeys()) { if (rs.next()) { return rs.getLong(1); } } } catch (SQLException e) { e.printStackTrace(); } return -1; } private void updateMessageStatus(long msgId, int status) { String sql = "UPDATE chat_message SET status = ? WHERE id = ?"; try (Connection conn = DataSourceUtil.getConnection(); PreparedStatement ps = conn.prepareStatement(sql)) { ps.setInt(1, status); ps.setLong(2, msgId); ps.executeUpdate(); } catch (SQLException e) { e.printStackTrace(); } }

DataSourceUtil 是什么?
一个极简的数据库连接工具类(非 HikariCP,仅为演示):

public class DataSourceUtil { private static final String URL = "jdbc:mysql://localhost:3306/chatdb?useSSL=false&serverTimezone=Asia/Shanghai"; private static final String USER = "root"; private static final String PASS = "123456"; static { try { Class.forName("com.mysql.cj.jdbc.Driver"); } catch (ClassNotFoundException e) { e.printStackTrace(); } } public static Connection getConnection() throws SQLException { return DriverManager.getConnection(URL, USER, PASS); } }

5.3 上线拉取离线消息:当用户 WebSocket 连接建立时,主动查询未读消息

在ChatEndpoint.onOpen()的末尾,加入拉取逻辑:

@OnOpen public void onOpen(Session session, EndpointConfig config) { // ... 前面的身份校验代码 ... // 【新增】拉取离线消息 String username = user.getUsername(); List<OfflineMessage> offlineMsgs = queryOfflineMessages(username); for (OfflineMessage msg : offlineMsgs) { try { session.getBasicRemote().sendText( String.format("{\"type\":\"offline\",\"from\":\"%s\",\"content\":\"%s\",\"time\":%d}", msg.sender, msg.content, msg.sendTime.getTime()) ); } catch (IOException e) { e.printStackTrace(); } } // 拉取完成后,批量更新状态为“已投递” markOfflineMessagesAsDelivered(username); } private List<OfflineMessage> queryOfflineMessages(String username) { String sql = "SELECT id, sender, content, send_time FROM chat_message WHERE receiver = ? AND status = 0"; List<OfflineMessage> list = new ArrayList<>(); try (Connection conn = DataSourceUtil.getConnection(); PreparedStatement ps = conn.prepareStatement(sql)) { ps.setString(1, username); try (ResultSet rs = ps.executeQuery()) { while (rs.next()) { OfflineMessage msg = new OfflineMessage(); msg.id = rs.getLong("id"); msg.sender = rs.getString("sender"); msg.content = rs.getString("content"); msg.sendTime = rs.getTimestamp("send_time"); list.add(msg); } } } catch (SQLException e) { e.printStackTrace(); } return list; } private void markOfflineMessagesAsDelivered(String username) { String sql = "UPDATE chat_message SET status = 1 WHERE receiver = ? AND status = 0"; try (Connection conn = DataSourceUtil.getConnection(); PreparedStatement ps = conn.prepareStatement(sql)) { ps.setString(1, username); ps.executeUpdate(); } catch (SQLException e) { e.printStackTrace(); } }

OfflineMessage 是一个 POJO:

public class OfflineMessage { long id; String sender; String content; Timestamp sendTime; // getter/setter 略 }

5.4 前端消息状态同步:用msg_id实现“已发送 → 已送达 → 已读”闭环

后端已返回msg_id,前端需据此更新 UI 状态:

// 发送消息时记录 function sendMessage() { const input = document.getElementById('msg-input'); const content = input.value.trim(); if (!content || !currentTarget) return; const msg = { to: currentTarget, content: content }; if (ws && ws.readyState === WebSocket.OPEN) { // 生成临时 ID 用于前端标记 const tempId = Date.now() + '-' + Math.random().toString(36).substr(2, 9); const msgEl = document.createElement('div'); msgEl.className = 'sent pending'; msgEl.id = 'msg-' + tempId; msgEl.innerHTML = `<strong>我:</strong> ${content} <small>发送中...</small>`; document.getElementById('chat-area').appendChild(msgEl); ws.send(JSON.stringify(msg)); input.value = ''; } } // 收到后端回执时更新 ws.onmessage = function(event) { const data = JSON.parse(event.data); if (data.type === 'sent' && data.msg_id) { // 找到对应 DOM,更新为“已送达” const el = document.getElementById('msg-' + tempId); // 需在闭包中保存 tempId if (el) { el.className = 'sent delivered'; el.innerHTML = `<strong>我:</strong> ${content} <small>已送达</small>`; } } };

提示:实际项目中,tempId应与后端msg_id一一对应,可通过data.msg_id更新 DOM ID,实现精准控制。本例为简化,未展示完整闭包逻辑。


6. 从“能跑”到“能交差”:用 IDEA 配置一键部署 + 日志追踪 + 性能压测技巧

写完代码只是开始。老板/导师要看的,是你能不能把它变成一个“别人下载就能跑、出了问题能定位、扛得住压力”的交付物。这一章不讲新功能,只讲三个实战技巧:如何让 IDEA 一键启动带数据库的完整环境、如何用 log4j2 追踪每条消息的完整生命周期、如何用 JMeter 模拟 100 个用户并发聊天。

6.1 IDEA 配置 Tomcat + MySQL 一键启动:告别“先启 DB 再启 Tomcat 再清缓存”

很多新手卡在环境配置:MySQL 没开、端口被占、jdbc:mysql://localhost:3306/chatdb连不上……其实 IDEA 早为你准备了自动化方案。

步骤:

  1. 打开Run → Edit Configurations...;
  2. 左侧Templates → Tomcat Server → Local,右侧设置:
    • Application server: 点击Configure...,添加你的 Tomcat 9 目录;
    • Deployment → + → Artifact → your-project:war exploded`;
    • Before launch → + → Run External tool → Configure...;
  3. 新建 External Tool:
    • Name:Start MySQL;
    • Program:C:\Program Files\MySQL\MySQL Server 8.0\bin\mysqld.exe(Windows)或/usr/local/mysql/bin/mysqld(macOS);
    • Arguments:--defaults-file="C:\my.cnf"(指向你的配置文件);
    • Working directory:C:\Program Files\MySQL\MySQL Server 8.0\bin\;
  4. 再加一个 External Tool:
    • Name:Create DB if not exists;
    • Program:C:\Program Files\MySQL\MySQL Server 8.0\bin\mysql.exe;
    • Arguments: `-u root -p123456 -e "CREATE DATABASE IF

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询