简介:这份「多人聊天系统」面向 Java Web 初学者、课程设计开发者以及需要网页聊天原型的技术人员,核心是以 JSP 构建动态页面,配合 Servlet 接收与响应请求、维护 session 会话状态,并把用户消息广播给其他在线成员,帮助理解多人实时通信的基本链路。压缩包为 rar 格式,共 11 个文件,以 class 字节码、Java 源码及 classpath、project、prefs 等 Eclipse 工程配置为主,整体约 12KB,属于轻量级工程骨架,便于导入开发工具后阅读目录与类结构。已有 677 人学习或下载,说明其作为入门参考具有一定关注度。读者可据此梳理 JSP 页面与 Servlet 的分工、AJAX 无刷新交互以及 WebSocket 双向通信的落地思路,也能把它当作课程设计、毕业设计或小型聊天室原型的起步代码,后续自行补充界面美化、数据库连接、聊天记录持久化与在线用户管理等功能,从而完成一个可演示的多人聊天应用。
1. 演示给谁看都不掉链子的多人聊天系统,难点从来不在"能发消息"
带过几轮课程设计之后我发现一个规律:凡是选 Java 多人聊天系统这个题目的,最后卡住的地方几乎都不是语法,而是"为什么我这边发出去了,对面非得刷新一下才看得见"。这份基于 JSP + Servlet 的源码正好把这条链路完整摊开:登录校验、在线用户列表、消息广播、历史记录落库、断线兜底,一整套跑在 Tomcat 上的最小闭环。它适合三类人——要交课程设计但不想直接套模板的同学、想弄明白 Servlet 生命周期和 Session 并发的新手、以及需要一套轻量聊天骨架往上加功能的人。代码量不算大,但没有哪一层是摆设,每一层都能对应到浏览器里看得见的行为。
2. 选型与目录结构:Servlet 容器里搭一条最小可用的聊天链路
2.1 为什么是 JSP + Servlet,而不是直接上 Spring Boot
先说清楚这套组合的定位。JSP 是服务端模板,请求进来之后在服务器上拼好 HTML 再吐给浏览器,不需要前后端分离、不需要 Node 环境、不需要单独起前端服务;Servlet 负责接请求、查数据、转发视图;Tomcat 提供容器和 Session 管理;JDBC 直连数据库。整个项目打成一个 war 包丢进容器就能跑,对一台 2 核 4G 的机器来说完全够用。
很多人第一反应是"都什么年代了还用 JSP"。这个判断放在商业项目里没错,但放在学习和二次开发场景里恰好相反:JSP + Servlet 把 HTTP 请求、响应、会话、转发这些 Web 最底层的东西全暴露在你面前,没有任何框架帮你挡住。你在 Spring Boot 里写一个@RestController觉得理所当然,但会话是怎么保持的、消息是怎么推回去的、连接断了之后服务端怎么知道——这些问题在裸 Servlet 项目里必须自己回答一遍。
代价也要讲明白:JSP 页面混写 Java 代码,复杂页面会很难维护;没有内置的连接池和事务管理,得自己配;热部署体验一般,改完 Java 要重启容器。所以这套结构的合理边界是"单机、中小并发、以演示和教学为主、需要往上加功能做课程设计或小工具"。真要扛几千人在线,那该换 Netty 或者上消息中间件,不在这个项目讨论范围内。
pom 里最关键的就是打包方式和两个依赖,别的一概不要:
<!-- pom.xml 关键片段 --> <packaging>war</packaging> <!-- 必须是 war,Tomcat 才能部署 --> <dependencies> <!-- Servlet API:编译期需要,运行时由容器提供,所以 scope 是 provided --> <dependency> <groupId>javax.servlet</groupId> <artifactId>javax.servlet-api</artifactId> <version>4.0.1</version> <scope>provided</scope> </dependency> <!-- JSTL:JSP 里做循环和转义用,少写一堆 scriptlet --> <dependency> <groupId>javax.servlet</groupId> <artifactId>jstl</artifactId> <version>1.2</version> </dependency> <!-- MySQL 驱动,运行时要打进 war 里,不能 provided --> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <version>8.0.28</version> </dependency> </dependencies>scope这两处是最容易翻车的地方:Servlet API 写错成 compile,war 里就会带一份 servlet-api.jar,部署时和容器自带的版本打架,启动直接报类加载冲突;MySQL 驱动写成 provided,本地 IDE 能跑,一部署到容器里就抛No suitable driver found。记住一条:容器给的写 provided,自己用的打进包。
2.2 目录结构与各层职责
项目的包结构是典型的三层加一个 Web 层,没有花活,但对新手足够清晰:
chat-system/ ├── src/main/java/com/example/chat/ │ ├── controller/ # Servlet 层:接请求、调 service、转发 JSP │ │ ├── LoginServlet.java # 登录、写入 Session │ │ ├── ChatServlet.java # 消息发送入口 │ │ ├── HistoryServlet.java # 历史消息分页接口(返回 JSON) │ │ └── LogoutServlet.java # 退出并清理在线表 │ ├── service/ # 业务层:消息校验、广播分发 │ │ ├── ChatService.java │ │ └── OnlineUserService.java │ ├── dao/ # 数据访问层:纯 JDBC,手写 SQL │ │ ├── MessageDao.java │ │ └── UserDao.java │ ├── model/ # 实体:User、Message │ ├── filter/AuthFilter.java # 未登录拦截 │ ├── listener/SessionListener.java # 会话销毁时踢出在线表 │ └── ws/ChatEndpoint.java # WebSocket 端点(可选长连接方案) ├── src/main/webapp/ │ ├── WEB-INF/web.xml # 欢迎页、过滤器、Servlet 映射 │ ├── WEB-INF/jdbc.properties # 数据库连接配置 │ ├── login.jsp # 登录页 │ ├── chat.jsp # 主聊天页:在线列表 + 消息流 + 输入框 │ └── static/js/chat.js # 前端连接与渲染逻辑职责划分只有一条铁律:Servlet 不写 SQL,DAO 不碰 request。很多同学把查询直接写在 doGet 里,后面加个分页就得改三处,改到第三次就乱了。分层不是为了好看,是为了让你改一个需求只动一个文件。
web.xml里要留意的是欢迎页顺序和过滤器顺序,这两处顺序错了行为完全不同:
<!-- web.xml:顺序决定行为,别随意调换 --> <welcome-file-list> <welcome-file>login.jsp</welcome-file> <!-- 直接访问根路径先进登录页 --> </welcome-file-list> <filter> <filter-name>auth</filter-name> <filter-class>com.example.chat.filter.AuthFilter</filter-class> </filter> <filter-mapping> <filter-name>auth</filter-name> <url-pattern>/chat.jsp</url-pattern> <!-- 只拦聊天页,登录页放行 --> </filter-mapping>如果过滤器写成/*,登录请求本身也会被拦下来,用户永远进不去,现象是"点登录没反应,日志里疯狂重定向"。这种问题排查起来最费时间,因为它不报错,只是静默跳转循环。
2.3 数据模型:三张表加两个内存容器
表设计得克制一点,够用就行。用户表、消息表、房间表(先只留一个默认房间,方便后面扩展多房间):
-- 用户表:密码用 MD5 + 盐存,别存明文 CREATE TABLE t_user ( id BIGINT PRIMARY KEY AUTO_INCREMENT, nickname VARCHAR(32) NOT NULL UNIQUE, -- 登录名,同时作为展示名 password VARCHAR(64) NOT NULL, -- 加盐后的 MD5 salt VARCHAR(16) NOT NULL, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 消息表:room_id 先固定为 1,后面扩多房间直接用它区分 CREATE TABLE t_message ( id BIGINT PRIMARY KEY AUTO_INCREMENT, room_id BIGINT NOT NULL DEFAULT 1, sender_id BIGINT NOT NULL, content VARCHAR(1000) NOT NULL, msg_type TINYINT NOT NULL DEFAULT 0, -- 0 文本、1 图片、2 系统通知 create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, KEY idx_room_time (room_id, id) -- 历史消息按 id 倒序翻页的命门 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;idx_room_time这个联合索引是必须的。历史消息的查询模式永远是"某个房间最新的 N 条",没有索引的话消息表过了几万行,每次进聊天页都要全表扫,前端转圈能转到你以为服务挂了。
内存里维护的在线状态只有一个容器就够,用ConcurrentHashMap而不是HashMap:
// OnlineUserService:全局单例,保存"谁在线" public class OnlineUserService { // key 用 sessionId 而不是 userId,因为同一账号可能开两个标签页 private static final Map<String, String> ONLINE = new ConcurrentHashMap<>(); public static void add(String sessionId, String nickname) { ONLINE.put(sessionId, nickname); } public static void remove(String sessionId) { ONLINE.remove(sessionId); // 必须在 SessionListener 里调,否则在线列表只增不减 } public static Set<String> nicknames() { return new HashSet<>(ONLINE.values()); } }这里有个设计取舍值得说一句:key 用 sessionId 而不是用户 id,是为了允许同一账号多端在线;代价是同一个人的名字可能在列表里出现两次,服务端得在外面做一次去重。如果你更希望"后登录踢掉前一个",那就在add里先遍历删掉同名项,两种做法各有场景,改起来也就几行。
3. 核心链路拆解:一条消息从输入框到别人屏幕的完整路径
3.1 连接建立:登录、Session 与在线注册
整条链路的起点是登录。LoginServlet干三件事:校验密码、往 Session 里塞用户信息、把昵称注册进在线表。
@WebServlet("/login") public class LoginServlet extends HttpServlet { private final UserDao userDao = new UserDao(); @Override protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { String nickname = req.getParameter("nickname"); String password = req.getParameter("password"); // 参数校验放最前面,空值直接打回,别走到查库 if (nickname == null || nickname.trim().isEmpty() || password == null) { req.setAttribute("error", "用户名或密码不能为空"); req.getRequestDispatcher("/login.jsp").forward(req, resp); return; } User user = userDao.findByNickname(nickname.trim()); // 用户不存在和密码错误统一提示,避免被枚举账号 if (user == null || !userDao.matches(user, password)) { req.setAttribute("error", "用户名或密码不正确"); req.getRequestDispatcher("/login.jsp").forward(req, resp); return; } HttpSession session = req.getSession(true); // 没有就新建 session.setAttribute("uid", user.getId()); session.setAttribute("nickname", user.getNickname()); session.setMaxInactiveInterval(30 * 60); // 30 分钟无操作失效 OnlineUserService.add(session.getId(), user.getNickname()); resp.sendRedirect(req.getContextPath() + "/chat.jsp"); // 重定向而非转发,避免刷新重复提交 } }几个参数点值得盯住:setMaxInactiveInterval单位是秒,写 30 会变成 30 秒就掉线,这个坑新手踩得很多,表现为"登录进去聊两句就被踢回登录页";sendRedirect一定要带getContextPath(),否则部署在非根路径(比如/chat)时跳转直接 404;最后用重定向而不是转发,是为了让浏览器的地址栏变成聊天页地址,用户按 F5 不会重新提交一次登录表单。
服务端还得配一个HttpSessionListener,负责会话过期或者浏览器关掉之后把人从在线表里清出去:
@WebListener public class SessionListener implements HttpSessionListener { @Override public void sessionDestroyed(HttpSessionEvent se) { // 会话失效(超时 / 主动 logout / 容器关闭)时统一清理 OnlineUserService.remove(se.getSession().getId()); } }没这个监听器,在线人数只会往上涨,用户关了浏览器刷新一下列表,还能看见自己的名字挂在那里。这种"幽灵在线"问题在演示时特别尴尬。
3.2 消息广播:轮询、长轮询与 WebSocket 的三条路
这是整个项目最核心、也是最多人问的部分。Servlet 天生是"请求-响应"模型,服务端没有主动开口说话的通道,所以要实现"别人发消息我能立刻看到",只有三条路:
| 方案 | 原理 | 延迟 | 服务端资源占用 | 适用场景 |
|---|---|---|---|---|
| 短轮询 | 前端每 2 秒发一次请求问"有没有新消息" | 0~2 秒 | 请求数多,但每次很快释放 | 最简单,人数少 |
| 长轮询 | 请求挂起不返回,有消息或超时才响应 | 接近实时 | 每个在线用户占一个线程 | 中等人数,兼容性最好 |
| WebSocket | 一次握手升级协议,双向长连接 | 实时 | 连接常驻,线程占用少 | 真正的聊天场景 |
这份源码里三种方式其实都有痕迹,但主推 WebSocket,因为 Tomcat 7 之后原生支持 JSR-356 标准,一行注解就能开一个端点,不需要额外依赖。核心实现:
@ServerEndpoint("/ws/chat") // 前端 new WebSocket('ws://host/context/ws/chat') public class ChatEndpoint { // 所有在线连接,key 用 sessionId,value 是 WebSocket 会话 private static final Map<String, Session> CONNECTIONS = new ConcurrentHashMap<>(); private static final Map<String, String> NICKNAMES = new ConcurrentHashMap<>(); private Session session; @OnOpen public void onOpen(Session session) { this.session = session; CONNECTIONS.put(session.getId(), session); // 这里先不广播,等客户端发第一条 join 消息带上昵称再上线 } @OnMessage public void onMessage(String raw, Session session) { // 约定协议:{"type":"join|msg","nickname":"...","content":"..."} JSONObject json = JSONObject.parseObject(raw); String type = json.getString("type"); if ("join".equals(type)) { NICKNAMES.put(session.getId(), json.getString("nickname")); broadcast(systemNotice(json.getString("nickname") + " 进入聊天室")); return; } String nickname = NICKNAMES.get(session.getId()); if (nickname == null) return; // 没 join 就发消息,直接丢弃,防刷 String content = json.getString("content"); if (content == null || content.trim().isEmpty() || content.length() > 1000) { return; // 长度上限和表字段对齐 } // 先落库再广播,保证历史记录和实时消息顺序一致 MessageDao.save(1L, nickname, content); broadcast(chatMessage(nickname, content)); } @OnClose public void onClose(Session session) { CONNECTIONS.remove(session.getId()); String nick = NICKNAMES.remove(session.getId()); if (nick != null) broadcast(systemNotice(nick + " 离开了聊天室")); } @OnError public void onError(Session session, Throwable t) { // 不处理这个异常,断线时容器会打一堆堆栈但连接不会清理干净 CONNECTIONS.remove(session.getId()); NICKNAMES.remove(session.getId()); } private void broadcast(String payload) { for (Session s : CONNECTIONS.values()) { if (s.isOpen()) { // getAsyncRemote 是非阻塞发送,同步发送在慢客户端上会拖住整个循环 s.getAsyncRemote().sendText(payload); } } } }几个参数和写法上的关键决定:
getAsyncRemote()而不是getBasicRemote()。同步发送在一个客户端网络卡住时会阻塞住整个 for 循环,一个人卡住全房间都收不到消息,这个现象特别迷惑——你会以为是自己代码逻辑错了,其实是发送方式选错了。
先落库再广播。反过来做的话,消息发出去了但入库失败,用户刷新页面发现刚才那条自己说过的话消失了,会以为系统吞消息。
广播时遍历的是全量连接。人数上百之后这里会有性能问题,需要按房间分桶,见最后一章的改造。
@OnError一定要实现。不实现的话,客户端异常断开时连接对象可能残留在 map 里,广播时对已关闭的 session 发送会抛异常,异常又把后续的发送打断。
如果你的运行环境只支持到 Servlet 3.0(老版本容器),那就得退回到长轮询。核心是用AsyncContext把请求挂起:
// 长轮询兜底方案:请求挂起,有新消息立即返回,超时 20 秒返回空 @WebServlet(urlPatterns = "/poll", asyncSupported = true) public class PollServlet extends HttpServlet { @Override protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { long since = Long.parseLong(req.getParameter("since")); // 客户端已收到的最大消息 id AsyncContext ctx = req.startAsync(); ctx.setTimeout(20000); // 容器超时兜底 // 交给后台线程轮询数据库或内存队列,有新数据就 ctx.getResponse() 写回并 complete() PollTask.submit(ctx, since); } }长轮询的坑在于线程占用:每个在线用户挂起期间占一个容器线程,Tomcat 默认最大线程 200,也就是两百人左右就撑满了。所以这条路线只适合小规模或者当作兼容降级方案。
3.3 消息落库与历史分页
MessageDao是纯 JDBC,没有 ORM,代码直白但要注意资源关闭和 SQL 参数化:
public class MessageDao { public static void save(Long roomId, String nickname, String content) { String sql = "INSERT INTO t_message(room_id, sender_id, content) VALUES(?,?,?)"; // try-with-resources 保证连接和语句一定关,不然连接池几下就耗尽 try (Connection conn = DbUtil.getConnection(); PreparedStatement ps = conn.prepareStatement(sql)) { ps.setLong(1, roomId); ps.setLong(2, UserDao.findIdByNickname(nickname)); ps.setString(3, content); ps.executeUpdate(); } catch (SQLException e) { throw new RuntimeException("消息落库失败", e); } } // 用 id 做游标翻页,别用 offset,深翻页会越来越慢 public List<Message> page(Long roomId, long beforeId, int size) { String sql = "SELECT id, sender_id, content, create_time FROM t_message " + "WHERE room_id = ? AND id < ? ORDER BY id DESC LIMIT ?"; List<Message> list = new ArrayList<>(); try (Connection conn = DbUtil.getConnection(); PreparedStatement ps = conn.prepareStatement(sql)) { ps.setLong(1, roomId); ps.setLong(2, beforeId <= 0 ? Long.MAX_VALUE : beforeId); ps.setInt(3, Math.min(size, 50)); // 上限卡死,防止前端传个 10000 try (ResultSet rs = ps.executeQuery()) { while (rs.next()) { list.add(map(rs)); } } } catch (SQLException e) { throw new RuntimeException("查询历史消息失败", e); } return list; } }beforeId游标比offset好在哪:LIMIT 10000, 20这种写法数据库要先把前一万行扫出来丢掉,消息表上百万行时延迟肉眼可见;改成id < lastId直接走主键索引定位,翻到多深都是一样的速度。Math.min(size, 50)是防呆,接口参数一定要在服务端再卡一次上限,不能信任前端。
3.4 JSP 页面拼装:三块区域加一个必须做的转义
chat.jsp的结构就是三块:左边在线列表、中间消息流、底部输入框。服务端渲染首屏历史消息,后面的增量靠 WebSocket 往 DOM 里追加。
<!-- chat.jsp 片段:首屏渲染历史消息 --> <div id="msgList"> <c:forEach items="${history}" var="m"> <div class="msg"> <span class="nick"><c:out value="${m.nickname}"/></span> <!-- 必须转义:不转义就是存储型 XSS,别人发一段 script 就能偷你的 session --> <span class="content"><c:out value="${m.content}"/></span> <span class="time"><fmt:formatDate value="${m.createTime}" pattern="HH:mm:ss"/></span> </div> </c:forEach> </div>这里必须强调<c:out>。用${m.content}直接输出,用户只要发一条<img src=x onerror=alert(document.cookie)>,全房间的人打开聊天页都会执行这段脚本,Session 就没了。这是聊天类项目最典型的安全漏洞,课程设计演示时没人会攻击你,但代码评审一眼就能看出来。前端 JS 往 DOM 里插消息时同理,用textContent,别用innerHTML。
4. 部署跑通:Tomcat、数据库与几个容易漏的配置
4.1 环境与依赖准备
要准备的东西不多,但版本要对上:
| 组件 | 建议版本 | 说明 |
|---|---|---|
| JDK | 8 或 11 | 用 javax.servlet 的话别上 JDK 17+ 配 Tomcat 10 |
| Tomcat | 8.5 / 9.0 | Tomcat 10 之后包名改成 jakarta.*,Servlet 和 WebSocket 注解全要改 |
| MySQL | 5.7 / 8.0 | 8.0 注意驱动类名和时区参数 |
| Maven | 3.6+ | 打包用 |
最容易翻车的是 Tomcat 版本。Tomcat 9 及以前用javax.servlet/javax.websocket,Tomcat 10 开始全部迁到jakarta.*。如果你拿到的代码 import 是 javax,却丢进 Tomcat 10 里跑,启动就报ClassNotFoundException: javax.servlet.http.HttpServlet,看着像依赖没导进来,其实是命名空间换了。这时候要么降容器版本,要么全局替换包名并换 pom 依赖,别混着来。
数据库连接配置放在WEB-INF/jdbc.properties,放在 WEB-INF 下是为了让容器不直接对外暴露这个文件:
jdbc.url=jdbc:mysql://127.0.0.1:3306/chat_db?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false jdbc.username=chat_user jdbc.password=change_me jdbc.driver=com.mysql.cj.jdbc.DriverserverTimezone不写,MySQL 8 驱动在某些时区下会抛The server time zone value is unrecognized;useSSL=false是本地开发图省事,线上环境别这么干。密码别用 root 直接连,建一个只有这个库权限的账号,省得以后换环境时到处改。
4.2 建库建表与初始化数据
建库和账号在命令行走一遍,别用图形工具点,因为后面换机器还要重复:
# 建库和业务账号,字符集必须是 utf8mb4,否则 emoji 存不进去 mysql -uroot -p <<'SQL' CREATE DATABASE chat_db DEFAULT CHARSET utf8mb4 COLLATE utf8mb4_general_ci; CREATE USER 'chat_user'@'%' IDENTIFIED BY 'change_me'; GRANT SELECT, INSERT, UPDATE, DELETE ON chat_db.* TO 'chat_user'@'%'; FLUSH PRIVILEGES; SQL # 导入表结构和一条测试账号 mysql -uroot -p chat_db < src/main/resources/schema.sql mysql -uroot -p chat_db < src/main/resources/data.sqlschema.sql里放前面那两张建表语句,data.sql里塞两三个测试账号,方便你开两个浏览器互发消息。密码字段记得跟后端加密方式对得上,手工插明文密码然后登录失败,是最常见的"代码没问题但进不去"的原因。
4.3 打包部署与自测清单
打包和部署就两条命令:
# 跳过测试打包,产物在 target/ 下面 mvn clean package -DskipTests # 拷进容器 webapps,注意 war 名字决定访问路径 cp target/chat-system.war $TOMCAT_HOME/webapps/ # 启动,看日志里有没有部署成功的行 $TOMCAT_HOME/bin/startup.sh && tail -f $TOMCAT_HOME/logs/catalina.outwar 包名字就是上下文路径。打成chat-system.war,访问地址就是http://localhost:8080/chat-system/login.jsp。想直接走根路径就把 war 改成ROOT.war,但记得把代码里所有硬编码的/chat-system/前缀改成getContextPath()动态取,否则一换名字全部 404。
部署完按这个顺序自测,三步能覆盖 90% 的问题:
- 打开登录页,随便输错密码,看有没有回到登录页并显示错误提示——验证过滤器没把登录请求拦住。
- 用两个不同浏览器(或一个正常窗口一个隐身窗口)分别登录两个账号,在 A 里发一条消息,看 B 有没有立刻出现——验证 WebSocket 握手和广播。
- 关掉 B 的浏览器,等半分钟,在 A 的在线列表里看 B 有没有消失——验证 SessionListener 生效。
三步里任何一步不动,直接去看catalina.out最后 50 行,WebSocket 握手失败、数据库连不上、类找不到,日志里都写得很清楚,比在浏览器控制台瞎猜快得多。
5. 避坑与排查:五个把我卡过半天的现场问题
5.1 消息发出去了,对面必须刷新才看得见
现象:A 发消息,A 自己的页面能看到,B 端毫无反应,一刷新历史记录里又有这条。
原因基本是两个:一是前端根本没连上 WebSocket,new WebSocket()那行的地址写错了(漏了 contextPath,或者用了http://而不是ws://),浏览器控制台会有一条红色的握手失败;二是连上了但服务端广播时遍历的是空 map,因为@OnOpen里没有把 session 放进CONNECTIONS,或者放进去的 key 在 close 时被误删。
解决:先按 F12 看 Network 面板里有没有 101 Switching Protocols,有就说明连接通了,问题在服务端广播;没有就是地址或拦截器问题——注意有些安全配置会拦掉 ws 升级请求。确认连接通之后再在broadcast()里打一行日志输出当前连接数,数字是 0 就说明注册那步漏了。
5.2 部署到容器里启动就报 NoClassDefFoundError
现象:IDE 里跑得好好的,打成 war 丢进 Tomcat 就报java.lang.NoClassDefFoundError: javax/servlet/http/HttpServlet或者com/mysql/cj/jdbc/Driver。
原因:Servlet API 被错误打成 compile 打进了 war,跟容器自带的版本冲突;或者 MySQL 驱动被写成 provided,容器里没有所以找不到。
解决:打开 war 包(unzip -l target/chat-system.war | grep jar)直接看 WEB-INF/lib 里有什么。servlet-api.jar 不该在里面,mysql-connector 必须在里面。pom 里provided和默认 compile 的区别,在这个环节一次就能记住。
5.3 聊一会儿连接就断,控制台反复重连
现象:能正常聊,但每隔几十秒或者一分钟左右就断一次,前端重连逻辑疯狂触发。
原因:中间有反向代理或者容器配置了空闲超时,WebSocket 长时间没有数据帧被判定为空闲连接掐掉。Tomcat 的connectionTimeout、代理的proxy_read_timeout默认值都在这个量级。
解决:服务端加心跳,客户端每 25 秒发一个{"type":"ping"},服务端回pong。这样连接上始终有流量,不会被判空闲。同时把容器的超时调大一点。别指望前端重连兜底解决,频繁重连会导致消息丢失和重复。
5.4 中文变问号,emoji 存进去直接报错
现象:消息里的中文显示成???,或者带 emoji 的消息一提交就抛Incorrect string value。
原因:三个环节任意一个字符集不对都会这样——数据库和表的字符集是 utf8(三字节,存不下 emoji)、JDBC URL 没带characterEncoding=utf8、JSP 页面没写pageEncoding="UTF-8"。
解决:建库建表统一用 utf8mb4;连接串加useUnicode=true&characterEncoding=utf8;每个 JSP 头部写<%@ page contentType="text/html;charset=UTF-8" pageEncoding="UTF-8" %>;Servlet 里在getParameter之前调req.setCharacterEncoding("UTF-8")。这四步少一步都可能出问题,别只改一处就以为好了。
5.5 用户关了页面,在线列表里人还在
现象:测试的时候开了七八个标签页,关掉一半,在线列表纹丝不动。
原因:@OnClose只在连接正常关闭时触发,浏览器崩溃、断网、直接杀进程这些情况可能不触发;同时如果 Session 没超时,sessionDestroyed也不会执行。
解决:两头都要兜。WebSocket 侧加心跳超时检测,超过 90 秒没收到 pong 就主动关连接并清理;Session 侧把setMaxInactiveInterval调到合理值(比如 30 分钟)。在线列表渲染时再按"最近活跃时间"过滤一遍,超过阈值的直接不展示,比指望清理逻辑一个不漏要可靠。
6. 二次开发:把单房间广播改成多房间加私聊
真要把这套东西用起来,第一个要动的就是"所有消息都往一个池子里广播"这个设计。改造的核心是把原来扁平的CONNECTIONS换成按房间分组的二级结构,再给消息体加一个to字段区分群发和私聊。
// 改造后:房间 -> (sessionId -> session) private static final Map<String, Map<String, Session>> ROOMS = new ConcurrentHashMap<>(); @OnOpen public void onOpen(Session session, @PathParam("roomId") String roomId) { this.roomId = roomId; // computeIfAbsent 保证并发下只初始化一次,别用 if(containsKey) 再 put ROOMS.computeIfAbsent(roomId, k -> new ConcurrentHashMap<>()) .put(session.getId(), session); } // 广播时只遍历当前房间,人数上千也不会互相拖累 private void broadcastToRoom(String roomId, String payload) { Map<String, Session> members = ROOMS.getOrDefault(roomId, Collections.emptyMap()); members.values().forEach(s -> { if (s.isOpen()) s.getAsyncRemote().sendText(payload); }); }私聊不需要额外结构,在消息体里加一个to字段,服务端从NICKNAMES里反查目标 sessionId,只发给那一个连接就行。离线消息就落到t_message表里,加一个receiver_id字段,用户上线时先拉一次"发给我的未读",再拉房间历史。
改完之后必须验证的三件事:开三个浏览器进两个不同房间,确认跨房间消息不串;私聊消息只有目标收到;退出房间后该房间的 map 里对应 key 被删掉(打印 map 大小就知道,不删会内存泄漏)。我一般改完这类多级 map 的结构,都会在onClose里加一行日志把当前所有房间的连接数打出来,跑一轮进出房间的操作看数字对不对得上。
另外一个值得加的改造是消息发送限流。现在服务端对发消息频率没有任何约束,前端拿个 for 循环就能把数据库打满。最简单的做法是在onMessage里按 session 记录上次发送时间,间隔小于 200 毫秒直接丢弃:
private static final Map<String, Long> LAST_SEND = new ConcurrentHashMap<>(); long now = System.currentTimeMillis(); Long last = LAST_SEND.put(session.getId(), now); if (last != null && now - last < 200) { return; // 太频繁,静默丢弃,别抛异常给客户端 }血泪经验是:这类"看起来不会有人攻击"的小项目,演示当天最容易被同学拿浏览器控制台手动连发几十条刷屏。加完限流之后,记得把LAST_SEND在onClose里清掉,否则 map 随着连接数增长只增不减,这是个比消息丢失更隐蔽的泄漏。
从那以后我每次动这类共享容器的代码,都会先在纸上把"谁往里放、谁从里删、什么情况下漏删"这三行写清楚,再动手改——比改完再回头找泄漏点省太多时间。希望帮到你。
本文还有配套的精品资源,点击获取