☰
JavaWeb一对一网页聊天系统实战:从登录到消息落地的完整闭环
2026/11/2 19:09:55 网站建设 项目流程

简介:这份资源是一套基于JSP+JavaScript+Ajax+Servlet+Tomcat+MySQL实现的一对一网页聊天系统源码,面向正在学习JavaWeb、需要课程设计或入门项目实战的学生与开发者,重点解决聊天消息实时收发与页面局部刷新的实现问题。压缩包共54个文件、约2.88MB,包含12个Java类与12个class文件承载Servlet与业务逻辑,8个JSP页面负责登录、注册、好友列表与聊天界面,另有7个jar依赖、4个xml配置及js脚本等,结构完整可直接导入Eclipse运行。目前已有348人学习下载。项目核心在于Ajax每秒轮询Servlet获取新消息,TalkServlet处理发送请求、TalkFromServlet负责消息更新,配合c3p0连接池与sql.txt建表脚本,便于读者理解前后端交互流程、消息刷新机制与数据库设计,是掌握JavaWeb聊天功能实现细节的实用参考,UI部分尚未设计,适合在此基础上自行扩展界面与功能。

1. 一对一网页聊天系统:从登录到消息落地的完整闭环

两个人同时打开浏览器,一个发消息,另一个页面不刷新就能收到——这件事听起来简单,真动手做的时候,麻烦全在细节里:会话怎么建、消息怎么推、断线怎么补、历史怎么存。这个 JavaWeb 一对一网页聊天系统就是围绕这条主线搭起来的完整工程,适合正在学 JavaWeb、想做课程设计或想拿一个能跑通的即时通讯 Demo 二次开发的人。它不追求大而全,而是把「用户登录 → 建立会话 → 双向收发 → 消息持久化」这条链路做扎实,代码量可控,结构清晰,新手照着能跑起来,熟手能直接在上面加功能。下面按「能干什么 → 怎么跑 → 怎么改」的顺序拆。

2. 技术栈与目录结构:一个请求从登录到收消息走了哪些层

2.1 技术选型与各层职责

这个项目的定位是「JavaWeb 入门级即时通讯」,所以技术栈不会堆得太重。常见做法是后端用 Servlet + JSP 打底,数据库用 MySQL 存用户和消息,前端用原生 JavaScript 加 AJAX 做页面交互,消息推送部分用轮询或 WebSocket 二选一。我一般会建议:如果只是课程设计,轮询足够;如果想体验真正的服务端推送,就上 WebSocket。

层次技术职责
表现层JSP + HTML/CSS/JS登录页、聊天页渲染,消息展示
控制层Servlet接收请求、参数校验、调用业务
业务层Java 普通类(Service)用户校验、消息存取、会话管理
持久层JDBC / DAO用户表、消息表读写
数据库MySQL存账号、密码、聊天记录
通信AJAX 轮询 / WebSocket消息实时到达

选 Servlet 而不是 Spring 的原因很直接:依赖少、启动快、不用配一堆 XML,新手能看清一个请求到底经过了哪些类。代价是要自己写不少重复代码,但对理解原理反而是好事。

2.2 目录结构长什么样

一个典型的 Maven Web 工程目录大致如下,不同人写法会有差异,但核心分层不会变:

chat-system/ ├── src/main/java/ │ ├── controller/ # Servlet:LoginServlet、ChatServlet、MessageServlet │ ├── service/ # 业务逻辑:UserService、MessageService │ ├── dao/ # 数据访问:UserDao、MessageDao │ ├── model/ # 实体类:User、Message │ └── util/ # 工具类:DBUtil、JsonUtil ├── src/main/webapp/ │ ├── login.jsp # 登录页 │ ├── chat.jsp # 聊天主页面 │ ├── js/chat.js # 前端收发逻辑 │ └── WEB-INF/web.xml # Servlet 映射 └── pom.xml # 依赖:mysql-connector、servlet-api、gson

controller只做参数接收和转发,service放业务判断,dao只管 SQL,model是纯数据载体。这种分层看起来啰嗦,但改起来省心——比如以后要把轮询换成 WebSocket,只需要动controller和前端,service、dao基本不用碰。

2.3 一次登录请求的完整链路

以登录为例,走一遍链路能看清各层怎么配合。前端表单提交到LoginServlet,Servlet 取参数、调UserService.login(),Service 调UserDao.findByUsername()查库比对密码,结果回传后决定跳转chat.jsp还是回登录页报错。

// LoginServlet.java 核心片段 protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { String username = req.getParameter("username"); String password = req.getParameter("password"); UserService userService = new UserService(); User user = userService.login(username, password); // 返回 null 表示失败 if (user != null) { req.getSession().setAttribute("currentUser", user); // 登录态存 Session resp.sendRedirect("chat.jsp"); } else { req.setAttribute("error", "用户名或密码错误"); req.getRequestDispatcher("login.jsp").forward(req, resp); } }

这里的关键点是登录态放在HttpSession里,后续所有需要身份的接口都从 Session 取currentUser,而不是每次请求都带用户名参数——否则别人改个参数就能冒充。userService.login()内部先按用户名查记录,再用查出的盐值对密码做一次哈希比对,明文密码不落库。参数说明:username、password来自表单字段名,必须和 JSP 里<input name="...">保持一致,这是新手最容易对不上的地方。

3. 核心模块拆解:消息怎么发出去、又怎么到达对方

3.1 消息表设计与 DAO 写入

消息能不能查得回来,全看表设计。最小可用的消息表需要四个关键字段:发送者、接收者、内容、时间。常见做法是加一个自增主键和已读标记,方便以后做未读提醒。

CREATE TABLE message ( id BIGINT PRIMARY KEY AUTO_INCREMENT, from_user VARCHAR(50) NOT NULL, -- 发送者用户名 to_user VARCHAR(50) NOT NULL, -- 接收者用户名 content TEXT NOT NULL, -- 消息正文 send_time DATETIME DEFAULT CURRENT_TIMESTAMP, is_read TINYINT DEFAULT 0, -- 0 未读 1 已读 INDEX idx_to_user (to_user, id) -- 按接收者拉取时走索引 );

idx_to_user这个联合索引是血泪经验:轮询模式下前端每隔一两秒就来拉一次「发给我的新消息」,没有索引时数据一多查询就慢,页面会明显卡顿。DAO 层写入就是一条标准INSERT,读取则按to_user加id > lastId增量拉取,避免每次全量返回。

// MessageDao.java 增量拉取新消息 public List<Message> findNewMessages(String toUser, long lastId) { String sql = "SELECT * FROM message WHERE to_user = ? AND id > ? ORDER BY id ASC"; List<Message> list = new ArrayList<>(); try (Connection conn = DBUtil.getConnection(); PreparedStatement ps = conn.prepareStatement(sql)) { ps.setString(1, toUser); ps.setLong(2, lastId); // 前端记住上次最大 id,只取更新的 ResultSet rs = ps.executeQuery(); while (rs.next()) { list.add(mapRow(rs)); // 逐行映射成 Message 对象 } } catch (SQLException e) { e.printStackTrace(); } return list; }

lastId由前端保存并在每次请求时带上,服务端只返回比它大的记录。这样即使轮询频率高,单次返回的数据量也很小。参数toUser必须从 Session 里取当前登录用户,不能信任前端传的值,否则就能拉到别人的消息。

3.2 前端轮询与消息渲染

前端部分负责定时拉取和把消息贴到页面上。轮询的间隔要权衡:太短请求多,太长延迟明显。一般 1~2 秒比较合适。

// chat.js 轮询核心逻辑 let lastId = 0; const currentUser = document.getElementById('currentUser').value; function pollMessages() { fetch(`/message?action=pull&lastId=${lastId}`) .then(res => res.json()) .then(data => { data.forEach(msg => { appendMessage(msg); // 渲染到聊天框 lastId = Math.max(lastId, msg.id); // 更新游标 }); }) .catch(err => console.error('拉取失败', err)); } setInterval(pollMessages, 1500); // 每 1.5 秒拉一次

lastId是游标,只增不减,保证不重复渲染也不漏消息。appendMessage里做两件事:拼 DOM 节点、滚动到底部。注意currentUser从页面隐藏域取,服务端仍要以 Session 为准做二次校验,前端传的只用于展示区分左右气泡。

3.3 换成 WebSocket 的改造点

如果不想忍受轮询延迟,可以把拉取换成 WebSocket。服务端用@ServerEndpoint标注一个端点,连接建立时把用户和 Session 绑定,收到消息后直接推给目标用户的连接。

@ServerEndpoint("/ws/{username}") public class ChatEndpoint { private static final Map<String, Session> ONLINE = new ConcurrentHashMap<>(); @OnOpen public void onOpen(Session session, @PathParam("username") String username) { ONLINE.put(username, session); // 上线登记 } @OnMessage public void onMessage(String text, Session session) { // text 约定格式:toUser|content,实际项目建议用 JSON String[] parts = text.split("\\|", 2); Session target = ONLINE.get(parts[0]); if (target != null && target.isOpen()) { target.getAsyncRemote().sendText(parts[1]); // 推给对方 } } @OnClose public void onClose(@PathParam("username") String username) { ONLINE.remove(username); // 下线清理 } }

ONLINE用ConcurrentHashMap是因为多个连接会并发读写,普通HashMap在高并发下可能出问题。@PathParam把 URL 里的用户名带进来做绑定。改造时前端把setInterval换成new WebSocket(...),消息到达走onmessage回调,其余渲染逻辑可以复用。注意 WebSocket 端点也要做身份校验,不能只靠 URL 里的用户名。

4. 本地跑起来:环境准备到双窗口互发消息

4.1 环境与依赖准备

跑这个项目需要 JDK 8 及以上、Maven、MySQL 5.7 或 8.0、一个 Servlet 容器(Tomcat 8/9 都行)。数据库先建库建表,用户表至少要有username、password、salt三个字段。依赖在pom.xml里声明:

<dependencies> <dependency> <groupId>javax.servlet</groupId> <artifactId>javax.servlet-api</artifactId> <version>4.0.1</version> <scope>provided</scope> <!-- 容器已提供,不打进 war --> </dependency> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <version>8.0.33</version> </dependency> <dependency> <groupId>com.google.code.gson</groupId> <artifactId>gson</artifactId> <version>2.10.1</version> <!-- 前后端 JSON 序列化 --> </dependency> </dependencies>

servlet-api的scope必须是provided,否则和容器自带的包冲突,启动时报类加载异常。数据库连接信息写在DBUtil或配置文件里,常见做法是放db.properties,避免硬编码。

4.2 启动与验证步骤

第一步,执行建表 SQL,插入两个测试账号。第二步,mvn clean package打出 war 包,丢进 Tomcat 的webapps目录,启动容器。第三步,浏览器开两个窗口(或一个正常窗口加一个隐身窗口),分别登录两个账号,在聊天页选中对方发消息,观察另一个窗口是否在 1~2 秒内出现新消息。

验证时重点看三处:登录后 Session 里有没有currentUser、消息表里有没有新记录、前端控制台的轮询请求返回是否正常。任何一处断了,消息都不会到达。数据库连不上时先看DBUtil里的 URL、账号、密码和驱动类名,MySQL 8 的驱动类是com.mysql.cj.jdbc.Driver,写成旧版com.mysql.jdbc.Driver会报驱动找不到。

提示:两个窗口登录同一账号会互相覆盖 Session 里的用户信息,测试时必须用两个不同账号,这是最常见的「消息发出去对方收不到」的假象来源。

5. 避坑与排查:那些让消息「发出去却收不到」的坑

5.1 消息发出但对方页面无反应

现象:发送方提示成功,接收方页面一直不刷新。原因通常是轮询请求没带对身份,服务端从 Session 取不到当前用户,查询条件落空。解决:检查前端请求是否携带 Cookie(同源请求默认带),确认pull接口里用的是session.getAttribute("currentUser")而不是前端传的用户名。另一个可能是lastId初始化错误,前端一开始就传了一个很大的值,导致新消息全被过滤掉。

5.2 中文消息变乱码

现象:数据库里存的中文正常,页面显示成问号或方块。原因多在请求编码没统一。解决:在 Servlet 取参数前设置req.setCharacterEncoding("UTF-8"),响应设置resp.setContentType("text/html;charset=UTF-8"),数据库连接 URL 加上useUnicode=true&characterEncoding=utf8。三处缺一处都可能翻车,我一般三处一起配。

5.3 轮询越跑越卡

现象:聊一会儿页面明显变慢,请求响应时间越来越长。原因是没有增量拉取,每次全量查历史消息,数据量涨上去自然慢。解决:确认lastId游标生效,SQL 带id > ?条件,并给to_user建索引。如果已经上了 WebSocket 还卡,检查是不是在onMessage里做了同步数据库写入,改成异步或批量落库。

5.4 部署后 404 找不到 Servlet

现象:本地 IDE 里能跑,打成 war 部署到 Tomcat 后访问 404。原因通常是web.xml里的url-pattern和前端请求路径对不上,或者用了注解@WebServlet但web.xml的metadata-complete="true"把注解扫描关了。解决:统一用注解方式并确认metadata-complete为false,或者两边都写清楚映射,路径大小写也要一致。

5.5 连接池耗尽

现象:用一段时间后报「too many connections」。原因是每次请求都新建连接却没关闭。解决:try-with-resources确保Connection、PreparedStatement、ResultSet都关闭,或者引入一个简单连接池统一管理。这个项目规模小,try-with-resources基本够用。

6. 二次开发:在现有骨架上加已读回执和离线消息

拿到这个项目后,最值得先加的两个功能是已读回执和离线消息,因为它们直接决定聊天体验是否「像个真东西」。已读回执的思路很简单:接收方拉取到消息后,回一个确认接口,把对应id的is_read置 1;发送方轮询时顺带查自己发出的消息里哪些已读,在气泡上打勾。改造点集中在MessageDao加一个markRead(List<Long> ids)方法,MessageServlet加一个action=read分支,前端在appendMessage后调一次确认。

离线消息则依赖「消息本来就落库」这个前提——用户不在线时消息照样写进表里,下次登录拉历史时自然带出来。要注意的是拉历史的接口和拉新消息的接口要分开:历史按时间倒序分页,新消息按id增量,混在一起容易重复渲染。我一般会约定action=history&page=1和action=pull&lastId=x两个入口,前端首次进入加载历史,之后只走增量。

验证改造是否成功,别只看页面,直接查库最可靠:发一条消息,确认is_read从 0 变 1;退出一个账号再发消息,重新登录后确认历史里能查到。还有一个容易忽略的点是并发:两个用户同时给对方发消息时,ONLINE映射和数据库写入都要保证线程安全,ConcurrentHashMap和数据库事务各管一段,别在 Service 里用成员变量缓存状态。

从那以后我每次改这类聊天项目,都会先把「发一条消息 → 查库 → 对方拉取 → 标记已读」这条链路在数据库层面手动走一遍,确认每一步都有痕迹,再去动前端。页面上的玄学问题,十有八九能在表里找到答案。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询