简介:这是一份基于jsp+servlet+ajax+tomcat+mysql的javaweb一对一网页聊天系统源码,面向学习JavaWeb开发、准备课程设计或毕业设计的开发者。压缩包共包含54个文件,包体大小2.88MB,主要包含java源码、jsp页面、jar依赖库、xml配置文件及项目工程文件等,其中jsp与java文件构成聊天核心逻辑,jar包提供运行依赖,配置文件用于数据库与服务器连接。目前已有347人学习使用。系统完整实现了消息发送与定时刷新的聊天流程:聊天页面利用jsp渲染消息框和输入框,通过js与ajax每隔一秒向TalkServlet和TalkFromServlet发起请求,分别处理发送消息和更新页面消息,配合MySQL持久化数据并附带数据库脚本,让读者可以直观理解异步交互、Servlet处理机制及会话管理,也可作为二次开发的基础框架。虽然未设计UI,但功能逻辑完整,整体结构精简,便于专注于核心代码的学习与改造。
1. javaweb 一对一网页聊天:最容易翻车却最值得复现的入门项目
做 javaweb 一对一网页聊天系统这个题目的人不少,真能跑通、敢拿给别人演示的不多。原因很直接:网上能下载的源码包大多是单机演示,登录取个用户名就算完事,真正聊起来要么消息发不过去,要么刷新页面聊天记录就没了。但这正是复现这套系统的价值所在——它把服务端状态管理、长连接维持、消息路由、并发写入这几块最折磨人的内容压缩到了一套功能里。如果你在准备课设、想给简历加一个能现场演示的项目,或者想在一周内搞明白 HTTP 无状态和 WebSocket 长连接的区别,这套源码值得照着推一遍。
整套系统的复杂度一点都不高:一个登录接口、一个 WebSocket 端点、一张消息表、一个聊天页面,加起来不过十来段核心代码。但越是这样,细节越藏不住。下面按我拆过这套资源的顺序来讲——先把连接模型、在线状态、消息落库这三件地基事定下来,再贴核心代码,最后把五个高频故障的排查过程完整写出来。
2. 动手前先想清楚三件事:连接模型、在线状态与消息落库方案
2.1 连接模型:为什么纯轮询方案撑不过 10 个并发用户
很多同学拿到题目第一反应是:每两秒发一次 Ajax 请求,把新消息捞回来。这个方案在只有一个用户测试的时候没问题,一旦让几个同事同时开着页面,Tomcat 默认的 200 个工作线程很快就被这些固定轮询的请求占满;消息实时性也最多做到两秒,和"聊天"两个字不太匹配。
正确方向是用 WebSocket 把 HTTP 升级成一条长连接,服务端能主动把消息推到浏览器。javaweb 里做这个首选是 javax.websocket 这套标准 API,Tomcat 从 7.0 开始就内置支持,不需要额外引入第三方框架。也就是说,你的项目如果跑在 Tomcat 上,直接写 @ServerEndpoint 注解就能用,不用为了一个聊天功能去引进 Spring 那套 WebSocket 封装,避免把简单的功能复杂化。
参数上需要注意两个值。一个是 WebSocket 会话超时时间,默认是 24 小时没消息就断开,调试阶段建议临时改短,方便看到重连逻辑是否生效;另一个是 Tomcat 对 WebSocket 的缓冲区大小,默认 8192 字节,一条消息超过这个值会被拦截,后面第 5 章会给出具体调整参数。
| 方案 | 实时性 | 浏览器兼容 | 服务端成本 | 适用场景 |
|---|---|---|---|---|
| 短轮询(2s) | 2 秒延迟 | 完美兼容 | Tomcat 线程被反复占用 | 只有演示、无并发要求 |
| 长轮询 | 响应后立即重连 | 兼容,但要处理 pending 请求 | 连接积压时容易串消息 | 临时兼容旧浏览器 |
| WebSocket | 毫秒级 | 现代浏览器全支持 | 一个连接占一个会话,线程不大 | 一对一/群聊的真实场景 |
当时判断轮询方案行不行,主要看一个数字:如果页面要开着超过 5 分钟,轮询产生的请求数就是几百上千,而一套 WebSocket 连接只产生一次握手请求。这个对比足够说明问题。实际写这套系统时,我在 onOpen 里只做了一件事:把当前会话的 Session 对象注册到在线表。所有逻辑都围绕这张在线表展开。
2.2 在线用户管理:userId 与 WebSocket Session 的绑定关系
聊天系统必须知道"哪个用户在线,该往哪条连接推消息"。常见做法是登录成功后,把 userId 和当前用户的 HttpSession 绑定;WebSocket 握手完成后,再把 userId 与一个 WebSocketSession 对象存进一个全局的 ConcurrentHashMap。这个表就是全系统的"通讯录",所有消息投递都以它为准。
这里最容易踩的坑是把 HttpSession 和 WebSocketSession 搞混。前者是 HTTP 协议层的会话对象,后端往 response 里写消息时根本用不到它;后者才真正对应一条能主动推数据的通道。我的做法是,登录接口只负责校验账号、把 userId 放进 HttpSession 或签发一个 token,前端拿着这个凭证去连 WebSocket,握手通过后服务端立即把 userId 和 WebSocketSession 注册到在线表里。
在线表的数据结构我见过两种写法:一种是 ConcurrentHashMap<String, Session>,key 是 userId;另一种是 ConcurrentHashMap<String, CopyOnWriteArrayList >,key 也是 userId,值是一组连接。一对一场景用第一种就够了,第二种是给"同一个人多终端登录"准备的。如果你想保证"后登录的挤掉先登录的",用第一种,put 同一个 key 天然覆盖旧连接。这里我把注册逻辑单独封装成了一个类:
// OnlineUserManager.java public class OnlineUserManager { private static final Map<Integer, Session> ONLINE = new ConcurrentHashMap<>(); public static void register(int userId, Session session) { Session old = ONLINE.get(userId); if (old != null && old.isOpen()) { try { old.close(); } catch (IOException e) { /* 旧连接关闭失败忽略 */ } } ONLINE.put(userId, session); } public static void remove(int userId) { Session s = ONLINE.get(userId); if (s != null && s.isOpen()) { try { s.close(); } catch (IOException e) { /* 忽略 */ } } ONLINE.remove(userId); } public static Session get(int userId) { return ONLINE.get(userId); } }这个方法在 register 的时候先取旧连接,close 掉再替换,避免两个页面同时在线导致消息推错窗口。注意 close 要放在 remove 之前,否则先移除了 key,旧连接即使 close 失败也不会影响新连接。另外,这个类的所有方法都必须是 static,因为 WebSocket 端点为每个连接创建实例,只有 static 容器才能让所有实例共享这份在线表。
提示:删除时不能只 remove(key),还要先判断 session 是否仍然 open,然后调用 session.close(),否则服务端会有大量半开连接残留,日志里全是 Connection reset 异常。
2.3 消息落库:聊天记录表按这个结构设计,查询和写入都不别扭
网页聊天要做到"刷新不丢消息",必须有一个消息表。字段设计我复盘过几次,够用且不冗余的结构是下面这张,SQL 建表语句可以直接抄:
CREATE TABLE chat_message ( id BIGINT AUTO_INCREMENT PRIMARY KEY, from_user VARCHAR(32) NOT NULL COMMENT '发送方 userId', to_user VARCHAR(32) NOT NULL COMMENT '接收方 userId', content VARCHAR(1000) NOT NULL COMMENT '消息内容', send_time DATETIME NOT NULL, is_read TINYINT DEFAULT 0 COMMENT '0 未读,1 已读', KEY idx_from_to_time (from_user, to_user, send_time) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;这条建表语句里三个设计点值得说。第一,content 用 VARCHAR(1000) 而不是 TEXT,普通聊天足够,还能让行大小更可控;第二,联合索引 idx_from_to_time 放在 from_user、to_user、send_time 三个字段上,历史记录查询走这个索引,不会全表扫描;第三,is_read 用 TINYINT 不用 BOOLEAN,MySQL 的 BOOLEAN 本质也是 TINYINT,但显式写 TINYINT 更直白。
写入时机这里有个常见分歧:是先落库再推送,还是先推送再落库?我一般选择先落库、再推送。原因很简单,推送失败时消息还在库里,用户刷新页面后还能看到,不至于把消息弄丢;而先推后存如果落库失败,用户明明看到消息了,刷新后却消失,体验上就是"凭空吞消息",很难排查。由于聊天写入量并不大,一次 insert 的耗时对推送延迟的影响可以忽略。
查询历史记录时,SQL 里要尤其注意"和谁聊"的判断不能只写 from_user = 某个值,否则会导致两人看到各自的单向记录。正确写法是用 (from_user='A' AND to_user='B') OR (from_user='B' AND to_user='A'),再按 send_time 升序排列。这一步很多人漏掉第二个 OR 条件,查出来的聊天记录只有自己发出去的,接收方内容全丢,后面排查半天才发现是查询条件少了一半。
3. 核心代码落地:登录鉴权、消息路由与前端断线重连
3.1 登录接口与 token 鉴权:只做两步,别把 WebSocket 握手复杂化
登录这块不用做得太重,账号密码校验通过后,给前端返回一个 token,同时把这个 token 以 userId_token 的形式放入在线状态表,作用是让 WebSocket 握手时可以不携带任何 Cookie 就能完成身份确认。常见的登录 Servlet 核心逻辑如下:
// LoginServlet.java protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { String username = req.getParameter("username"); String password = req.getParameter("password"); // userMapper.validate(...) 返回 userId,这里省略具体 DB 操作 int userId = userMapper.validate(username, password); if (userId > 0) { String token = UUID.randomUUID().toString().replace("-", ""); // 把 token 与 userId 的映射存进一个进程内的 Map,生产环境可换成 Redis TokenStore.bind(token, userId); resp.setContentType("application/json;charset=utf-8"); resp.getWriter().write("{\"token\":\"" + token + "\",\"userId\":" + userId + "}"); } else { resp.setStatus(401); resp.getWriter().write("{\"error\":\"用户名或密码错误\"}"); } }这段代码里 TokenStore 是一个自己写的静态 ConcurrentHashMap 封装,key 是 token,value 是 userId。它解决的问题是:WebSocket 握手时拿不到 HttpSession,前端在 URL 后面拼一个 token 参数,服务端在握手请求里查一次 TokenStore 就能确认身份,避免把密码或 userId 明文放在 URL 里。token 失效时间建议设为 30 分钟,聊天气氛下用户不会频繁刷新,过期后重新登录一次即可。
前端拿到 token 后,把它拼到 WebSocket 地址上,例如 ws://localhost:8080/chat/ws?token=xxx。为什么不用 Cookie?因为 WebSocket 握手时的 Cookie 携带在浏览器环境里通常没问题,但如果你做的是把页面嵌入到别的系统里(iframe 环境),Cookie 的 SameSite 策略会突然让握手失败,这类跨域问题排查起来非常耗时。用 URL 参数虽然多暴露一点信息,但对本地项目、课程设计、内部演示来说,是稳定性和实现成本权衡下最合适的选择。
3.2 服务端 WebSocket 端点:接收消息并转发到目标连接
这是整个系统最关键的一段代码。用 @ServerEndpoint 注解注册端点,在 onOpen 里注册在线连接,在 onMessage 里解析 JSON、判断接收人、从在线表里找到目标 Session 并推送。核心转发逻辑如下:
// ChatServer.java @ServerEndpoint("/chat/ws") public class ChatServer { private int currentUserId; @OnOpen public void onOpen(Session session) { String token = session.getRequestParameterMap().get("token").get(0); currentUserId = TokenStore.getUserId(token); OnlineUserManager.register(currentUserId, session); } @OnMessage public void onMessage(String json, Session session) throws IOException { JsonObject msg = JsonParser.parseString(json).getAsJsonObject(); int fromUserId = currentUserId; int toUserId = msg.get("toUserId").getAsInt(); String content = msg.get("content").getAsString(); // 1. 先落库,保证消息不丢 MessageDao.insert(new Message(fromUserId, toUserId, content, new Date())); // 2. 再尝试将消息推送给对方 Session target = OnlineUserManager.get(toUserId); if (target != null && target.isOpen()) { String payload = "{\"fromUserId\":" + fromUserId + ",\"content\":\"" + content + "\",\"time\":\"" + new SimpleDateFormat("HH:mm:ss").format(new Date()) + "\"}"; target.getBasicRemote().sendText(payload); } // 对方不在线则只靠 is_read=0 标记,等对方上线后再按查询捞历史记录 } @OnClose public void onClose(Session session) { OnlineUserManager.remove(currentUserId); } @OnError public void onError(Session session, Throwable error) { // 只记录异常日志,不要把堆栈打到客户端 error.printStackTrace(); } }这段逻辑里有两个容易出问题的地方。第一,OnlineUserManager 里的 Map 必须是 static,每个 WebSocket 连接对应一个端点实例,不同连接之间共享这一份在线表全靠 static 修饰;漏掉 static 会导致 A 用户发的消息找不到 B 的连接,表现为"对方明明在线却收不到消息"。
第二,target.isOpen() 判断不能省,否则目标连接异常关闭时,调 sendText 会抛 IOException,虽然 onError 会接到异常,但当前这条消息就断在中间了。第三,每次找到 target 之后直接用 sendText 同步发送,对一对一场景完全够用;如果以后做群聊,建议给每个 Session 配一个发送队列,用异步批量发送,避免前一条消息没发完阻塞后一条。
这里还没有处理"消息回执"。对方如果在线,推送成功这条消息就闭环了;对方不在线,消息落库里标记为未读,等他下次上线拉取历史记录时自然能看到。一对一场景不需要复杂的 ACK 机制,有 is_read 字段就能解释清楚所有状态。
3.3 前端页面:连接建立、发送与断线重连的完整写法
前端页面用一个独立的 chat.html 承载。页面打开时建立 WebSocket 连接,输入框按回车或点击发送按钮提交消息,同时监听 onclose 事件做断线重连。页面初始化和重连代码可以这样写:
let ws = null; let reconnectCount = 0; const myUserId = Number(sessionStorage.getItem("userId")); const token = sessionStorage.getItem("token"); function connect() { const url = `ws://${location.host}/chat/ws?token=${token}`; ws = new WebSocket(url); ws.onopen = function() { reconnectCount = 0; fetchHistory(); }; ws.onmessage = function(event) { const data = JSON.parse(event.data); appendMessage(data.fromUserId, data.content, data.time); }; ws.onclose = function() { reconnectCount++; if (reconnectCount <= 5) { setTimeout(connect, 3000); } else { alert("连接已断开,请刷新页面重新进入"); } }; } function sendMessage() { const content = document.getElementById("input").value.trim(); if (!content) return; ws.send(JSON.stringify({ toUserId: Number(document.getElementById("peerId").value), content: content })); document.getElementById("input").value = ""; }这段 JS 里有两个细节值得说明:一是所有状态都通过 sessionStorage 携带,页面刷新后 userId 和 token 不丢,可以直接重建连接,不用重新登录;二是 onclose 里做了 3 秒定时重连,但用一个 reconnectCount 变量限制了次数,超过 5 次就提示用户手动刷新,避免定时器无限叠加把服务器打满。
fetchHistory 对应后端一个普通 Servlet:读取 (from=userId AND to=peerId) OR (from=peerId AND to=userId) 的聊天记录,按时间升序返回,前端直接在消息区按顺序渲染。这一步做完,前面第 2 章说的"刷新不丢消息"就闭环了。渲染时注意转义 content 里的 HTML 标签,否则对方发一段"