资讯详情

JavaWeb原生聊天系统:Servlet+WebSocket+MySQL工程实践

📅 2026/10/7 21:58:33 | 华诺云谱 👁 阅读
JavaWeb原生聊天系统:Servlet+WebSocket+MySQL工程实践
简介这是一套基于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人学习下载适合在TomcatMySQL本地环境中快速运行调试。读者可完整获得从用户登录、好友列表加载、消息实时收发到数据库持久化的全链路实现尤其能深入体会每秒轮询获取新消息的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) { MapString, User onlineUsers (MapString, 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.jspsetMaxInactiveInterval(1800)是硬性要求WebSocket 连接可能比 HTTP 请求长得多若 session 过期而 WebSocket 还连着后续session.getAttribute(user)就会返回 null —— 这是后期排查“用户身份丢失”的第一大坑ServletContext作为全局容器存放onlineUsers是因为HttpSession是单个用户的而“在线列表”需要跨 session 可见用ConcurrentHashMap是因多线程并发读写多个用户同时登录/登出。2.2 在线用户列表用 ServletContext JSTL 动态渲染chat.jsp页面顶部需实时显示当前在线用户不含自己并支持点击发起私聊% page contentTypetext/html;charsetUTF-8 languagejava % % taglib prefixc urihttp://java.sun.com/jsp/jstl/core % html headtitleJavaWeb 聊天室/title/head body div idonline-list h3在线用户span idonline-count${fn:length(applicationScope.onlineUsers)}/span人/h3 ul c:forEach items${applicationScope.onlineUsers} varentry c:if test${not empty sessionScope.user and entry.key ! session.id} li a href# onclickstartChat(${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无需额外 Controllerc:if过滤掉自己session.id ! entry.key这是“一对一”的前提startChat()只设window.currentTarget不立即建 WebSocket 连接——连接由后续消息发送触发避免空闲连接堆积。2.3 用户登出显式清理 session 与全局映射很多人忽略登出逻辑导致onlineUsers越积越多最终 OOMWebServlet(/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(); MapString, User onlineUsers (MapString, 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 MapString, Session userToSession new ConcurrentHashMap(); private static final MapString, String sessionToUser new ConcurrentHashMap(); private static final MapString, 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 MapString, ListString cookies request.getHeaders(); ListString jsessionIds cookies.get(Cookie); String sessionId null; if (jsessionIds ! null !jsessionIds.isEmpty()) { String cookieStr jsessionIds.get(0); // 解析 Cookie 字符串提取 JSESSIONIDxxx 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 RequestgetSession()必然返回 null实际生产中更健壮的做法是登录成功后后端生成一个短期有效的token返回前端前端在 WebSocket URL 中携带如/ws/chat?tokenabc123服务端在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-completefalseTomcat 7 默认为 true会跳过WebServlet/ServerEndpoint扫描类文件未编译到WEB-INF/classes下IDEA 中常见于 Output path 配置错误。解决检查pom.xml是否有dependency groupIdjavax.websocket/groupId artifactIdjavax.websocket-api/artifactId version1.1/version scopeprovided/scope /dependency检查web.xml根节点是否含metadata-completefalseweb-app xmlnshttp://xmlns.jcp.org/xml/ns/javaee xmlns:xsihttp://www.w3.org/2001/XMLSchema-instance xsi:schemaLocationhttp://xmlns.jcp.org/xml/ns/javaee http://xmlns.jcp.org/xml/ns/javaee/web-app_4_0.xsd version4.0 metadata-completefalseIDEA 中右键项目 →Reload project确认target/classes下存在ChatEndpoint.class。4.2 现象用户 A 发消息给 BB 收到了但 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,status0已发送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 浏览器能连 WebSocketFirefox 报SEC_ERROR_UNKNOWN_ISSUER或直接拒绝连接原因开发环境用了 HTTPS 代理如 Charles/Fiddler或本地证书不受 Firefox 信任。WebSocket 协议严格遵循 HTTP(S) 协议栈ws://对应http://wss://对应https://。若页面是http://localhost:8080WebSocket 必须用ws://若页面是https://localhost:8443则必须用wss://且证书有效。解决开发阶段统一用http://localhost:8080ws://localhost:8080如需 HTTPS用keytool生成自签名证书并导入浏览器信任库或使用mkcert工具推荐。4.5 现象Tomcat 启动时报java.lang.NoClassDefFoundError: javax/websocket/Endpoint原因javax.websocket-api依赖 scope 错误。若设为compile会与 Tomcat 自带的tomcat-websocket.jar冲突若设为runtime编译时又找不到类。解决必须设为providedscopeprovided/scope且确保 Tomcat 版本 ≥ 7.0.47WebSocket 1.0或 ≥ 8.0.0WebSocket 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-8send_timeDATETIME DEFAULT CURRENT_TIMESTAMP发送时间statusTINYINT DEFAULT 0消息状态0待投递1已投递2已读3发送失败ws_session_idVARCHAR(100)WebSocket Session ID用于重试建表 SQLMySQLCREATE 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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;为什么用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?useSSLfalseserverTimezoneAsia/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(); ListOfflineMessage 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 ListOfflineMessage queryOfflineMessages(String username) { String sql SELECT id, sender, content, send_time FROM chat_message WHERE receiver ? AND status 0; ListOfflineMessage 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 是一个 POJOpublic 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 早为你准备了自动化方案。步骤打开Run → Edit Configurations...左侧Templates → Tomcat Server → Local右侧设置Application server: 点击Configure...添加你的 Tomcat 9 目录Deployment → → Artifact → your-project:war explodedBefore launch → → Run External tool → Configure...新建 External ToolName:Start MySQL;Program:C:\Program Files\MySQL\MySQL Server 8.0\bin\mysqld.exeWindows或/usr/local/mysql/bin/mysqldmacOSArguments:--defaults-fileC:\my.cnf指向你的配置文件Working directory:C:\Program Files\MySQL\MySQL Server 8.0\bin\再加一个 External ToolName:Create DB if not exists;Program:C:\Program Files\MySQL\MySQL Server 8.0\bin\mysql.exeArguments: -u root -p123456 -e CREATE DATABASE IF本文还有配套的精品资源点击获取
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑