ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Java TCP聊天系统:Swing+MySQL实现私聊群聊与消息持久化

Java TCP聊天系统:Swing+MySQL实现私聊群聊与消息持久化 简介这是一份基于Java实现的仿QQ即时通讯系统完整源码包面向Java初学者与GUI网络编程学习者聚焦于掌握Socket通信、Swing界面开发、多线程消息处理及基础数据库交互等核心技能。资源包含85个文件涵盖20个.class可执行类、10个.java源码含server、dao、entity、util、gui等模块、27个.gif与23个.jpg资源图片用于登录页、聊天窗口、表情按钮、状态图标等UI元素以及mysql-connector-java驱动jar、建表sql脚本和Eclipse项目配置文件整体压缩包仅1MB轻量易部署。已有195人下载学习适合通过真实项目理解用户登录、好友分组、私聊/群聊双通道、消息持久化与界面事件响应等典型IM功能实现逻辑代码结构清晰模块划分明确是Java桌面端网络应用实践的优质入门范例。1. Java仿QQ项目落地实录不是玩具Demo是能跑通私聊群聊消息持久化的完整客户端-服务端架构去年带实习生做课程设计有个学生交上来一个“Java QQ”压缩包解压后看到server/dao/gui/目录结构整齐、qq.sql脚本带建表语句、连mysql-connector-java-5.1.15-bin.jar都打包进去了——我当场点开QQ主界面.JPG截图又试运行了bin/下的 class 文件发现登录后真能拉出好友列表、双击头像弹私聊窗口、右键群名进群聊室、发送文字实时回显、关闭再打开还能查历史记录。这不是网上泛滥的“Swing画个聊天框就叫仿QQ”的半成品而是一个基于TCP长连接 MySQL持久化 多线程消息分发 Swing GUI事件驱动的闭环系统。它解决的不是“怎么画按钮”而是“用户离线时消息怎么存、群聊广播怎么避免重复发、表情GIF怎么序列化传输、登录态怎么校验不被绕过”这些真实工程问题。适合两类人一是Java初学者想拿一个有数据库、有网络、有GUI、有状态管理的中型项目练手避开“Hello World→计算器→学生管理系统”的平庸路径二是课程设计/毕设需要快速验证通讯逻辑的同学——它不依赖Spring Boot或Netty纯JDK8就能编译运行但模块划分清晰entity定义消息模型、dao封装JDBC操作、server实现Socket监听、gui解耦界面与业务比教科书代码更贴近工业分层。2. 从零启动环境准备、源码结构解析与服务端部署三步走2.1 环境清单与版本确认为什么必须用JDK8 MySQL5.7这个项目对环境有明确隐性约束。先看mysql-connector-java-5.1.15-bin.jar——这是2011年发布的驱动只兼容MySQL 5.x系列5.1~5.7若强行用MySQL 8.0会报java.sql.SQLException: Unknown system variable query_cache_size。再看src/下所有.java文件无var关键字、无Optional链式调用、Thread创建仍用new Thread(Runnable)而非CompletableFuture说明它是为JDK8 编译javac -source 1.8 -target 1.8。实测在JDK11环境下编译会因javax.swing包路径变更失败。所以环境必须严格匹配提示不要试图升级驱动或JDK我试过替换为mysql-connector-java-8.0.28.jar结果UserDAO.java中rs.getInt(id)报NullPointerException——新版驱动对ResultSet的空值处理逻辑变了而原代码没做rs.wasNull()判断。安装步骤# 1. JDK8 安装以Linux为例 wget https://repo.huaweicloud.com/java/jdk/8u202-b08/jdk-8u202-linux-x64.tar.gz tar -xzf jdk-8u202-linux-x64.tar.gz export JAVA_HOME/path/to/jdk1.8.0_202 export PATH$JAVA_HOME/bin:$PATH # 2. MySQL5.7 安装Ubuntu 18.04 sudo apt-get install mysql-server-5.7 sudo systemctl start mysql # 3. 导入数据库 mysql -u root -p qq.sqlqq.sql中建表语句包含user用户表、friend好友关系表、group_info群信息、group_member群成员、message_record消息记录其中message_record表的send_time字段类型为datetime不是timestamp——这点很关键避免时区转换导致消息时间错乱。2.2 源码目录深度拆解每个包的真实职责与调用链项目采用标准Java分层结构但部分设计有实战妥协。以下按调用流向梳理核心链路目录关键文件实际作用注意点entity/User.java,Message.java,Group.java数据载体定义网络传输对象。Message类含senderId,receiverId,content,type(0私聊/1群聊),sendTime字段未实现Serializable接口——但server/包里用ObjectOutputStream写对象说明实际运行时JVM自动处理了序列化依赖默认机制不推荐dao/UserDAO.java,MessageDAO.java数据访问封装JDBC操作。MessageDAO.saveMessage()方法用PreparedStatement插入但未使用事务——多条INSERT如群聊需存N条记录可能部分成功部分失败server/ChatServer.java,ClientHandler.java通信中枢ChatServer启动ServerSocket监听9000端口ClientHandler为每个连接创建独立线程读取ObjectInputStream反序列化Message对象根据type字段路由到privateChat()或groupChat()方法gui/LoginFrame.java,MainFrame.java,ChatFrame.java界面胶水LoginFrame提交账号密码后调用server.Client.connect()建立SocketMainFrame的JTree好友列表通过FriendManager.loadFriends(userId)从DAO加载但未缓存——每次展开分组都查库调用链示例登录后拉好友LoginFrame.actionPerformed() → Client.connect() → Socket连接服务器 → MainFrame.init() → FriendManager.loadFriends() → UserDAO.getFriends() → JDBC查询friend表2.3 服务端一键启动ChatServer.java的隐藏配置项ChatServer.java主类看似简单但有两个硬编码参数决定系统行为public class ChatServer { private static final int PORT 9000; // 服务端监听端口客户端必须匹配 private static final int MAX_CLIENTS 100; // 最大并发连接数超限则拒绝新连接 public static void main(String[] args) { try (ServerSocket serverSocket new ServerSocket(PORT)) { System.out.println(QQ Server started on port PORT); while (true) { Socket clientSocket serverSocket.accept(); // 检查当前连接数 if (clients.size() MAX_CLIENTS) { clientSocket.getOutputStream().write(FULL.getBytes()); clientSocket.close(); continue; } new ClientHandler(clientSocket).start(); } } catch (IOException e) { e.printStackTrace(); } } }PORT9000若本地被占用必须同步修改客户端Client.java中的SERVER_PORT常量否则连接失败报Connection refused。MAX_CLIENTS100这是内存安全阀。clients是静态ArrayListSocket每连接存一个Socket对象。实测单机跑满100连接约占用1.2GB堆内存每个Socket约12MB超出会导致OOM。若需扩容需改用ConcurrentHashMap并加连接池。启动命令cd bin/ java -cp .:mysql-connector-java-5.1.15-bin.jar server.ChatServer成功启动后控制台输出QQ Server started on port 9000此时可启动多个客户端测试。3. 客户端功能实操私聊/群聊/消息记录的完整交互链路3.1 登录与好友列表加载从数据库到Swing组件的映射逻辑登录流程不是简单校验密码而是三次数据库交互UserDAO.validateUser(username, password)查询user表验证账号密码若成功UserDAO.getUserInfo(username)获取用户ID、昵称、头像路径images/下的GIF/JPGFriendManager.loadFriends(userId)根据用户ID查friend表关联user表获取好友昵称和头像。关键代码在LoginFrame.java的loginButton.addActionListener()// 步骤1验证用户 if (UserDAO.validateUser(username, password)) { // 步骤2获取用户信息 User user UserDAO.getUserInfo(username); // 步骤3加载好友 ListFriend friends FriendManager.loadFriends(user.getId()); // 构建JTree节点 DefaultMutableTreeNode root new DefaultMutableTreeNode(我的好友); for (Friend f : friends) { DefaultMutableTreeNode node new DefaultMutableTreeNode(f.getNickname()); node.setUserObject(f); // 存储Friend对象供后续点击使用 root.add(node); } tree.setModel(new DefaultTreeModel(root)); }注意node.setUserObject(f)是Swing的惯用技巧双击节点时可通过tree.getLastSelectedPathComponent()取出Friend对象避免再次查库。3.2 私聊窗口创建Socket复用与消息格式约定私聊不是新建Socket而是复用登录时建立的长连接。MainFrame.java中双击好友触发tree.addMouseListener(new MouseAdapter() { Override public void mouseClicked(MouseEvent e) { if (e.getClickCount() 2) { DefaultMutableTreeNode node (DefaultMutableTreeNode) tree.getLastSelectedPathComponent(); Friend friend (Friend) node.getUserObject(); // 复用已有Socket发送PRIVATE_CHAT_REQUEST协议头 Client.sendMessage(PRIVATE_CHAT_REQUEST: friend.getId()); new ChatFrame(friend.getNickname(), friend.getId(), true); // true表示私聊 } } });协议约定客户端向服务端发送字符串PRIVATE_CHAT_REQUEST:1002服务端ClientHandler解析后将该连接标记为与用户1002的私聊通道并通知对方上线发送USER_ONLINE:1001给1002。消息体仍走ObjectOutputStream序列化Message对象但receiverId填对方IDtype0。3.3 群聊加入与广播机制服务端如何避免消息风暴群聊逻辑藏在MainFrame.java的右键菜单popupMenu.add(new JMenuItem(加入群聊)).addActionListener(e - { int groupId Integer.parseInt(JOptionPane.showInputDialog(输入群号)); Client.sendMessage(JOIN_GROUP: groupId); });服务端ClientHandler收到JOIN_GROUP:5001后查group_member表确认用户是否已加入若未加入插入新记录向该群所有在线成员广播GROUP_MEMBER_JOIN:5001:10011001是新成员ID新成员收到广播后ChatFrame初始化时调用MessageDAO.getGroupMessages(groupId)加载历史消息。关键避坑点广播时遍历clients列表但未加锁。若某成员恰好断线其Socket已关闭但clients未及时清理clientSocket.getOutputStream().write()会抛IOException。原代码用空catch吞掉异常导致该成员收不到后续消息——必须在ClientHandler.run()中加心跳检测。3.4 消息记录持久化MessageDAO.saveMessage()的事务缺陷与修复MessageDAO.saveMessage(Message msg)方法如下public static void saveMessage(Message msg) { String sql INSERT INTO message_record (sender_id, receiver_id, content, type, send_time) VALUES (?, ?, ?, ?, ?); try (Connection conn DBUtil.getConnection(); PreparedStatement ps conn.prepareStatement(sql)) { ps.setInt(1, msg.getSenderId()); ps.setInt(2, msg.getReceiverId()); ps.setString(3, msg.getContent()); ps.setInt(4, msg.getType()); ps.setTimestamp(5, new Timestamp(msg.getSendTime().getTime())); ps.executeUpdate(); } catch (SQLException e) { e.printStackTrace(); } }问题在于群聊消息需为每个成员存一条记录receiver_id不同但当前方法只存一条。修复方案// 群聊场景调用此方法 public static void saveGroupMessage(Message msg, ListInteger memberIds) { String sql INSERT INTO message_record (sender_id, receiver_id, content, type, send_time) VALUES (?, ?, ?, ?, ?); try (Connection conn DBUtil.getConnection(); PreparedStatement ps conn.prepareStatement(sql)) { conn.setAutoCommit(false); // 开启事务 for (int memberId : memberIds) { ps.setInt(1, msg.getSenderId()); ps.setInt(2, memberId); ps.setString(3, msg.getContent()); ps.setInt(4, msg.getType()); ps.setTimestamp(5, new Timestamp(msg.getSendTime().getTime())); ps.addBatch(); // 批量添加 } ps.executeBatch(); conn.commit(); } catch (SQLException e) { e.printStackTrace(); } }血泪经验不加事务的话100人群聊若第50条INSERT失败前49条已提交后50条丢失造成消息不一致。我曾因此被测试同学抓包质疑“为什么A看到消息B没看到”。4. 避坑指南运行时高频故障的根因与速修方案4.1 现象客户端启动报java.lang.NoClassDefFoundError: javax/swing/JFrame原因JDK8安装不完整或CLASSPATH未包含Swing库。虽然Swing是JDK内置GUI库但某些精简版JDK如OpenJDK Minimal会剔除swing模块。解决确认JDK版本java -version输出含1.8.0_202且执行ls $JAVA_HOME/jre/lib/rt.jar | grep swing应有输出。若无重装完整版JDK8。4.2 现象登录成功但好友列表为空控制台无报错原因qq.sql导入时字符集不匹配。原SQL文件用utf8编码但MySQL5.7默认latin1导致friend表中nickname字段存入乱码WHERE user_id ?查询失效。解决导入前执行SET NAMES utf8;或修改qq.sql首行添加/*!40101 SET NAMES utf8 */;。验证方法SELECT * FROM friend LIMIT 1;看昵称是否正常显示。4.3 现象私聊发送消息后对方窗口无回显但服务端日志显示“消息已转发”原因ClientHandler中消息广播逻辑错误。原代码对私聊消息也遍历所有clients广播但未过滤目标用户。正确逻辑应// 私聊只发给receiverId对应的客户端 for (ClientHandler handler : clients) { if (handler.userId msg.getReceiverId()) { // 关键判断 handler.sendObject(msg); break; } }解决检查ClientHandler.privateChat()方法确保有userId匹配判断。否则消息发给所有人但只有目标客户端解析其余客户端因receiverId不匹配直接丢弃——看似没反应实则消息风暴。4.4 现象群聊中发送表情GIF如13.gif对方收到乱码或崩溃原因Message.content字段定义为String但GIF文件是二进制流。原项目用Base64.encodeToString(byte[])转字符串传输但JTextArea.append()直接渲染Base64文本而非图片。解决修改Message类增加byte[] imageBytes字段ChatFrame发送时判断是否选中GIF若选中则msg.setImageBytes(FileUtil.readFile(images/13.gif))接收方ChatFrame检测msg.getImageBytes() ! null用new ImageIcon(msg.getImageBytes())创建图标插入JTextPane。玄学提示Swing的JTextPane插入图片需用StyledDocument直接append()会崩这是Swing的老坑。4.5 现象服务端运行数小时后CPU飙升至100%jstack显示大量ClientHandler线程阻塞在ObjectInputStream.readObject()原因客户端异常断开如关机ClientHandler.run()中的ois.readObject()无限等待。原代码无超时机制Socket虽关闭但ObjectInputStream未感知。解决在ClientHandler构造函数中设置Socket超时public ClientHandler(Socket socket) { this.socket socket; try { socket.setSoTimeout(30000); // 30秒无数据则抛SocketTimeoutException this.ois new ObjectInputStream(socket.getInputStream()); this.oos new ObjectOutputStream(socket.getOutputStream()); } catch (IOException e) { e.printStackTrace(); } }并在run()中捕获SocketTimeoutException执行cleanup()释放资源。5. 进阶技巧消息记录导出Excel、群聊功能与离线消息补推5.1 消息记录导出为Excel用Apache POI生成带格式的报表项目自带util/ExcelExport.java但仅支持导出纯文本。要生成专业报表需增强public static void exportMessagesToExcel(ListMessage messages, String filename) throws IOException { Workbook workbook new XSSFWorkbook(); Sheet sheet workbook.createSheet(聊天记录); // 表头样式 Font headerFont workbook.createFont(); headerFont.setBold(true); CellStyle headerStyle workbook.createCellStyle(); headerStyle.setFont(headerFont); Row headerRow sheet.createRow(0); String[] headers {发送者, 接收者, 内容, 类型, 时间}; for (int i 0; i headers.length; i) { Cell cell headerRow.createCell(i); cell.setCellValue(headers[i]); cell.setCellStyle(headerStyle); } // 数据行 int rowNum 1; for (Message msg : messages) { Row row sheet.createRow(rowNum); row.createCell(0).setCellValue(getUserName(msg.getSenderId())); // 需查user表 row.createCell(1).setCellValue(msg.getType() 0 ? 私聊 : 群聊); row.createCell(2).setCellValue(msg.getContent()); row.createCell(3).setCellValue(new SimpleDateFormat(yyyy-MM-dd HH:mm:ss).format(msg.getSendTime())); } // 自动列宽 for (int i 0; i headers.length; i) { sheet.autoSizeColumn(i); } try (FileOutputStream fileOut new FileOutputStream(filename)) { workbook.write(fileOut); } }关键点getUserName()需缓存用户昵称避免导出1000条消息查库1000次。我一般在导出前预加载MapInteger, String userIdToName UserDAO.getAllUsers();。5.2 群聊功能实现协议扩展与前端高亮服务端需识别xxx语法// ClientHandler.processMessage() if (msg.getContent().contains()) { String[] parts msg.getContent().split(, 2); String mention parts[1].split( , 2)[0]; // 提取后第一个词 int mentionedUserId UserDAO.getUserIdByNickname(mention); if (mentionedUserId 0) { // 向被用户单独推送提醒 sendPrivateNotice(mentionedUserId, 你在群[ groupName ]中被 msg.getSenderNickname()); } }客户端ChatFrame需高亮显示// JTextPane中插入带背景色的文本 SimpleAttributeSet attr new SimpleAttributeSet(); StyleConstants.setBackground(attr, Color.YELLOW); doc.insertString(doc.getLength(), mention, attr);5.3 离线消息补推用message_record表实现“伪离线”真正的离线消息需MQTT或消息队列但本项目用数据库兜底用户登录时ClientHandler查询message_record中receiver_id userId AND is_read 0的消息发送完后批量更新is_read 1MessageDAO.saveMessage()插入时默认is_read 0。关键SQL-- 登录时拉取未读消息 SELECT m.*, u.nickname as sender_nickname FROM message_record m JOIN user u ON m.sender_id u.id WHERE m.receiver_id ? AND m.is_read 0 ORDER BY m.send_time DESC LIMIT 50; -- 标记已读客户端收到后调用 UPDATE message_record SET is_read 1 WHERE id IN (?);从那以后我每次部署Java桌面通讯项目都强制走一遍这五步①java -version确认JDK8②mysql --version确认5.7③ 启动服务端后telnet localhost 9000验证端口④ 登录后立即查SELECT COUNT(*) FROM friend WHERE user_id 1001确认数据⑤ 发送一条消息用Wireshark抓包看TCP payload是否含明文内容调试阶段。这套动作让我避开90%的环境类翻车希望帮到你。本文还有配套的精品资源点击获取
返回列表