十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

JavaWeb聊天室实战:WebSocket+Tomcat+MySQL完整项目

JavaWeb聊天室实战:WebSocket+Tomcat+MySQL完整项目 简介本资源是一个基于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 ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; -- 消息历史表支持按用户/时间范围查询加索引是刚需 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已读用于私聊状态 ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; -- 关键索引查某人发的所有消息、查某时间段消息都靠它 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 APITomcat 提供scopeprovided编译用运行时不打包 -- dependency groupIdjavax.servlet/groupId artifactIdjavax.servlet-api/artifactId version4.0.1/version scopeprovided/scope /dependency !-- JSP 支持Tomcat 9 默认带但 IDE 编译需显式声明 -- dependency groupIdjavax.servlet.jsp/groupId artifactIdjavax.servlet.jsp-api/artifactId version2.3.3/version scopeprovided/scope /dependency !-- JSTL 标签库简化 JSP 中的 if/foreach -- dependency groupIdjavax.servlet/groupId artifactIdjstl/artifactId version1.2/version /dependency !-- MySQL JDBC 驱动必须用 8.0 版本兼容 MySQL 8.0 认证插件 -- dependency groupIdmysql/groupId artifactIdmysql-connector-java/artifactId version8.0.33/version /dependency !-- WebSocket APIJava EE 7 标准Tomcat 9 原生支持 -- dependency groupIdjavax.websocket/groupId artifactIdjavax.websocket-api/artifactId version1.1/version scopeprovided/scope /dependency /dependencies关键点javax.websocket-api的scopeprovided因为 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 version1.0 encodingUTF-8? web-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 !-- 应用名称显示在 Tomcat Manager 页面 -- display-nameJavaWeb Chatroom/display-name !-- Session 超时设为 30 分钟用户 30 分钟无操作自动登出 -- session-config session-timeout30/session-timeout /session-config !-- 登录 Servlet处理 /login POST 请求 -- servlet servlet-nameUserLoginServlet/servlet-name servlet-classcom.chatroom.servlet.UserLoginServlet/servlet-class /servlet servlet-mapping servlet-nameUserLoginServlet/servlet-name url-pattern/login/url-pattern /servlet-mapping !-- 注册 Servlet处理 /register POST 请求 -- servlet servlet-nameUserRegisterServlet/servlet-name servlet-classcom.chatroom.servlet.UserRegisterServlet/servlet-class /servlet servlet-mapping servlet-nameUserRegisterServlet/servlet-name url-pattern/register/url-pattern /servlet-mapping !-- WebSocket Endpoint路径 /ws/chat由 ChatEndpoint.java 实现 -- servlet servlet-nameChatWebSocket/servlet-name servlet-classcom.chatroom.websocket.ChatEndpoint/servlet-class /servlet servlet-mapping servlet-nameChatWebSocket/servlet-name url-pattern/ws/chat/url-pattern /servlet-mapping !-- 全局监听器记录用户上线/下线清理 WebSocket 会话 -- listener listener-classcom.chatroom.listener.SessionListener/listener-class /listener !-- 欢迎页设为 login.jsp -- welcome-file-list welcome-filelogin.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 contentTypetext/html;charsetUTF-8 languagejava % % taglib prefixc urihttp://java.sun.com/jsp/jstl/core % html headtitle登录 - 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 methodpost label用户名input typetext nameusername required/labelbr label密码input typepassword namepassword required/labelbr button typesubmit登录/button /form pa href/register.jsp还没有账号立即注册/a/p !-- 显示登录失败提示来自 servlet 的 request.setAttribute -- c:if test${not empty requestScope.errorMsg} div stylecolor: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三次校验缺一不可密码比对必须用加盐 SHA256UserLoginServlet不是简单SELECT * FROM users WHERE username? AND password?。它必须完成三件事1查用户是否存在2用数据库存的 salt 对输入密码做 SHA2563比对 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 CodecMaven 已引入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 MapSession, User将 WebSocket 会话和登录用户绑定否则你收到消息时根本不知道是谁发的// ChatEndpoint.java ServerEndpoint(value /ws/chat, configurator ChatConfigurator.class) public class ChatEndpoint { // 静态 mapWebSocket Session ↔ 登录用户注意线程安全 private static final MapSession, User SESSION_USER_MAP new ConcurrentHashMap(); // 存所有在线用户 Session用于广播 private static final SetSession 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 sessiononClose 会自动触发 } // 工具方法广播消息给所有在线用户 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会抛ConcurrentModificationExceptionsession.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 contentTypetext/html;charsetUTF-8 languagejava % % taglib prefixc urihttp://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 h2JavaWeb 聊天室/h2 div idchat-log/div div input typetext idmsg-input placeholder输入消息... stylewidth: 300px; button onclicksendMessage()发送/button button onclickdisconnect()断开/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 classmessage system已连接到服务器/div; log.scrollTop log.scrollHeight; }; ws.onmessage function(event) { const data JSON.parse(event.data); let html ; if (data.type system) { html div classmessage system${data.status}/div; } else if (data.type chat || data.type private) { const cls data.type private ? private : ; html div classmessage${cls}strong${data.sender}:/strong ${data.content} span stylefont-size:0.8em;color:#999;${data.time}/span/div; } log.innerHTML html; log.scrollTop log.scrollHeight; }; ws.onclose function() { log.innerHTML div classmessage system连接已断开正在尝试重连.../div; log.scrollTop log.scrollHeight; setTimeout(connect, 3000); // 3秒后重连 }; ws.onerror function(error) { console.error(WebSocket error:, error); log.innerHTML div classmessage 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 未启用 WebSocketTomcat 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()2broadcastMessage()内部必须synchronized (ONLINE_SESSIONS)3sendText()必须包裹在try-catch中单个失败不能影响整体。5.4 现象MySQL 8.0 连接报错Unable to load authentication plugin caching_sha2_password原因mysql-connector-java版本低于 8.0不支持 MySQL 8.0 默认认证插件。解决1Maven 依赖必须用8.0.332JDBC URL 加参数?serverTimezoneUTCallowPublicKeyRetrievaltrueuseSSLfalse3或降级 MySQL 用户认证插件不推荐ALTER USER rootlocalhost IDENTIFIED WITH mysql_native_password BY yourpassword; FLUSH PRIVILEGES;5.5 现象中文消息存入数据库变成???或 JSP 页面显示乱码原因MySQL 数据库/表字符集不是utf8mb4或 JDBC URL 没加useUnicodetruecharacterEncodingUTF-8或web.xml没设request.setCharacterEncoding(UTF-8)。解决1建库建表时明确CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci2JDBC URL 加?useUnicodetruecharacterEncodingUTF-8serverTimezoneUTC3在UserLoginServlet.doPost()开头加req.setCharacterEncoding(UTF-8)4JSP 顶部加% page contentTypetext/html;charsetUTF-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 分钟无操作本文还有配套的精品资源点击获取
返回列表