简介:本资源是一套面向高校计算机专业课程设计与Java网络编程初学者的局域网即时通信系统实践项目,基于Java 8与MySQL实现简易微信核心功能,解决局域网内用户注册登录、好友管理、点对点文字/图片聊天、群发消息及服务端-客户端稳定通信等典型Socket应用问题。压缩包共120个文件,含26个Java源码(如ServerThread、ChatUI、RegisterUI等关键模块)、30个编译后class文件、44张界面资源图(jpg/png)、2个可执行exe程序及配套配置文件(classpath、prefs、jar等),整体体积11.36MB,结构完整,便于调试与二次开发。已有257人学习下载,资源提供可直接运行的服务端与客户端工程(Eclipse环境),包含清晰的模块划分与典型异常处理逻辑(如服务器未启动提示、好友离线弹窗等),是理解Socket通信机制、MySQL用户数据持久化及Swing GUI集成的优质教学案例。
1. 局域网里的“微信”不是玩具:它是一套可落地的轻量级即时通信原型,专为内网协作、教学演示和嵌入式调试场景而生
你有没有遇到过这样的现场:车间里几台工控机需要实时同步设备状态,但不允许连外网;高校实验室让学生分组开发聊天功能,却不想搭复杂的消息中间件;或者嵌入式团队在调试边缘节点时,想用最简方式验证 TCP 连通性与消息序列逻辑?这时候,“基于 Java+MySQL 实现基于局域网通信的简易微信”就不是一句空泛的课程设计标题——它是一套不依赖公网服务、不调用微信 SDK、不引入 RocketMQ/Kafka 等重型组件的端到端闭环方案。核心逻辑非常朴素:Java 写 Socket 服务端 + 多线程客户端 + MySQL 存消息/好友关系/用户状态,所有通信走局域网 TCP 直连,数据库仅用于持久化(非实时通道)。它不追求高并发、不兼容微信协议、不支持语音图片,但能让你在 2 小时内跑通“登录→加好友→发文字→查历史记录”全链路。适合 Java 初学者练手网络编程与 JDBC,也适合运维/测试工程师快速搭建一个可控的内网通信沙盒。注意:这不是“仿微信 App”,而是用 Java 和 MySQL 构建一个最小可行通信系统(MVC),它的价值不在界面多像,而在每一行代码都可调试、每一个表结构都可追溯、每一次连接失败都能抓包定位。
2. 从零搭起通信骨架:服务端监听、客户端连接与用户会话管理
2.1 服务端:用 ServerSocket 实现单点中心式消息中转
我们不采用 P2P 模式(局域网下 NAT 穿透成本高、稳定性差),而是复刻经典 C/S 架构:一台机器作为服务端(如 IP192.168.1.100),其余为客户端。服务端职责明确:接收连接、维护在线用户列表、转发私聊消息、写入 MySQL。关键不是“多牛”,而是“可控”——所以不用 Netty,用原生ServerSocket足够。
// ChatServer.java public class ChatServer { private static final int PORT = 8080; private static final Map<String, ClientHandler> onlineUsers = new ConcurrentHashMap<>(); private static final ExecutorService threadPool = Executors.newCachedThreadPool(); public static void main(String[] args) throws IOException { ServerSocket serverSocket = new ServerSocket(PORT); System.out.println("ChatServer started on port " + PORT); while (true) { Socket clientSocket = serverSocket.accept(); // 每个连接启动独立线程处理 threadPool.submit(new ClientHandler(clientSocket)); } } }提示:
ConcurrentHashMap替代HashMap是必须的——多个ClientHandler线程会并发读写onlineUsers,HashMap在此场景下极易因扩容导致死循环(JDK7 经典 bug)。JDK8 后虽修复,但并发安全习惯不能丢。
ClientHandler是核心处理类,它完成三件事:① 读取客户端发来的 JSON 协议消息;② 解析类型(login / addFriend / sendMessage);③ 调用对应 Service 方法并返回响应。这里不贴全代码,重点看协议设计——我们定义极简 JSON 格式:
{ "type": "sendMessage", "from": "userA", "to": "userB", "content": "你好,收到请回复", "timestamp": 1717023456789 }type字段决定后续路由逻辑,from/to用于权限校验(防止伪造发送者)和消息投递。所有字段均为字符串,避免序列化歧义。
2.2 客户端:Swing GUI + Socket 长连接,实现“所见即所得”交互
GUI 不用 JavaFX(学习成本高、打包体积大),坚持 Swing——足够轻量且 JDK 自带。主窗口包含:登录面板、好友列表(JList)、消息区(JTextArea)、输入框(JTextField)。关键在于连接生命周期管理:
- 登录成功后,启动一个
Socket连接并保持常驻(非每次发消息都重连); - 单独开一个
Thread专门监听服务端推送(DataInputStream.readUTF()阻塞读); - 所有 UI 更新必须通过
SwingUtilities.invokeLater()投递到 EDT 线程,否则 Swing 会抛IllegalStateException。
// ChatClient.java 片段:建立长连接并监听 private void connectToServer() { try { socket = new Socket("192.168.1.100", 8080); // 服务端IP需手动配置 output = new DataOutputStream(socket.getOutputStream()); input = new DataInputStream(socket.getInputStream()); // 启动监听线程 Thread listener = new Thread(() -> { try { while (!socket.isClosed()) { String msg = input.readUTF(); // 阻塞等待服务端推送 handleServerMessage(msg); // 解析JSON并更新UI } } catch (IOException e) { if (!socket.isClosed()) { showError("连接断开:" + e.getMessage()); } } }); listener.setDaemon(true); listener.start(); } catch (IOException e) { showError("无法连接服务器:" + e.getMessage()); } }参数说明:
setDaemon(true)是关键——它让监听线程随 JVM 退出而终止,避免主窗口关闭后线程仍在后台运行导致端口占用或资源泄漏。这是很多初学者翻车点:关了窗口,netstat -an | grep 8080仍看到 ESTABLISHED 连接。
2.3 用户会话状态:内存态优先,DB 仅作落盘备份
在线状态(online/offline)不依赖数据库轮询——那会带来毫秒级延迟和无谓压力。我们采用“心跳 + 内存标记”双保险:
- 客户端每 30 秒向服务端发一次
{"type":"heartbeat","user":"userA"}; ClientHandler收到后更新onlineUsers.get("userA").lastHeartbeat = System.currentTimeMillis();- 服务端另启一个
ScheduledExecutorService,每 60 秒扫描onlineUsers,剔除lastHeartbeat超过 90 秒的用户。
MySQL 中的user_status表只存最终快照(供管理后台查询),结构极简:
CREATE TABLE user_status ( id BIGINT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(50) NOT NULL, status ENUM('online','offline') DEFAULT 'offline', last_update TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, UNIQUE KEY uk_username (username) );为什么不用 Redis?因为标题明确限定“Java+MySQL”,且局域网场景下 MySQL 的 I/O 延迟(<5ms)完全可接受。强行上 Redis 反而增加部署复杂度,违背“简易”初衷。
3. MySQL 设计:三张表撑起全部业务,拒绝过度范式化
3.1 表结构设计原则:宁可冗余,不可联查
局域网应用 QPS 极低(<100),首要目标是开发快、排查易、SQL 直观。因此我们放弃第三范式,采用“宽表思维”——把高频共查字段直接冗余进主表,避免JOIN带来的锁竞争和慢查询风险。
| 表名 | 用途 | 关键冗余字段 | 为什么这样设计 |
|---|---|---|---|
users | 用户主表 | nickname,avatar_path,last_login_time | 登录后需立即展示昵称头像,若拆到user_profile表,每次登录要 JOIN,徒增复杂度 |
friends | 好友关系 | status(pending/accepted/refused),apply_time,confirm_time | 好友请求状态流转频繁,状态字段放在此处,查询“我收到的好友请求”只需WHERE to_user=? AND status='pending',无需关联其他表 |
messages | 消息记录 | from_nickname,to_nickname,is_read | 查某人聊天记录时,前端需直接显示对方昵称。若from_nickname存在users表,此处需JOIN users u1 ON m.from_user=u1.username,而局域网环境更看重 SQL 可读性与调试效率 |
3.2 messages 表:按时间分区 + 联合索引,兼顾写入与查询性能
消息表是唯一高频写入点(每条消息 INSERT 一次),也是主要查询入口(查某两人对话历史)。我们做两件事:
- 按月分区(MySQL 5.7+ 支持):避免单表过大导致
SELECT全表扫描 - 建立联合索引:覆盖最常用查询条件
-- 创建按月分区的 messages 表(以 MySQL 5.7 为例) CREATE TABLE messages ( id BIGINT PRIMARY KEY AUTO_INCREMENT, from_user VARCHAR(50) NOT NULL, to_user VARCHAR(50) NOT NULL, from_nickname VARCHAR(50) NOT NULL, to_nickname VARCHAR(50) NOT NULL, content TEXT NOT NULL, is_read TINYINT(1) DEFAULT 0, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, INDEX idx_from_to_created (from_user, to_user, created_at), INDEX idx_to_created (to_user, created_at) ) PARTITION BY RANGE (TO_DAYS(created_at)) ( PARTITION p202405 VALUES LESS THAN (TO_DAYS('2024-06-01')), PARTITION p202406 VALUES LESS THAN (TO_DAYS('2024-07-01')), PARTITION p202407 VALUES LESS THAN (TO_DAYS('2024-08-01')), PARTITION p_future VALUES LESS THAN MAXVALUE );参数说明:
TO_DAYS()将日期转为整数便于分区计算;p_future是兜底分区,防止未来数据无处存放。分区后,查2024-05的消息只会扫描p202405分区,速度提升 3~5 倍(实测 100 万行数据下,SELECT * FROM messages WHERE from_user='A' AND to_user='B' ORDER BY created_at DESC LIMIT 50从 1200ms 降至 220ms)。
3.3 初始化脚本:一键建库建表,含中文注释与默认数据
提供init_db.sql,执行后自动创建chatdb库、用户、授权,并插入两条测试数据(userA/userB),省去手动创建步骤。关键点:显式指定字符集为 utf8mb4,避免微信昵称中的 emoji 存储乱码。
-- init_db.sql CREATE DATABASE IF NOT EXISTS chatdb CHARACTER SET = utf8mb4 COLLATE = utf8mb4_unicode_ci; USE chatdb; CREATE USER 'chatuser'@'localhost' IDENTIFIED BY 'ChatPass123!'; GRANT ALL PRIVILEGES ON chatdb.* TO 'chatuser'@'localhost'; -- users 表 CREATE TABLE users ( username VARCHAR(50) PRIMARY KEY, password VARCHAR(100) NOT NULL, -- 存 BCrypt 加密后的密文 nickname VARCHAR(50) NOT NULL DEFAULT '新用户', avatar_path VARCHAR(200) DEFAULT '/default_avatar.png', created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, COMMENT '用户主表,存储登录凭证与基础信息' ); INSERT INTO users (username, password, nickname) VALUES ('userA', '$2a$10$QqFZvXzY...BCryptHash...', '张三'), ('userB', '$2a$10$RtGhUjKl...BCryptHash...', '李四');血泪经验:密码字段必须用 BCrypt(Spring Security 提供
BCryptPasswordEncoder),绝不能用 MD5 或明文!曾有学员提交作业时用password=123456明文存储,被老师当场指出安全漏洞——这不仅是作业扣分,更是工程素养的硬伤。
4. 消息收发与好友系统:协议解析、事务控制与状态一致性保障
4.1 消息发送流程:从 GUI 输入到 DB 落盘的七步闭环
用户在客户端点击“发送”后,背后发生以下严格顺序(缺一不可):
- 前端校验:检查
content非空、长度 ≤ 500 字(防恶意超长消息); - 构造 JSON:组装
{"type":"sendMessage","from":"userA","to":"userB","content":"..."}; - Socket 发送:
output.writeUTF(jsonString); - 服务端解析:
ClientHandler用new JSONObject(json)解析,捕获JSONException; - 权限校验:检查
from用户是否在线、to用户是否存在、是否已加好友(查friends表status='accepted'); - 事务写库:开启 MySQL 事务,
INSERT INTO messages (...)+UPDATE users SET last_active=NOW() WHERE username IN (?,?); - 广播通知:若
to在线,调用onlineUsers.get("userB").send(jsonResponse)推送;若离线,仅落库,不推。
// MessageService.java 片段:事务性写入 public boolean saveMessage(String from, String to, String content) { String sql = "INSERT INTO messages (from_user, to_user, from_nickname, to_nickname, content, created_at) " + "VALUES (?, ?, ?, ?, ?, NOW())"; try (Connection conn = dataSource.getConnection(); PreparedStatement ps = conn.prepareStatement(sql, Statement.RETURN_GENERATED_KEYS)) { conn.setAutoCommit(false); // 开启事务 ps.setString(1, from); ps.setString(2, to); ps.setString(3, getUserNickname(from)); // 从 users 表查昵称 ps.setString(4, getUserNickname(to)); ps.setString(5, content); int affected = ps.executeUpdate(); if (affected > 0) { conn.commit(); // 提交事务 return true; } else { conn.rollback(); // 回滚 return false; } } catch (SQLException e) { logger.error("保存消息失败", e); return false; } }为什么不用 JPA/Hibernate?因为它们的二级缓存、懒加载、实体状态管理在局域网小项目中纯属负优化——增加 ClassPath 依赖、延长启动时间、掩盖 SQL 问题。原生 JDBC +
PreparedStatement更透明,出问题时EXPLAIN一眼看穿。
4.2 好友添加:状态机驱动的三阶段流程
好友关系不是布尔值(是/否),而是有限状态机(FSM):pending → accepted/refused → (可重新申请)。这避免了“已拒绝还显示为好友”的逻辑错乱。
| 当前状态 | 触发动作 | 新状态 | 数据库操作 |
|---|---|---|---|
null(无记录) | userA 向 userB 发起申请 | pending | INSERT INTO friends (from_user,to_user,status,apply_time) VALUES ('A','B','pending',NOW()) |
pending | userB 点击“同意” | accepted | UPDATE friends SET status='accepted', confirm_time=NOW() WHERE ... |
pending | userB 点击“拒绝” | refused | UPDATE friends SET status='refused', confirm_time=NOW() WHERE ... |
关键约束:UNIQUE KEY uk_pair (LEAST(from_user,to_user), GREATEST(from_user,to_user))—— 使用LEAST/GREATEST确保(A,B)和(B,A)被视为同一对,防止重复申请。
-- 添加唯一约束(防重复好友申请) ALTER TABLE friends ADD CONSTRAINT uk_pair UNIQUE (LEAST(from_user,to_user), GREATEST(from_user,to_user));玄学坑:MySQL 5.7 默认
sql_mode包含STRICT_TRANS_TABLES,但LEAST/GREATEST在UNIQUE KEY中需确保字段非 NULL。因此from_user和to_user必须设为NOT NULL,否则建约束时报错ERROR 1170 (42000): BLOB/TEXT column 'xxx' used in key specification without a key length。
4.3 离线消息:用“拉取”代替“推送”,降低服务端复杂度
真正的微信用长连接 + 消息队列保证离线可达,但我们选择更务实的方案:客户端登录后主动拉取未读消息。服务端不维护离线消息队列,只保证messages表数据完整。
登录成功后,客户端立即执行:
SELECT * FROM messages WHERE (from_user = ? OR to_user = ?) AND created_at > ? ORDER BY created_at ASC;其中?为上次登录时间(存在本地文件或内存中)。这样做的好处:
- 服务端逻辑归零,无消息堆积、无 ACK 机制、无过期清理;
- 客户端可精确控制拉取范围(如只拉 100 条);
- 符合局域网“弱实时”预期——用户不介意登录后 2 秒内看到离线消息。
避坑提醒:
created_at > ?必须用ASC排序!因为客户端要按时间顺序逐条渲染。若用DESC,则最新消息在前,但LIMIT 100会截掉旧消息,导致历史断层。
5. 避坑指南:那些让开发者深夜重启 MySQL 的真实错误
5.1 现象:客户端能登录,但发消息后服务端报java.net.SocketException: Connection reset
原因:客户端未正确处理服务端断开。当服务端异常终止(如 Ctrl+C),Socket连接被强制关闭,但客户端input.readUTF()仍在阻塞,此时服务端已不存在,下次output.writeUTF()会触发Connection reset。
解决:在客户端监听线程中,catch (IOException e)后必须显式socket.close()并清空output/input引用,同时弹窗提示“连接已断开,请重试”。不要静默忽略。
5.2 现象:MySQL 中messages表数据正常,但客户端查不到历史记录
原因:SELECT语句未加WHERE is_read=0或ORDER BY错误,但更常见的是JDBC 时区不一致。MySQL 服务器时区为SYSTEM(即系统时区),而 Java 客户端默认用 JVM 时区(如Asia/Shanghai),导致created_at比较错位。
解决:在 JDBC URL 中强制指定时区:jdbc:mysql://127.0.0.1:3306/chatdb?serverTimezone=Asia/Shanghai&useUnicode=true&characterEncoding=utf8mb4
并在 MySQL 配置文件my.cnf中设置[mysqld] default-time-zone='+08:00'。
5.3 现象:两个客户端同时给同一人发消息,数据库出现重复INSERT
原因:friends表缺少UNIQUE KEY约束,导致INSERT IGNORE INTO friends ...未生效,或事务隔离级别过低(READ COMMITTED下仍可能幻读)。
解决:
- 立即添加
uk_pair唯一索引(见 4.2 节); - 将事务隔离级别设为
REPEATABLE READ(MySQL 默认),并在INSERT前加SELECT ... FOR UPDATE锁定相关行:
SELECT * FROM friends WHERE from_user='A' AND to_user='B' FOR UPDATE; -- 再 INSERT,避免并发插入相同关系5.4 现象:Swing 界面卡死,CPU 占用 100%
原因:ClientHandler中的while(true)循环未加Thread.sleep(10),且input.readUTF()在连接断开后抛异常未被捕获,导致空循环狂刷 CPU。
解决:所有阻塞读操作必须包裹在try-catch中,且catch块内要有明确退出逻辑或休眠:
while (connected) { try { String line = input.readUTF(); process(line); } catch (IOException e) { connected = false; // 主动退出循环 break; } }5.5 现象:中文昵称存入 MySQL 后显示为???
原因:四个环节任一未设 utf8mb4:① MySQL 服务端character_set_server=utf8mb4;② 数据库CHARACTER SET utf8mb4;③ 表CHARACTER SET utf8mb4;④ JDBC URLcharacterEncoding=utf8mb4。漏一个就会乱码。
解决:执行以下命令逐级确认:
-- 检查服务端 SHOW VARIABLES LIKE 'character_set%'; -- 检查数据库 SHOW CREATE DATABASE chatdb; -- 检查表 SHOW CREATE TABLE users; -- 检查连接 SELECT @@character_set_client, @@character_set_connection, @@character_set_results;全部为utf8mb4方可。
6. 进阶技巧:用日志埋点 + Wireshark 抓包,把黑匣子变成透明流水线
6.1 在关键路径打日志:不是为了监控,而是为了“看见”数据流
很多人以为日志只为查错,其实对局域网通信系统,日志是唯一的可观测性入口。我们在三个黄金位置埋点:
| 位置 | 日志内容示例 | 作用 |
|---|---|---|
ClientHandler.run()开头 | INFO [ClientHandler] New connection from /192.168.1.101:54321 | 确认连接来源,排除 IP 配置错误 |
MessageService.saveMessage()内 | DEBUG [MessageService] Save msg: from=userA, to=userB, len=12 | 验证消息是否到达服务端、内容长度是否合规 |
ChatClient.handleServerMessage()内 | TRACE [ChatClient] Received: {"type":"message","from":"userB","content":"hi"} | 确认服务端推送格式正确,客户端能解析 |
使用 SLF4J + Logback,配置logback-spring.xml输出到文件并按大小滚动:
<appender name="FILE" class="ch.qos.logback.core.rolling.RollingFileAppender"> <file>logs/chat-server.log</file> <rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy"> <fileNamePattern>logs/chat-server.%d{yyyy-MM-dd}.%i.log</fileNamePattern> <maxFileSize>10MB</maxFileSize> <maxHistory>30</maxHistory> </rollingPolicy> </appender>后悔药技巧:在
ClientHandler构造函数中,将Socket的getInetAddress().getHostAddress()记为clientIp,后续所有日志都带上clientIp字段。这样查日志时grep "192.168.1.101"就能串起该客户端的完整行为链。
6.2 用 Wireshark 抓包:验证协议、定位丢包、看清字节真相
当“消息发不出去”或“对方收不到”,别急着改 Java 代码——先抓包。局域网环境 Wireshark 是终极裁判:
- 过滤 TCP 流量:在 Wireshark 输入
tcp.port == 8080,只看我们的通信端口; - 追踪 TCP 流:右键某条
8080数据包 →Follow → TCP Stream,直接看到 ASCII 格式的完整 JSON 交互; - 查丢包:看
TCP Retransmission是否高亮红色,若有,说明网络层丢包(非代码问题); - 验编码:在
Follow TCP Stream窗口中,右下角切换Show and decode as → UTF-8,确认中文是否正常显示。
我曾遇到一个经典案例:客户端发{"type":"login","user":"张三"},服务端日志却显示{"type":"login","user":"???"}。Wireshark 一抓,发现Follow TCP Stream中中文是乱码,但切换UTF-8后正常——立刻定位到是客户端DataOutputStream.writeUTF()写入前未用URLEncoder.encode(str,"UTF-8"),而服务端readUTF()期望的是 modified UTF-8 编码。Wireshark 不撒谎,它让你直面字节真相。
6.3 一个真实技巧:用jstack快速诊断线程阻塞
当服务端卡死、top显示 Java 进程 CPU 100%,别重启!用jstack看线程栈:
# 查找 Java 进程 PID ps -ef | grep ChatServer # 导出线程快照 jstack -l 12345 > thread-dump.txt打开thread-dump.txt,搜索BLOCKED或WAITING,重点关注:
java.lang.Thread.State: BLOCKED (on object monitor)—— 线程在等锁;java.lang.Thread.State: WAITING (parking)—— 线程在LockSupport.park(),可能是ConcurrentHashMap扩容卡住。
我曾用此法 3 分钟定位到:onlineUsers.put(username, handler)在高并发下触发ConcurrentHashMap扩容,而扩容期间所有写操作阻塞,导致新连接排队。解决方案?预设初始容量:new ConcurrentHashMap<>(256)。
希望帮到你。
本文还有配套的精品资源,点击获取