简介:面向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/.../servlet | Servlet 与过滤器 | 包名、注解、web.xml映射是否一致 |
src/main/webapp/*.jsp | JSP 页面 | pageEncoding是否为 UTF-8 |
src/main/webapp/WEB-INF/web.xml | 映射、欢迎页、会话配置 | metadata-complete不能随手设成 true |
src/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 ) ENGINE=InnoDB; 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) ) ENGINE=InnoDB;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.url=jdbc:mysql://127.0.0.1:3306/chat_demo?useUnicode=true&characterEncoding=UTF-8&serverTimezone=Asia/Shanghai&useSSL=false jdbc.user=chat_app jdbc.password=change_me jdbc.driver=com.mysql.cj.jdbc.DriveruseUnicode=true和characterEncoding=UTF-8负责客户端与数据库之间的字符集;serverTimezone不配时,某些驱动会因为时区识别失败直接连不上;useSSL=false只适合本机调试,别把它当成生产配置。jdbc.user不要用 root,单独建一个只有增删改查权限的账号。
CREATE USER 'chat_app'@'127.0.0.1' IDENTIFIED BY 'change_me'; GRANT SELECT, INSERT, UPDATE, DELETE ON chat_demo.* TO 'chat_app'@'127.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;charset=UTF-8"); response.getWriter().write("{\"code\":401,\"msg\":\"login required\"}"); return; } chain.doFilter(req, resp); } }这段过滤器的关键点是getSession(false)。如果写成getSession(true)或getSession(),未登录请求也会被创建一个新会话,接口看起来没报错,但用户状态是空的,后面更难查。uid在登录 Servlet 里校验密码成功后写入,类型要保持一致,后面强转才不会炸。还要注意:如果web.xml里写了metadata-complete="true",@WebFilter不会被扫描,过滤器等于没配;要么去掉这个属性,要么在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;charset=UTF-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 练习场。
轮询参数可以按下面这张表调:
| 参数 | 建议值 | 原因 |
|---|---|---|
interval | 2000ms | 小规模够用,再低请求数明显上升 |
limit | 50 | 一次拉太多会拖慢首屏和渲染 |
lastId | 单调递增 | 避免时钟回拨导致重复或漏消息 |
roomId | 白名单校验 | 防止任意字符串打穿查询 |
如果在线人数超过几百,或者消息延迟要求压到 1 秒以内,轮询就会开始吃力,这时候再考虑第 4 章的 WebSocket,而不是一开始就把简单链路拆得七零八落。
4. 实时性升级:用 WebSocket 端点替代轮询的边界
轮询改 WebSocket,不是把setInterval换成new WebSocket就完了。JSP 页面里的登录态怎么传到端点、在线表怎么清理、广播和私聊怎么路由,这三件事决定升级后是“更实时”还是“更多 bug”。我的建议是保留 JSP 首屏渲染,只把增量消息切到 WebSocket,这样历史消息和 SEO 无关,但首屏速度和可调试性更好。
4.1 WebSocket 握手与在线表:@ServerEndpoint不是万能
WebSocket 握手本质上是 HTTP 升级请求,浏览器会自动带上同域 Cookie。也就是说,JSP
本文还有配套的精品资源,点击获取