简介:面向计算机专业毕业设计与Qt入门进阶者的局域网聊天室项目源码,围绕私聊、文件传输与管理员权限管理展开,需配合本地或服务器MySQL 5.7使用,并限定在同一局域网网段运行。压缩包共87个文件,约6.54MB,以16个cpp源文件、14个h头文件、7个ui界面文件为主,辅以图片、qrc资源、工程配置与构建产物,便于梳理界面层、逻辑层和数据库访问层的组织方式。项目通过定时器轮询数据库标志位实现在线/离线状态刷新;私聊普通消息走UDP,文件传输走TCP,并在无中转服务器时由发送双方动态切换服务端与客户端身份。已有4111人学习下载,适合参考协议选择、身份切换、权限控制和Qt工程搭建,也能据此理解局域网即时通信的完整实现思路。
1. 局域网聊天室的需求边界:谁在用、要交什么、不做什么
选题会上最容易被质疑的一类题目,就是“功能听起来谁都懂”。局域网聊天室正好卡在这个位置:说出来三句话讲完,真动手才会撞上粘包、线程亲和性、在线状态残留、断线重连后消息重复这些硬骨头。用 Qt 做这件事的好处在于,QTcpServer、QTcpSocket、QThread 加上信号槽这套机制,能把网络层和界面层拆得比较干净,答辩时你能指着代码讲清楚一条消息从哪个线程进来、被哪个对象接住、最后落到哪个窗口。这套方案面向三类人:要交一份能跑通、能讲清、还能往外扩展的毕设或课设的同学;想把 Qt 网络编程从头串一遍的初学者;以及需要一个可复用 C/S 通信骨架的开发者。下面按需求边界、协议设计、服务端、客户端、踩坑排查、验收答辩的顺序,把这份局域网聊天室方案完整拆开。
2. 通信协议与线程模型:自定义帧 + 网络线程隔离
局域网聊天室的技术难点不在“把字发出去”,而在“发出去之后对方能不能完整、有序、不重复地收到”。这一章定两件事:应用层协议长什么样,以及 socket 归哪条线程管。这两件事定错了,后面界面做得再漂亮也是返工。
2.1 传输层选型:TCP 长连接、UDP 广播还是 HTTP 轮询
先排除几个看起来省事的方案。HTTP 轮询的延迟取决于轮询间隔,1 秒一次的话聊天体验像发邮件,改成长轮询又要维护挂起的请求,服务端连接数一多就难看。UDP 广播做局域网发现很舒服,但消息可靠性、顺序、去重全得自己补,毕设周期里补不完整就是一堆偶发丢消息。WebSocket 语义上合适,可在 Qt 里要么依赖 QtWebSockets 模块,要么自己实现握手,反而多一层。
我一般给这个场景的结论是:消息通道走 TCP 长连接,自己定一层轻量应用层协议。理由是 Qt 原生就有 QTcpServer 与 QTcpSocket,依赖为零;TCP 本身保证有序可靠,粘包问题在应用层用长度字段就能干净解决;局域网内 RTT 极低,长连接的心跳开销可以忽略。
| 方案 | 延迟 | 可靠性 | Qt 依赖 | 毕设讲解难度 |
|---|---|---|---|---|
| TCP 长连接 + 自定义帧 | 低 | TCP 保证 | 仅 QtNetwork | 中,协议可画图 |
| UDP 广播 | 最低 | 需自行实现 | 仅 QtNetwork | 高,重传逻辑难讲圆 |
| HTTP 轮询 | 高 | HTTP 保证 | 需 QNetworkAccessManager | 低,但技术含量不足 |
| WebSocket | 低 | TCP 保证 | 需 QtWebSockets | 中高,握手要额外解释 |
选型定了之后还有一个容易忽略的点:连接的生命周期。一条 TCP 连接从登录成功开始,到主动退出或心跳超时结束,中间所有消息都复用同一条连接,不在每条消息上重新建连。这样服务端的会话表才有意义,在线状态也才有地方挂。
2.2 帧格式定义:魔数、长度、类型与 JSON 负载
TCP 是字节流,没有消息边界。你write两次,对面可能一次readyRead全收到;你write一次大包,对面可能分三次收到。解决办法是在应用层给每条消息加一个头,写明“这条有多长”。
帧头设计成固定的 9 个字节:
| 偏移 | 长度 | 字段 | 说明 |
|---|---|---|---|
| 0 | 4 | magic | 固定值,用来识别非法连接与错位流 |
| 4 | 4 | length | 负载字节数,网络字节序 |
| 8 | 1 | type | 消息类型枚举 |
| 9 | length | body | UTF-8 编码的 JSON |
魔数看着像炫技,实际很有用:客户端连错端口、或者有人拿 telnet 乱敲,服务端第一帧就对不上魔数,可以直接断开,不用等解析 JSON 报错。长度字段用大端(网络字节序),是为了避免 x86 与 ARM 混编时对不上,毕设里虽然大概率同架构,但这个习惯值得保留。
编码部分:
// frame_codec.h —— 应用层帧的封装与拆解 #pragma once #include <QByteArray> #include <QJsonObject> #include <QJsonDocument> #include <QtEndian> constexpr quint32 kMagic = 0x51544348; // "QTCH",帧起始标识 constexpr int kHeaderSize = 9; // 4 魔数 + 4 长度 + 1 类型 constexpr quint32 kMaxBody = 1024 * 1024; // 单帧上限 1MB,防止被撑爆内存 enum class MsgType : quint8 { Login = 1, LoginAck = 2, Chat = 3, Broadcast = 4, UserList = 5, Heartbeat = 6, Logout = 7, Error = 8 }; inline QByteArray encodeFrame(MsgType type, const QJsonObject &payload) { const QByteArray body = QJsonDocument(payload).toJson(QJsonDocument::Compact); QByteArray frame; frame.reserve(kHeaderSize + body.size()); const quint32 magic = qToBigEndian<quint32>(kMagic); const quint32 len = qToBigEndian<quint32>(quint32(body.size())); frame.append(reinterpret_cast<const char *>(&magic), 4); frame.append(reinterpret_cast<const char *>(&len), 4); frame.append(char(type)); frame.append(body); return frame; } // 返回值 false 有两种含义:数据还不够一帧,或流已损坏 // 调用方需要自己保留 buffer,等下一次 readyRead 再试 inline bool decodeFrame(QByteArray &buffer, MsgType &type, QJsonObject &payload) { if (buffer.size() < kHeaderSize) return false; const quint32 magic = qFromBigEndian<quint32>(buffer.constData()); if (magic != kMagic) { buffer.clear(); return false; } // 非法流,直接丢弃 const quint32 len = qFromBigEndian<quint32>(buffer.constData() + 4); if (len > kMaxBody) { buffer.clear(); return false; } // 超长帧视为攻击 if (buffer.size() < kHeaderSize + int(len)) return false; // 半包,等更多数据 type = MsgType(quint8(buffer.at(8))); payload = QJsonDocument::fromJson( buffer.mid(kHeaderSize, int(len))).object(); buffer.remove(0, kHeaderSize + int(len)); // 消费掉这一帧 return true; }几个参数值得在答辩时点出来。kMaxBody是防御性设计,没有上限的话,一个伪造的 length 字段就能让服务端尝试分配几个 G;buffer.remove(0, n)是消费已解析数据,写错成buffer.clear()就会把半包一起清掉,表现为“偶尔丢消息”;MsgType用quint8而不是int,是为了让帧头长度固定,解析时不用猜。
负载选 JSON 而不是 protobuf 或自定义二进制,理由很实际:JSON 可读,抓包能直接看懂,答辩演示时把一条帧贴到屏幕上就能讲清字段含义,调试阶段也能用最小脚本发测试消息。代价是体积和解析开销,局域网几十人规模完全感知不到。
2.3 线程模型:socket 归属线程与跨线程投递
Qt 有一条铁律:QObject 有线程亲和性,QTcpSocket必须在它所属的线程里创建和使用,别的线程直接调它的方法就是未定义行为,典型症状是界面偶发卡死或者QSocketNotifier: Socket notifiers cannot be enabled or disabled from another thread这条警告刷屏。
服务端的规模判断很简单:局域网聊天室按 50 到 200 人估,每人几条心跳和聊天消息,单线程事件循环完全吃得下,因为事件驱动本身不阻塞,处理一条消息就是几百微秒。所以我一般不在服务端开线程池,而是把精力放在“单条消息处理不能有阻塞调用”上。真要展示多线程,把每个客户端会话包进一个QThread是可以的,但会引入锁和生命周期管理,答辩时被追问“你为什么要多线程”反而不好回答。
客户端不一样:界面要重绘、要滚动、要处理输入法,网络 I/O 放在 UI 线程虽然能跑,但接收大消息或重连时会出现肉眼可见的卡顿。做法是把网络层塞进独立线程:
// network_worker.h —— 客户端网络层,跑在独立线程 class NetworkWorker : public QObject { Q_OBJECT public: explicit NetworkWorker(QObject *parent = nullptr) : QObject(parent) {} public slots: // 必须在 worker 线程里调用,socket 也在这里 new void start(const QString &host, quint16 port) { socket_ = new QTcpSocket(this); connect(socket_, &QTcpSocket::connected, this, &NetworkWorker::onConnected); connect(socket_, &QTcpSocket::disconnected, this, &NetworkWorker::onDisconnected); connect(socket_, &QTcpSocket::readyRead, this, &NetworkWorker::onReadyRead); connect(socket_, &QTcpSocket::errorOccurred, this, &NetworkWorker::onError); socket_->connectToHost(host, port); } void sendFrame(int type, const QJsonObject &payload) { if (!socket_ || socket_->state() != QAbstractSocket::ConnectedState) return; socket_->write(encodeFrame(MsgType(type), payload)); // 小包无需 flush,事件循环会处理;大数据量时可在此判断 bytesToWrite } signals: void frameReceived(int type, const QJsonObject &payload); void stateChanged(const QString &text); // 交给界面显示“已连接/重连中” void errorRaised(const QString &text); private slots: void onReadyRead() { buffer_.append(socket_->readAll()); // 一次 readAll 拿全,避免漏读 MsgType type; QJsonObject payload; while (decodeFrame(buffer_, type, payload)) { emit frameReceived(int(type), payload); // 跨线程自动走 QueuedConnection } } private: QTcpSocket *socket_ = nullptr; QByteArray buffer_; // 半包缓存,必须跟着连接走,不能是局部变量 };宿主侧的接法是这样的:QThread *t = new QThread(this); auto *w = new NetworkWorker; w->moveToThread(t); connect(t, &QThread::started, w, [w]{ ... });。注意两点,一是moveToThread之前不能有父对象,否则 Qt 会拒绝移动;二是信号槽跨线程默认是Qt::QueuedConnection,参数必须能被 Qt 元对象系统拷贝,QJsonObject满足这个条件,传QByteArray或自定义结构体时要先qRegisterMetaType。
onReadyRead里的while循环是关键:一次readyRead可能带来一帧半,也可能带来五帧,只解一次就是典型的“表情包消息发得快就丢”。这一点在后面的排查章节还会再提。
3. 服务端实现:会话表、消息路由与在线状态维护
服务端是这套方案的“黑匣子”,也是答辩时最能体现设计水平的部分。它要维护三样东西:谁在线、消息往哪送、连接什么时候该被踢掉。
3.1 会话表设计:两套索引把 socket 和用户绑在一起
只用一张QList<QTcpSocket*>也能广播,但一旦要做私聊和“某某上线了”的定向通知,就得能从一个用户找到连接,也能从一条连接反查用户。所以我用两套索引:
| 结构 | 键 | 值 | 用途 |
|---|---|---|---|
| socketIndex | QTcpSocket* | quint64 sessionId | 收到数据时反查会话 |
| userIndex | QString userName | quint64 sessionId | 私聊、踢人、判断重名 |
| sessions | quint64 sessionId | Session 结构体 | 存昵称、进入时间、最近心跳 |
// session.h —— 一条连接对应的会话实体 struct Session { quint64 id = 0; QString userName; // 登录成功后才有值 QByteArray buffer; // 半包缓存,跟着连接走 qint64 lastActiveMs = 0; // 最近一次收到任意帧的时间 bool authed = false; // 是否已完成登录握手 };注意buffer放在Session里而不是放在处理函数里,这是粘包处理能正确工作的前提。很多人写成void onReadyRead() { QByteArray buf = socket->readAll(); ... },看起来能用,实际上只要一次半包,剩下的半截就永远丢了。
还有一个细节:sessionId用自增整数而不是指针,是为了日志里能打印、能给客户端引用,也避免了 socket 销毁后指针悬空的问题。所有对外的引用都用 id,通过 id 再去查表,查不到就当已被清理。
用户重名怎么处理,是需求阶段就该定的事。我一般定成:同名登录时服务端返回Error帧,code=1001,客户端在登录页提示“昵称已被占用”,而不是服务端强行踢掉旧连接。后者演示时容易出现两个人互相踢的奇观,讲起来很尴尬。
3.2 消息路由:单播、广播、离线缓存的三种投递
消息路由集中在一个函数里,所有入口都调它,好处是分发逻辑只有一份,加新消息类型时不用改八处:
// server.cpp —— 统一出口:所有下行消息都从这里走 void ChatServer::deliver(DeliverMode mode, quint64 targetId, MsgType type, const QJsonObject &payload) { const QByteArray frame = encodeFrame(type, payload); switch (mode) { case DeliverMode::Unicast: { // 私聊 / 定向回执 Session *s = findSession(targetId); if (s && s->authed) s->socket->write(frame); break; } case DeliverMode::Broadcast: { // 群聊 / 在线列表变更 for (Session &s : sessions_) { if (s.authed && s.id != targetId) // 群聊不回显给自己 s.socket->write(frame); } break; } case DeliverMode::BroadcastAll: { // 上线通知等需要包含自己 for (Session &s : sessions_) { if (s.authed) s.socket->write(frame); } break; } } }三种模式的差别值得说清楚。Unicast用于私聊和登录回执,找不到会话就静默丢弃,不报错——对方可能刚好下线了。Broadcast排除发送者自己,因为客户端本地已经乐观显示了自己发的消息,服务端再回显一次就是重影。BroadcastAll用在在线列表推送这类所有人都要知道的事件上。
群聊消息里我建议带一个服务端生成的msgId和时间戳,不要用客户端时间。原因是局域网里几台机器的时间可能差几分钟,界面上按时间排序会乱;而msgId单调递增,客户端去重和排序都有依据。这也是后面做“断线重连补消息”的伏笔。
write之后不需要手动flush,Qt 的事件循环会在合适的时候把缓冲送出去。但如果你要统计“发送失败”,就得接bytesWritten信号,光看write的返回值只能说明数据进了缓冲区。
3.3 心跳与超时剔除:在线列表怎么保证不“僵尸”
局域网里最常见的场景是笔记本合盖、网线被拔、程序被强制结束。这些情况下 TCP 不会立刻感知,服务端在线列表里就会挂着一个永远不说话的用户,别人给他发私聊全石沉大海。
做法是双向心跳:服务端每 30 秒广播一次Heartbeat,客户端收到后回一个Heartbeat,并更新lastActiveMs。服务端用QTimer每 15 秒扫一遍会话表,超过 90 秒没有任何帧的会话直接abort()并清理。
// 超时扫描:90 秒没有任何帧往来就判定掉线 void ChatServer::onSweepTimeout() { const qint64 now = QDateTime::currentMSecsSinceEpoch(); QList<quint64> dead; for (const Session &s : sessions_) { if (s.authed && now - s.lastActiveMs > 90 * 1000) dead.append(s.id); } for (quint64 id : dead) { removeSession(id, QStringLiteral("心跳超时")); // 内部会广播 UserList } }参数怎么定:心跳间隔 30 秒、扫描周期 15 秒、超时阈值 90 秒,是留了两次丢包的余量。间隔写太短(比如 5 秒)会让日志刷屏,写太长(比如 5 分钟)演示时等不到人下线,答辩现场会很尴尬。用abort()而不是disconnectFromHost(),是因为后者在对方已经半死的情况下会等 TCP 四次挥手,可能卡住十几秒,而abort()立即释放描述符。
清理会话时一定要先广播新的在线列表再销毁 socket,顺序反了会出现在线列表里少一个人、但下一个事件又把他加回来的诡异现象。这个坑我在第 5 章里展开说。
4. 客户端实现:三段式界面 + 信号槽解耦
客户端最容易写成“能跑但没法改”的形态:所有逻辑堆在MainWindow里,socket 是成员变量,收到数据直接ui->textEdit->append()。这样第一版很快,但加一个私聊窗口就要动十处代码。
4.1 界面结构:登录页、会话列表、消息区的三段式布局
用 Qt Widgets 就够了,不需要 QML。整体分三层:登录页是一个独立的QDialog,只负责收集主机、端口、昵称,点“连接”之后把参数抛出去,自己不碰网络;主窗口用QSplitter左右分栏,左侧是QListWidget显示在线用户和群聊入口,右侧是QTextBrowser显示消息、下方QLineEdit加发送按钮。
QTextBrowser而不是QTextEdit,因为它支持富文本但默认只读,天然避免用户在手滑时改了历史消息。消息渲染用 HTML 拼:
// 消息气泡渲染:昵称加粗、时间置灰、正文转义 void ChatWindow::appendMessage(const QString &user, const QString &text, const QDateTime &ts, bool self) { const QString color = self ? QStringLiteral("#2b6cb0") : QStringLiteral("#2d3748"); const QString html = QStringLiteral( "<div style='margin:4px 0;color:%1'>" "<b>%2</b> <span style='color:#a0aec0;font-size:11px'>%3</span><br/>" "<span>%4</span></div>") .arg(color, user.toHtmlEscaped(), ts.toString("HH:mm:ss"), text.toHtmlEscaped()); // 必须转义,否则对方发 <img> 就中招 ui->chatView->append(html); }toHtmlEscaped()这行不能省。有人在群里发一个<b>或者一段<style>,整个聊天窗口的样式就炸了,排查半天发现是渲染层的问题。这属于典型的“演示前十分钟才发现”的坑。
左侧在线列表要在用户上下线时更新,我用QListWidget的 item 数据槽存 sessionId 或昵称,避免用显示文本反查。
4.2 网络层与界面层解耦:只传信号不传 socket
界面层拿到的东西应该只有“有人发了一条消息”这种语义事件,而不是QJsonObject。所以在NetworkWorker和MainWindow之间再放一层轻量的ChatClient,把协议细节翻译成业务信号:
// chat_client.cpp —— 协议翻译层,界面只认这里的信号 void ChatClient::onFrame(int type, const QJsonObject &obj) { switch (MsgType(type)) { case MsgType::LoginAck: { if (obj.value("ok").toBool()) { myName_ = obj.value("name").toString(); emit loginSucceeded(myName_); } else { emit loginFailed(obj.value("msg").toString()); // 昵称重复等 } break; } case MsgType::Chat: { emit messageArrived(obj.value("from").toString(), obj.value("text").toString(), QDateTime::fromMSecsSinceEpoch( obj.value("ts").toVariant().toLongLong())); break; } case MsgType::UserList: { QStringList users; for (const auto &v : obj.value("users").toArray()) users << v.toString(); emit userListChanged(users); break; } case MsgType::Error: emit serverError(obj.value("code").toInt(), obj.value("msg").toString()); break; default: break; } }这样分层的好处,在加功能的时候体现得最明显。想加“未读红点”,只要在ChatClient里挂messageArrived统计,不用动网络层;想把 JSON 换成别的序列化格式,只改编解码和这一层,界面零改动。答辩时被问“你的模块怎么划分的”,这张图(网络层—协议层—界面层)能直接画出来。
信号槽还有一处容易翻车:emit在 worker 线程里发出,槽在 UI 线程执行,默认排队连接,所以槽函数里可以直接改界面,不需要加锁。但如果槽里做了耗时操作(比如查 SQLite 写历史),就会卡住 UI 线程,这个度要自己把握。
4.3 本地消息记录与未读数:QSettings 还是 SQLite
聊天记录存不存、怎么存,是需求阶段就要定的。以毕设的体量,两种方案都合理:
| 方案 | 优点 | 缺点 | 适用 |
|---|---|---|---|
| QSettings + JSON 文件 | 零依赖、几行代码 | 每次全量读写,条数一多就慢 | 只存最近 N 条,演示够用 |
| SQLite(QtSql) | 支持分页查询、按会话过滤 | 要建表、要处理连接线程问题 | 想体现数据持久化设计 |
我倾向于选 SQLite,因为“消息持久化”本身就是个可以写进论文的模块,而且实现成本不高。建一张表就够:
-- 单表存所有消息,session_key 区分群聊与私聊 CREATE TABLE IF NOT EXISTS messages ( id INTEGER PRIMARY KEY AUTOINCREMENT, session_key TEXT NOT NULL, -- 'group' 或 'private:<昵称>' sender TEXT NOT NULL, content TEXT NOT NULL, ts INTEGER NOT NULL, -- 毫秒时间戳 is_read INTEGER NOT NULL DEFAULT 0 ); CREATE INDEX IF NOT EXISTS idx_session_ts ON messages(session_key, ts);session_key用字符串拼而不是建会话表,是为了少一次 join,代价是改名后历史消息对不上,毕设里可以接受。索引建在(session_key, ts)上,因为唯一的查询模式是“某个会话按时间倒序取最近 50 条”。
一个必须提醒的点:QSqlDatabase的连接不能跨线程共享。如果你把数据库操作放在 UI 线程,而网络层在 worker 线程,那就只在 UI 线程用数据库,别图省事在 worker 里也开一个连接。未读数就存在内存里,退出时再刷一次库,简单可靠。
5. 避坑与排查:粘包、跨线程 UI 崩溃和重连状态错乱
这一章是我在类似项目里踩过的、也是答辩现场最容易被追问的几处。每一条都按“现象—原因—解决”讲。
现象一:短消息正常,长消息或连发消息丢失、错位。原因在readyRead里只解一帧。TCP 没有消息边界,一次信号可能对应零点多帧到多帧,只调一次decodeFrame剩下的数据就留在buffer里等下次信号,而下次信号可能很久才来。解决办法是把解析写成while (decodeFrame(...))循环,并且buffer必须是会话级的成员变量,随连接创建、随连接销毁。顺带检查buffer.remove(0, kHeaderSize + len)是否真的消费了数据,写成clear()会在半包时把已收到的部分也清掉。
现象二:程序偶发崩溃,调用栈指向QWidget::update()或某个ui->成员;控制台偶尔有Cannot create children for a parent in a different thread。原因是在 worker 线程里直接操作了界面对象。Qt 的界面类只能在主线程使用,跨线程的直接调用不会报错,只会在某个不确定的时刻崩。解决办法是所有 UI 更新都通过信号槽走,连接方式保持默认的Qt::AutoConnection(跨线程自动排队);如果确实需要在 worker 里触发一次界面更新,用QMetaObject::invokeMethod(widget, [w]{ ... }, Qt::QueuedConnection)。排查手段是在槽函数里打印QThread::currentThreadId(),和主线程 id 对一下,一眼能看出来。
现象三:断线重连之后,界面出现重复消息,或者发送按钮点了没反应。原因通常是重连时复用了旧的QTcpSocket对象,同时旧的信号连接还挂着,一条数据被两个槽处理;或者NetworkWorker::start被调了两次,socket_被覆盖,旧对象泄漏。解决办法是把重连做成“销毁—重建”而不是“复用”:先socket_->deleteLater(),把buffer_清空,再new QTcpSocket(this)重新 connect 信号。所有connect都用成员函数指针形式,避免字符串形式在重连时重复绑定。
现象四:中文昵称在服务端日志里是乱码,或者在线列表里显示成问号方块。原因是编码不统一。JSON 本身是 UTF-8,但有人图快用QString::toLocal8Bit()编码 body,在 GBK 环境下就崩了。统一做法是QJsonDocument::toJson()出来的QByteArray直接用,不额外转码;界面显示时QString::fromUtf8();源文件保存为 UTF-8 且在main里不需要再设setCodecForLocale。排查方式是在服务端把收到的 body 前 32 字节以十六进制打印,看E4 BD A0(“你”的 UTF-8)还是C4 E3(GBK),一眼定位。
现象五:本机能连,同宿舍另一台机器连不上;或者演示当天服务端启动就bind失败。前者多半是监听地址写成了QHostAddress::LocalHost,只绑了回环,改成QHostAddress::Any即可;另外要确认防火墙对监听端口的入站规则,演示机最好提前放行。后者是端口被上一次没退干净的进程占着,listen返回 false 但没人检查返回值。我的做法是启动时检查if (!server_->listen(QHostAddress::Any, port))就弹窗提示具体错误并支持换端口,而不是静默失败——静默失败是演示事故的头号来源。
6. 验收与答辩:一份能复现的测试清单和三个演示脚本
到这一步,功能都写完了,真正决定成绩的是“能不能证明它可靠”。我一般准备一张测试清单,逐条跑、逐条打勾,答辩时直接投出来,比口头说“功能都实现了”有说服力得多。
| 编号 | 测试项 | 操作 | 预期结果 |
|---|---|---|---|
| T1 | 多端登录 | 三台客户端依次登录 | 各自收到完整在线列表,列表长度一致 |
| T2 | 群聊广播 | A 连续发 20 条含表情的消息 | B、C 顺序正确、无丢失、无重复 |
| T3 | 私聊定向 | A 私聊 B | 只有 B 收到,C 无感知 |
| T4 | 昵称冲突 | 用已在线昵称登录 | 返回错误码,提示“昵称已被占用”,不影响在线者 |
| T5 | 异常断开 | 直接结束 B 的进程 | 90 秒内 A、C 的在线列表移除 B |
| T6 | 断线重连 | B 断网 30 秒后恢复 | 自动重连成功,历史消息仍在,无重复条目 |
| T7 | 大消息 | 发送 8000 字文本 | 对端完整显示,程序不卡顿 |
| T8 | 非法连接 | 用 telnet 连接并乱发字节 | 服务端断开该连接,其他会话不受影响 |
演示脚本我固定写三个,每个两分钟以内,避免现场翻车。第一个是“三端上线 + 群聊 + 私聊”,重点讲清楚一条消息从QLineEdit到encodeFrame、到服务端路由、再到对端appendMessage的完整链路,边走边在代码里定位到对应函数。第二个是“拔网线”,故意杀掉一个客户端,等在线列表更新,讲心跳与超时剔除的参数怎么定的。第三个是“连发压力”,用一个循环脚本连发 50 条消息,证明粘包处理是对的——这一条最能体现你懂 TCP,而不是只会调 API。
答辩提问高频的三个问题,答案要提前背熟:为什么不用 UDP(可靠性要自己做,收益在局域网里不明显);为什么消息用 JSON(可读、可调试,规模小不需要二进制优化);粘包怎么解决的(长度字段 + 会话级 buffer + while 循环解析)。如果被问到扩展性,就说清楚当前是单机服务端、会话表在内存里,往多人在线方向走要么加房间概念,要么把会话表外置——说得出边界,比硬吹“支持万人”可信得多。
最后给一个我自己的习惯:每次改完协议相关代码,先不急着开界面,写个十几行的小脚本往服务端灌一帧半包、一帧超长帧、一帧魔数错误的帧,看服务端是不是按预期断开而不是崩掉。这个动作花不了五分钟,但救过我两次演示。希望帮到你。
本文还有配套的精品资源,点击获取