JSP多人聊天系统:Servlet会话校验与WebSocket实时升级 简介面向Java Web初学者与课程设计者的多人聊天系统参考工程聚焦网页端多用户实时交流场景涉及JSP页面、Servlet请求处理、session会话维护以及AJAX无刷新交互等知识点可用于理解聊天室的基本分层、消息接收与广播思路。压缩包共11个文件、约12KB以6个class和2个java源码为主另含Eclipse工程配置体量较小更接近可导入开发环境的精简工程骨架便于查看类结构、包组织与项目配置。已有677人学习可作为课程作业、毕业设计前期调研或二次开发的基础样本帮助读者对照JSP与Servlet协作方式梳理登录会话、消息转发和前端异步更新等模块的实现脉络并据此补全页面、调试接口与验证功能。1. JSP 多人聊天系统能解决什么从整页刷新到会话级消息增量一套 java 多人聊天系统前端落在 jsp 页面后端用 Servlet 接请求最怕的不是聊天框画不出来而是把“多人”做成“多刷新”。它适合课程设计、Java Web 练手和内部轻量沟通登录、在线列表、公共房间、历史消息、私聊都能拆开讲清楚。拿这份资源之前先明确一点JSP 只负责首屏和表单真正的消息增量要靠接口或 WebSocket把这条边界想明白后面部署、改实时性、压测才不会一路翻车。2. 环境与数据模型先把 JSP 页面挂到 Servlet 容器上拿到一个 JSP 聊天项目先别急着改页面。容器版本、war 结构、数据库字符集这三件事没对齐后面所有现象都会像玄学页面能开但登录 404消息能发但中文变问号WebSocket 能连但用户是空的。我的习惯是先跑最小闭环一个用户登录、一个房间、一条消息落库、另一个浏览器能拉到。这个闭环不通改多少前端特效都没意义。2.1 目录结构与容器选择别把 JSP 当静态页JSP 不是双击就能看的 HTML它要由 Servlet 容器编译成 Servlet 再执行。传统项目常见两种形态Maven 的 war 工程或者普通 webapp 目录加WEB-INF/lib手工放 jar。先看目录不要凭感觉往容器里丢。路径作用检查点src/main/java/.../servletServlet 与过滤器包名、注解、web.xml映射是否一致src/main/webapp/*.jspJSP 页面pageEncoding是否为 UTF-8src/main/webapp/WEB-INF/web.xml映射、欢迎页、会话配置metadata-complete不能随手设成 truesrc/main/resources/jdbc.properties数据库连接库名、账号、时区、字符集pom.xml打包方式packaging必须是war# 先看项目是不是标准 war 结构不是 Maven 项目也能跑但依赖要放进 WEB-INF/lib find . -maxdepth 3 -type f | sort | sed -n 1,40p # 打包产物在 target 下-DskipTests 避免测试库连不上导致构建失败 mvn -q clean package -DskipTests这段命令只做两件事先用find确认页面、配置、Java 源码的位置再用 Maven 打出 war。-q是安静模式减少无关日志clean清掉旧产物避免改完 JSP 后还部署了上一次的 war-DskipTests只适合先跑通正式交付前要把测试恢复。产物一般在target/下文件名就是 war 名部署后也会成为上下文路径的一部分。容器选择上老项目常见javax.servlet包名配 Tomcat 9 这类支持 Servlet 3.1/4.0 的容器更省事。如果你把它丢进 Tomcat 10可能会看到ClassNotFoundException: javax.servlet.http.HttpServlet因为 Tomcat 10 已经切到jakarta.*命名空间。这不是代码写得不对是包名和容器代际不匹配。要么换容器要么整体迁移 import不要只改一两个文件。2.2 数据模型用户表与消息表要支撑“增量拉取”聊天系统最小数据模型就两张表用户表和消息表。用户表负责登录与昵称消息表负责房间、发送者、内容和顺序。这里的关键不是字段多而是消息表要能让后端用id lastId做增量查询而不是每次把整个房间的历史消息全部翻出来。CREATE DATABASE IF NOT EXISTS chat_demo DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci; USE chat_demo; CREATE TABLE chat_user ( id BIGINT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(32) NOT NULL UNIQUE, password_hash CHAR(64) NOT NULL, nickname VARCHAR(32) NOT NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ) ENGINEInnoDB; CREATE TABLE chat_message ( id BIGINT PRIMARY KEY AUTO_INCREMENT, room_id VARCHAR(32) NOT NULL, user_id BIGINT NOT NULL, content VARCHAR(500) NOT NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, KEY idx_room_id_id (room_id, id), KEY idx_user_id (user_id) ) ENGINEInnoDB;password_hash CHAR(64)是按 SHA-256 十六进制存的常见做法目的是不落明文密码。如果原资源里是明文密码先改登录逻辑再上线不要觉得“练手项目无所谓”。room_id用VARCHAR(32)而不是整型是为了兼容public、class-01这类房间标识。idx_room_id_id (room_id, id)是核心索引对应查询WHERE room_id? AND id? ORDER BY id ASC LIMIT 50既走索引又保证顺序。content VARCHAR(500)在 MySQL 里按字符计数不是按字节中文也能存 500 个字符如果确实要发长文改成TEXT但不要给内容字段建索引。消息表不要用created_at做增量游标。时间受服务器时钟、数据库时区、并发插入影响同一秒内多条消息很容易重复或漏掉。自增id虽然可能有空洞但在单库单表下是单调递增的拿它做lastId更稳。这个选择会在第 3 章的前端轮询里直接体现。2.3 启动顺序先跑通登录与一条消息落库数据库建好后把连接配置放到资源目录。很多项目把账号密码写死在 Java 里先不要跟着学至少抽到jdbc.properties后面换环境不用改代码。jdbc.urljdbc:mysql://127.0.0.1:3306/chat_demo?useUnicodetruecharacterEncodingUTF-8serverTimezoneAsia/ShanghaiuseSSLfalse jdbc.userchat_app jdbc.passwordchange_me jdbc.drivercom.mysql.cj.jdbc.DriveruseUnicodetrue和characterEncodingUTF-8负责客户端与数据库之间的字符集serverTimezone不配时某些驱动会因为时区识别失败直接连不上useSSLfalse只适合本机调试别把它当成生产配置。jdbc.user不要用 root单独建一个只有增删改查权限的账号。CREATE USER chat_app127.0.0.1 IDENTIFIED BY change_me; GRANT SELECT, INSERT, UPDATE, DELETE ON chat_demo.* TO chat_app127.0.0.1; FLUSH PRIVILEGES;启动顺序按下面走别跳步执行建库建表 SQL确认chat_user、chat_message两张表存在。创建应用数据库账号确认jdbc.properties里的库名、账号、密码一致。把 war 放进容器的webapps启动容器看日志里有没有SQLException或ClassNotFoundException。浏览器访问http://127.0.0.1:8080/war-name/login.jsp注意war-name是 war 文件名去掉.war不是固定的chat。开两个浏览器一个正常窗口、一个隐身窗口各登录一个账号往同一个房间发一条消息再刷新另一个窗口看是否能拉到。# 只看登录页是否能被容器正确解析404 多半是 war 名、JSP 路径或上下文路径不对 curl -i http://127.0.0.1:8080/chat-demo/login.jspcurl -i会打印响应头和状态码。200 说明容器至少能找到页面404 先查 war 名和 JSP 路径不要先怀疑数据库500 再去看容器日志里的第一行Caused by。这一步过了再谈 WebSocket 和压测。3. 消息链路实现Servlet 接收、JSP 渲染与 AJAX 轮询的取舍JSP 聊天系统的消息链路一般是这样登录成功后把uid写进HttpSession发送消息走 Servlet消息落库前端定时拉取id lastId的增量JSP 只负责首屏历史消息。这个方案实时性一般但结构清楚、排错容易适合先跑通再升级。真正容易出问题的地方不是 SQL而是会话校验、参数边界和轮询游标。3.1 登录态与 Session 校验别让聊天接口裸奔聊天接口不能只靠页面上的登录框做判断。用户直接拿接口地址发请求照样能写库。常见做法是加一个过滤器只拦/api/chat/*登录页和静态资源放行。package demo.chat.web; import javax.servlet.*; import javax.servlet.annotation.WebFilter; import javax.servlet.http.*; import java.io.IOException; WebFilter(urlPatterns {/api/chat/*}) public class AuthFilter implements Filter { Override public void doFilter(ServletRequest req, ServletResponse resp, FilterChain chain) throws IOException, ServletException { HttpServletRequest request (HttpServletRequest) req; HttpServletResponse response (HttpServletResponse) resp; // 不新建会话避免未登录请求也拿到一个新的 JSESSIONID HttpSession session request.getSession(false); Object uid session null ? null : session.getAttribute(uid); if (uid null) { response.setStatus(401); response.setContentType(application/json;charsetUTF-8); response.getWriter().write({\code\:401,\msg\:\login required\}); return; } chain.doFilter(req, resp); } }这段过滤器的关键点是getSession(false)。如果写成getSession(true)或getSession()未登录请求也会被创建一个新会话接口看起来没报错但用户状态是空的后面更难查。uid在登录 Servlet 里校验密码成功后写入类型要保持一致后面强转才不会炸。还要注意如果web.xml里写了metadata-completetrueWebFilter不会被扫描过滤器等于没配要么去掉这个属性要么在web.xml里显式注册。3.2 发送消息的 Servlet参数校验、限流与事务边界发送消息接口只做三件事确认用户、校验内容、插入消息。不要在这一步做“顺便更新在线人数”“顺便刷新房间列表”之类的大事务单条消息插入不需要事务硬包一个大事务反而扩大锁范围。WebServlet(/api/chat/send) public class SendMessageServlet extends HttpServlet { Override protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws IOException { req.setCharacterEncoding(UTF-8); // 必须在 getParameter 之前 resp.setContentType(application/json;charsetUTF-8); HttpSession session req.getSession(false); if (session null || session.getAttribute(uid) null) { resp.setStatus(401); resp.getWriter().write({\code\:401}); return; } Long uid (Long) session.getAttribute(uid); String roomId req.getParameter(roomId); String content req.getParameter(content); String text content null ? : content.trim(); if (roomId null || !roomId.matches([a-zA-Z0-9_-]{1,32})) { resp.setStatus(400); resp.getWriter().write({\code\:400,\msg\:\bad room\}); return; } if (text.isEmpty() || text.length() 500) { resp.setStatus(400); resp.getWriter().write({\code\:400,\msg\:\bad content\}); return; } String sql INSERT INTO chat_message(room_id,user_id,content) VALUES(?,?,?); try (Connection conn Db.getConnection(); PreparedStatement ps conn.prepareStatement(sql)) { ps.setString(1, roomId); ps.setLong(2, uid); ps.setString(3, text); ps.executeUpdate(); resp.getWriter().write({\code\:0}); } catch (SQLException e) { getServletContext().log(send message failed, e); resp.setStatus(500); resp.getWriter().write({\code\:500}); } } }setCharacterEncoding必须在第一次getParameter之前调用否则中文已经按容器默认编码解析完了后面再设也没用。roomId用白名单正则限制长度和字符避免把奇怪字符串写进房间字段。内容先trim再判空和长度防止“500 个空格加一句话”绕过长度检查。SQL 用PreparedStatement不拼字符串避免注入。Db.getConnection()是项目里的连接工具类如果它每次请求都DriverManager.getConnection先换成连接池否则并发一上来连接数很快见底。3.3 前端轮询与增量拉取用lastId而不是sinceTime轮询不是“越频繁越实时”。2 秒一次对课程设计和几十人的内部房间够用再低就会把请求数抬上去。关键是每次只拉新增消息并且用一个单调递增的lastId做游标。let lastId 0; let timer null; async function pull() { const roomId public; const res await fetch( /api/chat/pull?roomId${encodeURIComponent(roomId)}lastId${lastId}, { credentials: same-origin } // 带上 JSESSIONID否则后端一直 401 ); if (res.status 401) { location.href /login.jsp; return; } if (!res.ok) return; const data await res.json(); (data.list || []).forEach(m { lastId Math.max(lastId, Number(m.id)); appendMessage(m); // 内部用 textContent 或转义不要直接拼 innerHTML }); } function startPolling() { if (timer) return; pull(); timer setInterval(pull, 2000); } document.addEventListener(visibilitychange, () { if (document.hidden) { clearInterval(timer); timer null; } else { startPolling(); } }); startPolling();credentials: same-origin是前端最容易漏的一行漏了以后浏览器不带JSESSIONID后端过滤器一直返回 401但登录页看起来又是成功的。lastId初始为 0服务端按id ASC返回最多 50 条前端用Math.max更新避免多条消息时游标回退。visibilitychange用来在页面切到后台时停掉定时器减少无意义请求。appendMessage里不要用innerHTML拼用户内容否则一条img onerror...就能把页面变成 XSS 练习场。轮询参数可以按下面这张表调参数建议值原因interval2000ms小规模够用再低请求数明显上升limit50一次拉太多会拖慢首屏和渲染lastId单调递增避免时钟回拨导致重复或漏消息roomId白名单校验防止任意字符串打穿查询如果在线人数超过几百或者消息延迟要求压到 1 秒以内轮询就会开始吃力这时候再考虑第 4 章的 WebSocket而不是一开始就把简单链路拆得七零八落。4. 实时性升级用 WebSocket 端点替代轮询的边界轮询改 WebSocket不是把setInterval换成new WebSocket就完了。JSP 页面里的登录态怎么传到端点、在线表怎么清理、广播和私聊怎么路由这三件事决定升级后是“更实时”还是“更多 bug”。我的建议是保留 JSP 首屏渲染只把增量消息切到 WebSocket这样历史消息和 SEO 无关但首屏速度和可调试性更好。4.1 WebSocket 握手与在线表ServerEndpoint不是万能WebSocket 握手本质上是 HTTP 升级请求浏览器会自动带上同域 Cookie。也就是说JSP本文还有配套的精品资源点击获取