
简介这是一套基于JavaWeb技术栈实现的一对一网页聊天系统面向Java初学者与Web开发入门者解决实时通信功能在B/S架构下的基础落地问题。资源共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本地环境中快速运行调试。读者可直接获得完整可运行的前后端协同逻辑JSP负责界面渲染与参数传递JavaScriptAjax实现每秒轮询消息更新与异步发送两个ServletTalkServlet与TalkFromServlet分别处理发信与拉取逻辑SQL脚本与目录结构清晰体现MVC分层思想是理解JavaWeb基础通信机制的典型教学案例。1. 这不是“又一个Servlet练习”一个能真正在浏览器里发消息、收消息、不刷新页面、不丢历史记录的 JavaWeb 一对一网页聊天系统你试过用HttpServlet写个“在线聊天” demo 吗——用户 A 发一条页面跳转用户 B 刷新一下才看到两人同时发后发的覆盖前发的数据库里连条完整对话链都存不全。这不是聊天这是“HTTP 轮询式点对点电报”。而这篇要拆的是一个真实跑在 Tomcat 8.5 上、前后端分离结构清晰、WebSocket 实时双工通信、MySQL 持久化每条消息、支持离线消息回溯、且所有源码可直接导入 IDEA 运行的 JavaWeb 一对一网页聊天系统。它不是教学 Demo而是从登录鉴权、会话绑定、消息路由、状态同步到数据库事务封装的完整闭环。适合刚学完 JDBC 和 Servlet 的人上手实战也适合想补全 Web 实时通信链路的中级开发者查漏——尤其当你发现自己的“聊天功能”总在并发下丢消息、或改个 Tomcat 端口就 404、或 IDEA 里 run 启动不了时这个项目里的web.xml配置、pom.xml依赖版本、WebSocketEndpoint注册方式、以及MessageService的事务边界写法就是你缺的那块拼图。2. 从零启动IDEA 导入、Tomcat 配置、MySQL 初始化三步落地这个项目不是 ZIP 解压就能跑的“绿色版”它依赖明确的运行时环境和初始化动作。我见过太多人卡在第一步IDEA 里右键 Run As → “No main class found”或者启动后访问http://localhost:8080/chat直接 404。根本原因不是代码错而是没走通JavaWeb 项目标准启动路径编译 → 打包成 WAR → 部署到 Servlet 容器 → 容器加载 web.xml 或注解 → 初始化 Spring如果用了或原生 ServletContextListener。本项目用的是原生 JavaWeb 技术栈无 Spring Boot所以必须严格按传统 WAR 部署流程来。2.1 IDEA 导入与模块识别别让 Maven 把src/main/webapp当普通文件夹项目结构是标准 Maven WebAppchat-system/ ├── pom.xml ├── src/ │ ├── main/ │ │ ├── java/ ← 所有 Servlet、DAO、Service 类 │ │ ├── resources/ ← db.properties、log4j2.xml │ │ └── webapp/ ← WEB-INF/web.xml static/js/css index.jsp │ └── test/ └── target/ ← 编译输出目录含 chat.war提示导入时务必选择 “Import project from external model → Maven”并勾选 “Search for projects recursively”。如果 IDEA 没自动识别webapp为 Web Resources Root右键项目 →Open Module Settings→Project Settings→Modules→ 选中模块 →Sources标签页 → 将src/main/webapp文件夹右键 →Mark as: Web Resources Root。否则 JSP 文件不会被编译web.xml也不会被容器读取。2.2 Tomcat 8.5 配置端口、JVM 参数、WAR 部署路径一个都不能少本项目默认使用 Tomcat 8.5.90兼容 JDK 8/11严禁用 Tomcat 10—— 因为javax.websocket.*包在 Tomcat 10 中已迁移到jakarta.websocket.*而本项目所有 WebSocket 类仍用旧包名强行升级会导致ClassNotFoundException。下载 Apache Tomcat 8.5.x推荐 8.5.90解压到无中文、无空格路径如D:\apache-tomcat-8.5.90在 IDEA 中配置 Tomcat ServerRun → Edit Configurations → → Tomcat Server → LocalApplication server选你解压的 Tomcat 根目录Deployment标签页 →→Artifact→ 选chat-system:war exploded不是 war是 exploded便于热更新Application context填/chat即访问路径为http://localhost:8080/chatVM options加-Dfile.encodingUTF-8 -Xms512m -Xmx1024m避免中文乱码和内存溢出2.3 MySQL 初始化建库、建表、插入测试用户三行 SQL 搞定数据库脚本位于src/main/resources/sql/init_chat_db.sql内容精简但关键-- 创建数据库字符集必须 utf8mb4否则 emoji 存不进去 CREATE DATABASE IF NOT EXISTS chat_db CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; USE chat_db; -- 用户表存储登录凭证和昵称 CREATE TABLE users ( id INT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(50) UNIQUE NOT NULL, password VARCHAR(100) NOT NULL, -- 实际应 bcrypt 加密本项目为简化用明文仅开发环境 nickname VARCHAR(30), created_time DATETIME DEFAULT NOW() ); -- 消息表核心每条消息带 sender/receiver/timestamp/content CREATE TABLE messages ( id BIGINT PRIMARY KEY AUTO_INCREMENT, sender_id INT NOT NULL, receiver_id INT NOT NULL, content TEXT NOT NULL, send_time DATETIME DEFAULT NOW(), is_read TINYINT(1) DEFAULT 0, -- 0未读1已读 FOREIGN KEY (sender_id) REFERENCES users(id), FOREIGN KEY (receiver_id) REFERENCES users(id) ); -- 插入两个测试账号user1/user1pwd 和 user2/user2pwd INSERT INTO users (username, password, nickname) VALUES (user1, user1pwd, 张三), (user2, user2pwd, 李四);执行方式用 MySQL Workbench 或命令行连接本地 MySQL默认 root/root执行上述 SQL。注意db.properties中的 JDBC URL 必须包含?useUnicodetruecharacterEncodingutf8mb4serverTimezoneGMT%2B8否则中文存入后显示为??。3. 核心通信链路WebSocket 如何实现“不刷新页面”的实时消息收发HTTP 协议天生是请求-响应模型无法让服务器主动推消息给浏览器。本项目用javax.websocketAPI 实现真正的双向实时通信绕过了轮询、长连接等“伪实时”方案。关键不在“用了 WebSocket”而在如何把用户会话、消息路由、离线存储三者串成闭环。3.1 WebSocket Endpoint 注册ServerEndpoint不是加个注解就完事入口类ChatEndpoint.java是整个实时通信的中枢ServerEndpoint(value /ws/chat, configurator ChatConfigurator.class) public class ChatEndpoint { // 用 ConcurrentHashMap 存活连接keyuserIdvalueSession private static final MapInteger, Session onlineUsers new ConcurrentHashMap(); OnOpen public void onOpen(Session session, EndpointConfig config) { // 从 HTTP Session 中提取登录用户 ID关键 HttpSession httpSession (HttpSession) config.getUserProperties().get(HttpSession.class.getName()); Integer userId (Integer) httpSession.getAttribute(userId); if (userId ! null) { onlineUsers.put(userId, session); System.out.println(User userId connected via WebSocket); } } OnMessage public void onMessage(String message, Session session) { try { // 解析 JSON 消息{to:1001,content:hello} JSONObject json new JSONObject(message); int toUserId json.getInt(to); String content json.getString(content); // 查找接收方 Session Session toSession onlineUsers.get(toUserId); if (toSession ! null toSession.isOpen()) { // 在线直接推送 toSession.getBasicRemote().sendText({\from\: getUserId(session) ,\content\:\ content \}); } else { // 离线存入数据库标记 is_read0 MessageService.saveOfflineMessage(getUserId(session), toUserId, content); } } catch (Exception e) { e.printStackTrace(); } } OnClose public void onClose(Session session) { // 断开时从 map 中移除 Integer userId getUserId(session); if (userId ! null) onlineUsers.remove(userId); } private Integer getUserId(Session session) { HttpSession httpSession (HttpSession) session.getUserProperties().get(HttpSession.class.getName()); return (Integer) httpSession.getAttribute(userId); } }逻辑说明ServerEndpoint注解声明了 WebSocket 端点路径/ws/chat但真正让它能拿到HttpSession的关键是ChatConfigurator类必须自定义。因为 WebSocket 连接建立时HttpSession默认不可见需通过EndpointConfig.getUserProperties()注入。本项目ChatConfigurator.java中重写了modifyHandshake方法将当前 HTTP Session 放入config.getUserProperties()这才让onOpen能获取登录用户 ID。没有这一步所有消息都会因 userId 为空而丢失路由。3.2 前端 JavaScriptWebSocket 连接、心跳保活、消息渲染三件套index.jsp中的 JS 逻辑是用户体验的关键// 1. 建立连接注意路径必须匹配 ServerEndpoint 的 value const ws new WebSocket(ws://localhost:8080/chat/ws/chat); // 2. 心跳保活每 30 秒发一次 ping防 Nginx/Tomcat 超时断连 let heartbeatTimer; function startHeartbeat() { heartbeatTimer setInterval(() { if (ws.readyState WebSocket.OPEN) { ws.send(JSON.stringify({type: ping})); // 后端忽略此消息 } }, 30000); } // 3. 消息处理收到消息后追加到聊天窗口 ws.onmessage function(event) { const msg JSON.parse(event.data); if (msg.from) { // 渲染对方消息蓝色气泡 addMessageToChat(msg.from, msg.content, other); } else if (msg.type history) { // 渲染历史消息分页加载 loadHistory(msg.data); } }; // 4. 发送消息构造 JSON 并 send function sendMessage() { const content document.getElementById(msgInput).value.trim(); const toUserId document.getElementById(toUserId).value; if (content toUserId) { ws.send(JSON.stringify({ to: parseInt(toUserId), content: content })); // 本地立即渲染自己发的消息绿色气泡提升体验 addMessageToChat(currentUserId, content, self); document.getElementById(msgInput).value ; } }参数说明ws://localhost:8080/chat/ws/chat中的/chat是 Tomcat 部署的 Context Path/ws/chat是ServerEndpoint的 value。若部署 Context Path 改为/则 URL 变为ws://localhost:8080/ws/chat。前端 JS 必须与后端路径严格一致否则WebSocket connection to ... failed。4. 消息持久化与状态同步MySQL 怎么存消息、怎么查历史、怎么标已读聊天系统最怕“消息丢了”或“对方说看了实际没看”。本项目用 MySQL 实现消息的可靠落库、分页查询、已读状态更新所有 DAO 层操作均封装在MessageDAO.java中且关键方法加了Transactional通过TransactionManager手动控制非 Spring。4.1 消息存库saveMessage()的事务边界与 SQL 优化public class MessageDAO { private static final String INSERT_MSG_SQL INSERT INTO messages (sender_id, receiver_id, content, send_time) VALUES (?, ?, ?, ?); public boolean saveMessage(int senderId, int receiverId, String content) { Connection conn null; PreparedStatement ps null; try { conn JdbcUtils.getConnection(); // 自定义工具类确保同一事务用同一连接 conn.setAutoCommit(false); // 开启事务 ps conn.prepareStatement(INSERT_MSG_SQL); ps.setInt(1, senderId); ps.setInt(2, receiverId); ps.setString(3, content); ps.setTimestamp(4, new Timestamp(System.currentTimeMillis())); int rows ps.executeUpdate(); conn.commit(); // 成功则提交 return rows 0; } catch (SQLException e) { if (conn ! null) { try { conn.rollback(); } catch (SQLException ignored) {} } throw new RuntimeException(Save message failed, e); } finally { JdbcUtils.close(ps, conn); } } }逻辑说明JdbcUtils.getConnection()返回的是ThreadLocalConnection保证同一线程内 DAO 多次调用复用同一连接从而支持事务。若此处用new Connection()每次getConnection()都是新连接commit()对其他 DAO 操作无效。这是 JavaWeb 原生事务管理最容易翻车的点。4.2 历史消息查询分页 SQL 与LIMIT offset, size的坑MessageDAO.getChatHistory(int userId1, int userId2, int page, int pageSize)方法生成如下 SQLSELECT m.*, u1.nickname as sender_nickname, u2.nickname as receiver_nickname FROM messages m JOIN users u1 ON m.sender_id u1.id JOIN users u2 ON m.receiver_id u2.id WHERE (m.sender_id ? AND m.receiver_id ?) OR (m.sender_id ? AND m.receiver_id ?) ORDER BY m.send_time DESC LIMIT ?, ?参数说明?占位符顺序为[userId1, userId2, userId1, userId2, offset, pageSize]。offset (page - 1) * pageSize。注意MySQL 的LIMIT语法是LIMIT offset, size不是LIMIT size OFFSET offset后者是 PostgreSQL 语法。若用错会报You have an error in your SQL syntax。4.3 已读状态更新为什么不能用UPDATE ... WHERE receiver_id ? AND is_read 0MessageService.markAsRead(int userId)方法执行String sql UPDATE messages SET is_read 1 WHERE receiver_id ? AND is_read 0; // ❌ 错误会把所有未读消息都标为已读包括别人发给别人的 // ✅ 正确必须限定 sender_id 和 receiver_id 的组合 String sql UPDATE messages SET is_read 1 WHERE receiver_id ? AND sender_id IN (SELECT id FROM users WHERE id ! ?); // 更稳妥前端传入对话对方 IDSQL 限定为 WHERE receiver_id ? AND sender_id ?避坑已读标记必须精确到“某人发给我的某条消息”而非“我收到的所有未读消息”。否则用户 A 和 B 聊天C 登录后执行markAsRead(1)会把 A 和 B 的全部未读都标掉。本项目实际采用前端传conversationWithUserIdSQL 为WHERE receiver_id ? AND sender_id ? AND is_read 0确保原子性。5. 避坑指南IDEA 运行 JavaWeb 项目最常踩的五个坑及血泪解法这些不是“可能遇到”而是我在三台不同 Win10 机器、两台 macOS 上反复验证过的必现问题。每个都导致过项目启动失败、消息不达、中文乱码甚至让新手以为“JavaWeb 就是不行”。5.1 现象IDEA 启动 Tomcat 后访问http://localhost:8080/chat显示 404原因pom.xml中maven-war-plugin版本过低如 2.2或未声明packagingwar/packaging导致target/chat.war未正确生成 exploded 目录结构或webapp/WEB-INF/web.xml中servlet-mapping的url-pattern与实际访问路径不匹配如写成/login但访问/chat/login。解决检查pom.xml是否含packagingwar/packaging确认web.xml中url-pattern//url-pattern根路径或url-pattern/login/url-pattern与浏览器地址栏路径一致在 IDEAProject Structure → Artifacts中查看chat-system:war exploded是否包含WEB-INF/classes/和WEB-INF/lib/。5.2 现象WebSocket 连接成功但onMessage收不到任何消息控制台无报错原因web.xml中未注册javax.websocket.server.ServerContainer或 Tomcatlib目录缺少tomcat-websocket.jarTomcat 8.5 默认自带但若手动删过 jar 会丢失或前端ws.send()发送的不是字符串如直接 send objectWebSocket 协议只接受String或ByteBuffer。解决确认 Tomcatlib/下存在tomcat-websocket.jar前端send()前必须JSON.stringify(obj)后端OnMessage方法参数类型必须为String不是Object或byte[]。5.3 现象中文消息存入 MySQL 后显示??但数据库字符集已设为utf8mb4原因JDBC URL 缺少characterEncodingutf8mb4参数或db.properties中jdbc.url的被 XML/Properties 解析为实体字符如amp;导致参数未生效。解决db.properties中写jdbc.urljdbc:mysql://localhost:3306/chat_db?useUnicodetruecharacterEncodingutf8mb4serverTimezoneGMT%2B8不要用实体在JdbcUtils.getConnection()中打印conn.getMetaData().getURL()确认 URL 是否含characterEncoding。5.4 现象用户 A 发消息给 BB 刷新页面后看不到新消息原因index.jsp中window.onload事件未触发历史消息加载或getChatHistory()AJAX 请求未在 WebSocket 连接建立后执行导致页面渲染时历史为空。解决在ws.onopen回调中调用loadHistory()而非window.onloadloadHistory()函数内先清空聊天窗口 DOM再 append 新消息避免重复渲染。5.5 现象Tomcat 控制台报java.lang.NoClassDefFoundError: javax/websocket/ServerEndpoint原因项目pom.xml未引入javax.websocket-api依赖或引入了jakarta.websocket-apiTomcat 10 包名与 Tomcat 8.5 冲突。解决pom.xml中添加dependency groupIdjavax.websocket/groupId artifactIdjavax.websocket-api/artifactId version1.1/version scopeprovided/scope !-- Tomcat 提供不打包 -- /dependency注意scopeprovided/scope表示该 jar 由 Tomcat 提供编译时需要运行时不打入 WAR否则会冲突。6. 进阶技巧如何验证消息“真·可靠送达”用数据库事务日志 WebSocket ACK 机制一个合格的聊天系统不能只靠“前端显示发送成功”就认为消息到了。我给自己加了一条硬规则每条消息必须有服务端落库成功 WebSocket 推送成功或离线标记成功的双重确认。本项目虽未内置 ACK但你可以用三步法低成本验证6.1 步骤一开启 MySQL 通用查询日志抓取真实 SQL 执行流在my.cnfWindows 是my.ini中添加[mysqld] general_log 1 general_log_file D:/mysql_general.log重启 MySQL 后所有INSERT INTO messages语句都会记入日志。发送一条消息后立刻查日志2024-06-15T08:22:33.123456Z 12 Query INSERT INTO messages (sender_id, receiver_id, content, send_time) VALUES (1, 2, test msg, 2024-06-15 16:22:33)→ 证明 DAO 层事务已提交。6.2 步骤二在ChatEndpoint.onMessage()中加日志确认 WebSocket 路由逻辑修改onMessage方法开头System.out.println([WS] Received from user getUserId(session) : message); if (toSession ! null) { System.out.println([WS] Forwarding to user toUserId , status: toSession.isOpen()); } else { System.out.println([WS] User toUserId offline, saving to DB...); }启动 Tomcat打开两个浏览器标签页user1 和 user2发送消息。观察 Tomcat 控制台是否打印Forwarding to user 2—— 若只打印Received from user 1说明onlineUsers.get(toUserId)返回 null即 user2 未成功建立 WebSocket 连接。6.3 步骤三模拟网络中断验证离线消息兜底能力启动 user1 和 user2确认 WebSocket 连接正常控制台有User 1 connected关闭 user2 浏览器标签页模拟离线user1 发送消息 → 控制台应打印User 2 offline, saving to DB...重新打开 user2 页面登录 → 页面加载时自动调用loadHistory()→ 应显示 user1 刚发的消息终极验证查 MySQLmessages表is_read字段应为0未读证明离线逻辑生效我现在每次重构消息模块都强制走一遍这三步开日志看 SQL、加 println 看路由、关浏览器验离线。不是为了炫技而是十年前我上线一个客服系统因没做离线兜底客户投诉“发了 5 条消息客服一条没看见”老板直接让我滚去机房拔网线自省。从那以后我每次写通信逻辑都先写验证脚本再写业务代码。希望帮到你。本文还有配套的精品资源点击获取