简介:这份资源是一套基于JSP+JavaScript+Ajax+Servlet+Tomcat+MySQL实现的一对一网页聊天系统源码,面向正在学习JavaWeb、需要课程设计或练手项目的学生与初级开发者,重点解决前后端消息收发与定时刷新这一核心交互问题。压缩包共54个文件,约2.88MB,包含12个Java类与12个class文件承载Servlet与业务逻辑,8个JSP页面负责登录、注册、好友列表与聊天界面,另有7个jar依赖、4个xml配置及js脚本等,结构完整可直接导入Eclipse运行。已有348人学习下载。项目将聊天拆分为发送消息与轮询拉取消息两条Servlet链路,前端每秒请求一次获取更新,配合c3p0连接池与sql脚本可快速搭建数据库环境,适合借此理解Ajax轮询机制、Servlet请求分发与JSP参数传递,并在此基础上自行完善UI与扩展功能。
1. 从一次“消息发出去对方没收到”说起
去年帮一个做职业培训的朋友搭内部答疑工具,需求很朴素:两个学员和一位助教之间能私聊,不要群、不要花哨的社交功能,浏览器打开就能用。我第一反应是找现成的 IM 服务,结果对接成本比预期高得多,最后干脆用 JavaWeb 那套老底子自己写了一个一对一网页聊天系统。它做的事情很聚焦:用户登录后看到在线联系人列表,点开某个人就能建立一条独立的会话通道,消息实时到达对方浏览器,历史记录落库可查。技术栈是典型的 Servlet + JSP + JDBC + 前端原生 JavaScript,会话层用 WebSocket 或长轮询都能落地,数据库存用户、会话、消息三张核心表。适合谁?适合正在学 JavaWeb 想找一个“能跑通、能改、逻辑闭环”的练手项目的人,也适合需要给内部系统加一个轻量私聊模块的开发者。它不追求高并发架构,但把“一对一”这条链路讲透了,你拿去改群聊、改客服坐席,都有清晰的切入点。
2. 项目骨架与技术选型:为什么是 Servlet + WebSocket 这套组合
2.1 整体分层与请求走向
这个项目的结构不复杂,但分层是清楚的,照着目录就能判断一个改动该落在哪一层。常见做法是标准的 Maven Web 工程,src/main/java下按包划分,src/main/webapp放页面和静态资源。下面是我一般会采用的目录形态,你可以对照自己拿到的源码看是否一致:
chat-one-to-one/ ├── pom.xml ├── src/main/java/com/example/chat/ │ ├── controller/ # Servlet:登录、联系人、历史消息接口 │ ├── websocket/ # 会话端点,负责消息收发 │ ├── service/ # 业务逻辑:会话建立、消息落库 │ ├── dao/ # JDBC 数据访问 │ ├── model/ # User / Conversation / Message 实体 │ └── util/ # 连接池、JSON 工具、会话管理 ├── src/main/resources/ │ ├── db.properties # 数据库连接配置 │ └── schema.sql # 建表脚本 └── src/main/webapp/ ├── login.jsp ├── chat.jsp # 主聊天界面 ├── js/chat.js # WebSocket 客户端逻辑 └── WEB-INF/web.xml一次完整的消息链路是这样的:浏览器在chat.jsp里通过chat.js建立 WebSocket 连接,握手阶段带上登录时写入 HttpSession 的用户标识;服务端的端点收到连接后把userId -> session注册进一个全局的在线会话表;发送方提交消息时,端点先做参数校验,再交给 Service 落库,然后从在线表里找到接收方的会话把消息推过去;接收方浏览器收到帧后渲染气泡。HTTP 那部分 Servlet 只负责登录鉴权和历史消息分页查询,实时通道完全走 WebSocket,职责切得很干净。
为什么不用纯轮询?一对一场景下消息频率不高,轮询也能用,但延迟和无效请求都难看。WebSocket 在 Tomcat 7 以后就是容器原生支持,javax.websocket的注解式端点写起来比想象中轻,不需要额外引入 Netty 这类重框架。这就是选型的核心逻辑:用容器能力解决实时性,用 Servlet 解决鉴权和查询,两边各干各的。
2.2 三张核心表与实体映射
数据模型是一对一聊天的地基,表设计错了后面全是补丁。这个项目最少需要三张表:用户表、会话表、消息表。会话表的存在很关键,它把“谁和谁”抽象成一个稳定的conversation_id,消息只挂会话不挂人,后续要加已读状态、会话置顶都好扩展。
-- 用户表:登录与在线状态的基础 CREATE TABLE t_user ( id BIGINT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(50) NOT NULL UNIQUE, password VARCHAR(128) NOT NULL, -- 存加盐哈希,别存明文 nickname VARCHAR(50), avatar VARCHAR(255), created_at DATETIME DEFAULT CURRENT_TIMESTAMP ); -- 会话表:一对一关系的唯一锚点 CREATE TABLE t_conversation ( id BIGINT PRIMARY KEY AUTO_INCREMENT, user_a BIGINT NOT NULL, -- 约定 user_a < user_b,避免重复建会话 user_b BIGINT NOT NULL, last_msg_at DATETIME, UNIQUE KEY uk_pair (user_a, user_b) ); -- 消息表:所有聊天内容落这里 CREATE TABLE t_message ( id BIGINT PRIMARY KEY AUTO_INCREMENT, conversation_id BIGINT NOT NULL, sender_id BIGINT NOT NULL, content TEXT NOT NULL, msg_type TINYINT DEFAULT 1, -- 1文本 2图片,预留扩展 created_at DATETIME DEFAULT CURRENT_TIMESTAMP, KEY idx_conv_time (conversation_id, created_at) );参数说明几个容易忽略的点。uk_pair唯一索引配合“小 id 在前”的写入约定,能保证同一对用户永远只有一个会话,避免并发下建出两条会话导致消息分裂。idx_conv_time是给历史消息分页用的,按会话加时间排序走这个索引,翻页不会全表扫。msg_type现在只用文本,但字段先留着,后面加图片消息不用改表结构。密码字段长度给到 128,是因为加盐哈希后的字符串比明文长得多,用 MD5 那种 32 位反而是血泪教训,别省这个空间。
2.3 在线会话表:内存里的“通讯录”
WebSocket 端点是无状态的,容器为每个连接创建一个端点实例,所以“谁在线、对应哪个会话”必须放在一个共享的地方。这个项目里最典型的是一个线程安全的静态 Map,键是用户 id,值是Session对象。
public final class OnlineSessionRegistry { // ConcurrentHashMap 保证多连接并发注册时的线程安全 private static final Map<Long, Session> ONLINE = new ConcurrentHashMap<>(); public static void register(Long userId, Session session) { ONLINE.put(userId, session); } public static void remove(Long userId) { ONLINE.remove(userId); } public static Session get(Long userId) { return ONLINE.get(userId); } public static Set<Long> onlineUserIds() { return ONLINE.keySet(); } }这段代码短,但它是整个实时链路的中枢。ConcurrentHashMap而不是HashMap,是因为多个 WebSocket 线程会同时注册和移除,普通 Map 在高并发下会出问题。注册时机在@OnOpen里,移除在@OnClose和异常回调里都要做,只写一处的话,客户端异常断线会留下“幽灵在线”记录,联系人列表里显示在线但消息发不过去,这是这个项目最常见的翻车点之一。另外要注意,单机内存表在多实例部署下不成立,如果后面要横向扩展,得换成 Redis 的发布订阅,这一点在改造章节会展开。
3. 核心链路拆解:一条消息从发送到渲染的完整实现
3.1 服务端端点:握手、注册与消息分发
WebSocket 端点承担了实时通道的全部职责,写法上用注解式端点最省事。下面这段是核心骨架,包含连接建立、消息接收、连接关闭三个回调:
@ServerEndpoint("/ws/chat/{userId}") public class ChatEndpoint { @OnOpen public void onOpen(Session session, @PathParam("userId") Long userId) { // 把当前连接登记到在线表,供其他用户定向推送 OnlineSessionRegistry.register(userId, session); session.getUserProperties().put("userId", userId); } @OnMessage public void onMessage(String payload, Session session) { Long senderId = (Long) session.getUserProperties().get("userId"); // payload 是 JSON:{"to":2,"content":"在吗"} ChatMessage msg = JsonUtil.parse(payload, ChatMessage.class); if (msg.getTo() == null || msg.getContent() == null || msg.getContent().trim().isEmpty()) { return; // 空消息直接丢弃,别让它进库 } // 1. 找到或创建会话,2. 落库,3. 推给接收方 Long convId = ConversationService.getOrCreate(senderId, msg.getTo()); Message saved = MessageService.save(convId, senderId, msg.getContent()); Session target = OnlineSessionRegistry.get(msg.getTo()); if (target != null && target.isOpen()) { target.getAsyncRemote().sendText(JsonUtil.toJson(saved)); } // 发送方自己也回显一份,保证本地气泡和服务端 id 一致 session.getAsyncRemote().sendText(JsonUtil.toJson(saved)); } @OnClose public void onClose(Session session) { Long userId = (Long) session.getUserProperties().get("userId"); OnlineSessionRegistry.remove(userId); } @OnError public void onError(Session session, Throwable t) { // 异常也要清理,否则在线表会残留失效连接 Long userId = (Long) session.getUserProperties().get("userId"); OnlineSessionRegistry.remove(userId); } }逐段说逻辑。@OnOpen里的@PathParam("userId")让连接地址自带用户标识,省去握手后再发一条认证消息的麻烦,但生产环境不能只信路径参数,常见做法是在握手前用ServerEndpointConfig.Configurator校验 HttpSession,或者用一次性 token 换连接,防止有人伪造 id 连上来偷消息。@OnMessage里先校验再落库,空内容和超长内容都要拦,TEXT字段虽然能存很大,但一条几兆的消息推给浏览器会卡死页面。getAsyncRemote()而不是getBasicRemote(),是因为同步发送在推给多个目标时会阻塞当前线程,异步发送更适合这种“一发多收”的场景。@OnError里重复清理在线表,是踩过坑之后的习惯:客户端网络抖动断开时,@OnClose不一定被调用,异常回调是最后一道保险。
3.2 会话与消息的 Service 层:幂等建会话与分页查询
Service 层要做两件容易写错的事:并发下保证会话唯一,以及历史消息按时间倒序分页。先看建会话:
public class ConversationService { public static Long getOrCreate(Long u1, Long u2) { // 约定小 id 在前,保证同一对用户只对应一条记录 long a = Math.min(u1, u2); long b = Math.max(u1, u2); Long id = ConversationDao.findByPair(a, b); if (id != null) { return id; } try { return ConversationDao.insert(a, b); } catch (DuplicateKeyException e) { // 并发下另一个线程先插入了,回查一次即可 return ConversationDao.findByPair(a, b); } } }这里的核心是“先查后插 + 唯一索引兜底 + 捕获重复键回查”。只写先查后插,两个用户同时发第一条消息时会各插一条,消息就分裂到两个会话里,历史记录看起来像丢了一半。只靠唯一索引不捕获异常,第二个线程直接 500。两者结合才是幂等的正确姿势。Math.min/max的排序约定必须和表上的uk_pair索引方向一致,否则唯一约束形同虚设。
历史消息查询走分页,别一次性把整个会话拉出来:
public static List<Message> page(Long convId, Long beforeId, int size) { // 用 id 游标而不是 offset,翻页越深也不会变慢 String sql = "SELECT id, conversation_id, sender_id, content, created_at " + "FROM t_message WHERE conversation_id = ? " + (beforeId != null ? "AND id < ? " : "") + "ORDER BY id DESC LIMIT ?"; // ... JDBC 参数绑定,返回后由前端反转顺序渲染 }参数说明:beforeId是上一页最旧那条消息的 id,首次加载传 null;size一般给 20 到 30,太大首屏渲染慢。用 id 游标而不是LIMIT offset, size,是因为 offset 在数据量大时会扫描并丢弃前面所有行,聊天记录越聊越多,这个差异会越来越明显。返回结果按 id 倒序,前端渲染前反转成时间正序,这样“加载更多”是往顶部插,符合聊天界面的直觉。
3.3 前端 chat.js:连接、重连与消息渲染
前端这块没有框架,原生 JavaScript 就够,关键是连接管理和重连策略。很多人第一次写 WebSocket 只写一个new WebSocket(),断线之后页面就静默失效了,用户以为对方没回,其实是连接早断了。
let ws = null; let retry = 0; function connect() { const userId = document.body.dataset.userId; ws = new WebSocket(`ws://${location.host}/ws/chat/${userId}`); ws.onopen = () => { retry = 0; // 连上就重置重试计数 setStatus('在线'); }; ws.onmessage = (evt) => { const msg = JSON.parse(evt.data); appendBubble(msg); // 渲染气泡,自己发的靠右 }; ws.onclose = () => { setStatus('已断开,重连中'); // 指数退避,避免服务端刚重启就被大量重连打满 const delay = Math.min(1000 * Math.pow(2, retry++), 30000); setTimeout(connect, delay); }; } function send(to, content) { if (!ws || ws.readyState !== WebSocket.OPEN) { alert('连接未就绪,请稍候'); return; } ws.send(JSON.stringify({ to, content })); } connect();逻辑说明:onopen里重置retry,否则一次断线后重试计数只增不减,退避时间会一路涨到 30 秒上限。onclose里的指数退避是必须的,服务端重启的瞬间所有客户端同时重连,没有退避就是自己打自己。send前检查readyState,是因为用户可能在断线期间点发送,直接ws.send会抛异常。appendBubble里要做 XSS 防护,消息内容用textContent而不是innerHTML,这一点在避坑章节还会提。另外ws://在 HTTPS 页面下会被浏览器拦截,部署到线上要换成wss://,本地开发用ws://没问题。
4. 跑起来:环境准备、建库与启动验证
4.1 环境清单与依赖版本
这个项目对环境的胃口不大,但版本对不上会浪费很多时间。下面是我验证过能跑通的组合,你按这个对齐基本不会卡在启动阶段:
| 组件 | 建议版本 | 说明 |
|---|---|---|
| JDK | 8 或 11 | 注解式 WebSocket 在 8 上就可用 |
| Tomcat | 8.5 / 9 | 必须支持javax.websocket,Tomcat 10 换成了 jakarta 包名,直接跑会报类找不到 |
| MySQL | 5.7 / 8.0 | 8.0 注意驱动包和时区参数 |
| Maven | 3.6+ | 用来拉依赖和打 war |
| 浏览器 | 任意现代浏览器 | 需要支持 WebSocket |
最容易翻车的是 Tomcat 版本。项目里import javax.websocket.*,如果部署到 Tomcat 10,容器提供的是jakarta.websocket,启动时端点注册失败,日志里是一串ClassNotFoundException。要么换 Tomcat 9,要么把源码里的包名整体迁移,二选一,别混着来。MySQL 8.0 还要注意连接串加serverTimezone=Asia/Shanghai,否则时间字段可能差 8 小时,聊天记录的时间戳看起来会很怪。
4.2 建库、改配置、打 war 包
第一步先把库建好,schema.sql直接执行,然后按你的环境改db.properties:
jdbc.url=jdbc:mysql://127.0.0.1:3306/chat_db?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai jdbc.username=root jdbc.password=你的密码 jdbc.driver=com.mysql.cj.jdbc.Driver第二步打包部署。用 Maven 的话一条命令出 war:
# 在项目根目录执行,跳过测试加快打包 mvn clean package -DskipTests # 产物在 target/ 下,复制到 Tomcat 的 webapps 目录 cp target/chat-one-to-one.war $TOMCAT_HOME/webapps/ # 启动 Tomcat $TOMCAT_HOME/bin/startup.sh参数说明:-DskipTests只是加快本地打包,正式构建别加。war 包名决定访问路径,chat-one-to-one.war部署后访问http://localhost:8080/chat-one-to-one/login.jsp。如果你用 IDEA 直接配 Tomcat 跑,注意 Application context 要设成和 war 包名一致,否则页面里的相对路径会 404。数据库连接池如果用的是 Druid 或 HikariCP,db.properties里的池参数按默认值先跑通,别一上来就调最大连接数,本地开发用不到。
4.3 双开验证:两个浏览器窗口的完整走查
跑起来之后怎么确认它真的通了?别只开一个窗口自己发给自己,那样测不出定向推送。正确姿势是用两个不同浏览器(或者一个正常窗口加一个无痕窗口),分别登录两个账号,然后按这个顺序走:
- 两个窗口都登录,观察联系人列表里对方是否显示在线。如果显示离线,先查在线表注册逻辑和
@OnOpen是否被触发。 - A 向 B 发一条消息,B 的页面应当即时出现气泡,A 的页面也应有回显。B 没收到但 A 有回显,说明落库和回显正常,问题在定向推送,重点看
OnlineSessionRegistry.get拿到的 session 是否为 null。 - 刷新 B 的页面,历史消息应当从库里加载出来。刷新后丢失,说明查询接口或分页参数有问题。
- 关掉 B 的窗口,A 再发消息,服务端不应报错,消息仍应落库。这一步验证的是接收方离线时的降级处理。
- 重启 Tomcat,两个窗口都应自动重连,状态从“已断开”回到“在线”。
这五步走完,基本能覆盖连接、推送、持久化、离线、重连五条主链路。哪一步断了,日志里通常有明确线索,Tomcat 的catalina.out里搜ChatEndpoint就能定位到具体回调。
5. 避坑与排查:五个真实踩过的坑
5.1 消息发出去对方收不到,但自己能看到回显
现象:发送方气泡正常出现,接收方页面毫无反应,服务端日志无异常。原因通常是接收方不在线表里,或者表里存的是失效连接。常见于客户端异常断线后@OnClose没触发,ONLINE里残留了旧的Session,isOpen()返回 false 但代码没判断。解决:推送前必须判target != null && target.isOpen(),同时在@OnError里也执行移除;排查时可以在onMessage里打印在线表的 key 集合,一眼就能看出接收方在不在。
5.2 部署到 Tomcat 10 启动即报类找不到
现象:war 包放进去,启动日志里一堆java.lang.ClassNotFoundException: javax.websocket.server.ServerEndpoint。原因:Tomcat 10 起把 Java EE 迁移到 Jakarta EE,包名从javax.*变成jakarta.*,源码里的注解和容器提供的 API 对不上。解决:最省事是换回 Tomcat 9;如果必须用 10,就把所有javax.websocket、javax.servlet的 import 改成jakarta对应包,并同步调整pom.xml里的依赖坐标,改完重新打包。
5.3 聊天记录时间差 8 小时
现象:数据库里created_at看着正常,页面上显示的时间却比实际早或晚 8 小时。原因:MySQL 8.0 驱动默认按 UTC 解析时间,而服务器和数据库都在东八区,连接串没指定时区。解决:JDBC URL 加serverTimezone=Asia/Shanghai,同时确认 MySQL 服务端的time_zone设置;如果历史数据已经写歪了,别急着批量改,先确认是展示层转换问题还是存储层问题,展示层用前端本地时区格式化往往更稳。
5.4 同一条消息在会话里出现两次
现象:快速连点发送,或者网络抖动后重发,接收方看到两条一样的内容。原因:前端没有防重复提交,服务端也没有幂等键,两次请求各自落库。解决:前端发送后短暂禁用按钮;更彻底的做法是客户端为每条消息生成一个client_msg_id,消息表加唯一索引,服务端插入时捕获重复键直接返回已有记录。这个改动不大,但对体验提升明显,尤其是移动网络不稳的场景。
5.5 消息内容里的 HTML 被当成标签执行
现象:有人发了一段带<script>的文本,接收方页面弹窗或布局错乱。原因:前端渲染用了innerHTML直接拼接消息内容。解决:统一用textContent赋值,需要富文本时走白名单过滤,别自己写正则去“清洗”,正则漏网的概率比想象中高。服务端落库前也可以做一次长度和字符校验,但根本防线在前端渲染方式。
6. 二次开发:从单机私聊到可扩展的会话服务
拿到这个项目之后,最值得先动的地方不是加功能,而是把“在线表”这个单机内存结构抽象掉。现在OnlineSessionRegistry是静态 Map,单实例跑没问题,一旦部署两个节点,A 连在节点一、B 连在节点二,消息就推不过去。我的改法是引入一个MessageRouter接口,单机实现走内存表,集群实现走 Redis 发布订阅:每个节点订阅自己的频道,发送方把消息投到接收方所在节点的频道,节点收到后再从本地在线表找 session 推送。接口先定好,实现可替换,这样本地开发不用起 Redis,线上再切集群实现。
public interface MessageRouter { // 把消息投递给目标用户,具体走内存还是走消息中间件由实现决定 void route(Long targetUserId, Message msg); } // 单机实现:直接查本地在线表 public class LocalRouter implements MessageRouter { @Override public void route(Long targetUserId, Message msg) { Session s = OnlineSessionRegistry.get(targetUserId); if (s != null && s.isOpen()) { s.getAsyncRemote().sendText(JsonUtil.toJson(msg)); } } }第二个改造点是消息可靠性的验证方法。现在消息落库和推送是顺序执行的,推送失败消息仍在库里,接收方下次拉历史能看到,这算一种兜底。但如果你要做“已送达”状态,就得在客户端收到消息后回一个 ack,服务端更新消息状态。验证这套逻辑别靠肉眼,写一个简单的压测脚本,模拟两个客户端互发一千条,统计落库条数、推送条数、ack 条数三者是否一致,不一致就说明某条链路有丢消息的窗口。这个习惯救过我很多次,尤其是改完推送逻辑自以为没问题的时候。
第三个技巧是关于会话列表的。现在联系人列表是查全量用户再标在线状态,用户一多就慢。改成“最近会话优先”更符合真实使用:按t_conversation.last_msg_at倒序取前 20 个,再补上在线但没聊过的人。last_msg_at在每次发消息时更新,这个字段现在表里就有,只是很多人没把它用起来。改完之后首屏查询从全表扫变成走索引,体验和性能一起改善。
最后说个我自己的教训。早些年改这类实时项目,我总习惯先动前端看效果,结果后端会话状态一乱,前端怎么调都是玄学。后来我定了个规矩:任何涉及连接和推送的改动,先在服务端日志里把“谁连上、谁断开、消息投给了谁”打全,用两个窗口走一遍第 4 章那五步验证,确认链路干净了再碰界面。从那以后我每次改 WebSocket 相关代码,都强制先把在线表的增删打点跑一遍,再谈功能。希望这个项目和你手上的改造,也能少走点弯路,帮到你。
本文还有配套的精品资源,点击获取