☰
JavaWeb一对一网页聊天系统:Servlet与WebSocket实践
2026/10/21 2:43:39 网站建设 项目流程

简介:这套JavaWeb一对一网页聊天系统,是一份适合JavaWeb初学者与课程设计使用的源码示例,覆盖登录注册、好友列表、聊天页面等基本模块,完整展示了基于Servlet与Ajax的一对一即时通信流程。前端采用JSP+JS+Ajax组织页面与异步交互,聊天页只负责消息显示和参数获取,Ajax定时向Servlet发送请求;TalkServlet处理用户发出的消息,TalkFromServlet以每秒一次的频率拉取更新,Tomcat提供运行环境,MySQL负责账号和聊天记录存储,整体演示了经典轮询式Web聊天的实现链路。压缩包共54个文件,含12个Java源码与对应class、8个JSP页面、7个Jar依赖库、数据库说明sql.txt以及若干项目配置文件,大小仅2.88MB,目录清晰,便于本地导入并对照调试验证;项目暂未设计UI,重心偏向功能与交互逻辑。目前已有347人学习浏览,尤其适合想快速掌握Servlet、Ajax异步通信及简易即时通讯思路的开发者参考。

1. 为什么需要自建一个 javaweb 一对一网页聊天系统

最近在给某个内部运营系统做客服工作台时,想给客服加一个能和客户“直接对话”的功能。最开始我选型时考虑过直接接第三方 IM SDK,但后来因为数据要留在服务器本地、聊天记录要和业务库打通,还需要支持私有化部署,于是干脆用 javaweb 传统技术栈写了一个“一对一网页聊天”模块。这个项目最终落地成了一个完整可运行的工程:用户登录、在线列表、点对点实时消息、历史记录查询全部包含,前端只需要浏览器,不用装任何客户端。它适合三类场景:给内部系统做简易私聊功能、开发私有化部署的客服系统,以及刚学完 Servlet / JSP / WebSocket、想拿一个完整可跑通项目当作参考的初学者。

整个项目不依赖 Spring,也不依赖前端框架,核心就是 Servlet + 原生 WebSocket + MySQL。这些技术在老项目里很常见,维护成本低,跑起来也轻。下面我把整个工程从选型到避坑完整拆一遍。

2. 架构与技术选型:先定边界,再写代码

2.1 实时通信方案取舍:用原生 WebSocket 而不是轮询

做这个模块之前,我先画了一条功能边界:只做“一对一私聊”,不做群聊、不做文件传输、不做音视频。目标就是解决“两个在浏览器里登录的用户能实时收发文字消息,并随时查回历史记录”这件事。听起来简单,真正实现时涉及用户身份、在线状态、消息路由、持久化四个问题。

实时通信方案有好几种:前端定时轮询、长轮询、SSE、WebSocket。我选了原生 WebSocket。Tomcat 从 7.0 开始就支持 javax.websocket 规范,项目不需要额外引入消息中间件。用轮询的话,前端每两三秒打一次接口,消息延迟高,而且每次请求都带一遍登录态,数据库压力明显增大。WebSocket 建立一次连接后,双方可以随时推送,服务端也能主动感知连接断开,贴合一对一聊天的即时性场景。

这里不是说“必须用 WebSocket”,实际上如果你的并发量很低、对实时性要求不苛刻,轮询也能用。但对聊天场景来说,WebSocket 的服务端推送能力和连接状态感知能力,能省掉很多“假在线”的判断逻辑。JavaWeb 项目在 Servlet 容器里直接使用 @ServerEndpoint 就能开一个 WebSocket 服务,依赖很轻,所以最终定了这条路线。

2.2 数据库表设计:用户、好友关系、聊天记录,三张表就够了

先把表结构确定下来,写代码就会顺很多。这个项目用 MySQL 作为存储,按最简单的模型设计了三张表:用户表、好友关系表、聊天消息表。用户表只存 id、用户名、密码、昵称;好友关系表记录两个用户之间的好友关系;聊天消息表用来持久化每一条私聊内容。SQL 如下:

CREATE TABLE `user` ( `id` int(11) NOT NULL AUTO_INCREMENT COMMENT '用户ID', `username` varchar(50) NOT NULL COMMENT '登录用户名', `password` varchar(64) NOT NULL COMMENT '密码(演示用,生产建议用哈希)', `nickname` varchar(50) DEFAULT NULL COMMENT '昵称', PRIMARY KEY (`id`), UNIQUE KEY `idx_username` (`username`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户表'; CREATE TABLE `friend_relation` ( `id` int(11) NOT NULL AUTO_INCREMENT, `user_id` int(11) NOT NULL COMMENT '用户A', `friend_id` int(11) NOT NULL COMMENT '用户B', `create_time` datetime DEFAULT NULL, PRIMARY KEY (`id`), UNIQUE KEY `uk_user_friend` (`user_id`,`friend_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='好友关系表'; CREATE TABLE `chat_message` ( `id` bigint(20) NOT NULL AUTO_INCREMENT, `from_user_id` int(11) NOT NULL, `to_user_id` int(11) NOT NULL, `content` text NOT NULL COMMENT '消息内容', `send_time` datetime DEFAULT NULL, `read_flag` tinyint(4) DEFAULT '0' COMMENT '0未读 1已读', PRIMARY KEY (`id`), KEY `idx_from_to` (`from_user_id`,`to_user_id`), KEY `idx_send_time` (`send_time`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='聊天消息表';

这三张表里,chat_message是写入最频繁的。查询历史记录时主要走from_user_id和to_user_id建立的双列索引,按时间排序时走id主键或者send_time普通索引都行。friend_relation表加联合唯一键,主要防止好友关系重复插入,在实际聊天系统里可以省掉很多“两个人到底是不是好友”的重复判断。

密码字段示例里直接存的是明文,这是为了演示方便。实际项目我一般会先用加盐的哈希算法(比如 BCrypt)处理后再入库,这一点在第 6 章改造部分会再提。

2.3 工程目录结构:Maven + JavaWeb 项目的模块划分

工程采用标准的 Maven Web 结构。pom.xml 里主要依赖:Servlet API、javax.websocket-api、MySQL JDBC 驱动,以及一个 JSON 处理库(Gson 或 Fastjson 都行)。核心代码按职责分成了 controller、websocket、dao、entity、service 五层。目录结构如下:

chat-project ├── pom.xml └── src/main ├── java │ └── com/internal/project/chat │ ├── controller │ │ ├── LoginServlet.java │ │ ├── LogoutServlet.java │ │ └── HistoryServlet.java │ ├── websocket │ │ ├── ChatEndpoint.java │ │ └── OnlineUserManager.java │ ├── dao │ │ ├── UserDao.java │ │ ├── FriendDao.java │ │ └── MessageDao.java │ ├── entity │ │ ├── User.java │ │ └── ChatMessage.java │ └── service │ └── ChatService.java ├── utils │ └── DbUtil.java └── webapp ├── index.html ├── login.html ├── chat.html └── WEB-INF/web.xml

这种分层的好处很直接:servlet 只负责接收 HTTP 请求和返回 JSON,websocket 只负责管理长连接和消息在线转发,dao 层只操作数据库。后面 WebSocket 端点如果要从 Tomcat 迁移到 Jetty,或者换成 Spring 的 WebSocket 模块,核心逻辑也能复用。

OnlineUserManager这个类单独拎出来,是因为它在整个系统里承担了“在线状态”的内存存储。它不是数据库,也不做持久化,只负责在进程内维护一个 userId 到 WebSocket Session 的映射。

3. 核心模块拆解:从登录到一条消息的完整链路

一条私聊消息从前端发送到对方收到,要经过四个环节:登录后拿到身份、建立 WebSocket 长连接、通过连接转发消息、落库保存。中间任何一个环节断了,都会表现为“消息发不过去”。下面按链路拆开说。

3.1 用户登录与在线状态管理

登录用的是传统 Servlet 接口。用户提交用户名和密码后,LoginServlet 先调用 UserDao 查库校验,成功后把 user 对象放进 HttpSession,同时把一个状态标记放到全站共享的在线用户 Map 里,方便后续 WebSocket 连接判断这个用户是否在线。

@WebServlet("/login") public class LoginServlet extends HttpServlet { @Override protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { String username = req.getParameter("username"); String password = req.getParameter("password"); UserDao dao = new UserDao(); User user = dao.findByUsernameAndPassword(username, password); resp.setContentType("application/json;charset=UTF-8"); PrintWriter out = resp.getWriter(); if (user == null) { out.write("{\"code\":1,\"msg\":\"用户名或密码错误\"}"); return; } // 登录信息存session,同时标记在线 req.getSession().setAttribute("loginUser", user); OnlineUserManager.getInstance().addUser(user.getId()); out.write("{\"code\":0,\"userId\":" + user.getId() + ",\"nickname\":\"" + user.getNickname() + "\"}"); } }

这段代码里,最关键的一行是OnlineUserManager.getInstance().addUser(user.getId())。它是一个进程内的在线状态集合,核心实现是一个 ConcurrentHashMap,key 是用户 id,value 是用户昵称或客户端连接信息。这里不用 HttpSession 判断在线,是因为 WebSocket 连接不走 HTTP 请求,服务端很难直接通过 HttpSession 判断对方是否在线,所以需要一个独立的内存状态。

public class OnlineUserManager { private static final OnlineUserManager INSTANCE = new OnlineUserManager(); private final ConcurrentHashMap<Integer, Session> onlineMap = new ConcurrentHashMap<>(); private OnlineUserManager() {} public static OnlineUserManager getInstance() { return INSTANCE; } public void addUser(Integer userId) { onlineMap.putIfAbsent(userId, null); } public void bindSession(Integer userId, Session session) { onlineMap.put(userId, session); } public Session getSession(Integer userId) { return onlineMap.get(userId); } public void removeSession(Integer userId) { onlineMap.remove(userId); } }

onlineMap的 value 存的是 WebSocket 的Session。登录时先 putIfAbsent 一个 null 占位,等 WebSocket 握手成功后再把真实 Session 绑上去。这样在 WebSocket 还没有建立前,HTTP 接口也能知道用户“登录过”;连接断开后把 Session 移除,这时用户在线状态就跟着消失。这里要注意,ConcurrentHashMap 只是保证单个 api 操作线程安全,如果后面做多实例部署,这个 Map 就要换成 Redis,否则不同 Tomcat 实例之间看不到彼此的在线状态。

3.2 WebSocket 端点:消息的接收、转发与通知

WebSocket 端点是整个聊天模块的“路由器”。我用@ServerEndpoint("/chat/{userId}")定义了一个端点,地址里直接带 userId,这样建立连接时就能确定当前连接属于哪个用户,避免依赖 Cookie 或 Session 解析。

@ServerEndpoint("/chat/{userId}") public class ChatEndpoint { private static final Gson gson = new Gson(); @OnOpen public void onOpen(Session session, @PathParam("userId") Integer userId) { session.setMaxIdleTimeout(0); // 不自动空闲断开 OnlineUserManager.getInstance().bindSession(userId, session); System.out.println("user connect: " + userId); } @OnMessage public void onMessage(String message, Session session) { MsgPacket msg = gson.fromJson(message, MsgPacket.class); // 心跳包:前端 ping 后端 pong if ("ping".equals(msg.getType())) { try { session.getBasicRemote().sendText("{\"type\":\"pong\"}"); } catch (IOException e) { e.printStackTrace(); } return; } Integer toUserId = msg.getToUserId(); Session target = OnlineUserManager.getInstance().getSession(toUserId); if (target != null && target.isOpen()) { // 目标在线,直接转发 target.getBasicRemote().sendText(message); } // 不管在不在线,都存库,不然离线消息会丢 ChatService.saveMessage(msg); } @OnClose public void onClose(Session session, @PathParam("userId") Integer userId) { OnlineUserManager.getInstance().removeSession(userId); System.out.println("user disconnect: " + userId); } @OnError public void onError(Session session, Throwable error) { error.printStackTrace(); } }

这里MsgPacket是消息实体,包含 fromUserId、toUserId、content、timestamp、type 等字段。为什么在 onMessage 里先把消息转发给目标、再存库?因为“实时性”优先。先转发,对方屏幕上立刻出现内容;然后落库,下次打开历史记录时再查出来。如果反过来先存库再发送,在对方连接状态是假在线时,就白存一条消息,体验上像“卡了一下”。

session.setMaxIdleTimeout(0)是 Tomcat 的 WebSocket 配置,0 表示不因为空闲而断开连接。但这只对应用层空闲有效,网络中间层可能仍然会通过 Nginx 或网关掐掉空闲连接,所以前端还需要做心跳。我在 onMessage 里做了心跳包兼容,ping类型不转发、不存库,只返回pong。

3.3 消息持久化与历史消息加载

保存消息放在 ChatService 里,它内部调用 MessageDao。MessageDao 的 insert 用 PreparedStatement,防止拼接 SQL 出问题。查询历史消息时,查出“互相之间”的所有消息,并按 send_time 升序返回。

public class MessageDao { public void insert(ChatMessage msg) throws SQLException { String sql = "INSERT INTO chat_message(from_user_id,to_user_id,content,send_time) " + "VALUES(?,?,?,?)"; try (Connection conn = DbUtil.getConnection(); PreparedStatement ps = conn.prepareStatement(sql)) { ps.setInt(1, msg.getFromUserId()); ps.setInt(2, msg.getToUserId()); ps.setString(3, msg.getContent()); ps.setObject(4, msg.getSendTime()); ps.executeUpdate(); } } public List<ChatMessage> findHistory(int userId1, int userId2) throws SQLException { String sql = "SELECT * FROM chat_message WHERE " + "(from_user_id=? AND to_user_id=?) " + "OR (from_user_id=? AND to_user_id=?) " + "ORDER BY send_time ASC"; List<ChatMessage> list = new ArrayList<>(); try (Connection conn = DbUtil.getConnection(); PreparedStatement ps = conn.prepareStatement(sql)) { ps.setInt(1, userId1); ps.setInt(2, userId2); ps.setInt(3, userId2); ps.setInt(4, userId1); ResultSet rs = ps.executeQuery(); while (rs.next()) { ChatMessage m = new ChatMessage(); m.setId(rs.getLong("id")); m.setFromUserId(rs.getInt("from_user_id")); m.setToUserId(rs.getInt("to_user_id")); m.setContent(rs.getString("content")); m.setSendTime(rs.getTimestamp("send_time")); list.add(m); } } return list; } }

用try-with-resources是重点。这个项目的数据库连接如果不关闭,Tomcat 的连接池很快就会把连接耗尽,表现就是系统用得越多越卡,最后登录都登不进去。用 try-with-resources 后,无论 SQL 是否报错,连接都会自动归还。

加载历史的接口是一个普通的 Servlet,例如/history?fromUserId=1&toUserId=2,返回 JSON 数组,前端渲染到聊天窗口上方。注意 fromUserId 不能简单相信前端传参,实际项目里要从登录 Session 里取,否则用户可以伪造聊天记录。

前端这边的核心逻辑也比较直接。建立 WebSocket 时带上当前用户 id,收到消息后如果msg.fromUserId等于当前聊天对象,就渲染到消息区;发送消息时把聊天的 toUserId 放进去。

const currentUserId = localStorage.getItem('userId'); const targetUserId = new URLSearchParams(location.search).get('targetUserId'); const ws = new WebSocket(`ws://${location.host}/chatProject/chat/${currentUserId}`); ws.onmessage = (event) => { const msg = JSON.parse(event.data); if (msg.type === 'chat') { renderMessage(msg); } else if (msg.type === 'pong') { // 心跳回复,什么都不做 } }; function sendMessage() { const content = document.getElementById('content').value; if (!content.trim()) return; const msg = { type: 'chat', fromUserId: currentUserId, toUserId: targetUserId, content: content, timestamp: Date.now() }; ws.send(JSON.stringify(msg)); renderMessage(msg); } // 心跳:每隔30秒发一次 setInterval(() => { ws.send(JSON.stringify({ type: 'ping' })); }, 30000);

这段代码有两点要注意:发送消息时不能直接使用像"content": "'" + content + "'"这种方式拼字符串,必须用JSON.stringify,否则消息里带引号或反斜杠就会让 JSON 解析失败;渲染时根据自己的 userId 判断是左侧还是右侧,避免消息方向错乱。

4. 把项目拉起来跑通:从源码到两个浏览器互相发消息

这一章记录我在本地把工程跑起来的过程。整体步骤是:准备环境 → 导入数据库 → 启动 Tomcat 部署 → 双窗口验证。跟着做一遍,大约二十分钟能跑起来。

4.1 环境准备:JDK、Maven、Tomcat 和 MySQL

我用的版本组合是 JDK8、Maven 3.6、Tomcat 8.5、MySQL 5.7。这个组合比较保守,javax.websocket 在 Tomcat 8.5 里支持得很稳定。如果 JDK 是 11 以上,Tomcat 也要对应换,比如 Tomcat 9。

在 pom.xml 里,Servlet 依赖和 WebSocket 依赖的作用域要确认是 provided,因为这两个 Jar 在 Tomcat 里已经有了。如果漏掉 provided,打成 war 包时把 Jar 塞进去,部署时会遇到类冲突。

<dependency> <groupId>javax.servlet</groupId> <artifactId>javax.servlet-api</artifactId> <version>3.1.0</version> <scope>provided</scope> </dependency> <dependency> <groupId>javax.websocket</groupId> <artifactId>javax.websocket-api</artifactId> <version>1.1</version> <scope>provided</scope> </dependency>

使用 Maven 打包:

mvn clean package

打包成功后,在target目录下会生成一个chatProject.war,把它复制到 Tomcat 的webapps目录下即可。

4.2 初始化数据库与配置连接

在 MySQL 中创建数据库 chat_db,然后执行第 2 章的三条建表 SQL,再插入两个测试账号。注意字符集要使用 utf8mb4,否则聊天内容里带 emoji 表情会出现乱码。

INSERT INTO user(username, password, nickname) VALUES ('admin', '123456', '管理员'); INSERT INTO user(username, password, nickname) VALUES ('alice', '123456', '爱丽丝');

数据库连接配置放在db.properties文件里,需要修改三个值:URL、用户名、密码。

jdbc.url=jdbc:mysql://localhost:3306/chat_db?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai jdbc.username=root jdbc.password=123456

DbUtil.java使用静态块加载驱动程序,每次 getConnection 从DriverManager.getConnection获取连接。这种写法在低并发演示时没问题,但在生产环境必须替换成连接池。

修改完配置后,启动 Tomcat:

bin/startup.sh

然后访问http://localhost:8080/chatProject/login.html。

4.3 功能验证清单

这里列一份我每次重新部署后都会跑一遍的验证清单,建议你也照着过一遍。

验证项操作预期结果
登录跳转输入 admin 密码登录跳到聊天页,显示用户名
用户不在线只登录 A,B 不登录A 发消息给 B,B 登录后历史记录可见
实时收发A 和 B 同时打开聊天页一方发消息,另一方无需刷新即可看到
连接断开关闭其中一个标签页另一个窗口再发消息不报错,消息落库
历史记录重新打开聊天页最近的聊天内容按时间顺序展示

“用户不在线”的验证比较容易翻车。因为 OnlineUserManager 是在进程内存里维护的,如果只开了 A 标签页,没有登录 B,B 的 Session 就不存在,消息只会落库。这是一对一聊天系统一个合理的降级行为。

5. 避坑指南:开发这个聊天项目时我踩过的四个坑

聊天系统的坑和别人不太一样,大多数问题不是逻辑不对,而是“连接状态”和“并发”造成的。我把最有代表性的四个问题放在这里,每条都按“现象 → 原因 → 解决”来写。

5.1 现象:WebSocket 连接建立后几十秒就被断开

现象:前端看到 WebSocket 的 onclose 被触发,重新连上过一会儿又断。

原因:浏览器和 Tomcat 之间经过了 Nginx,Nginx 默认的 proxy_read_timeout 是 60 秒;如果不对 Tomcat 做空闲超时设置,Tomat 也会回收空闲连接。

解决:在 ChatEndpoint 的 onOpen 里调用session.setMaxIdleTimeout(0),让服务端不主动断开;同时前端每 30 秒发一个{"type":"ping"}心跳包;如果经过 Nginx,要把proxy_read_timeout和proxy_send_timeout都调到 300 秒以上。我在 onMessage 里兼容了心跳包,收到 ping 不转发、不存库,只返回一个 pong。

5.2 现象:消息偶尔发送失败,且服务端控制台出现 JSON 解析异常

现象:消息内容里包含英文引号、反斜杠或者表情时,对方收不到消息。

原因:前端拼接 JSON 时用手工字符串拼接,比如"content": "'" + msg + "'",消息里含特殊字符就会破坏 JSON 格式。

解决:前端统一用 JSON.stringify 构造消息,后端使用 Gson 或 Jackson 解析,不要手工拼 JSON。后端 onMessage 里做好 try-catch,解析失败时返回错误码,不要把异常直接抛出去导致连接关闭。

5.3 现象:WebSocket 连接里判断不了用户身份

现象:WebSocket 端点代码里通过session.getRequestParameterMap()或 Cookie 获取登录信息,结果拿不到。

原因:浏览器发起 WebSocket 时也会带 Cookie,但在跨域或反向代理场景下,Cookie 可能被剥离;而且把登录态放在 HttpSession 里,WebSocket 端获取 Session 比较绕。

解决:这个项目直接用 URL 路径传 userId,例如/chat/1。如果需要更安全的做法,可以在登录后发一个短期 token 参数,后端校验后再绑定用户。token 方式代价是每次连接都要验证一次,但更符合分布式部署的预期。

5.4 现象:运行久了,登录请求越来越慢

现象:系统刚启动时流畅,几个小时后登录和查询接口响应时间飙升。

原因:数据库连接没释放。某个查询方法里 new 了一个 Connection 但只在 finally 中关闭了 Statement,Connection 一直占着连接池名额。

解决:统一改用 try-with-resources 或封装一个 BaseDao,确保 Connection、Statement、ResultSet 全部自动关闭。再往后,可以直接引入 HikariCP 连接池,效果更好。排查时可以先看 MySQL 的 max_connections 是否打满,再用show processlist看是哪些连接卡在 Sleep 状态。

6. 二次开发改造:从能跑的 Demo 到能落地的私聊模块

如果你已经把这个项目跑通,下一步通常会想加一些更像“产品”的功能。我挑四个改造方向聊聊,它们的改动量都不大。

6.1 增加最近会话列表和未读计数

现在的聊天页靠“输入对方 id”找到对方。二次开发可以加一个左侧会话列表。数据库新增一张 conversation 表,保存 last_user_id、other_user_id、last_message、unread_count、update_time。每次发消息时更新这张表,登录后按 update_time 倒序查最近会话。这样用户不用记 id,直接点列表就能进入聊天。

6.2 增加“已读”回执

chat_message 表里已经有 read_flag 字段。后端在对方拉取历史消息时,把 from_user_id 等于对方且 to_user_id 等于自己的消息更新为已读;WebSocket 端点里也可以定义一个 ack 消息,通知发送方“对方已读”。这样可以在聊天窗口底部显示“已读/未读”。

6.3 把密码改成加盐哈希

项目里为了方便调试,密码用明文。改造时用 BCrypt 生成哈希串替换掉 password 字段,登录校验时用 BCrypt.matches 比对。注意先考虑已有数据,可以加一列 password_hash,或者直接升级原密码字段为 60 位哈希串。这个改动不影响 WebSocket 和在线状态。

6.4 多实例部署与 WebSocket 集群

如果将来系统需要部署多个 Tomcat 实例,进程内的 OnlineUserManager 就失效了。常见做法是用 Redis 保存 userId 到节点地址的映射,消息先写入 Redis 的订阅频道,各节点再从频道取消息路由到本地 Session。这个改动比较大,但逻辑可以复用现有的 MsgPacket 结构,代价是引入新的中间件。

做完这几个方向,这个项目的骨架就从“演示级”成长成“工程级”了。开发过程中最让我记忆深刻的是:连接池和心跳问题,它们都是“你以为跑通了,其实没跑通”的隐藏问题。从那以后我每次做类似项目,都会先检查数据库连接的关闭是否收敛,再检查 WebSocket 是否有心跳兜底。这两个问题确认没问题,基本就不会对外丢人了。希望帮到你。

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

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

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

立即咨询