首页 / 资讯中心 / 文章详情

Java多人聊天系统实战:JSP+Servlet+WebSocket消息广播与部署避坑

Java多人聊天系统实战:JSP+Servlet+WebSocket消息广播与部署避坑 ★ FEATURED ARTICLE
简介这份「多人聊天系统」面向 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 关键片段 -- packagingwar/packaging !-- 必须是 warTomcat 才能部署 -- dependencies !-- Servlet API编译期需要运行时由容器提供所以 scope 是 provided -- dependency groupIdjavax.servlet/groupId artifactIdjavax.servlet-api/artifactId version4.0.1/version scopeprovided/scope /dependency !-- JSTLJSP 里做循环和转义用少写一堆 scriptlet -- dependency groupIdjavax.servlet/groupId artifactIdjstl/artifactId version1.2/version /dependency !-- MySQL 驱动运行时要打进 war 里不能 provided -- dependency groupIdmysql/groupId artifactIdmysql-connector-java/artifactId version8.0.28/version /dependency /dependenciesscope这两处是最容易翻车的地方Servlet API 写错成 compilewar 里就会带一份 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 不写 SQLDAO 不碰 request。很多同学把查询直接写在 doGet 里后面加个分页就得改三处改到第三次就乱了。分层不是为了好看是为了让你改一个需求只动一个文件。web.xml里要留意的是欢迎页顺序和过滤器顺序这两处顺序错了行为完全不同!-- web.xml顺序决定行为别随意调换 -- welcome-file-list welcome-filelogin.jsp/welcome-file !-- 直接访问根路径先进登录页 -- /welcome-file-list filter filter-nameauth/filter-name filter-classcom.example.chat.filter.AuthFilter/filter-class /filter filter-mapping filter-nameauth/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 ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; -- 消息表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 倒序翻页的命门 ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;idx_room_time这个联合索引是必须的。历史消息的查询模式永远是某个房间最新的 N 条没有索引的话消息表过了几万行每次进聊天页都要全表扫前端转圈能转到你以为服务挂了。内存里维护的在线状态只有一个容器就够用ConcurrentHashMap而不是HashMap// OnlineUserService全局单例保存谁在线 public class OnlineUserService { // key 用 sessionId 而不是 userId因为同一账号可能开两个标签页 private static final MapString, 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 SetString 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 秒发一次请求问有没有新消息02 秒请求数多但每次很快释放最简单人数少长轮询请求挂起不返回有消息或超时才响应接近实时每个在线用户占一个线程中等人数兼容性最好WebSocket一次握手升级协议双向长连接实时连接常驻线程占用少真正的聊天场景这份源码里三种方式其实都有痕迹但主推 WebSocket因为 Tomcat 7 之后原生支持 JSR-356 标准一行注解就能开一个端点不需要额外依赖。核心实现ServerEndpoint(/ws/chat) // 前端 new WebSocket(ws://host/context/ws/chat) public class ChatEndpoint { // 所有在线连接key 用 sessionIdvalue 是 WebSocket 会话 private static final MapString, Session CONNECTIONS new ConcurrentHashMap(); private static final MapString, 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 ListMessage 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 ?; ListMessage 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 idmsgList c:forEach items${history} varm div classmsg span classnickc:out value${m.nickname}//span !-- 必须转义不转义就是存储型 XSS别人发一段 script 就能偷你的 session -- span classcontentc:out value${m.content}//span span classtimefmt:formatDate value${m.createTime} patternHH:mm:ss//span /div /c:forEach /div这里必须强调c:out。用${m.content}直接输出用户只要发一条img srcx onerroralert(document.cookie)全房间的人打开聊天页都会执行这段脚本Session 就没了。这是聊天类项目最典型的安全漏洞课程设计演示时没人会攻击你但代码评审一眼就能看出来。前端 JS 往 DOM 里插消息时同理用textContent别用innerHTML。4. 部署跑通Tomcat、数据库与几个容易漏的配置4.1 环境与依赖准备要准备的东西不多但版本要对上组件建议版本说明JDK8 或 11用 javax.servlet 的话别上 JDK 17 配 Tomcat 10Tomcat8.5 / 9.0Tomcat 10 之后包名改成 jakarta.*Servlet 和 WebSocket 注解全要改MySQL5.7 / 8.08.0 注意驱动类名和时区参数Maven3.6打包用最容易翻车的是 Tomcat 版本。Tomcat 9 及以前用javax.servlet/javax.websocketTomcat 10 开始全部迁到jakarta.*。如果你拿到的代码 import 是 javax却丢进 Tomcat 10 里跑启动就报ClassNotFoundException: javax.servlet.http.HttpServlet看着像依赖没导进来其实是命名空间换了。这时候要么降容器版本要么全局替换包名并换 pom 依赖别混着来。数据库连接配置放在WEB-INF/jdbc.properties放在 WEB-INF 下是为了让容器不直接对外暴露这个文件jdbc.urljdbc:mysql://127.0.0.1:3306/chat_db?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/ShanghaiuseSSLfalse jdbc.usernamechat_user jdbc.passwordchange_me jdbc.drivercom.mysql.cj.jdbc.DriverserverTimezone不写MySQL 8 驱动在某些时区下会抛The server time zone value is unrecognizeduseSSLfalse是本地开发图省事线上环境别这么干。密码别用 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 端毫无反应一刷新历史记录里又有这条。原因基本是两个一是前端根本没连上 WebSocketnew 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 没带characterEncodingutf8、JSP 页面没写pageEncodingUTF-8。解决建库建表统一用 utf8mb4连接串加useUnicodetruecharacterEncodingutf8每个 JSP 头部写% page contentTypetext/html;charsetUTF-8 pageEncodingUTF-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 MapString, MapString, 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) { MapString, 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 MapString, 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 随着连接数增长只增不减这是个比消息丢失更隐蔽的泄漏。从那以后我每次动这类共享容器的代码都会先在纸上把谁往里放、谁从里删、什么情况下漏删这三行写清楚再动手改——比改完再回头找泄漏点省太多时间。希望帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?
咨询建站