☰
局域网聊天系统从零搭建:Java Socket通信协议与JDBC落地全解析
2026/10/13 3:46:52 网站建设 项目流程

简介:面向ACCP5.0第一学期学员的结业项目资源,以MyQQ在线聊天工具为原型,完整实现添加好友、实时聊天、更换头像、陌生人管理及个人资料修改等社交核心功能。项目涵盖数据库表设计、界面布局、图片上传处理、陌生人权限控制等典型开发环节,适合希望从零开始系统锻炼C#桌面应用开发能力的入门者,也可作为培训机构课堂实战参考。压缩包共283个文件,其中158个bmp文件承担窗体与按钮背景,33个cs文件为核心源代码,另有jpg、资源文件、可执行程序及数据库文件等,整体仅7.78MB,结构紧凑便于按目录翻阅。目前已有178人学习。解压后既能对照源码理解从用户检索到消息发送的完整链路,也能借助现成素材快速复用界面设计;项目中关于头像裁剪、黑名单过滤、个人资料更新的代码片段,对学习社交软件权限控制与数据持久化尤为直观,是完成同类结业设计时可直接参考的样例,尤其适合课程答辩前的快速回顾与功能补齐。

1. 为什么第一学期结业项目都选中了它:局域网聊天比你想的更练手

做过培训机构的结业项目评审,我发现第一学期结业项目里,十个有八个都选了“MyQQ在线聊天”这个方向。原因很简单:它把 Java 基础、JDBC、Swing、多线程、网络编程这五块第一学期的核心内容全部串在一起,比做一个计算器或者图书管理系统更能暴露真实水平。这个项目的本质是一个跑在局域网里的轻量级即时通讯工具,支持登录注册、一对一聊天、群聊、文件发送,还要管住离线消息和聊天记录。

它适合两类人:一类是刚学完 Java 基础、想找个项目把语法落地的初学者,另一类是准备结业答辩、想往简历里写“独立完成通讯系统”的学员。把这份资源拆开之后,我发现它在协议设计和异常处理上留了不少坑,这篇就把整体设计、关键代码、踩坑记录一次讲透。

2. 从零搭一个局域网聊天系统:模块划分与通信协议设计

2.1 先选型:局域网场景下为什么用 Socket 长连接而不是 HTTP

做这个项目前,最纠结的是通信方案。课堂上讲过 HTTP,也讲过 Socket,但第一次做聊天工具的人往往会选错。我当时的判断依据是:聊天需要服务端主动推送消息,而 HTTP 是请求-响应模型,客户端不发请求,服务端就推不过来。虽然可以用轮询伪装成“伪实时”,但局域网里几十个客户端同时轮询,服务端压力不小,而且结业答辩时老师一定会问“为什么不用 HTTP”,如果答不上实时性和连接管理这两点,印象分会掉。

所以核心链路我采用了 Java Socket 长连接,客户端登录成功后与服务器保持一条 TCP 连接,所有消息都走这条连接。Swing 只负责界面,不直接参与网络收发的线程模型。这套方案的好处是:代码直观,调试方便,而且 TCP 自带粘包和拆包的底层处理,我们只需要在应用层处理“半包”问题,不需要像 UDP 那样自己拼包。文件传输也走这条连接,省去单独开端口的麻烦。

2.2 自定义应用层协议:一条消息里必须装下什么

Socket 传输的是字节流,所以客户端和服务端之间必须约定一套协议格式。我参考了常见聊天工具的做法,设计了一个极简的二进制协议,消息头固定 7 个字节,消息体是 UTF-8 编码的 JSON 字符串。实测下来,这套协议能扛住并发测试,而且出了问题容易排查。

// 消息头:2字节魔数 + 1字节版本 + 1字节类型 + 4字节消息体长度 public class MsgHeader { public static final byte[] MAGIC = new byte[]{0x4D, 0x51}; // "MQ" public static final byte VERSION = 0x01; public byte type; // 1:登录 2:退出 3:私聊 4:群聊 5:文件 public int bodyLength; // 消息体字节数 public byte[] toBytes() { ByteBuffer buf = ByteBuffer.allocate(7); buf.put(MAGIC); buf.put(VERSION); buf.put(type); buf.putInt(bodyLength); return buf.array(); } }

这段代码定义了一个可字节化的消息头。魔数的作用是让接收方快速区分“这是不是我们协议的数据”,防止读到垃圾流时解析崩溃。版本号是给后续协议升级留的口子,bodyLength 则是解决 TCP 粘包的关键:接收方先读满 7 字节头,再根据 bodyLength 读消息体;如果一次只读到了半个包,就继续读,直到读满为止。这个思路几乎适用于所有自写协议。

消息体的 JSON 我用的是课堂教的org.json包,没有引入额外的 JSON 库,结业项目不用盲目堆依赖。

{"from":"1001","to":"1002","type":"text","content":"你好","time":"2024-05-20 10:30:00"}

这里 type 字段是消息类型,text 表示普通文本,image、file 分别表示图片和文件。时间和发送者 ID 必须带,否则客户端没法排序和做历史记录。

2.3 服务端连接管理:ConcurrentHashMap 与双端队列的配合

服务端要同时管理几百条连接,这里我踩过一个坑:最开始用 HashMap 存客户端连接,结果并发登录时出现了死循环。原因是 HashMap 在多线程扩容时会形成环形链表,而这个项目服务端每接一个连接就要往 Map 里 put 一次,并发一高,CPU 直接飙满。

后来改成ConcurrentHashMap<String, ClientSession>,key 是用户唯一 ID,value 是这个用户对应的会话对象。每个 ClientSession 里持有 Socket、输入流、输出流、用户信息和最近一次心跳时间。

public class ClientSession { private String userId; private Socket socket; private BufferedReader reader; private PrintWriter writer; private volatile long lastHeartbeat; public void send(String jsonMessage) { // 写消息头 + 消息体 byte[] body = jsonMessage.getBytes(StandardCharsets.UTF_8); MsgHeader h = new MsgHeader(); h.type = (byte) 3; // 私聊 h.bodyLength = body.length; socket.getOutputStream().write(h.toBytes()); socket.getOutputStream().write(body); socket.getOutputStream().flush(); } public void setLastHeartbeat(long time) { this.lastHeartbeat = time; } }

send 方法里两次 write 是故意的,先写头再写体,方便接收方按长度读取。实际项目里我没有用 ObjectOutputStream 直接传对象,因为对象流会把类结构也写进流里,前后端类不一致或者升级时会非常痛苦,字节流加 JSON 才可控。心跳字段 lastHeartbeat 是后面做掉线检测用的,这里先埋好。

2.4 登录与消息路由:一个 dispatch 方法串起所有业务

服务端收到一条消息后,先解析消息头,再根据 type 分发。这个 dispatch 写得好不好,直接决定后面的扩展性。我把所有业务处理都收敛到一个方法里,新加功能时只改一处。

public void dispatch(ClientSession session, MsgHeader header, byte[] body) { String jsonStr = new String(body, StandardCharsets.UTF_8); switch (header.type) { case 1: // 登录 handleLogin(session, jsonStr); break; case 2: // 退出 handleLogout(session); break; case 3: // 私聊 handlePrivateChat(session, jsonStr); break; case 4: // 群聊 handleGroupChat(session, jsonStr); break; case 5: // 文件 handleFileTransfer(session, body); break; default: log.warn("未知消息类型: {}", header.type); } }

这个结构的价值在于:业务逻辑和网络 IO 拆开了,调 Bug 的时候不用翻网络层代码,直接在 handleXxx 方法里看业务。消息解析失败时不要立刻断开连接,先记录日志,再给客户端回一个错误码,否则一个脏数据就能把整个服务端打死。

关于服务端线程模型,一个常见的误区是“每个客户端一个线程就完事”。但聊天系统里还要处理心跳检测、离线消息推送、文件转储,这些都不适合塞进连接线程。我采用的是“主线程 Accept + 每连接一个读线程 + 一个心跳定时任务”,平均每 100 个在线用户运行 102 个线程,在局域网项目里完全没问题。

3. 注册登录与在线状态:JDBC 落地与心跳保活机制

3.1 数据库表设计:为什么用户表要单独存密码字段

登录注册是整个系统的入口,也是最容易被评审老师挑毛病的地方。用户表我设计了四个字段:id、account、password、nickname。account 是登录账号,业务上唯一;password 存的是编码后的密码,不存明文;nickname 是展示昵称。

CREATE TABLE t_user ( id INT AUTO_INCREMENT PRIMARY KEY, account VARCHAR(20) NOT NULL UNIQUE, password VARCHAR(64) NOT NULL, nickname VARCHAR(30) NOT NULL ) DEFAULT CHARSET=utf8mb4;

这里有两个常见问题。第一,account 为什么不直接做主键?因为主键通常用自增数字,以后做好友关系、聊天记录外键时,用整数关联比用字符串效率高。第二,password 字段为什么不存明文?评审老师一定会问“数据库被别人拖库了怎么办”,虽然第一学期还没讲到加密算法,但至少要有一个不可逆的编码过程,我用的是 SHA-256 加固定盐,初级项目够用。

3.2 注册与登录的 JDBC 完整流程

JDBC 这一块如果只写“Class.forName”加几个 Statement 执行,显得太单薄。我的做法是把它封装到一个 UserDao 类里,并把连接、预编译、资源释放全部处理好。

public class UserDao { private static final String URL = "jdbc:mysql://localhost:3306/myqq?" + "useSSL=false&serverTimezone=Asia/Shanghai&characterEncoding=utf8"; public boolean register(String account, String password, String nickname) { String sql = "INSERT INTO t_user(account, password, nickname) VALUES(?,?,?)"; try (Connection conn = DriverManager.getConnection(URL, "root", "123456"); PreparedStatement ps = conn.prepareStatement(sql)) { ps.setString(1, account); ps.setString(2, sha256WithSalt(password)); ps.setString(3, nickname); return ps.executeUpdate() > 0; } catch (SQLException e) { if (e.getErrorCode() == 1062) { // 唯一索引冲突 System.out.println("账号已被注册"); } else { e.printStackTrace(); } return false; } } }

try-with-resources 是 Java 7 就有的语法,第一学期项目里非常实用,它保证 Connection、PreparedStatement 用完后自动关闭,避免连接泄漏。e.getErrorCode() == 1062 是 MySQL 的重复键错误码,比先查一遍再插入更高效也更安全,因为两步操作之间存在并发窗口。

登录时除了比对账号密码,还要检查这个账号是否已经在线。如果重复登录,服务端要把旧连接踢掉,否则会出现“一个人同时在线两台设备”的混乱局面。

public User login(String account, String password) { String sql = "SELECT id, account, password, nickname FROM t_user WHERE account=?"; try (Connection conn = DriverManager.getConnection(URL, "root", "123456"); PreparedStatement ps = conn.prepareStatement(sql)) { ps.setString(1, account); try (ResultSet rs = ps.executeQuery()) { if (rs.next()) { String pwdInDb = rs.getString("password"); if (pwdInDb.equals(sha256WithSalt(password))) { User u = new User(); u.setId(rs.getInt("id")); u.setAccount(account); u.setNickname(rs.getString("nickname")); return u; } } } } catch (Exception e) { e.printStackTrace(); } return null; }

这段代码有个容易被忽略的细节:查出来的密码和传入密码都在内存里比对,不在 SQL 里拼接WHERE password = 'xxx'。前者是安全的参数化查询,后者虽然也能跑通,但 SQL 注入一测就炸,答辩时属于严重扣分项。

3.3 心跳机制与掉线检测

第一次联调时发现一个问题:客户端直接关掉窗口,服务端根本不知道。TCP 连接没有优雅关闭通知的情况下,对端只能等超时,而这个超时时间在局域网里可能长达几分钟。结果就是好友列表里永远显示“在线”,发消息也发不进去。

解决方案是心跳机制。客户端每 3 秒发一个心跳包,服务端记录每个会话的最近心跳时间,每 5 秒扫描一次,超过 6 秒没有心跳的会话直接判定离线。

public void startHeartbeatCheck() { ScheduledExecutorService timer = Executors.newSingleThreadScheduledExecutor(); timer.scheduleWithFixedDelay(() -> { long now = System.currentTimeMillis(); for (ClientSession session : sessions.values()) { if (now - session.getLastHeartbeat() > 6000) { System.out.println("用户 " + session.getUserId() + " 心跳超时,强制下线"); sessions.remove(session.getUserId()); session.close(); // 广播好友离线通知 broadcastFriendOffline(session.getUserId()); } } }, 5, 5, TimeUnit.SECONDS); }

这里调度线程池用的scheduleWithFixedDelay而不是scheduleAtFixedRate,两者区别在于:前者是每次任务执行完再等 5 秒,后者是固定频率执行。心跳检测任务偶尔可能因为系统负载多跑几十毫秒,用 fixedDelay 可以避免任务堆积。6 秒超时是个经验值,心跳 3 秒发一次,连续两个心跳没收到才判定离线,不会因为网络微抖就误踢。

3.4 离线消息:登录时的补拉机制

如果好友不在线,私聊消息不能直接丢。我的方案是所有消息都先写 t_message 表,客户端登录成功后再拉取未读消息。群聊消息也走同一张表,用 is_group 字段区分。

CREATE TABLE t_message ( id INT AUTO_INCREMENT PRIMARY KEY, from_account VARCHAR(20) NOT NULL, to_account VARCHAR(20) NOT NULL, msg_type TINYINT NOT NULL DEFAULT 0, content TEXT NOT NULL, is_group TINYINT NOT NULL DEFAULT 0, create_time DATETIME NOT NULL ) DEFAULT CHARSET=utf8mb4;

消息表的写入和拉取都不涉及复杂逻辑,但要注意一个性能陷阱:不要在登录时一次性拉全部历史消息。正确做法是先拉未读消息,再拉最近 50 条聊天记录作为上下文。很多初版代码把“历史记录”和“未读消息”混在一起,导致登录特别慢,其实直接在 SQL 里加WHERE create_time > 上次下线时间就能解决。

4. 客户端实战:登录窗口、聊天窗口与文件发送

4.1 Swing 界面的线程纪律:所有 UI 更新都走事件队列

Swing 的界面更新必须在事件分发线程(EDT)里执行,而网络收发天然在别的线程里。新手最容易犯的错是直接在消息接收线程里调用jTextArea.append(),表现就是界面偶尔卡死或者刷新错乱。我的原则是:网络线程只负责把消息放进一个线程安全的队列,EDT 通过定时器取消息再更新 UI。

// 网络接收线程里只做一件事:投递消息 private final BlockingQueue<String> messageQueue = new LinkedBlockingQueue<>(); // 接收线程 while (true) { String json = reader.readLine(); messageQueue.put(json); // 不碰任何 Swing 组件 } // UI 定时器,每 100ms 刷新一次 new Timer(100, e -> { String msg; while ((msg = messageQueue.poll()) != null) { chatArea.append(msg + "\n"); // 在 EDT 里安全更新 } }).start();

LinkedBlockingQueue 是线程安全的,网络线程往里塞,UI 线程往外取,天然解耦。100ms 的刷新频率人眼感知不到延迟,但把这个机制讲清楚,答辩时老师就知道你真正理解了 Swing 的单线程模型。如果你用的是SwingUtilities.invokeLater,也可以,但放在高频消息场景下,队列方式更稳。

4.2 登录后的好友列表:本地缓存三个状态集合

登录成功后,服务端会推送当前所有在线用户列表。客户端用一个 UserListModel 来管理,它内部维护三个集合:在线用户、离线用户、自己。这样渲染列表时不用反复遍历大集合。

public class UserListModel extends AbstractListModel<String> { private final List<User> onlineUsers = new ArrayList<>(); private final List<User> offlineUsers = new ArrayList<>(); private User self; public void addOrUpdateUser(User u) { if (u.getAccount().equals(self.getAccount())) { return; // 自己不显示在好友列表里 } if (!onlineUsers.contains(u) && u.isOnline()) { onlineUsers.add(u); fireIntervalAdded(this, onlineUsers.size() - 1, onlineUsers.size() - 1); } } public void userOffline(String account) { for (int i = 0; i < onlineUsers.size(); i++) { if (onlineUsers.get(i).getAccount().equals(account)) { User u = onlineUsers.remove(i); fireIntervalRemoved(this, i, i); offlineUsers.add(u); break; } } } }

这个模型解决了两个痛点:第一,列表渲染和数据存储分离,UI 刷新只需要调用fireIntervalAdded,不用整个 JList 重新 setModel;第二,维护离线用户集合,聊天窗口里依然能显示离线好友的名字,只是置灰。如果只用一个 ArrayList 存全部用户,上线下线时频繁增删元素,界面会闪烁不说,索引还容易错位。

4.3 私聊流程:单人消息的完整收发链路

私聊消息从发送到展示,经历四个环节:输入框回车捕获、封装 JSON、发送消息、接收端解析追加到聊天区。

private void sendPrivateMessage(String toAccount) { String content = inputArea.getText().trim(); if (content.isEmpty()) { return; } Map<String, Object> msg = new HashMap<>(); msg.put("from", currentUser.getAccount()); msg.put("to", toAccount); msg.put("type", "text"); msg.put("content", content); msg.put("time", LocalDateTime.now().toString()); try { byte[] bodyBytes = objectMapper.writeValueAsBytes(msg); MsgHeader header = new MsgHeader(); header.type = (byte) 3; header.bodyLength = bodyBytes.length; OutputStream out = socket.getOutputStream(); out.write(header.toBytes()); out.write(bodyBytes); out.flush(); chatArea.append("我(" + msg.get("time") + "): " + content + "\n"); inputArea.setText(""); } catch (Exception ex) { chatArea.append("消息发送失败: " + ex.getMessage() + "\n"); } }

发送端在本地聊天区直接追加“我: xx”,是为了避免等服务器回显再显示导致的延迟,局域网里感觉不出来,但如果以后做公网版,本地回显是必须的。接收端收到的消息经过队列后追加到聊天区,不需要区分是谁发的,服务端已经通过 JSON 里的 from 字段告诉客户端了。

4.4 文件发送:分成小块传输,避免大文件卡死界面

文件传输是本项目里最容易翻车的地方。如果直接把整个文件读进 byte 数组再一次性写入 Socket,5MB 以上的文件就会让界面卡死,而且一旦失败没有任何进度提示。我实现了一个简单的分块发送:文件按 4KB 一块读取,每读一块就发送,并更新进度条。接收端逐块写入本地文件。

private void sendFile(File file, String toAccount) throws IOException { try (FileInputStream fis = new FileInputStream(file)) { byte[] buffer = new byte[4096]; int len; long total = 0; long fileSize = file.length(); FileMetadata meta = new FileMetadata(file.getName(), fileSize); // 先发元数据 sendJsonMessage(MessageType.FILE_META, objectMapper.writeValueAsString(meta)); OutputStream out = socket.getOutputStream(); while ((len = fis.read(buffer)) != -1) { out.write(buffer, 0, len); total += len; int percent = (int) (total * 100 / fileSize); progressBar.setValue(percent); } out.flush(); } }

这里有个关键细节:文件传输必须先发一个文件元信息 JSON,包含文件名和文件大小,接收端根据文件大小判断接收是否完成。很多初版代码不传大小,接收端直到流关闭才发现文件不完整,到时候排查起来就是玄学问题。4KB 缓冲块是性能和内存的平衡点,调成 1KB 会慢,调成 64KB 则占内存,但这个量级在局域网里差别不大。接收端需要一个独立的读取循环处理文件块,不能和普通消息共用一个循环,否则大文件传输期间会阻塞其他聊天消息。

4.5 群聊与广播:一条消息如何扩散到所有人

群聊和私聊在服务端唯一的区别是目标字段不同。私聊的目标是一个账号,群聊的目标是一个群组 ID,服务端根据群组 ID 查出所有成员账号,再逐个转发。如果只有“所有在线用户都可以看到群聊”,那不需要建群组表,直接把除发送者以外的在线用户都广播一遍即可。考虑到第一学期项目篇幅,我用的是全局广播方案:群聊消息发给所有在线用户。

public void handleGroupChat(ClientSession fromSession, String jsonStr) { for (ClientSession session : sessions.values()) { if (session.getUserId().equals(fromSession.getUserId())) { continue; // 不给自己回显 } session.send(jsonStr); } }

注意这里跳过了发送者自己,避免客户端重复显示。实际开发中,发送者本地已经回显了,再收到服务器广播就会显示两遍。如果要让消息格式统一,也可以不跳过,由客户端判断 “如果 from 是自己就忽略”,两种方案都行,选择一种保持统一更省心。

5. 联调避坑记录:聊天系统最常见的 5 个翻车现场

5.1 中文乱码:BufferedReader 和 PrintWriter 的默认编码陷阱

现象:服务端收到的中文全部变成问号,或者客户端显示乱码。

原因:很多教程里的 Socket 通信例子直接用PrintWriter和BufferedReader默认编码,而 Windows 控制台默认 GBK,Linux 服务器默认 UTF-8,两端一交叉就乱。

解决:统一在 Socket 获取流时指定 UTF-8。建议不要用PrintWriter writer = new PrintWriter(socket.getOutputStream()),改成new PrintWriter(new OutputStreamWriter(socket.getOutputStream(), StandardCharsets.UTF_8))或者干脆和我一样直接操作 OutputStream 写字节。字节流加 UTF-8 编码的 JSON,从源头杜绝编码问题。

5.2 粘包/半包:按长度读取而不是按行读取

现象:消息偶发地连在一起,接收端解析出错,或者消息内容被截断成两半。

原因:TCP 是流协议,没有消息边界。多次write的数据可能在底层合成一次到达,也可能一条数据分多次到达。如果接收端直接按readLine()读,就无法可靠判断一条完整消息到哪里结束。

解决:严格按消息头里的 bodyLength 读取。先读满 7 字节头,再用循环读取 body 数据,直到读满指定长度。如果读到了超过预期的字节,说明下一条消息已经到达,要把多余字节缓存起来,和下一次的数据合并。这个处理逻辑是网络编程的基本功,建议单独封装一个readFully(InputStream, int)方法。

5.3 客户端直接关闭导致服务端没有感知

现象:用户关掉聊天窗口,但其他好友的列表里他始终在线。

原因:客户端没有发退出协议,直接断开 TCP 连接,服务端的读循环会抛 SocketException,但如果异常处理代码写得不完整,服务端可能没执行离线广播。

解决:两件事都要做。第一,客户端在窗口关闭事件里发送退出消息;第二,服务端在读循环的 catch 里补上会话清理逻辑。窗口关闭事件用windowClosing回调发送 LOGOUT 包,同时设置一个超时兜底:如果服务端 10 秒内读不到任何数据且心跳超时,就强制清理。

5.4 Swing 跨线程更新 UI 导致界面随机卡死

现象:消息接收频繁时,聊天界面时不时卡住,或者出现组件不刷新的情况。

原因:接收消息的线程是网络线程,不是 EDT,直接在这个线程里调用jTextArea.append()或者jList.setModel(),Swing 的内部状态就会被多个线程同时修改,导致偶发死锁或状态不一致。

解决:网络线程只负责投递消息到阻塞队列,EDT 用定时器定时取消息并更新界面。这个方案比invokeLater更稳妥,消息量大时不会把 EDT 的任务队列塞爆。如果必须用invokeLater,记得把一批消息合并成一次 UI 更新,减少线程切换。

5.5 文件传输到一半断掉,接收端留下不完整文件

现象:大文件传输过程中一方关机,接收端留下一个大小不对的文件,再次发送时显示失败。

原因:接收端只写了“读到 EOF 就结束”,没有校验最终文件大小是否等于文件元信息里声明的大小。连接异常断开时,EOF 提前到达,文件自然不完整。

解决:接收结束后比较实际写入字节数和元信息里的 fileSize,不一致就删除文件并提示发送失败。同时给文件传输加一个简单的心跳检查:如果超过 30 秒没有任何新的文件块到达,判定传输超时,主动关闭文件流。这个逻辑写在接收端的循环里,代码量不大但能避免大量后续排查时间。

6. 验证与验收技巧:用客户端双开把全链路测试跑透

做这种通信类项目,最怕的是“演示的时候能用,换台电脑就崩”。我的习惯是验收前强制走一遍完整测试流程,这份流程也是答辩时最好的素材。

第一步,协议连通性测试。不启动完整客户端,用命令行工具模拟发一条登录报文,验证服务端能不能正确处理。把消息头按字节拼好,用 Python 脚本或现成的网络调试工具发过去,观察服务端日志。能过这一关,说明协议本身没问题,后续排查时可以把 UI 因素完全排除。

import socket import struct import json msg = {"account": "1001", "password": "test123"} body = json.dumps(msg).encode("utf-8") header = struct.pack(">2sBBi", b"MQ", 1, 1, len(body)) with socket.create_connection(("127.0.0.1", 8888), timeout=5) as s: s.sendall(header + body) resp = s.recv(1024) print(resp)

这段脚本模拟了一个最原始的客户端,重点在struct.pack(">2sBBi"):大端序打包,2 字节魔数、1 字节版本、1 字节类型、4 字节长度。大端序是网络字节序的标准选择,和 Java 的 ByteBuffer 默认大端对齐。用这种脚本测试,能发现服务端解析逻辑里的字节序问题。

第二步,双客户端在线互聊测试。启动两个客户端实例,分别登录两个账号,跑一遍私聊、群聊、文件发送三条主链路。注意观察三个点:私聊消息是否实时到达;群聊消息是否所有在线客户端都收到;文件传输完成后接收端文件是否能正常打开。这三个点覆盖了本项目 90% 的功能。

第三步,异常场景测试。这一步很多学员会跳过,但评审老师最爱问。我的测试清单是:断网后重连,看消息是否丢失;重复登录,看旧连接是否被踢;发一个空的群聊消息,看服务端是否会异常;文件传了一半取消,看接收端是否留下垃圾文件。每个异常场景都要有对应的日志输出。

第四步,聊天记录持久化验证。重新登录后,拉取历史消息和离线消息,确认顺序不乱,内容完整。这里要检查一下消息表里的时间字段,数据库存的是 DATETIME,客户端显示时要注意时区问题,我遇到过时间差 8 小时的情况,原因是 JDBC 连接串里没加 serverTimezone 参数。

最后一步,服务器端日志全量检查。测试完打开服务端日志,搜索Exception和ERROR,如果有,逐条排查。很多隐藏并发问题在测试时没暴露,但日志会如实记录,答辩前清理干净,现场演示才稳。

从那以后,我每次做完通信类项目,都会强制走一遍“清日志、验链路、测异常”的流程,写代码时的很多侥幸心理也被这套流程治好了。测试不是为了证明项目没 bug,而是为了在评审前把所有 bug 变成已知 bug——已知的能解释,未知的才致命。希望这篇拆解能帮你在做 MyQQ 这类结业项目时少踩几个坑,把精力放在真正能加分的设计上。

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

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

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

立即咨询