简介:这是一套基于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人学习下载,适合在Tomcat+MySQL本地环境中快速运行调试。读者可直接获得完整可运行的前后端协同逻辑:JSP负责界面渲染与参数传递,JavaScript+Ajax实现每秒轮询消息更新与异步发送,两个Servlet(TalkServlet与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 WebApp:
chat-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 Server:
Run → Edit Configurations → + → Tomcat Server → Local Application server选你解压的 Tomcat 根目录Deployment标签页 →+→Artifact→ 选chat-system:war exploded(不是 war,是 exploded,便于热更新)Application context填/chat(即访问路径为http://localhost:8080/chat)VM options加-Dfile.encoding=UTF-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 必须包含?useUnicode=true&characterEncoding=utf8mb4&serverTimezone=GMT%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 存活连接,key=userId,value=Session private static final Map<Integer, 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_read=0 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 前端 JavaScript:WebSocket 连接、心跳保活、消息渲染三件套
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()返回的是ThreadLocal<Connection>,保证同一线程内 DAO 多次调用复用同一连接,从而支持事务。若此处用new Connection(),每次getConnection()都是新连接,commit()对其他 DAO 操作无效。这是 JavaWeb 原生事务管理最容易翻车的点。
4.2 历史消息查询:分页 SQL 与LIMIT offset, size的坑
MessageDAO.getChatHistory(int userId1, int userId2, int page, int pageSize)方法生成如下 SQL:
SELECT 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 = 0
MessageService.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 != ?)"; // 更稳妥:前端传入对话对方 ID,SQL 限定为 WHERE receiver_id = ? AND sender_id = ?避坑:已读标记必须精确到“某人发给我的某条消息”,而非“我收到的所有未读消息”。否则用户 A 和 B 聊天,C 登录后执行
markAsRead(1),会把 A 和 B 的全部未读都标掉。本项目实际采用前端传conversationWithUserId,SQL 为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),或未声明<packaging>war</packaging>,导致target/chat.war未正确生成 exploded 目录结构;或webapp/WEB-INF/web.xml中servlet-mapping的url-pattern与实际访问路径不匹配(如写成/login但访问/chat/login)。
解决:检查pom.xml是否含<packaging>war</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.jar(Tomcat 8.5 默认自带,但若手动删过 jar 会丢失);或前端ws.send()发送的不是字符串(如直接 send object),WebSocket 协议只接受String或ByteBuffer。
解决:确认 Tomcatlib/下存在tomcat-websocket.jar;前端send()前必须JSON.stringify(obj);后端@OnMessage方法参数类型必须为String(不是Object或byte[])。
5.3 现象:中文消息存入 MySQL 后显示??,但数据库字符集已设为utf8mb4
原因:JDBC URL 缺少characterEncoding=utf8mb4参数,或db.properties中jdbc.url的&被 XML/Properties 解析为实体字符(如&),导致参数未生效。
解决:db.properties中写jdbc.url=jdbc:mysql://localhost:3306/chat_db?useUnicode=true&characterEncoding=utf8mb4&serverTimezone=GMT%2B8;不要用&实体;在JdbcUtils.getConnection()中打印conn.getMetaData().getURL()确认 URL 是否含characterEncoding。
5.4 现象:用户 A 发消息给 B,B 刷新页面后看不到新消息
原因:index.jsp中window.onload事件未触发历史消息加载,或getChatHistory()AJAX 请求未在 WebSocket 连接建立后执行,导致页面渲染时历史为空。
解决:在ws.onopen回调中调用loadHistory(),而非window.onload;loadHistory()函数内先清空聊天窗口 DOM,再 append 新消息,避免重复渲染。
5.5 现象:Tomcat 控制台报java.lang.NoClassDefFoundError: javax/websocket/ServerEndpoint
原因:项目pom.xml未引入javax.websocket-api依赖,或引入了jakarta.websocket-api(Tomcat 10+ 包名),与 Tomcat 8.5 冲突。
解决:pom.xml中添加:
<dependency> <groupId>javax.websocket</groupId> <artifactId>javax.websocket-api</artifactId> <version>1.1</version> <scope>provided</scope> <!-- Tomcat 提供,不打包 --> </dependency>注意:<scope>provided</scope>表示该 jar 由 Tomcat 提供,编译时需要,运行时不打入 WAR,否则会冲突。
6. 进阶技巧:如何验证消息“真·可靠送达”?用数据库事务日志 + WebSocket ACK 机制
一个合格的聊天系统,不能只靠“前端显示发送成功”就认为消息到了。我给自己加了一条硬规则:每条消息必须有服务端落库成功 + WebSocket 推送成功(或离线标记成功)的双重确认。本项目虽未内置 ACK,但你可以用三步法低成本验证:
6.1 步骤一:开启 MySQL 通用查询日志,抓取真实 SQL 执行流
在my.cnf(Windows 是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 刚发的消息 - 终极验证:查 MySQL
messages表,is_read字段应为0(未读),证明离线逻辑生效
我现在每次重构消息模块,都强制走一遍这三步:开日志看 SQL、加 println 看路由、关浏览器验离线。不是为了炫技,而是十年前我上线一个客服系统,因没做离线兜底,客户投诉“发了 5 条消息,客服一条没看见”,老板直接让我滚去机房拔网线自省。从那以后我每次写通信逻辑,都先写验证脚本,再写业务代码。希望帮到你。
本文还有配套的精品资源,点击获取