简介:一套基于JSP与Servlet的网页多人聊天系统源码包,面向正在学习Java Web、希望理解多用户实时会话与消息广播机制的初、中级开发者。项目围绕聊天页面、服务端请求处理、会话状态维护与客户端无刷新交互展开,可用于课程设计参考、小型交流模块原型搭建或Java Web综合练习。压缩包共11个文件,约12KB,包含2个java源文件、6个class编译文件,以及工程类路径、项目描述与JDT首选项等Eclipse配置,便于导入IDE后查看源码、编译产物与工程结构的对应关系。已有677人浏览学习。受包体较小的限制,它更适合作为精简示例,帮助读者梳理JSP页面、Servlet控制流程与前后端数据传递的基本脉络,并在此基础上补充数据库存储、在线用户管理和界面样式等模块,逐步完善可运行的多人聊天应用。
1. 多人聊天系统:JSP 加 Servlet 这套老组合,跑通一次比看十篇原理强
前段时间有个做课程设计的朋友找我,说他的 Java 多人聊天系统卡在一个特别原始的地方:A 窗口发出去的消息,B 窗口刷新一下才能看见,不刷新就永远是死的。我让他把项目发过来,一看是 JSP 页面套着 Servlet,AJAX 没写,定时器也没写,消息发完只是往数据库一插就完事了。这个问题其实特别典型——很多人手里这套 JSP 多人聊天系统的骨架是对的,缺的只是「怎么把新消息推到页面上」这一小段链路没打通。
这类项目适合谁?适合想把 Servlet 会话、JDBC 落库、AJAX 轮询这三件事串成一条完整链路的人,也适合拿它当二次开发底座、往里加房间、私聊、表情、已读的开发者。它不适合直接拿去扛几千人在线,那得上 WebSocket 加消息队列。但作为一套能跑、能改、结构清楚的 Java Web 项目,它的价值在于每一层都摊开在你面前,不用猜框架帮你做了什么。
2. 请求链路与目录结构:一条消息从 JSP 输入框到 MySQL 再回到页面
2.1 分层设计与一次完整请求的走向
这套多人聊天系统没有用 Spring 那一套,走的是最朴素的 Servlet + JSP + JDBC。分层很清楚:JSP 负责出页面,Servlet 负责收请求和校验,DAO 负责碰数据库,model 是纯数据对象,filter 管编码和登录拦截。浏览器这边靠 jQuery 发 AJAX,Servlet 回一段 JSON,前端把消息节点追加到列表里。
具体到「发一条消息」这件事,链路是这样的:用户在 chat.jsp 的输入框敲字回车,chat.js 拦下表单提交,$.post打到/send,请求先过EncodingFilter统一成 UTF-8,再过LoginFilter检查 session 里有没有loginUser,没有就回 401;SendMessageServlet拿到内容做长度和空值校验,组装成Message对象交给MessageDao.save(),DAO 用PreparedStatement插库并返回自增主键;Servlet 回{"code":200,"id":123},前端拿到 id 之后清空输入框。
「收一条消息」是反过来的方向。chat.jsp 加载完成后,chat.js起一个 2 秒的定时器,带着当前的sinceId去打/poll,PollMessageServlet转给MessageDao.listAfter(roomId, sinceId, 50),SQL 是WHERE room_id = ? AND id > ? ORDER BY id ASC LIMIT 50,查出来拼成 JSON 数组回给前端,前端逐条渲染并把sinceId推到最新那条的 id 上。下一次请求就只会拿比这个 id 更大的消息,天然做到增量。
为什么选轮询而不是 WebSocket?因为这套项目的定位是「跑得起来、看得懂」。Tomcat 7 之后虽然支持 WebSocket,但要引入@ServerEndpoint、维护连接集合、处理断线重连,代码量翻一倍,新手第一次跑大概率卡在依赖和容器版本上。轮询的代价是延迟 2 秒左右、请求数多一些,但链路是同步的,出问题看 Tomcat 日志一眼就能定位。等把功能改顺了再换 WebSocket,那是第 6 章要聊的事。
2.2 目录结构与关键文件职责
工程按 Maven 的标准 Web 结构摆,源码在src/main/java,页面和静态资源在src/main/webapp,SQL 脚本单独放一个目录,方便第一次跑的人直接导入。
chat-system/ ├── pom.xml ├── sql/ │ └── chat.sql # 建库建表 + 初始测试数据 └── src/main/ ├── java/com/example/chat/ │ ├── servlet/ │ │ ├── LoginServlet.java # 登录校验 + 写 session │ │ ├── RegisterServlet.java # 注册,密码摘要后入库 │ │ ├── SendMessageServlet.java# 发消息,校验 + 落库 │ │ ├── PollMessageServlet.java# 增量拉消息,返回 JSON │ │ └── OnlineUserServlet.java # 在线用户列表 │ ├── dao/ │ │ ├── UserDao.java │ │ └── MessageDao.java │ ├── model/ │ │ ├── User.java │ │ └── Message.java │ ├── filter/ │ │ ├── EncodingFilter.java # 统一 UTF-8 │ │ └── LoginFilter.java # 拦未登录请求 │ └── util/ │ ├── DBUtil.java # 拿连接、关连接 │ ├── JsonUtil.java # 手写 JSON 转义 │ └── OnlineHolder.java # ServletContext 里的在线表 └── webapp/ ├── WEB-INF/web.xml ├── login.jsp ├── register.jsp ├── chat.jsp ├── css/chat.css └── js/chat.js几个文件值得单独说一句。OnlineHolder不是数据库表,它把在线用户挂在ServletContext上,key 用 sessionId,这样同一个账号开两个浏览器窗口不会互相覆盖,重登也不会把旧记录顶掉。JsonUtil看起来多余,但它是后面第 5 章「消息里有引号就整段崩」那个坑的解药,手拼 JSON 时所有字符串都要过它。
| 文件 | 职责 | 第一次跑要不要改 |
|---|---|---|
DBUtil.java | 数据库地址、账号、密码 | 必须改 |
chat.sql | 建表与初始数据 | 直接导入 |
web.xml | 欢迎页、过滤器注册 | 一般不用 |
chat.js | 轮询间隔、接口地址 | 按需调 |
pom.xml | 依赖版本、打包方式 | 看 JDK 版本 |
2.3 从零把环境跑起来
先把依赖和容器对齐:JDK 8、Maven 3.6 以上、Tomcat 8.5 或 9、MySQL 5.7 或 8.0。Tomcat 10 要慎重,它把javax.servlet换成了jakarta.servlet,这套代码里所有import javax.servlet.*都会编译不过,这个坑在第 5 章还会展开。
第一步,建库导表。用 root 登录 MySQL 执行脚本,脚本里已经写了CREATE DATABASE chat DEFAULT CHARSET utf8mb4,不用手动建库。
mysql -uroot -p < sql/chat.sql第二步,改数据库连接。打开DBUtil.java,把地址、账号、密码换成自己机器上的。连接串里三个参数别删:useUnicode=true&characterEncoding=utf8保证中文不乱码,serverTimezone=Asia/Shanghai保证时间不差 8 小时,useSSL=false避免本地连接时的证书告警。
第三步,打包部署。
mvn clean package -DskipTests cp target/chat.war $CATALINA_HOME/webapps/ $CATALINA_HOME/bin/startup.sh第四步,验证。浏览器打开http://localhost:8080/chat/login.jsp,用脚本里预置的测试账号登录(一般是admin / 123456和一个普通账号),登录成功会跳到chat.jsp。开两个不同浏览器(或者一个正常窗口一个隐私窗口),各登一个账号,在 A 里发一句话,B 页面 2 秒内应该自己冒出来。如果 B 没反应但 A 刷新后能看到自己的消息,说明发送链路是通的,问题出在拉取或前端渲染上,直接去看/poll这个请求的返回值。
提示:部署后先看 Tomcat 的
logs/catalina.out有没有启动异常,再看浏览器 Network 面板里/poll的响应体。这两处能覆盖八成的首次运行失败。
3. 核心模块拆解:登录会话、消息落库、增量拉取三段代码
3.1 登录与会话:HttpSession 里存什么、在线表怎么挂
登录这块最容易写歪的地方是把用户对象往 session 里一塞就完事,然后在线列表单独维护一个静态 Map,结果重启就乱、多开就串。这套项目的做法是:session 里只放User,在线表挂ServletContext,key 是 sessionId。
@WebServlet("/login") public class LoginServlet extends HttpServlet { private final UserDao userDao = new UserDao(); @Override protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { req.setCharacterEncoding("UTF-8"); String username = req.getParameter("username"); String password = req.getParameter("password"); // 空值先挡掉,不然后面拼 SQL 参数没意义 if (username == null || username.trim().isEmpty() || password == null || password.isEmpty()) { req.setAttribute("error", "账号或密码不能为空"); req.getRequestDispatcher("/login.jsp").forward(req, resp); return; } User user = userDao.findByUsernameAndPassword(username.trim(), md5(password)); if (user == null) { // forward 而不是 redirect,error 才能带到页面上 req.setAttribute("error", "账号或密码不正确"); req.getRequestDispatcher("/login.jsp").forward(req, resp); return; } HttpSession session = req.getSession(true); session.setAttribute("loginUser", user); session.setMaxInactiveInterval(30 * 60); // 30 分钟不动就过期 // 在线表挂在 ServletContext 上,key 用 sessionId,支持同账号多开 Map<String, User> online = OnlineHolder.get(req.getServletContext()); online.put(session.getId(), user); resp.sendRedirect(req.getContextPath() + "/chat.jsp"); } private String md5(String raw) { // 练手项目够用,正式环境换 BCrypt,别拿 MD5 存密码 return DigestUtils.md5Hex(raw); } }这段代码里有三个判断值得注意。第一,空值校验放在查库之前,避免拿null去拼参数。第二,失败时用forward不用redirect,因为request.setAttribute("error", ...)只在同一次请求里有效,重定向会把它丢掉,页面上就什么提示都没有,新手经常在这里以为「登录没反应」。第三,sendRedirect后面拼了req.getContextPath(),因为 war 包部署后上下文路径是/chat,硬编码/chat.jsp换个部署名就 404。
在线表用ServletContext而不是静态变量,是因为ServletContext跟随应用生命周期,应用停了表就没了,不会残留脏数据。清理动作放在LogoutServlet和 session 监听器里,session 过期时把对应 key 移掉,在线人数才不会只增不减。
3.2 发消息:校验、落库、只回一个 id
发送接口的职责要收窄:只负责校验和写库,不负责返回整条消息内容。前端拿到 id 之后,下一轮轮询自然会把它拉回来,这样发送和展示只有一条数据通路,不会出现「本地先插一条、服务端又推一条」的重复消息。
@WebServlet("/send") public class SendMessageServlet extends HttpServlet { private final MessageDao messageDao = new MessageDao(); @Override protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { resp.setContentType("application/json;charset=UTF-8"); User loginUser = (User) req.getSession().getAttribute("loginUser"); if (loginUser == null) { resp.getWriter().write("{\"code\":401,\"msg\":\"not login\"}"); return; } String content = req.getParameter("content"); // 空内容、超长内容都挡掉,长度上限和前端 maxlength 保持一致 if (content == null || content.trim().isEmpty() || content.length() > 500) { resp.getWriter().write("{\"code\":400,\"msg\":\"content invalid\"}"); return; } long roomId = 1L; try { roomId = Long.parseLong(req.getParameter("roomId")); } catch (Exception ignore) { // 没传就进默认房间,不抛异常 } Message msg = new Message(); msg.setRoomId(roomId); msg.setUserId(loginUser.getId()); msg.setUsername(loginUser.getNickname()); msg.setContent(content.trim()); msg.setType("text"); long id = messageDao.save(msg); resp.getWriter().write("{\"code\":200,\"id\":" + id + "}"); } }参数上,content前端用maxlength="500"限制,后端再判一次,两边必须一致,不然用户粘贴超长文本会绕过前端。roomId缺省为 1,是为了让单房间版本不用改前端就能跑,后面加多房间只要前端把真实房间号带上。username存的是昵称而不是登录名,这是展示用的冗余字段,第 4 章建表时会解释为什么不 join。
MessageDao.save里用PreparedStatement.RETURN_GENERATED_KEYS拿自增主键,这个 id 是整套增量拉取机制的基石,必须返回。
public long save(Message m) { String sql = "INSERT INTO message(room_id, user_id, username, content, type) VALUES(?,?,?,?,?)"; Connection conn = null; PreparedStatement ps = null; ResultSet rs = null; try { conn = DBUtil.getConnection(); ps = conn.prepareStatement(sql, Statement.RETURN_GENERATED_KEYS); ps.setLong(1, m.getRoomId()); ps.setLong(2, m.getUserId()); ps.setString(3, m.getUsername()); ps.setString(4, m.getContent()); ps.setString(5, m.getType()); ps.executeUpdate(); rs = ps.getGeneratedKeys(); return rs.next() ? rs.getLong(1) : -1L; } catch (SQLException e) { throw new RuntimeException("save message failed", e); } finally { DBUtil.close(conn, ps, rs); } }3.3 拉消息:sinceId 游标与 JSON 转义
拉取接口是这套系统的核心,逻辑只有一句:给我 id 比sinceId大的、指定房间的、最多 50 条消息,按 id 正序。SQL 走(room_id, id)联合索引,数据量到几十万行也是毫秒级。
@WebServlet("/poll") public class PollMessageServlet extends HttpServlet { private final MessageDao messageDao = new MessageDao(); @Override protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { resp.setContentType("application/json;charset=UTF-8"); resp.setHeader("Cache-Control", "no-store"); // 别让浏览器缓存轮询结果 long sinceId = parseLong(req.getParameter("sinceId"), 0L); long roomId = parseLong(req.getParameter("roomId"), 1L); List<Message> list = messageDao.listAfter(roomId, sinceId, 50); StringBuilder sb = new StringBuilder("{\"code\":200,\"list\":["); for (int i = 0; i < list.size(); i++) { Message m = list.get(i); if (i > 0) { sb.append(','); } sb.append("{\"id\":").append(m.getId()) .append(",\"user\":\"").append(JsonUtil.escape(m.getUsername())).append('"') .append(",\"content\":\"").append(JsonUtil.escape(m.getContent())).append('"') .append(",\"time\":\"").append(m.getCreateTime()).append("\"}"); } sb.append("]}"); resp.getWriter().write(sb.toString()); } private long parseLong(String raw, long def) { try { return Long.parseLong(raw); } catch (Exception e) { return def; } } }sinceId解析失败时回退到 0,意思是「从头拉」,第一次进页面就是这个行为。limit固定 50 是个保守值,能保证一次响应不会太大,也不会因为积压太多消息把页面卡死。真正要防的是JsonUtil.escape这一层:消息里只要出现双引号、反斜杠、换行、<script>,不转义就会把整段 JSON 结构撑破,前端$.getJSON直接进 error 分支,表现就是「聊天页面突然不刷新了」,这个现象在第 5 章还会再提。
DAO 那一侧就一行 SQL 的事:
public List<Message> listAfter(long roomId, long sinceId, int limit) { String sql = "SELECT id, room_id, user_id, username, content, type, create_time " + "FROM message WHERE room_id = ? AND id > ? ORDER BY id ASC LIMIT ?"; List<Message> result = new ArrayList<>(); try (Connection conn = DBUtil.getConnection(); PreparedStatement ps = conn.prepareStatement(sql)) { ps.setLong(1, roomId); ps.setLong(2, sinceId); ps.setInt(3, limit); try (ResultSet rs = ps.executeQuery()) { while (rs.next()) { result.add(mapRow(rs)); } } } catch (SQLException e) { throw new RuntimeException("list message failed", e); } return result; }3.4 前端轮询:定时器、游标推进和防堆积
前端这块决定了「消息会不会自己冒出来」。核心是三个变量:sinceId记游标,polling防请求堆积,ctx存上下文路径。
var sinceId = 0; var polling = false; var ctx = $('#ctx').val(); // chat.jsp 里用一个隐藏域把 contextPath 带过来 function appendMessage(m) { var $item = $('<div class="msg-item"></div>'); var $name = $('<span class="msg-user"></span>').text(m.user + ':'); // 用 text() 而不是 html(),消息里的标签不会被当成 DOM 执行 var $text = $('<span class="msg-content"></span>').text(m.content); var $time = $('<span class="msg-time"></span>').text(m.time); $item.append($name).append($text).append($time); $('#msgList').append($item); $('#msgList').scrollTop($('#msgList')[0].scrollHeight); } function poll() { if (polling) { return; // 上一次还没回来就跳过,避免请求越堆越多 } polling = true; $.getJSON(ctx + '/poll', { roomId: 1, sinceId: sinceId }) .done(function (res) { if (!res || res.code !== 200) { return; } (res.list || []).forEach(function (m) { appendMessage(m); if (m.id > sinceId) { sinceId = m.id; // 渲染成功之后才推进游标 } }); }) .always(function () { polling = false; }); } $(function () { poll(); // 进页面先拉一次历史 setInterval(poll, 2000); // 之后每 2 秒一次 });游标的推进时机很关键:必须放在appendMessage之后。如果先推进再渲染,渲染抛异常的时候游标已经跑了,那几条消息永远拉不回来,用户刷新才能看见,现象就是「偶尔丢消息」。polling标志解决的是另一个问题——网络慢的时候 2 秒内上一次请求没回来,定时器又发一次,越积越多,最后 Tomcat 线程池被这一批请求占满,整个应用卡死。
发送侧就简单了:
$('#sendBtn').on('click', function () { var content = $('#msgInput').val(); if (!content || !content.trim()) { return; } $.post(ctx + '/send', { content: content, roomId: 1 }, function (res) { if (res.code === 200) { $('#msgInput').val(''); // 清空输入框,消息等下一轮轮询回来 } else { alert('发送失败:' + res.msg); } }, 'json'); });4. 数据库与部署:两张表、一个连接池和一次完整的 Tomcat 启动
4.1 表结构设计与索引取舍
整个项目两张表就够:user和message。看着简陋,但字段和索引的取舍都有说法。
CREATE DATABASE IF NOT EXISTS chat DEFAULT CHARSET utf8mb4 COLLATE utf8mb4_general_ci; USE chat; CREATE TABLE `user` ( `id` BIGINT NOT NULL AUTO_INCREMENT, `username` VARCHAR(32) NOT NULL COMMENT '登录名', `password` CHAR(32) NOT NULL COMMENT 'MD5 摘要', `nickname` VARCHAR(32) NOT NULL COMMENT '聊天展示名', `avatar` VARCHAR(255) DEFAULT NULL, `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_username` (`username`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE `message` ( `id` BIGINT NOT NULL AUTO_INCREMENT, `room_id` BIGINT NOT NULL DEFAULT 1 COMMENT '房间号,单房间版本恒为 1', `user_id` BIGINT NOT NULL, `username` VARCHAR(32) NOT NULL COMMENT '发送时昵称,冗余字段', `content` VARCHAR(1000) NOT NULL, `type` VARCHAR(16) NOT NULL DEFAULT 'text', `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), KEY `idx_room_id` (`room_id`, `id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; INSERT INTO `user`(`username`, `password`, `nickname`) VALUES ('admin', 'e10adc3949ba59abbe56e057f20f883e', '管理员'), ('tom', 'e10adc3949ba59abbe56e057f20f883e', '汤姆');第一个设计点是message.username冗余。拉消息的 SQL 里不 joinuser表,展示名直接来自消息行。好处是查询只碰一张表、走一个索引;代价是用户改昵称之后历史消息还是旧名字。聊天场景里这反而符合直觉——消息发出去那一刻的称呼就该定住。
第二个设计点是idx_room_id (room_id, id)联合索引。WHERE room_id = ? AND id > ? ORDER BY id ASC正好能被这个索引完整覆盖,扫的是索引区间,不用回表排序。如果只建room_id单列索引,ORDER BY id还得额外排一次序,数据量大了就明显了。
第三个设计点是字符集用utf8mb4而不是utf8。MySQL 的utf8只存三字节,emoji 和部分生僻字插进去会直接报错或者变问号,聊天项目里用户发个表情太正常了,这个不改迟早翻车。
4.2 连接管理与 DBUtil 的写法
练手规模用DriverManager每次新建连接完全够用,代码短、看得懂。它的上限也明确:并发几十个轮询请求的时候,每次建连接的开销会变成瓶颈。真要多人在线,把getConnection()换成连接池的dataSource.getConnection(),外面调用的代码一行不用改。
public class DBUtil { private static final String URL = "jdbc:mysql://127.0.0.1:3306/chat" + "?useUnicode=true&characterEncoding=utf8" + "&useSSL=false&serverTimezone=Asia/Shanghai"; private static final String USER = "chat"; private static final String PASS = "chat123"; static { try { // MySQL 8 用 com.mysql.cj.jdbc.Driver,5.x 用 com.mysql.jdbc.Driver Class.forName("com.mysql.cj.jdbc.Driver"); } catch (ClassNotFoundException e) { throw new ExceptionInInitializerError("mysql driver not found"); } } public static Connection getConnection() throws SQLException { return DriverManager.getConnection(URL, USER, PASS); } public static void close(Connection c, Statement s, ResultSet r) { // 关闭顺序和打开顺序相反,每个都单独 try,前一个抛异常不影响后面的 if (r != null) { try { r.close(); } catch (SQLException ignore) {} } if (s != null) { try { s.close(); } catch (SQLException ignore) {} } if (c != null) { try { c.close(); } catch (SQLException ignore) {} } } }连接串里characterEncoding=utf8和建表时的utf8mb4不冲突,驱动会按连接字符集协商。真正要留意的是serverTimezone:不写这一项,MySQL 8 驱动启动就抛The server time zone value is unrecognized,很多人第一次跑就卡在这。写Asia/Shanghai之后,CURRENT_TIMESTAMP落库和 Java 侧读出来的时间才对得上。
4.3 web.xml 与过滤器注册
Servlet 3.0 之后可以用@WebServlet注解,web.xml就只剩下欢迎页和过滤器这些不方便用注解的部分。过滤器顺序按声明顺序执行,编码过滤器必须排在登录过滤器前面,否则登录校验读参数的时候还是乱码。
<?xml version="1.0" encoding="UTF-8"?> <web-app xmlns="http://xmlns.jcp.org/xml/ns/javaee" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://xmlns.jcp.org/xml/ns/javaee http://xmlns.jcp.org/xml/ns/javaee/web-app_3_1.xsd" version="3.1"> <display-name>chat-system</display-name> <welcome-file-list> <welcome-file>login.jsp</welcome-file> </welcome-file-list> <filter> <filter-name>encodingFilter</filter-name> <filter-class>com.example.chat.filter.EncodingFilter</filter-class> <init-param> <param-name>encoding</param-name> <param-value>UTF-8</param-value> </init-param> </filter> <filter-mapping> <filter-name>encodingFilter</filter-name> <url-pattern>/*</url-pattern> </filter-mapping> <filter> <filter-name>loginFilter</filter-name> <filter-class>com.example.chat.filter.LoginFilter</filter-class> </filter> <filter-mapping> <filter-name>loginFilter</filter-name> <url-pattern>/chat.jsp</url-pattern> <url-pattern>/send</url-pattern> <url-pattern>/poll</url-pattern> <url-pattern>/online</url-pattern> </filter-mapping> <session-config> <session-timeout>30</session-timeout> </session-config> </web-app>LoginFilter的url-pattern只列需要登录的路径,别图省事写/*。写成/*会把login.jsp、register.jsp、css、js全拦下来,未登录用户连登录页都打不开,浏览器表现是「重定向次数过多」。这个坑在第 5 章单独说。
EncodingFilter里除了req.setCharacterEncoding("UTF-8"),还要resp.setCharacterEncoding("UTF-8")。GET 请求的参数编码在 Tomcat 8 之后默认就是 UTF-8,但如果你把项目部署到 Tomcat 7,就得去server.xml的 Connector 上加URIEncoding="UTF-8",否则 GET 带中文一定乱码。
4.4 启动验证与最小冒烟流程
打包部署之后,按下面这个顺序验证,每一步失败都能定位到具体层:
# 1. 确认端口和上下文路径,默认 http://localhost:8080/chat/ curl -I http://localhost:8080/chat/login.jsp # 2. 用测试账号登录,拿到 JSESSIONID curl -i -c cookie.txt -d "username=admin&password=123456" \ http://localhost:8080/chat/login # 3. 带着 cookie 发一条消息 curl -b cookie.txt -d "content=hello&roomId=1" \ http://localhost:8080/chat/send # 4. 拉一次消息,确认刚才那条能查出来 curl -b cookie.txt "http://localhost:8080/chat/poll?roomId=1&sinceId=0"第 2 步返回 302 并且Location指向chat.jsp,说明登录和 session 都正常。第 3 步返回{"code":200,"id":N},说明落库成功。第 4 步返回的 JSON 数组里能看到刚才那条,说明查询和序列化没问题。这四步过了,剩下的问题基本都在前端渲染上,打开浏览器控制台看有没有 JS 报错就行。
注意:
curl测试时-c和-b要成对用,第一次存 cookie 到文件,后面每次带上,否则每一步都是未登录状态,/send会一直回 401。
5. 排查避坑:第一次跑这套聊天系统最容易翻车的五个点
5.1 中文全变问号或者乱码方块
现象:登录页输入中文昵称,聊天窗口里显示成???或者测试这样的串。
原因有三层,得逐个排查。第一层是请求体编码,LoginServlet和SendMessageServlet里如果没写req.setCharacterEncoding("UTF-8"),POST 参数按容器默认的 ISO-8859-1 解,中文必乱。第二层是数据库连接,连接串缺characterEncoding=utf8,驱动会用平台默认编码传数据。第三层是表字符集,建表时用了utf8而不是utf8mb4,emoji 直接插不进去。
解决顺序是:先确认EncodingFilter挂在了/*上并且排在登录过滤器前面;再核对连接串里那两个参数;最后SHOW CREATE TABLE message看字符集,不对就ALTER TABLE ... CONVERT TO CHARACTER SET utf8mb4。
5.2 消息重复出现或者偶尔丢一条
现象:A 发一句话,B 那边同一条消息显示了两遍;或者偶尔有一条消息谁都看不见,刷新页面才出来。
重复的原因一般是发送成功后前端手动appendMessage了一次,下一轮轮询又拉回来一次。正确做法是发送接口只回 id,展示完全交给轮询,一条数据通路就不会重。如果确实想「发完立刻显示」,那就要在本地维护一个已渲染 id 集合,渲染前判重。
丢失的原因多半在游标推进时机。sinceId = m.id写在appendMessage之前,某次渲染因为 DOM 异常中断,游标已经跳过去了,那几条就永远拉不回来。把游标推进放到渲染之后,问题消失。
还有一种隐蔽情况:limit 50遇上积压。如果用户离开页面十分钟,回来时积压了 200 条,一次只拉 50 条且游标推到第 50 条,剩下的要等下一轮,看起来像「消息慢慢往外蹦」。想一次拉完就把 limit 调大,或者在前端做「如果返回条数等于 limit,立刻再拉一次」的循环。
5.3 轮询把连接池和 Tomcat 线程打满
现象:人一多,页面越来越卡,最后所有接口都超时,Tomcat 日志里出现大量连接等待或者Too many connections。
原因是轮询频率和并发数相乘。2 秒一次、20 个人在线,就是每秒 10 个请求;每个请求用DriverManager新建一次连接,再加上连接关闭不及时,MySQL 的连接数很快见顶。如果finally里漏了close,几分钟就能把max_connections耗尽。
解决分三步:一是把所有 DAO 的关闭逻辑统一走DBUtil.close,用 try-with-resources 更稳;二是把轮询间隔从 2 秒放到 3 到 5 秒,或者做成「有新消息就快、没消息就慢」的自适应;三是换连接池,HikariCP 或 Druid 都行,maximumPoolSize设 10 到 20,getConnection()改成从池里取,业务代码基本不动。
5.4 登录之后所有 AJAX 都返回登录页 HTML
现象:登录成功进了聊天页,但消息发不出去、列表也不刷新,Network 面板里/poll的响应是一整段login.jsp的 HTML。
原因基本是LoginFilter拦到了不该拦的路径,或者 session 没带上。前者是把url-pattern写成了/*,未登录时把login.jsp和静态资源也重定向了,浏览器一直跳;后者是 AJAX 跨域或者 cookie 被丢,$.getJSON默认会带同源 cookie,但如果页面地址是127.0.0.1而接口写的是localhost,就变成了跨域,cookie 不带,session 拿不到。
排查方法是在LoginFilter里打一行日志,把requestURI和session.getId()打出来,看每次请求到底带没带同一个 session。解决就是把过滤路径收窄到真正的业务接口,前端所有请求统一用ctx + '/poll'拼相对路径,不写死域名。
5.5 换个 Tomcat 版本就启动报 ClassNotFound
现象:本地 Tomcat 8.5 跑得好好的,换到另一台机器的 Tomcat 10 上,启动就报java.lang.ClassNotFoundException: javax.servlet.http.HttpServlet,或者页面 404 但日志没明显错误。
原因是 Tomcat 10 把 Servlet API 从javax.servlet换成了jakarta.servlet,包名整体迁移。这套代码所有import javax.servlet.*和web.xml里的 schema 都是旧的,在 Tomcat 10 上编译能过(如果依赖里还是旧 API),运行必挂。
解决有两条路:要么把容器降到 Tomcat 8.5 或 9,最省事;要么做一次包名迁移,全局替换javax.servlet为jakarta.servlet,web.xml的 schema 换成 5.0 版本,pom.xml里javax.servlet-api换成jakarta.servlet-api,并且确认 JDK 至少 8 以上。
提示:拿到任何一个 Java Web 项目,先看
pom.xml里的 servlet-api 是javax还是jakarta,再看本机 Tomcat 大版本,这两个不对齐,后面所有调试都是白费。
6. 二次开发:从「能聊天」改到「能交付」的四条改造路线
这套骨架最大的好处是改造点很明确,不用在框架的迷宫里面找入口。我一般按「先加数据维度、再换传输通道、最后补体验」的顺序推。
| 改造方向 | 改动位置 | 工作量 | 关键注意点 |
|---|---|---|---|
| 多房间/群组 | message.room_id、PollMessageServlet、前端房间切换 | 小 | 前端切房间时sinceId必须重置为 0 |
| 私聊 | 新增to_user_id字段、listAfter加条件 | 中 | 查询条件变成(room_id=? OR to_user_id=?),索引要重设计 |
| 换 WebSocket | 新增@ServerEndpoint、前端换new WebSocket() | 中 | 在线表从ServletContext迁到ConcurrentHashMap,注意线程安全 |
| 历史消息分页 | 新增/history接口,按 id 倒序翻页 | 小 | 别和轮询的sinceId共用游标,方向相反 |
多房间是最容易上手的一个。数据库那边不用动,room_id字段本来就留着,只要把SendMessageServlet和PollMessageServlet里写死的1L换成前端传上来的真实值,再在chat.jsp顶部加一排房间按钮。这里有个细节:切换房间时前端的sinceId必须清零,否则新房间的消息 id 比旧房间小,会被id > sinceId这个条件全部过滤掉,页面看起来像「进了新房间一条消息都没有」。
换 WebSocket 是很多人想做的下一步。Tomcat 8 以后原生支持,加一个@ServerEndpoint("/ws/{roomId}")的类,onOpen时把 session 存进ConcurrentHashMap<String, Session>,onMessage里落库之后遍历同房间的连接推出去。原来的/send接口可以保留做兜底,前端优先用 WebSocket,连不上再退回轮询。要注意的是Session的getBasicRemote().sendText()不是线程安全的,多线程同时推必须加锁或者用getAsyncRemote()配合队列发送。
我自己踩得最深的一次是给轮询加「未读消息数」。当时图省事,在select count(*)里直接按create_time > 上次登录时间算,结果用户改系统时间、服务器时区不一致、消息时间精度只到秒,三个问题叠在一起,未读数一会儿多一会儿少,特别玄学。后来改成用消息 id 做未读游标,服务端存每个用户在每个房间的最后已读 id,计数就是count(*) where id > last_read_id,问题一次消失。
从那以后我每次给聊天类项目加「基于时间的状态」都会先问自己一句:这个状态能不能换成基于自增 id 的游标?能换就换,时间的坑太多,后悔药不好买。这套项目的sinceId设计就是这么来的,希望帮到你。
本文还有配套的精品资源,点击获取