简介:这是一份基于JavaWeb技术栈的一对一网页聊天系统源码,面向正在学习JSP、Servlet、Ajax与MySQL整合开发的初学者或课程设计人员。系统核心由聊天页面、TalkServlet与TalkFromServlet两个后端处理类构成,前端通过Ajax每秒轮询Servlet获取最新消息,实现双向实时对话;项目运行于Tomcat,数据库使用MySQL,虽未设计UI但功能逻辑完整,适合作为二次开发或毕设基础。资源包共54个文件,压缩包约2.88MB,包含12个Java源文件、12个class编译文件、8个JSP页面、7个jar依赖库、4个XML配置及项目配置文件等,目录结构清晰,便于直接导入Eclipse等IDE运行调试。目前已有347人学习浏览,对想快速理解Ajax轮询聊天机制、Servlet请求处理与JSP页面交互的读者很有参考价值。代码注释与分层相对直白,可帮助读者梳理从登录、好友列表到聊天消息同步的完整链路。
1. 先说结论:这不是一个“发消息”的项目,而是一个“会话管理”的项目
做一对一网页聊天,真正难的地方不是把一句话从 A 送到 B,而是怎么管住“谁在线、谁跟谁聊、聊到一半断线了怎么办、历史记录去哪找”。这个 javaweb 一对一网页聊天系统,恰好把这几件事都用一套非常正统的 JavaWeb 技术栈做了出来:Servlet 负责登录鉴权,WebSocket 负责实时消息推送,MySQL 负责聊天记录落地,前端用 JSP 加少量 JavaScript 搞定。整套代码不算大,但从前端页面到后端数据库的链路是完整的,特别适合两类人:一类是刚学完 JavaWeb 想做课设、想看看 Servlet + WebSocket 怎么配合的在校生,另一类是工作后用惯了 Spring Boot 全家桶、想回头补一补原生 Servlet 时代长连接怎么写的一线开发。
这项目的另一个价值在于“可改”。因为结构简单,没有引入 Spring、MyBatis 这类重框架,所有代码都是裸 Servlet + JDBC,你能清楚看到每个请求进去之后干了什么。想加好友列表、改消息样式、加离线推送,改动范围都很可控。下面我从技术选型开始,把这套系统的骨架、核心代码、数据库设计和部署过程完整拆开。
2. 技术选型与整体架构:为什么是 Servlet + WebSocket,而不是 Spring Boot 全家桶
2.1 技术栈选型的底层逻辑
先说选型。这套系统用的是最“教科书”的 JavaWeb 组合:Servlet 3.1 及以上版本支持 WebSocket 端点,JSP 负责页面渲染,JDBC 直连 MySQL,前端原生 JavaScript 通过 WebSocket API 跟服务端建立长连接。很多第一次接触 WebSocket 的人会疑惑:为什么不直接用 Spring Boot 的 STOMP?因为这个项目的第一目标是让你看清底层机制,而不是让你学会调框架 API。Spring Boot 把握手、会话管理、消息路由都封装掉了,你写完根本不知道 WebSocket 协议本身长什么样。而这里用原生@ServerEndpoint注解,整个生命周期一目了然。
架构上做了前后端轻度分离的尝试:页面由 JSP 输出,但页面里的聊天窗口不是表单提交刷新,而是用 JavaScript 创建WebSocket对象连到单独的一个访问路径。也就是说,HTTP 和 WebSocket 各走各的端口,各管各的事。登录、查询好友、拉取历史记录走 HTTP 接口,实时收发消息走 WebSocket 长连接。这样设计有个实际好处:聊天过程中 HTTP 请求可以照常用,不会因为长连接占用线程资源而拖垮 Tomcat 的普通请求处理。
2.2 目录结构与分层方式
项目的包结构按经典的三层模式划分,我把关键部分列出来,对照着看会更容易理解代码间的关系:
src/main/java ├─ com.chat.entity // 实体类:User、Message ├─ com.chat.dao // 数据访问层:UserDao、MessageDao ├─ com.chat.service // 业务层:登录校验、消息落库、好友查询 ├─ com.chat.servlet // HTTP 入口:LoginServlet、HistoryServlet、UsersServlet ├─ com.chat.websocket // WebSocket 端点:ChatEndpoint、SessionManager └─ com.chat.util // 工具类:DBUtil、JsonUtil src/main/webapp ├─ login.jsp // 登录页 ├─ chat.jsp // 聊天主页 ├─ css/ // 样式文件 └─ js/chat.js // WebSocket 客户端脚本com.chat.websocket.SessionManager是整个系统的关键类,它维护一个在线用户映射表,键是用户名,值是 WebSocketSession对象。没有这个类,服务端就不知道一条消息该推给谁。这块在第三章会详细拆。
2.3 为什么“一对一”比“群聊”更考验设计
群聊的实现反而是容易的:一条消息广播给所有在线连接就行。一对一聊天意味着服务端必须维护“用户 — 连接”的精确映射,还必须处理“对方不在线”的情况。这套系统给出的方案是:消息先落库,再尝试推送给对方。如果对方在线就实时推,如果不在线,等他下次登录时从数据库里把未读历史拉出来。这种“先存后推”的思路,比单纯只做内存转发要稳得多——服务端一重启,聊天记录不会丢。
我最初拆这个项目时以为核心难点在 WebSocket 端点,读完整套代码才发现,真正值钱的是那个MessageDao的落库逻辑和SessionManager的状态管理。WebSocket 端点只是个壳,壳里面的业务逻辑才是决定项目能用在生产环境还是只能交作业的分水岭。
3. 核心模块拆解:一个消息从发送到存入数据库的完整链路
3.1 登录鉴权:用户身份与 WebSocket 会话是怎么绑定的
登录这块用的是标准的 Servlet + Session 机制。LoginServlet接收用户名和密码,查数据库校验,成功就把用户名写进 HttpSession,然后重定向到chat.jsp。这里有个细节值得注意:普通 Servlet 的会话和 WebSocket 的会话是两套独立的会话体系,HTTP 的HttpSession在 WebSocket 握手时并不会自动传递。所以这套系统的做法是——在chat.jsp页面里,先把用户名从HttpSession里拿出来,放进页面上的一个 JavaScript 全局变量,等 WebSocket 连接建立时再通过子协议或查询参数带给服务端。
@WebServlet("/login") public class LoginServlet extends HttpServlet { protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { req.setCharacterEncoding("UTF-8"); String username = req.getParameter("username"); String password = req.getParameter("password"); User user = userService.login(username, password); if (user != null) { req.getSession().setAttribute("loginUser", user.getUsername()); resp.sendRedirect("chat.jsp"); } else { req.setAttribute("errorMsg", "用户名或密码错误"); req.getRequestDispatcher("login.jsp").forward(req, resp); } } }这段逻辑很简单,但有两个参数容易踩坑。第一是req.setCharacterEncoding("UTF-8")必须在读取任何参数之前调用,否则中文用户名直接乱码;第二是登录成功后用sendRedirect而不是forward,因为forward会导致浏览器地址栏停留在/login,用户刷新页面时会重复提交登录请求。相比之下redirect会重新发起一次 GET 请求到chat.jsp,符合常规的登录后跳转习惯。
3.2 WebSocket 端点的生命周期管理:连接建立、消息接收、连接关闭
WebSocket 端点类用的是@ServerEndpoint注解方式,这是 JSR 356 规范的标准写法。关键在于用户连接进来时,要把Session对象注册到全局管理器里;发送消息时,要能根据接收方用户名找到对端的Session。
@ServerEndpoint("/chat/{username}") public class ChatEndpoint { // 端点实例与 Session 绑定 @OnOpen public void onOpen(Session session, @PathParam("username") String username) { // 同一个账号只能有一个在线连接,如果已存在则先关闭旧连接 Session oldSession = SessionManager.getSession(username); if (oldSession != null) { try { oldSession.close(); } catch (IOException ignored) {} } SessionManager.addSession(username, session); session.setMaxIdleTimeout(0); // 0 表示不因空闲超时而断开 } @OnMessage public void onMessage(String message, Session session) { // 将 JSON 字符串解析为 Message 对象 Message msg = JsonUtil.fromJson(message, Message.class); Session toSession = SessionManager.getSession(msg.getToUser()); if (toSession != null && toSession.isOpen()) { // 对方在线:先发送,再落库 sendToUser(toSession, msg); messageDao.save(msg); } else { // 对方不在线:只落库,等上线后拉取历史 messageDao.save(msg); } } @OnClose public void onClose(Session session) { SessionManager.removeSessionBySession(session); } }这段代码是整个系统最核心的部分,有两点值得展开。第一,session.setMaxIdleTimeout(0)这行很关键,默认情况下 WebSocket 连接如果长时间没有数据交互,Tomcat 可能会因为空闲超时把它切断。设为 0 表示不限制空闲时间,否则用户挂着聊天窗口两小时不动,连接就断了,消息发不出去还毫无提示。第二,同一账号强制踢下线的逻辑——通过oldSession.close()把旧连接关掉,让新登录的连接顶上。这个细节对“一对一聊天系统”非常必要,因为你不能允许同一个用户开两个窗口,那样消息会被随机推送到任意一个窗口,造成漏收。
3.3 消息落库与历史记录加载:保证“对方不在线也不丢消息”
消息的存取走的是MessageDao,一张message表存from_user、to_user、content、send_time、is_read五个字段。发送消息时先插库,再推送给对方;拉取历史时,把当前用户和对方之间所有消息按时间排序查出来,再标成已读。
public List<Message> getHistory(String username, String friendName) { String sql = "SELECT * FROM message WHERE (from_user=? AND to_user=?) " + "OR (from_user=? AND to_user=?) ORDER BY send_time ASC"; try (Connection conn = DBUtil.getConnection(); PreparedStatement ps = conn.prepareStatement(sql)) { ps.setString(1, username); ps.setString(2, friendName); ps.setString(3, friendName); ps.setString(4, username); try (ResultSet rs = ps.executeQuery()) { List<Message> list = new ArrayList<>(); while (rs.next()) { Message m = new Message(); m.setId(rs.getInt("id")); m.setFromUser(rs.getString("from_user")); m.setToUser(rs.getString("to_user")); m.setContent(rs.getString("content")); m.setSendTime(rs.getTimestamp("send_time")); m.setRead(rs.getInt("is_read") == 1); list.add(m); } return list; } } catch (SQLException e) { throw new RuntimeException("查询历史消息失败", e); } }这里的 SQL 写了四个占位符,条件是两个方向的组合查询——既要我发给对方的,也要对方发给我的。注意ORDER BY send_time ASC是升序,保证聊天记录按时间正序展示,切到聊天窗口时直接循环渲染就行,不需要前端再排序。is_read字段目前的用途是标记对方是否已读,但源码里并没有做“已读回执”的 UI,所以这个字段主要是为后续扩展预留的——你想做未读红点,直接用SELECT COUNT(*) WHERE to_user=? AND is_read=0就能统计出来。
3.4 WebSocket 客户端脚本:前端是怎么维持长连接的
前端chat.js的核心逻辑不算复杂,但有一个细节处理很到位——断线重连。
// 建立 WebSocket 连接 const ws = new WebSocket("ws://" + location.host + "/chat/" + username); ws.onopen = function() { console.log("连接已建立"); }; ws.onmessage = function(event) { const msg = JSON.parse(event.data); appendMessage(msg.fromUser, msg.content, msg.sendTime); }; ws.onclose = function() { // 服务端关闭连接时,自动重连并恢复状态 setTimeout(function() { location.reload(); }, 3000); }; function sendMessage() { const content = document.getElementById("contentInput").value; const toUser = document.getElementById("friendName").value; ws.send(JSON.stringify({ fromUser: username, toUser: toUser, content: content, sendTime: new Date().toISOString() })); }前端创建WebSocket时用的不是http://协议也不是https://,而是ws://。这段代码里我用location.host动态拼地址,避免了写死 IP 和端口导致换环境就跑不起来的尴尬。服务端onMessage接收到的是 JSON 字符串,所以前端用JSON.stringify序列化后发送。一个容易踩的坑是:WebSocket的send方法只能发送文本或二进制数据,不能直接发送 JavaScript 对象,如果忘了stringify,服务端解析时直接抛JsonParseException。页面上的处理方式是setTimeout(location.reload, 3000),这种做法比较粗暴但有效——反正聊天记录都落库了,刷新后从历史接口拉一次就是。线上系统通常会用心跳机制来区分“网络抖动”和“服务端重启”,但对于这个级别的项目,重载页面已经够用了。
4. 数据库设计与初始化:三张表如何支撑完整的聊天闭环
4.1 表结构设计:用户表、好友关系表、消息表的取舍
这套系统有三张核心表:用户表t_user、好友表t_friend、消息表t_message。其中好友表是很多初学者容易遗忘的——以为两个人只要能搜索到用户名就能聊天,但实际上“一对一聊天系统”必须有一张关系表来约束谁能跟谁聊,否则就变成全网互聊了。
CREATE TABLE t_user ( id INT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(50) NOT NULL UNIQUE, password VARCHAR(50) NOT NULL, nickname VARCHAR(50), create_time DATETIME DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE t_friend ( id INT PRIMARY KEY AUTO_INCREMENT, user_id INT NOT NULL, friend_id INT NOT NULL, remark VARCHAR(50), create_time DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_user_friend (user_id, friend_id) ); CREATE TABLE t_message ( id INT PRIMARY KEY AUTO_INCREMENT, from_user VARCHAR(50) NOT NULL, to_user VARCHAR(50) NOT NULL, content TEXT NOT NULL, send_time DATETIME DEFAULT CURRENT_TIMESTAMP, is_read TINYINT DEFAULT 0 );三个表的分工是这样:t_user只管身份认证,username加了唯一索引,登录时按用户名检索;t_friend存储单向好友关系,UNIQUE KEY uk_user_friend (user_id, friend_id)防止重复添加好友;t_message是聊天记录表,理论上应该拆成from_user_id和to_user_id两个外键关联t_user.id,但这套系统为了简化查询,直接存了用户名字符串。在用户量小的场景下这没什么问题,但如果用户改了用户名,历史消息里的名字就对不上了。我一般会建议保留这个设计——二开时如果想把用户名改成 ID 关联,改动涉及 DAO 层和 WebSocket 的消息实体,工作量不算大。
4.2 初始化数据:测试账号和必要的 UTF-8 设置
初始化脚本里除了建表,还必须加两样东西:一个 UTF-8 的设置,一个测试账号。
SET NAMES utf8mb4; ALTER DATABASE chat_db CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; INSERT INTO t_user (username, password, nickname) VALUES ('alice', '123456', '小明'), ('bob', '123456', '小红');关于utf8mb4多说一句:JavaWeb 老项目里最常见的乱码来源就是数据库字符集跟连接字符串的字符集不一致。数据库是 utf8mb4,但 JDBC 连接串写成了characterEncoding=utf8,这种情况会使得 emoji 表情符号被存成乱码。我用utf8mb4而不是utf8作为默认字符集,就是为了避免聊天时发个表情直接翻车。
4.3 JDBC 连接池与 DBUtil 的常见写法
由于项目没有引入 Spring,数据访问层直接用 JDBC。DBUtil里维护了一个DataSource,用的是 MySQL Connector/J 自带的连接池实现,避免每次操作都创建物理连接。
public class DBUtil { private static final String URL = "jdbc:mysql://localhost:3306/chat_db?useSSL=false&serverTimezone=Asia/Shanghai&characterEncoding=utf8mb4"; private static final String USERNAME = "root"; private static final String PASSWORD = "your_password"; private static DataSource dataSource = null; static { MysqlDataSource ds = new MysqlDataSource(); ds.setURL(URL); ds.setUser(USERNAME); ds.setPassword(PASSWORD); dataSource = ds; } public static Connection getConnection() throws SQLException { return dataSource.getConnection(); } }注意serverTimezone=Asia/Shanghai这个参数。高版本 MySQL JDBC 驱动如果没有指定时区,默认会去读系统的 time_zone 变量,如果数据库服务器时区设置不对,查询出来的时间会跟你本地差 8 个小时。这是老项目搬到新环境后最常见的“玄学问题”之一。另外我建议把密码改成读配置文件而不是硬编码在源码里,毕竟这个项目是要给别人跑或者部署到服务器上的,写死密码既不安全,也不方便别人改。
5. 部署运行与避坑记录:从源码到本地跑通的全过程
5.1 部署步骤:IDEA 配置 Tomcat 与数据库初始化顺序
先讲标准跑通顺序,按这个步骤操作,大部分环境都能一次过。
第一步,准备好 MySQL 数据库,执行 4.1 和 4.2 里的建表和初始化脚本。第二步,打开 IDEA,导入项目,确认 JDK 版本是 8 以上,因为 WebSocket 注解从 Java 7 就有了,但 Tomcat 版本建议用 8.5 以上,避免旧版本对 JSR 356 支持不完整。第三步,配置 Tomcat:Run → Edit Configurations → 新增 Tomcat Server → Local,Deployment 标签页里选择打包好的 war 包,Application context 设为/chat。第四步,修改DBUtil里的数据库名、用户名、密码为你本地的真实值。第五步,直接启动 Tomcat,浏览器访问http://localhost:8080/chat/login.jsp,用alice和bob两个账号分别开两个浏览器窗口(注意不能用同一个浏览器,因为 Session 会串),互相发消息测试。
5.2 避坑指南:我拆这个项目时踩过的五个坑
以下五条都是真实出现过的问题,按现象、原因、解决的顺序写清楚。
坑一:WebSocket 连接 404。
- 现象:前端控制台报
WebSocket connection to 'ws://localhost:8080/chat/alice' failed: Error during WebSocket handshake: Unexpected response code: 404。 - 原因:项目部署后的 context path 没有算进 WebSocket 路径里。如果你部署时的 context 是
/chat,而@ServerEndpoint的路径是/chat/{username},那最终的握手地址应该是ws://localhost:8080/chat/chat/alice而不是ws://localhost:8080/chat/alice。 - 解决:要么把前端代码里的
ws路径改成location.pathname动态拼接,要么在部署时把 context 设为根路径/。我建议用后者,省事。
坑二:消息能发出去但收不到,控制台也没有报错。
- 现象:A 给 B 发消息,A 这边显示发送成功,B 的页面完全没反应。
- 原因:B 的 WebSocket 连接因为网络原因已经断开,但
SessionManager里的映射没有及时清理,A 发消息时服务端拿到了一个已关闭的Session对象,调sendMessage时不报错但数据也没发出去。 - 解决:发送前判断
session.isOpen(),并在onError回调里也移除失效的映射。更稳妥的做法是加心跳检测,定期给所有在线用户发 ping,收不到 pong 就强制关闭。
坑三:聊天窗口打开五分钟就自动断线。
- 现象:页面开着不动,过几分钟再发消息,服务端直接报
Broken pipe或者连接已经关闭。 - 原因:Tomcat 的 WebSocket 默认有一个空闲超时时间,一段时间内连接上没有任何数据交互就会被自动关闭。
- 解决:调用
session.setMaxIdleTimeout(0)关闭空闲超时,或者在前端加一个定时 ping 消息,每隔 30 秒给服务端发一个心跳。
坑四:中文消息乱码。
- 现象:登录时用户名显示正常,但聊天内容里的中文全部变成
???。 - 原因:数据库表字符集是 utf8mb4,但 JDBC 连接的
characterEncoding没设置,或者 Tomcat 的URIEncoding没有设成 UTF-8。 - 解决:JDBC URL 里加
characterEncoding=utf8mb4;Tomcat 的server.xml里给 Connector 加URIEncoding="UTF-8";同时保证LoginServlet里先调req.setCharacterEncoding("UTF-8")再取参数。
坑五:同一个账号不能在两台电脑同时登录。
- 现象:两个浏览器同时登录同一个账号,第二个登录后发现第一个页面发不出消息。
- 原因:这不是 bug,是 3.2 节里踢下线逻辑的正常表现。
@OnOpen会检查SessionManager是否有同名的旧连接,有就 close 掉。 - 解决:如果你希望允许同一账号多端在线,把
@OnOpen里的踢下线逻辑删掉,改成用一个List存储同一个用户的多个Session。发消息时遍历这个列表逐个推送。但从“一对一聊天”的业务场景来看,踢下线反而是合理设计,防止一个用户开两个窗口导致消息推送到错误的窗口里。
6. 二次开发思路:三个改造方向和一个必须养成的习惯
这个项目的结构决定了它能改的地方非常多,我挑三个我实际试过、且改动成本低收益高的方向,最后补一个我认为做任何 WebSocket 项目都应该养成的习惯。
第一个方向是加心跳检测。你现在的前端断线后靠location.reload硬刷页面,这在本地没问题,但部署到服务器上,如果网络环境不稳定,用户聊着天突然页面刷新,体验是很差的。常见的方案是前端每 30 秒发一条类型为ping的 JSON,服务端收到后回一条pong,如果前端连续三次没收到 pong,就主动触发重连逻辑而不是直接刷新页面。代码改动集中在chat.js的setInterval和ChatEndpoint的onMessage里加一个类型判断分支,半小时能改完。
第二个方向是消息撤回。message表里已经有is_read字段了,再加一个is_deleted或者status字段,撤回的本质不是删记录,而是把内容替换成“你撤回了一条消息”。实现方案是给 WebSocket 消息类型加一个recall类型,服务端收到后先更新数据库再推送给对方。这个改造能让你顺手把消息类型的路由逻辑理一遍,学会在onMessage里用switch分支处理不同的 JSON 结构。
第三个方向是把前端从 JSP 拆成纯静态页面。现在的chat.jsp里混了 JSP 标签和原生 HTML,如果你想接入 Vue 或者 React,可以完全不依赖 JSP 的服务端渲染,只保留一个空的 HTML 页面,登录后把用户名存进sessionStorage,然后通过 Ajax 调后端接口拉好友列表和历史记录。后端只需要把LoginServlet改成返回 JSON,把HistoryServlet改成接收两个用户名参数返回消息数组。这个改造做完,你的前后端就彻底分离了,以后部署到 Nginx 后面也顺理成章。
最后说那个习惯。我一开始改 WebSocket 项目时,总习惯先改前端脚本,把消息格式改了,再去改后端的Message实体,结果经常是前端发了新格式,后端解析失败,两边调试半天才发现是消息协议对不上。从那以后我每次改 WebSocket 项目,都强制自己先写一份消息协议文档,哪怕是只写了“发送:fromUser/toUser/content/sendTime”这十几个字,也先落在纸上,然后再动任何一端的代码。做长连接项目,协议先行这句话不是口号,是真能省下几个小时的联调时间。希望这个习惯对你也有用。
本文还有配套的精品资源,点击获取