Qt 5.4双端点餐系统:TCP协议、JSON与SQLite完整实践
发布时间:2026/9/17 6:19:24来源:尧图网络
简介这是面向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 QListDish 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.023”被拼成“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_castquint32(body.size()); head.append(reinterpret_castconst char *(len), sizeof(quint32)); head.append(static_castchar(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_castint(len)) { break; } int type static_castunsigned 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 QListOrderItem 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 c11 TARGET ClientApp TEMPLATE app SOURCES main.cpp MainWindow.cpp NetworkClient.cpp HEADERS MainWindow.h NetworkClient.h注意QT widgets必须写Qt 5 之后 Widgets 从 QtGui 里分离成独立模块漏掉这一行编译会报一堆找不到 QMainWindow 的错误。CONFIG c11是为了启用 C11Qt 5.4 的编译器要求并不高MSVC2013 或 MinGW 4.8 都支持但 MSVC2013 需要装 Update 3否则部分 STL 头文件会编译失败。信号槽的连接Qt 5.4 已经支持函数指针新语法课程设计里遇到 QListWidget 的 itemClicked 这类重载信号时用传统的SIGNAL/SLOT宏反而更简洁新语法要借助static_castvoid (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); }); } }hasPendingConnections与nextPendingConnection配合循环是因为 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 / canceledcreate_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 字段服务端解析完先把它记入桌号映射表再返回菜单。映射表用QHashQTcpSocket*, 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), timeout3) 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。这两条检查完再配一个只含点餐流程的演示脚本答辩现场基本不会翻车。本文还有配套的精品资源点击获取
网站建设高端定制企业官网