Qt 5.4双端点餐系统:TCP协议、JSON与SQLite完整实践
2026/9/17 6:19:11 网站建设 项目流程

简介:这是面向C++课程设计与毕业设计的综合项目源码包,基于Qt5.4框架实现客户自助点餐系统,完整包含客户端与服务端两部分,覆盖GUI界面搭建、网络通信、订单管理、数据库存储等核心环节,也适合需要快速上手Qt项目架构或完成课设答辩演示的计算机专业学生。压缩包共201个文件,大小27.28MB,其中包含C++业务逻辑源码(39个.cpp、23个.h)、9个.ui界面布局文件、2个qrc资源文件、51张程序运行截图,以及可直接运行的exe可执行程序,目录结构清晰,源码、界面与编译产物分层存放,便于按需查看与二次开发。目前已有400人学习下载。这份材料能帮助读者深入理解Qt信号槽机制、TCP/IP套接字与多线程并发处理、SQLite/MySQL数据交互和完整软件开发生命周期;拿到手即可运行体验,也能在现有基础上扩展菜品管理、订单统计等模块,为课设或毕设提供扎实的项目原型。

1. 从课程设计到可交付:Qt 5.4 双端点餐系统的架构起点

一个 C++ 课程设计题目如果只做到“能跑”,答辩时往往经不起追问:客户端断网会不会崩?服务端同时进来十笔订单数据还对不对?订单存在哪?基于 Qt 5.4 的客户自助点餐系统(客户端+服务端)恰恰把 C++ 课程里最常考的几件事串了起来——对象封装与 STL 容器、TCP 网络编程、数据库持久化,外加 Qt 信号槽机制。Qt 5.4 的 Widgets 稳定,QJson、QTcpSocket、QSql 到 Qt 6 依然兼容,作为课程设计选型并不过时。

这套方案适合两类人:正在做课程设计的学生,和想把题目升级成实用工具的一线开发。下面按协议定义、客户端实现、服务端实现、联调验收四段把闭环讲完。理清“协议边界”与“状态流转”,才是这个项目真正的得分点。

2. 客户端:Qt 5.4 下拆清界面层、协议层与网络层

2.1 界面层只需要三个部件:菜单列表、购物车与状态栏

客户自助点餐系统的客户端,核心场景是顾客在平板上自选菜品并提交订单,不需要服务员介入。因此在 Qt 5.4 里做主窗口,只需要三个 Widget:左侧 QListWidget 展示菜名、价格与分类,右侧 QTableWidget 充当购物车,底部放“确认下单”按钮和订单状态标签。菜单数据必须在客户端启动时向服务端动态请求,而不是硬编码在程序里。硬编码的坏处有两个:一是改菜单要重新编译客户端,二是答辩时老师只要问一句“菜单从哪来”,就露馅了。

菜品的数据结构放在公共头文件里,客户端和服务端共用。这里用普通的 struct 而非 QObject 派生类,因为 Dish 只是值类型,不需要信号槽,也不会有长生命周期的对象管理问题。OrderItem 用来表示“哪个菜、几份”,提交订单时会被转换成一个 JSON 数组。

// common/models.h —— 双端共享的数据定义 struct Dish { int id = 0; QString name; double price = 0.0; QString category; }; struct OrderItem { int dishId = 0; int count = 1; };

服务端返回菜单后,客户端把菜品灌进列表控件。这段代码里最值得注意的不是遍历,而是setData(Qt::UserRole, d.id)。Qt 的 Item 体系为每个 item 预留了 UserRole 起始的自定义数据区,把菜品 id 挂进去之后,后面点击 item 时直接item->data(Qt::UserRole).toInt()拿 id 就行,不必再按显示文本反查菜单。显示文本里包含品名和价格,一旦价格带小数格式,反查很容易出错。

void MenuWidget::applyDishes(const QList<Dish> &dishes) { ui->listWidget->clear(); for (const Dish &d : dishes) { QListWidgetItem *item = new QListWidgetItem( QString("%1 ¥%2").arg(d.name).arg(d.price), ui->listWidget); item->setData(Qt::UserRole, d.id); item->setSizeHint(QSize(0, 40)); } }

购物车用 QTableWidget 实现时,有三列:菜品名、单价、数量。数量列可以用 QSpinBox 作为 cell widget,也可以在双击菜单项时累加一行。课程设计场景下,双击菜单项累加更简单,也少处理一个控件数组:双击一次数量加一,再次双击同一道菜就把对应行找出来setItem改文本。修改单元格文本前记得把 value 转成 double 再累加,避免“1.0+2=3”被拼成“1.02”这类字符串拼接错误。

2.2 自定义通信协议:JSON 载荷加 4 字节长度头

客户端与服务端的交互要定义一份双方都遵守的协议,这是“客户端+服务端”项目与单机程序最大的区别。课程设计里最常见的错误是直接write("give me menu")这种明文短句,再用readAll()一把抓。TCP 是流式协议,没有消息边界,一次 readAll 可能只读到半个消息,也可能一次读到两条消息,靠文本换行做分隔符会死在 JSON 的转义字符上。正确做法是自己定义帧格式:[4字节长度][1字节类型][JSON载荷]。长度字段用网络字节序,载荷用 JSON,因为 Qt 5.4 起 QJsonDocument 就是 QtCore 的稳定模块,不需要引入任何第三方库。

整个系统只需要五种帧类型,列出来反而会比一段文字更清楚:

帧类型方向载荷关键字段用途
0x01客户端→服务端拉取菜单
0x02客户端→服务端table_no, items, total提交订单
0x11服务端→客户端dishes返回菜单
0x12服务端→客户端order_id, total, status下单成功回执
0x13服务端→客户端order_id, status订单状态推送

封包函数同样放在公共模块里,两端共用一份代码。封包时先把 QJsonObject 压缩成紧凑格式的字节数组,再在前面拼上 4 字节长度和 1 字节类型。

// common/protocol.h —— 双端共用的封包函数 QByteArray buildFrame(int type, const QJsonObject &payload) { QJsonDocument doc(payload); QByteArray body = doc.toJson(QJsonDocument::Compact); QByteArray head; quint32 len = static_cast<quint32>(body.size()); head.append(reinterpret_cast<const char *>(&len), sizeof(quint32)); head.append(static_cast<char>(type)); return head + body; }

这里直接把 quint32 的内存副本追加进字节流,在 x86 上得到的是小端字节序。如果追求严谨,可以用qToBigEndian(len)先转成网络字节序再 append,这样与任何平台的客户端对接都不会产生歧义。QJsonDocument::Compact会去掉 JSON 里所有空白符,它和格式化输出相比,大约能省掉 30% 的载荷体积,在低速无线网络或云端部署时这个差异是能感觉到的。

2.3 网络层封装:readyRead 里的 while 循环拆包

客户端的网络层,我习惯用 NetworkClient 类把 QTcpSocket 包起来,对外只暴露connectToServer()fetchMenu()submitOrder()三个方法。UI 层完全不接触 socket API,接线上只连接 NetworkClient 发出的 orderConfirmed、networkError 这些自定义信号。这样做的直接收益是:后面如果想从普通 TCP 换成 WebSocket 或加密传输,只需要改这一个类,MainWindow 一行不动。QTcpSocket::write是异步的,返回的是写入系统缓冲区的字节数,不代表对端已收到;真正的错误要通过 errorOccurred 信号订阅,这个认知可以避免在界面层做无意义的waitForBytesWritten阻塞。

拆包是网络层里最容易写错的部分。正确逻辑是维护一个成员变量 m_buffer,每次 readyRead 触发时把readAll()的结果追加进去,然后 while 循环尝试解析。缓冲区大于帧头 5 字节后,先读长度,再判断缓冲区是否已包含完整的载荷,不够就 break 等下一轮;够就截出载荷、从缓冲区移除,然后继续下一轮循环。

void NetworkClient::onReadyRead() { m_buffer.append(m_socket->readAll()); while (m_buffer.size() >= 5) { quint32 len; memcpy(&len, m_buffer.constData(), 4); if (m_buffer.size() < 4 + 1 + static_cast<int>(len)) { break; } int type = static_cast<unsigned char>(m_buffer.at(4)); QByteArray jsonBody = m_buffer.mid(5, len); m_buffer.remove(0, 5 + len); QJsonParseError err; QJsonDocument doc = QJsonDocument::fromJson(jsonBody, &err); if (err.error == QJsonParseError::NoError) { handleFrame(type, doc.object()); } } }

为什么必须用 while 而不是 if,这是 Qt 网络编程的高频考点。TCP 是流,一次 readAll 可能包含多个完整帧,也可能只有半个帧。若只处理一帧就返回,剩下的帧会滞留到下一轮 readyRead,而 readyRead 不是每来一批数据都稳定触发一次,帧与帧的边界就会错乱。m_buffer 必须是成员变量而不是局部变量,局部变量会在函数退出后销毁,上次没拼完的半帧就丢了。QJsonParseError 判错后选择直接丢弃这一帧而不是重试,因为在长度头正确的前提下出现 JSON 解析错误,多半是协议版本不匹配,重试没有意义。

提交订单时,把购物车里的菜品转换成 JSON 数组,连同桌号、总价一起封帧发送,随后用QTimer::singleShot挂一个 10 秒超时。服务端正常情况下几百毫秒内就会回 0x12 帧,超时未回要提示用户而不是无限等待。发送端还要注意:total 字段只能作为提示信息,服务端必须根据菜品 id 与价格表重新计算总价,两端数据不一致时以服务端为准,这是网络程序不可信任输入的基础原则。

void NetworkClient::submitOrder(int tableNo, const QList<OrderItem> &items, double total) { QJsonArray arr; for (const OrderItem &it : items) { QJsonObject obj; obj["dish_id"] = it.dishId; obj["count"] = it.count; arr.append(obj); } QJsonObject payload; payload["table_no"] = tableNo; payload["items"] = arr; payload["total"] = total; m_socket->write(buildFrame(0x02, payload)); }
// 超时保护:10 秒没收到 0x12 回执就通知 UI QTimer::singleShot(10000, this, [this]() { if (!m_orderConfirmed) { emit networkError(QStringLiteral("下单超时,请重试")); } });

2.4 Qt 5.4 工程配置与信号槽写法选择

最后单独说工程配置,因为这一节踩的坑最多。Qt 5.4 时代推荐直接拿 Qt Creator 打开 .pro 文件,这比用 VSCode 配 C++ 环境省事得多。一个可用的客户端 .pro 配置长这样:

QT += core gui network widgets CONFIG += c++11 TARGET = ClientApp TEMPLATE = app SOURCES += main.cpp MainWindow.cpp NetworkClient.cpp HEADERS += MainWindow.h NetworkClient.h

注意QT += widgets必须写,Qt 5 之后 Widgets 从 QtGui 里分离成独立模块,漏掉这一行编译会报一堆找不到 QMainWindow 的错误。CONFIG += c++11是为了启用 C++11;Qt 5.4 的编译器要求并不高,MSVC2013 或 MinGW 4.8 都支持,但 MSVC2013 需要装 Update 3,否则部分 STL 头文件会编译失败。信号槽的连接,Qt 5.4 已经支持函数指针新语法,课程设计里遇到 QListWidget 的 itemClicked 这类重载信号时,用传统的SIGNAL/SLOT宏反而更简洁:新语法要借助static_cast<void (QListWidget::*)(QListWidgetItem*)>来消除重载歧义,写起来很长。这两种写法的对比是 Qt 面试里的保留题,能说出“新语法编译期检查、可跨线程、性能更好,但重载信号需要转型”这一句,就比背答案的候选人有区分度。

3. 服务端:QTcpServer 事件循环模型与 SQLite 持久化

3.1 并发选型:单线程事件循环还是 QThread

服务端的核心争论通常只有一个:要不要为每个客户端开一个线程。对这个系统而言,答案是不需要。点餐服务的请求频率极低,一分钟几十单已经算很忙,单线程事件循环的吞吐量完全够用;更重要的是,单线程模型不存在共享数据竞争,不需要加锁,也不会有 Qt 跨线程访问 socket 或数据库连接的问题,课程设计的代码量能因此减少三分之一。

常见的初学者方案是new QThread然后moveToThread,这在点餐系统里属于过度设计。真正的性能瓶颈不在并发数,而在订单入库时的 SQLite 写锁。SQLite 同一时刻只允许一个写事务,开十个线程反而会把时间花在等待锁上。单线程模型下所有 socket 的 readyRead 信号在同一个事件循环里排队执行,写库天然串行,SQLite 不会报 database is locked。这个决策本身就是答辩的加分点,要能说清楚“什么时候才需要多线程”,比盲目堆线程更能体现对网络编程的理解。

服务端的基本骨架如下,QTcpServer 的 newConnection 信号触发后,循环取走所有 pending 连接,并给每个 socket 挂好 readyRead 和 disconnected 两个处理函数。

void OrderServer::onNewConnection() { while (m_server->hasPendingConnections()) { QTcpSocket *client = m_server->nextPendingConnection(); connect(client, &QTcpSocket::readyRead, this, [this, client]() { handleSocketData(client); }); connect(client, &QTcpSocket::disconnected, this, [this, client]() { onClientDisconnected(client); }); } }

hasPendingConnectionsnextPendingConnection配合循环,是因为 QTcpServer 可能同时积压多个连接请求,一次 newConnection 信号并不保证只有一个。lambda 里按值捕获 client 指针没有问题,但连接断开时必须在槽函数里调用deleteLater()而不是delete,因为当前事件循环可能还有其他与该 socket 相关的延迟事件,直接删除会造成悬垂指针崩溃。这一点在 Qt 的 socket 生命周期管理里反复出现,属于必须背下来的点。

3.2 拆包解析与 SQLite 订单落库

服务端解析网络数据的逻辑与客户端完全对称,因此封装拆包函数的 protocol.h 文件应该放在双端公共目录里,而不是各写一份。若两端各自维护一份协议实现,一旦修改字段,编译期不会报错,联调时却会出现“客户端发的东西服务端看不懂”,这种问题在课程设计答辩现场极难排查。公共协议文件是一个很小的投入、很大的回报。

订单表的结构按“一个订单一行、菜品明细存 JSON 文本”设计,而不是拆成订单主表和订单明细表两张表。一张表对课程设计规模足够,还省去了联表查询的复杂度。服务端读取订单列表时,把 items_json 字段解析成 QJsonArray,遍历即可得到明细。

字段名类型说明
idINTEGER PRIMARY KEY AUTOINCREMENT订单号,自增
table_noINTEGER桌号
items_jsonTEXT菜品快照(JSON 数组)
totalREAL总价
statusTEXTpending / confirmed / done / canceled
create_timeTEXT下单时间,本地时区

写入订单使用 QSqlQuery 的 prepare 与 addBindValue 组合,而不是手动拼接 SQL 字符串。JSON 载荷里包含双引号与反斜杠,直接拼接极易破坏 SQL 语法;更严重的是,如果菜品 id 或桌号是外部输入,拼接字符串等于把 SQL 注入漏洞写进了课程设计,这在代码评审时是硬伤。

bool OrderStore::saveOrder(int tableNo, const QJsonArray &items, double total) { QSqlDatabase db = QSqlDatabase::database("orderDB"); QSqlQuery query(db); query.prepare( "INSERT INTO orders (table_no, items_json, total, status, create_time) " "VALUES (?, ?, ?, 'pending', datetime('now', 'localtime'))"); query.addBindValue(tableNo); query.addBindValue(QString::fromUtf8( QJsonDocument(items).toJson(QJsonDocument::Compact))); query.addBindValue(total); return query.exec(); }

这里有两个细节容易被忽略。第一,datetime('now', 'localtime')明确指定了本地时区,漏掉这个参数存进库里的是 UTC 时间,与中国标准时间相差 8 小时,显示订单时间时就会差一截。第二,QSqlDatabase::addDatabase("QSQLITE", "orderDB")要在程序启动时调用一次,第二个参数固定连接名为 "orderDB",请求处理时用QSqlDatabase::database("orderDB")取连接;不要在每个请求里重新 addDatabase,那会触发 removeDatabase 的 warning,并在第二次连接时导致 SQLite 驱动状态错乱。

注意:saveOrder返回 false 时,要用query.lastError().text()把错误打出来,常见的是 disk I/O error 和 database is locked。前者多半是路径不可写,后者说明你的并发模型出了问题,回查 3.1 节。

读取菜单同样走公共协议帧。服务端收到 0x01 后,把配置文件里的菜品数组整体封进 0x11 帧回给客户端。这个操作没有数据库参与,因为菜单是低频变更数据,从配置文件直接读比每次查库开销更小、也更直观。

3.3 桌号注册、订单状态流转与广播推送

客户端启动后的第一条消息不是拉菜单,而是先告诉服务端自己坐在几号桌,也就是“注册”。注册消息可以在 0x01 帧的载荷里顺带带上 table_no 字段,服务端解析完先把它记入桌号映射表,再返回菜单。映射表用QHash<QTcpSocket*, int>实现,key 是连接对象指针,value 是桌号,插入和删除都在事件循环里完成,不需要加锁。

订单状态在服务端统一管理,状态机只有四个状态:pending(已提交,等待确认)→ confirmed(后厨已接单)→ done(已完成),此外允许用户主动取消进入 canceled。状态迁移全部由服务端校验,客户端不能直接把订单状态改成 done。这是业务规则落到服务端而不是客户端的基本原因:客户端可以被绕过,服务端不可绕过。客户端能做的只是提交 0x02 订单帧,以及接收 0x13 状态推送。

当后厨确认订单后,服务端需要把状态变化推送回对应桌号的客户端。推送做不到“点到点”精确寻址,因为桌号与连接是多对多的——同一张桌可能既用平板点餐又用手机查单。因此 0x13 帧广播给所有注册了该桌号的连接:

void OrderServer::notifyOrder(int tableNo, int orderId, const QString &status) { QJsonObject obj; obj["order_id"] = orderId; obj["status"] = status; QByteArray frame = buildFrame(0x13, obj); for (auto it = m_clientTable.begin(); it != m_clientTable.end(); ++it) { if (it.value() == tableNo) { it.key()->write(frame); } } }

广播遍历的是 QHash,注意不能在遍历过程中删除或修改哈希表内容。桌号映射的清理放在 disconnected 槽函数里,通过m_clientTable.remove(client)完成。漏掉这一步的后果是:后来再有新客户端连接时,通知会发给一个已经失效的 socket 指针,轻则打印警告,重则程序崩溃。Qt 里判断 socket 状态可以用state() == QAbstractSocket::UnconnectedState,但更可靠的做法是依赖 disconnected 信号完成清理,不轮询。

3.4 菜单配置化与启动自检

最后一处可以在答辩中加分的服务端设计是菜单配置化。菜单不写在代码里,而是放在服务端同目录的 menu.json 文件中,服务端每次收到 0x01 拉菜单请求时读取并返回,或者更优雅一点:启动时读入内存,提供“刷新菜单”管理接口。改价格、上架新菜,只需要改配置文件,无需重新编译服务端。

{ "dishes": [ {"id": 1, "name": "宫保鸡丁", "price": 28.0, "category": "热菜"}, {"id": 2, "name": "酸辣土豆丝", "price": 12.0, "category": "热菜"}, {"id": 3, "name": "米饭", "price": 2.0, "category": "主食"} ] }

服务端启动时应读取并校验这个文件:文件缺失、JSON 语法错误、菜品 id 重复,都应当直接返回错误码并终止启动,不能带病运行。答辩现场如果问“菜单文件被删了怎么办”,能回答“启动自检会拒绝服务并输出错误路径与错误行号”,就比回答“报错后继续跑”完整得多。这里用 QFile 读取后交给QJsonDocument::fromJson,解析时拿到 QJsonParseError 的 errorString,把它拼进 qCritical 日志,是定位问题最快的手段。

4. 联调验收三板斧:裸报文冒烟测试、抓包与脏数据清理

4.1 用 Python 构造裸报文做服务端接口冒烟测试

客户端界面没完成时,协议联调也可以先跑起来。写一段几十行的 Python 脚本,用原生 socket 直接向服务端发帧,验证服务端的拆包、解析、回包逻辑是否正常。0x01 帧的 JSON 载荷是空的,因此可以只发 5 字节帧头:

import socket, struct TYPE_FETCH_MENU = 0x01 s = socket.create_connection(("127.0.0.1", 9000), timeout=3) frame = struct.pack(">I", 0) + bytes([TYPE_FETCH_MENU]) s.sendall(frame) resp = s.recv(4096) print(resp)

struct.pack(">I", 0)中的>表示网络字节序,I表示四字节无符号整数,载荷长度 0 意味着这帧没有 JSON 正文。真正的价值在后面:把两帧拼在一起一次性发送,也就是s.sendall(frame + frame),再观察服务端响应。如果响应里包含两条菜单报文,说明 while 循环拆包正确;如果只有一条,说明拆包逻辑只处理了第一帧就返回了,这正是最常见的粘包 bug。演示用 recv 一次即可,严谨场景要循环 recv 直到解析出完整帧。

4.2 Wireshark 看 TCP 分段与重传

协议冒烟测试通过后再接 GUI 联调,此时如果还有诡异的数据错乱,打开 Wireshark。抓回环流量需要 Npcap 支持,过滤器用tcp.port == 9000并加上tcp.len > 0排除纯 ACK。右键一条数据流选 Follow TCP Stream,能直观看到客户端发的 4 字节长度头加 JSON 载荷的重组结果。重点看三个信息:客户端 write 是否真的把完整帧发出了;服务端是否在同一 TCP 段里收到两帧;有没有重传包导致响应变慢。高频小数据包下若重传率高,多半是 Nagle 算法与延迟 ACK 交互导致的 40ms 延迟,可以在客户端连接建立后调用socket->setSocketOption(QAbstractSocket::LowDelayOption, 1)关闭 Nagle,点餐场景下每一帧交互都要求低延迟,值得开。

4.3 演示前清理脏数据并检查插件部署

正式演示前最后一步是清数据。执行DELETE FROM orders WHERE status = 'pending'把测试期的废单清掉,再执行VACUUM压缩数据库文件。另外两条 Windows 中文环境的经典坑必须提前确认:源码文件用 UTF-8 编码保存,但 MSVC 编译器默认按本地代码页解释字符串字面量,中文菜名会变成乱码,MSVC2013 环境在代码里加#pragma execution_character_set("utf-8"),VS2015 及以上则在 .pro 里加QMAKE_CXXFLAGS += /utf-8;发布版 exe 需要把 qsqlite 驱动插件放在sqldrivers子目录,把platforms/qwindows.dll放在 platforms 子目录,否则一运行连接数据库就报 driver not loaded,或者直接报 could not find the Qt platform plugin windows。这两条检查完,再配一个只含点餐流程的演示脚本,答辩现场基本不会翻车。

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

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

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

立即咨询