简介:本资源是一个基于JavaWeb技术栈实现的完整网络聊天室系统,面向Java初学者与Web全栈学习者,旨在帮助掌握前后端协同开发、实时通信与MVC架构实践。项目覆盖HTML/CSS构建界面、jQuery处理交互、Spring MVC组织后端逻辑、MySQL存储聊天记录,并集成WebSocket或AJAX实现消息实时收发,同时兼顾Session管理、用户认证及基础安全防护。压缩包共284个文件,含42个jar包(提供框架与工具依赖)、14个js文件(含前端交互逻辑)、10个css样式文件、5个xml配置文件、2个jsp页面及若干class编译文件,整体大小23.31MB,结构清晰,便于分层理解与调试。已有218人学习下载,配套代码可直接部署运行,包含用户注册登录、好友管理、消息发送与历史查询等核心功能模块,是练手Web应用开发、理解HTTP与会话机制、提升工程化编码能力的典型实战案例。
1. 这不是“做个网页弹窗聊两句”:一个真正能跑在 Tomcat 上、带用户状态管理、消息不丢不乱、数据库可查的 JavaWeb 网络聊天室,到底要填多少个坑?
你搜“JavaWeb 网络聊天室”,十个项目九个是 JSP 页面里写alert("欢迎进入聊天室"),剩下一个是用HttpServlet每秒发一次 AJAX 轮询——页面卡顿、消息延迟 3 秒起、用户刷新就掉线、两人同时发消息直接覆盖。这不是聊天室,这是“碰运气看谁的消息能被服务器听见”。真正的 JavaWeb 网络聊天室,核心不在“能显示文字”,而在连接可持续、状态可追踪、消息可追溯、用户可认证、异常可感知。它必须跑在标准 Servlet 容器(如 Tomcat 9+)里,用原生HttpSession做登录态绑定,用WebSocket实现双向低延迟通信,用 MySQL 存用户信息和历史记录,用UserLoginServlet和UserRegisterServlet承接表单、校验、跳转逻辑,最后所有资源打包成一个可部署的.war或直接解压运行的zip结构。适合刚学完 Servlet 生命周期、JSP 基础、MySQL JDBC 连接,正卡在“学了知识点但串不起来做完整项目”的 Java 初学者;也适合需要快速验证 WebSocket 在传统 JavaWeb 架构中落地可行性的后端工程师。它不炫技,不堆框架,只用最稳的 JDK 8+、Tomcat 9.0.83、MySQL 8.0、JDBC 8.0.33 组合,把教科书里的“会话管理”“异步通信”“事务边界”全落到web.xml、.java、.jsp和CREATE TABLE里。
2. 从零搭起骨架:建库、建表、配依赖、写 web.xml,四步定下整个项目的地基
2.1 创建 MySQL 用户表与消息历史表:字段设计直指真实场景需求
聊天室不是玩具,用户注册必须防重名、密码需加盐存储、消息必须带时间戳和发送者 ID。我坚持不用 Hibernate 或 MyBatis 自动生成,而是手写 SQL,因为只有亲手敲过user_id BIGINT PRIMARY KEY AUTO_INCREMENT,你才会理解为什么UserRegisterServlet里executeUpdate()后要用getGeneratedKeys()拿主键——否则用户注册成功却拿不到 ID,后续 WebSocket 会话就无法关联身份。
-- 创建数据库(UTF8MB4 防止 emoji 存储失败) CREATE DATABASE IF NOT EXISTS chatroom CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; USE chatroom; -- 用户表:密码用 SHA256 + 随机 salt 加密,非明文! CREATE TABLE users ( user_id BIGINT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(50) UNIQUE NOT NULL COMMENT '用户名,前端注册时校验唯一', password_hash CHAR(64) NOT NULL COMMENT 'SHA256(salt + raw_password)', salt CHAR(16) NOT NULL COMMENT '16位十六进制随机字符串,如 7a8b9c0d1e2f3a4b', created_at DATETIME DEFAULT CURRENT_TIMESTAMP, last_login DATETIME NULL ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 消息历史表:支持按用户/时间范围查询,加索引是刚需 CREATE TABLE messages ( msg_id BIGINT PRIMARY KEY AUTO_INCREMENT, sender_id BIGINT NOT NULL, receiver_id BIGINT NULL COMMENT 'NULL 表示群聊,非 NULL 表示私聊', content TEXT NOT NULL, send_time DATETIME DEFAULT CURRENT_TIMESTAMP, is_read TINYINT(1) DEFAULT 0 COMMENT '0未读,1已读,用于私聊状态' ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 关键索引:查某人发的所有消息、查某时间段消息都靠它 CREATE INDEX idx_sender_time ON messages(sender_id, send_time); CREATE INDEX idx_time ON messages(send_time);提示:
password_hash字段长度设为CHAR(64)是因为 SHA256 输出固定 64 字符十六进制字符串;salt用CHAR(16)存 16 字节随机数的 hex 编码(如a1b2c3d4e5f67890),比存原始字节数组更易调试。别用MD5,别存明文,这是血泪经验。
2.2 Maven 依赖精准锁定:只加必需项,拒绝“复制粘贴式 pom.xml”
很多初学者一上来就抄spring-boot-starter-websocket,结果发现项目跑不起来——因为 Spring Boot 的自动配置和传统 JavaWeb 的web.xml生命周期根本对不上。本项目走纯 Servlet 规范路线,依赖必须精简到最小可用集:
<!-- pom.xml --> <dependencies> <!-- Servlet API:Tomcat 提供,scope=provided,编译用,运行时不打包 --> <dependency> <groupId>javax.servlet</groupId> <artifactId>javax.servlet-api</artifactId> <version>4.0.1</version> <scope>provided</scope> </dependency> <!-- JSP 支持(Tomcat 9 默认带,但 IDE 编译需显式声明) --> <dependency> <groupId>javax.servlet.jsp</groupId> <artifactId>javax.servlet.jsp-api</artifactId> <version>2.3.3</version> <scope>provided</scope> </dependency> <!-- JSTL 标签库:简化 JSP 中的 if/foreach --> <dependency> <groupId>javax.servlet</groupId> <artifactId>jstl</artifactId> <version>1.2</version> </dependency> <!-- MySQL JDBC 驱动:必须用 8.0+ 版本,兼容 MySQL 8.0 认证插件 --> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <version>8.0.33</version> </dependency> <!-- WebSocket API:Java EE 7+ 标准,Tomcat 9 原生支持 --> <dependency> <groupId>javax.websocket</groupId> <artifactId>javax.websocket-api</artifactId> <version>1.1</version> <scope>provided</scope> </dependency> </dependencies>关键点:javax.websocket-api的scope=provided,因为 Tomcat 9 自带tomcat-websocket.jar,你打包进去反而会冲突;mysql-connector-java必须用8.0.33,低于此版本连不上 MySQL 8.0 默认的caching_sha2_password插件。
2.3 web.xml 全手动配置:Servlet 映射、监听器、会话超时,一个都不能少
IDEA 新建 JavaWeb 项目时默认不生成web.xml,或生成的是 Servlet 2.5 版本。本项目必须用 Servlet 3.1+ 规范,web.xml是控制中心:
<!-- web.xml --> <?xml version="1.0" encoding="UTF-8"?> <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"> <!-- 应用名称,显示在 Tomcat Manager 页面 --> <display-name>JavaWeb Chatroom</display-name> <!-- Session 超时设为 30 分钟:用户 30 分钟无操作自动登出 --> <session-config> <session-timeout>30</session-timeout> </session-config> <!-- 登录 Servlet:处理 /login POST 请求 --> <servlet> <servlet-name>UserLoginServlet</servlet-name> <servlet-class>com.chatroom.servlet.UserLoginServlet</servlet-class> </servlet> <servlet-mapping> <servlet-name>UserLoginServlet</servlet-name> <url-pattern>/login</url-pattern> </servlet-mapping> <!-- 注册 Servlet:处理 /register POST 请求 --> <servlet> <servlet-name>UserRegisterServlet</servlet-name> <servlet-class>com.chatroom.servlet.UserRegisterServlet</servlet-class> </servlet> <servlet-mapping> <servlet-name>UserRegisterServlet</servlet-name> <url-pattern>/register</url-pattern> </servlet-mapping> <!-- WebSocket Endpoint:路径 /ws/chat,由 ChatEndpoint.java 实现 --> <servlet> <servlet-name>ChatWebSocket</servlet-name> <servlet-class>com.chatroom.websocket.ChatEndpoint</servlet-class> </servlet> <servlet-mapping> <servlet-name>ChatWebSocket</servlet-name> <url-pattern>/ws/chat</url-pattern> </servlet-mapping> <!-- 全局监听器:记录用户上线/下线,清理 WebSocket 会话 --> <listener> <listener-class>com.chatroom.listener.SessionListener</listener-class> </listener> <!-- 欢迎页设为 login.jsp --> <welcome-file-list> <welcome-file>login.jsp</welcome-file> </welcome-file-list> </web-app>注意<url-pattern>/ws/chat</url-pattern>—— 这是前端 JavaScriptnew WebSocket("ws://localhost:8080/chatroom/ws/chat")的依据,路径必须完全一致,少一个/都 404。
3. 用户认证闭环:从 login.jsp 表单提交,到 UserLoginServlet 校验,再到 session 绑定用户对象
3.1 login.jsp:用 JSTL 控制登录态,避免“已登录还显示登录框”的低级错误
很多项目把登录页写成静态 HTML,结果用户登录成功跳转chat.jsp,但一刷新又回到login.jsp。根本原因是没用HttpSession做服务端状态判断。正确做法是在每个 JSP 顶部加判断:
<%-- login.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> <!-- 如果 session 中已有 user,直接重定向到聊天页 --> <c:if test="${not empty sessionScope.user}"> <c:redirect url="/chat.jsp"/> </c:if> <h2>用户登录</h2> <form action="/login" method="post"> <label>用户名:<input type="text" name="username" required></label><br> <label>密码:<input type="password" name="password" required></label><br> <button type="submit">登录</button> </form> <p><a href="/register.jsp">还没有账号?立即注册</a></p> <!-- 显示登录失败提示(来自 servlet 的 request.setAttribute) --> <c:if test="${not empty requestScope.errorMsg}"> <div style="color:red;">${requestScope.errorMsg}</div> </c:if> </body> </html>关键点:<c:if test="${not empty sessionScope.user}">检查 session 是否有user属性;requestScope.errorMsg是UserLoginServlet处理失败时request.setAttribute("errorMsg", "密码错误")传过来的,JSP 用<c:if>渲染,不拼 HTML 字符串。
3.2 UserLoginServlet:三次校验缺一不可,密码比对必须用加盐 SHA256
UserLoginServlet不是简单SELECT * FROM users WHERE username=? AND password=?。它必须完成三件事:1)查用户是否存在;2)用数据库存的 salt 对输入密码做 SHA256;3)比对 hash 值。漏一步,就可能被撞库或绕过。
// UserLoginServlet.java @WebServlet("/login") public class UserLoginServlet extends HttpServlet { private static final long serialVersionUID = 1L; @Override protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { String username = req.getParameter("username"); String rawPassword = req.getParameter("password"); // 1. 基础校验:空值拦截 if (username == null || username.trim().isEmpty() || rawPassword == null || rawPassword.trim().isEmpty()) { req.setAttribute("errorMsg", "用户名和密码不能为空"); req.getRequestDispatcher("/login.jsp").forward(req, resp); return; } // 2. 数据库校验:查用户 + salt User user = null; try (Connection conn = DBUtil.getConnection(); PreparedStatement ps = conn.prepareStatement( "SELECT user_id, username, password_hash, salt FROM users WHERE username = ?")) { ps.setString(1, username); try (ResultSet rs = ps.executeQuery()) { if (rs.next()) { user = new User(); user.setUserId(rs.getLong("user_id")); user.setUsername(rs.getString("username")); user.setPasswordHash(rs.getString("password_hash")); user.setSalt(rs.getString("salt")); } } } catch (SQLException e) { e.printStackTrace(); req.setAttribute("errorMsg", "系统繁忙,请稍后再试"); req.getRequestDispatcher("/login.jsp").forward(req, resp); return; } // 3. 密码校验:用查到的 salt 对输入密码做 SHA256 if (user == null || !user.getPasswordHash().equals( DigestUtils.sha256Hex(user.getSalt() + rawPassword))) { req.setAttribute("errorMsg", "用户名或密码错误"); req.getRequestDispatcher("/login.jsp").forward(req, resp); return; } // 4. 登录成功:存入 session,并更新 last_login req.getSession().setAttribute("user", user); try (Connection conn = DBUtil.getConnection(); PreparedStatement ps = conn.prepareStatement( "UPDATE users SET last_login = NOW() WHERE user_id = ?")) { ps.setLong(1, user.getUserId()); ps.executeUpdate(); } catch (SQLException e) { e.printStackTrace(); // 仅日志,不影响登录 } // 5. 重定向到聊天页(不能 forward,避免刷新重复提交) resp.sendRedirect(req.getContextPath() + "/chat.jsp"); } }注意:
DigestUtils.sha256Hex()来自 Apache Commons Codec,Maven 已引入;DBUtil.getConnection()是封装好的工具类,确保每次获取新连接;resp.sendRedirect()是关键,用重定向而非forward,防止用户刷新chat.jsp时重新触发登录逻辑。
3.3 UserRegisterServlet:注册时生成随机 salt,插入前必须判重
注册流程比登录更复杂:要生成 salt、算 hash、插入数据库、还要保证用户名唯一。很多人在这里用两次 SQL(先查再插),导致并发注册时出现重复用户名。正确做法是用唯一索引 + 捕获 SQL 异常:
// UserRegisterServlet.java @WebServlet("/register") public class UserRegisterServlet extends HttpServlet { private static final long serialVersionUID = 1L; @Override protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { String username = req.getParameter("username"); String rawPassword = req.getParameter("password"); if (username == null || username.trim().isEmpty() || rawPassword == null || rawPassword.trim().isEmpty()) { req.setAttribute("errorMsg", "用户名和密码不能为空"); req.getRequestDispatcher("/register.jsp").forward(req, resp); return; } // 生成 16 字节随机 salt,并转为 16 进制字符串(如 a1b2c3...) String salt = DigestUtils.md5Hex(UUID.randomUUID().toString()).substring(0, 16); String passwordHash = DigestUtils.sha256Hex(salt + rawPassword); try (Connection conn = DBUtil.getConnection(); PreparedStatement ps = conn.prepareStatement( "INSERT INTO users(username, password_hash, salt) VALUES (?, ?, ?)", Statement.RETURN_GENERATED_KEYS)) { ps.setString(1, username); ps.setString(2, passwordHash); ps.setString(3, salt); int affected = ps.executeUpdate(); if (affected == 0) { req.setAttribute("errorMsg", "注册失败,请重试"); req.getRequestDispatcher("/register.jsp").forward(req, resp); return; } // 获取自增主键 user_id try (ResultSet rs = ps.getGeneratedKeys()) { if (rs.next()) { long userId = rs.getLong(1); // 注册成功,直接登录:存入 session User user = new User(); user.setUserId(userId); user.setUsername(username); user.setPasswordHash(passwordHash); user.setSalt(salt); req.getSession().setAttribute("user", user); } } resp.sendRedirect(req.getContextPath() + "/chat.jsp"); } catch (SQLIntegrityConstraintViolationException e) { // MySQL 报错:Duplicate entry 'xxx' for key 'users.username' req.setAttribute("errorMsg", "用户名已被占用,请换一个"); req.getRequestDispatcher("/register.jsp").forward(req, resp); } catch (SQLException e) { e.printStackTrace(); req.setAttribute("ErrorMsg", "系统繁忙,请稍后再试"); req.getRequestDispatcher("/register.jsp").forward(req, resp); } } }这里SQLIntegrityConstraintViolationException是关键——它捕获的是数据库唯一约束失败,比应用层查重更可靠,且线程安全。
4. WebSocket 通信落地:ChatEndpoint 如何绑定用户、广播消息、处理断连
4.1 ChatEndpoint:用 @ServerEndpoint 注解 + Session 映射,实现“一人一连接”
@ServerEndpoint是 Java WebSocket API 的入口,但它本身不持有用户状态。必须用static Map<Session, User>将 WebSocket 会话和登录用户绑定,否则你收到消息时根本不知道是谁发的:
// ChatEndpoint.java @ServerEndpoint(value = "/ws/chat", configurator = ChatConfigurator.class) public class ChatEndpoint { // 静态 map:WebSocket Session ↔ 登录用户,注意线程安全! private static final Map<Session, User> SESSION_USER_MAP = new ConcurrentHashMap<>(); // 存所有在线用户 Session,用于广播 private static final Set<Session> ONLINE_SESSIONS = Collections.synchronizedSet(new HashSet<>()); @OnOpen public void onOpen(Session session, EndpointConfig config) { // 从 HttpSession 获取登录用户(关键!) HttpSession httpSession = (HttpSession) config.getUserProperties().get(HttpSession.class.getName()); User user = (User) httpSession.getAttribute("user"); if (user == null) { try { session.close(new CloseReason(CloseReason.CloseCodes.VIOLATED_POLICY, "未登录")); } catch (IOException e) { e.printStackTrace(); } return; } // 绑定 WebSocket Session 和 User SESSION_USER_MAP.put(session, user); ONLINE_SESSIONS.add(session); // 广播“XXX 上线了” broadcastOnlineStatus(user.getUsername() + " 上线了", true); } @OnMessage public void onMessage(String message, Session session) { User sender = SESSION_USER_MAP.get(session); if (sender == null) return; // 解析 JSON 消息:{"type":"chat","content":"hello"} 或 {"type":"private","to":"jack","content":"hi"} try { JSONObject json = new JSONObject(message); String type = json.optString("type", "chat"); String content = json.optString("content", "").trim(); if (content.isEmpty()) return; if ("chat".equals(type)) { // 群聊:存库 + 广播给所有人 saveMessageToDB(sender.getUserId(), null, content); broadcastMessage(sender.getUsername(), content, false); } else if ("private".equals(type)) { String toUsername = json.optString("to", ""); User receiver = findUserByUsername(toUsername); if (receiver != null) { Session receiverSession = findSessionByUser(receiver); if (receiverSession != null && receiverSession.isOpen()) { // 私聊:只发给指定用户,不存库(或另存私聊表) sendPrivateMessage(receiverSession, sender.getUsername(), content); } } } } catch (JSONException e) { e.printStackTrace(); } } @OnClose public void onClose(Session session) { User user = SESSION_USER_MAP.remove(session); if (user != null) { ONLINE_SESSIONS.remove(session); broadcastOnlineStatus(user.getUsername() + " 下线了", false); } } @OnError public void onError(Session session, Throwable error) { error.printStackTrace(); // 可记录日志,但不要 close session,onClose 会自动触发 } // 工具方法:广播消息给所有在线用户 private void broadcastMessage(String sender, String content, boolean isSystem) { String msgJson = new JSONObject() .put("type", isSystem ? "system" : "chat") .put("sender", sender) .put("content", content) .put("time", new SimpleDateFormat("HH:mm:ss").format(new Date())) .toString(); synchronized (ONLINE_SESSIONS) { ONLINE_SESSIONS.stream() .filter(Session::isOpen) .forEach(s -> { try { s.getBasicRemote().sendText(msgJson); } catch (IOException e) { e.printStackTrace(); } }); } } // 工具方法:保存消息到数据库(群聊) private void saveMessageToDB(long senderId, Long receiverId, String content) { try (Connection conn = DBUtil.getConnection(); PreparedStatement ps = conn.prepareStatement( "INSERT INTO messages(sender_id, receiver_id, content) VALUES (?, ?, ?)")) { ps.setLong(1, senderId); ps.setObject(2, receiverId); // NULL for group chat ps.setString(3, content); ps.executeUpdate(); } catch (SQLException e) { e.printStackTrace(); } } // 工具方法:根据用户名找 User 对象(简单遍历,生产环境应查库) private User findUserByUsername(String username) { return SESSION_USER_MAP.values().stream() .filter(u -> u.getUsername().equals(username)) .findFirst() .orElse(null); } // 工具方法:根据 User 找 Session private Session findSessionByUser(User user) { return SESSION_USER_MAP.entrySet().stream() .filter(e -> e.getValue().getUserId() == user.getUserId()) .map(Map.Entry::getKey) .filter(Session::isOpen) .findFirst() .orElse(null); } // 工具方法:广播上线/下线状态(系统消息) private void broadcastOnlineStatus(String status, boolean isOnline) { String msgJson = new JSONObject() .put("type", "status") .put("status", status) .put("isOnline", isOnline) .toString(); synchronized (ONLINE_SESSIONS) { ONLINE_SESSIONS.stream() .filter(Session::isOpen) .forEach(s -> { try { s.getBasicRemote().sendText(msgJson); } catch (IOException e) { e.printStackTrace(); } }); } } // 私聊专用发送 private void sendPrivateMessage(Session session, String sender, String content) { String msgJson = new JSONObject() .put("type", "private") .put("sender", sender) .put("content", content) .put("time", new SimpleDateFormat("HH:mm:ss").format(new Date())) .toString(); try { session.getBasicRemote().sendText(msgJson); } catch (IOException e) { e.printStackTrace(); } } }玄学警告:
ConcurrentHashMap是必须的,HashMap在多线程下会死锁;synchronized (ONLINE_SESSIONS)是必须的,否则遍历时remove会抛ConcurrentModificationException;session.getBasicRemote().sendText()必须在try-catch里,因为客户端断网时会抛IOException。
4.2 ChatConfigurator:把 HttpSession 注入 WebSocket,打通 HTTP 与 WS 的会话墙
@ServerEndpoint默认拿不到HttpSession,必须通过EndpointConfigurator注入:
// ChatConfigurator.java public class ChatConfigurator extends ServerEndpointConfig.Configurator { @Override public void modifyHandshake(ServerEndpointConfig sec, HttpServletRequest req, HttpServletResponse resp) { // 将 HttpSession 放入 WebSocket 的 userProperties,供 onOpen 使用 sec.getUserProperties().put(HttpSession.class.getName(), req.getSession()); } }没有这一步,onOpen里config.getUserProperties().get(HttpSession.class.getName())永远是null,用户永远“未登录”。
4.3 前端 JavaScript:用原生 WebSocket API,不依赖任何框架
chat.jsp里的 JS 必须处理连接、发消息、收消息、重连。别用 jQuery 或 Vue 封装,先搞懂原生:
<%-- 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>聊天室</title> <style> #chat-log { height: 400px; overflow-y: auto; border: 1px solid #ccc; padding: 10px; } .message { margin: 5px 0; } .system { color: #666; font-style: italic; } .private { background-color: #f0f8ff; } </style> </head> <body> <h2>JavaWeb 聊天室</h2> <div id="chat-log"></div> <div> <input type="text" id="msg-input" placeholder="输入消息..." style="width: 300px;"> <button onclick="sendMessage()">发送</button> <button onclick="disconnect()">断开</button> </div> <script> let ws = null; const log = document.getElementById('chat-log'); function connect() { const contextPath = "<%= request.getContextPath() %>"; const wsUrl = "ws://" + window.location.host + contextPath + "/ws/chat"; ws = new WebSocket(wsUrl); ws.onopen = function() { log.innerHTML += '<div class="message system">已连接到服务器</div>'; log.scrollTop = log.scrollHeight; }; ws.onmessage = function(event) { const data = JSON.parse(event.data); let html = ''; if (data.type === 'system') { html = `<div class="message system">${data.status}</div>`; } else if (data.type === 'chat' || data.type === 'private') { const cls = data.type === 'private' ? ' private' : ''; html = `<div class="message${cls}"><strong>${data.sender}:</strong> ${data.content} <span style="font-size:0.8em;color:#999;">${data.time}</span></div>`; } log.innerHTML += html; log.scrollTop = log.scrollHeight; }; ws.onclose = function() { log.innerHTML += '<div class="message system">连接已断开,正在尝试重连...</div>'; log.scrollTop = log.scrollHeight; setTimeout(connect, 3000); // 3秒后重连 }; ws.onerror = function(error) { console.error('WebSocket error:', error); log.innerHTML += '<div class="message system">连接出错,请检查网络</div>'; log.scrollTop = log.scrollHeight; }; } function sendMessage() { const input = document.getElementById('msg-input'); const content = input.value.trim(); if (!content) return; // 发送群聊 ws.send(JSON.stringify({type: 'chat', content: content})); // 清空输入框 input.value = ''; } function disconnect() { if (ws && ws.readyState === WebSocket.OPEN) { ws.close(); } } // 页面加载后自动连接 window.onload = connect; </script> </body> </html>关键点:ws.send(JSON.stringify({type: 'chat', content}))与后端onMessage解析逻辑严格对应;onclose里setTimeout(connect, 3000)实现自动重连;log.scrollTop = log.scrollHeight保证新消息自动滚动到底部。
5. 避坑指南:五个让新手调试三天找不到原因的真实问题
5.1 现象:WebSocket 连接 404,浏览器控制台报WebSocket connection to 'ws://...' failed: Error during WebSocket handshake: Unexpected response code: 404
原因:web.xml中<url-pattern>/ws/chat</url-pattern>与前端new WebSocket("ws://host:port/context/ws/chat")的路径不一致;或ChatEndpoint.java的@ServerEndpoint("/ws/chat")少了/;或 Tomcat 未启用 WebSocket(Tomcat 7+ 默认开启,但某些精简版可能关闭)。
解决:1)确认web.xml和@ServerEndpoint路径完全一致;2)访问http://localhost:8080/chatroom/ws/chat应返回 404(说明路径注册成功),而不是 404 页面(说明没注册);3)检查 Tomcatconf/web.xml中<servlet>的org.apache.catalina.websocket.WsServlet是否被注释。
5.2 现象:用户登录成功,但chat.jsp里sessionScope.user为空,WebSocketonOpen拿不到HttpSession
原因:ChatConfigurator.modifyHandshake()没被调用;或web.xml中ChatWebSocket的<servlet-class>写错了包名;或@ServerEndpoint类没被 Tomcat 扫描到(缺少@WebServlet或web.xml映射)。
解决:1)在ChatConfigurator.modifyHandshake()第一行加System.out.println("Configurator called");,启动时看控制台是否打印;2)确认web.xml中<servlet-class>与实际类路径完全一致(大小写敏感);3)删掉@ServerEndpoint上的value,改用@ServerEndpoint("/ws/chat"),避免路径解析歧义。
5.3 现象:两人聊天,A 发消息,B 收不到,但 A 自己能看到;或消息顺序错乱
原因:ConcurrentHashMap用了HashMap替代;或broadcastMessage()方法没加synchronized,多线程遍历HashSet时remove导致ConcurrentModificationException,部分消息没发出去;或session.getBasicRemote().sendText()没try-catch,一个用户断连导致整个广播中断。
解决:1)强制使用ConcurrentHashMap和Collections.synchronizedSet();2)broadcastMessage()内部必须synchronized (ONLINE_SESSIONS);3)sendText()必须包裹在try-catch中,单个失败不能影响整体。
5.4 现象:MySQL 8.0 连接报错Unable to load authentication plugin 'caching_sha2_password'
原因:mysql-connector-java版本低于 8.0,不支持 MySQL 8.0 默认认证插件。
解决:1)Maven 依赖必须用8.0.33;2)JDBC URL 加参数?serverTimezone=UTC&allowPublicKeyRetrieval=true&useSSL=false;3)或降级 MySQL 用户认证插件(不推荐):ALTER USER 'root'@'localhost' IDENTIFIED WITH mysql_native_password BY 'yourpassword'; FLUSH PRIVILEGES;
5.5 现象:中文消息存入数据库变成???,或 JSP 页面显示乱码
原因:MySQL 数据库/表字符集不是utf8mb4;或 JDBC URL 没加useUnicode=true&characterEncoding=UTF-8;或web.xml没设request.setCharacterEncoding("UTF-8")。
解决:1)建库建表时明确CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;2)JDBC URL 加?useUnicode=true&characterEncoding=UTF-8&serverTimezone=UTC;3)在UserLoginServlet.doPost()开头加req.setCharacterEncoding("UTF-8");4)JSP 顶部加<%@ page contentType="text/html;charset=UTF-8" %>。
6. 进阶技巧:用 SessionListener 实现“用户在线状态持久化”与“离线消息补推”
6.1 SessionListener:监听 HttpSession 创建与销毁,比 WebSocket 更早感知用户生命周期
HttpSessionListener能在用户登录成功(session.setAttribute("user", u))时就记录在线状态,比@OnOpen更早;也能在用户关闭浏览器、未正常断开 WebSocket 时,靠sessionDestroyed()清理资源:
// SessionListener.java public class SessionListener implements HttpSessionListener { private static final Logger logger = LoggerFactory.getLogger(SessionListener.class); @Override public void sessionCreated(HttpSessionEvent se) { // session 创建时,可初始化用户统计 logger.info("Session created: {}", se.getSession().getId()); } @Override public void sessionDestroyed(HttpSessionEvent se) { HttpSession session = se.getSession(); User user = (User) session.getAttribute("user"); if (user != null) { // 1. 从 WebSocket map 中移除该用户(如果还在) ChatEndpoint.SESSION_USER_MAP.entrySet().removeIf( entry -> entry.getValue().getUserId() == user.getUserId()); // 2. 更新数据库 last_logout 时间(可选) try (Connection conn = DBUtil.getConnection(); PreparedStatement ps = conn.prepareStatement( "UPDATE users SET last_logout = NOW() WHERE user_id = ?")) { ps.setLong(1, user.getUserId()); ps.executeUpdate(); } catch (SQLException e) { e.printStackTrace(); } logger.info("User {} logged out, session destroyed", user.getUsername()); } } }注意:
sessionDestroyed()触发时机是 session 过期(30 分钟无操作
本文还有配套的精品资源,点击获取