ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

C++与Qt点餐系统实战:从TCP长连接到订单状态机

C++与Qt点餐系统实战:从TCP长连接到订单状态机 简介基于C与Qt实现的在线点餐系统包含完整客户端与服务端工程适合作为计算机专业课程设计、毕业设计及Qt项目练手的参考资料。资源共126个文件压缩包8.74MB涵盖33个C源文件、31个头文件、10个UI界面文件以及QSS样式、PRO工程配置、SQL数据库脚本和TS翻译文件等覆盖界面设计、业务逻辑、网络通信与数据存储等关键环节。目前已有248人学习浏览代码经本地编译验证按配套文档配置环境即可直接运行难度适中且经过助教审定。通过整个工程可以学习Qt客户端与服务端的Socket交互方式、SQL数据库表设计、菜品与订单的状态管理等工程内模块划分明确从登录验证、菜单展示、下单结算到后台管理均有对应代码可根据自身需求替换或扩展功能模块快速实现自己的在线点餐项目。1. 点餐系统被低估了它不该只是 CRUD而是一个小型通信系统先说结论基于 C 与 Qt 做在线点餐系统最容易翻车的不是界面写不出来而是客户端和服务端之间的通信模型设计失误。你大概率能做出一个能点菜、能下单的 Demo但在食堂高峰期、多桌并发、服务员端和大屏端同时刷新时整个系统会卡在“客户端连着服务端但数据对不上”的尴尬状态。这个标题值得拆开看两层业务层菜单浏览、购物车、下单、订单状态流转、历史记录本质是“订单状态机”的驱动而不是普通增删改查。技术层客户端要有桌面级的实时响应服务端要承受短时间多个客户端同时下单二者之间要有清晰的消息协议、心跳机制和错误处理逻辑。所以这篇文章不打算讲“怎么画一个漂亮的点餐界面”而是把通信骨架、客户端状态管理、服务端并发与数据校验、以及最终部署联调里的坑一次说清楚。适合的读者有两种一是正在用 Qt 做课程设计或内部工具、需要一套“能跑起来且能答辩”的架构二是工作中要接手 Qt 客户端 服务端项目、想知道通信层怎么组织才不至于返工的人。2. 通信骨架先行TCP 长连接、JSON 消息与心跳 3 个参数2.1 为什么不优先用 HTTP而是 TCP 长连接点餐场景里客户端和服务端的关系不是“我请求一下你返回给我”就结束的。订单状态变化需要服务端主动推送给客户端后厨出餐了收银端和叫号屏要同步更新大屏端要显示叫号信息不能靠客人每隔 5 秒点一次刷新。HTTP 短连接在外卖 App 里够用但在这个标题的场景下——客户端和服务端都是 Qt 自研、部署在同一局域网用 TCP 长连接能省掉三件事每次请求的握手开销服务端无法主动触达客户端的尴尬客户端被迫轮询带来的数据库压力。这里说的 TCP 长连接不是让你在界面上直接 new 一个 socket。常见做法是客户端跑一个网络线程持有一个 QTcpSocket服务端用 QTcpServer 监听固定端口所有业务消息走同一个连接消息用“长度 JSON 体”的帧格式切分。2.2 消息格式一个能跑通的最小 JSON 协议我先定义一套够用的消息结构后面所有代码都按这个结构走。协议字段不用多能表达“谁、做什么、给谁、数据是什么”就够了。{ msg_id: 1001, type: submit_order, table_no: A12, seq: 20240116001, data: { items: [ {dish_id: 3, count: 2}, {dish_id: 7, count: 1} ], note: 少辣 } }字段名类型说明msg_id字符串消息唯一标识客户端生成服务端响应时原样带回type字符串消息类型如 login、query_menu、submit_order、order_status、heartbeattable_no字符串桌号或客户端编号服务端用它区分会话seq整数业务流水号用于幂等判断避免重复下单data对象业务数据不同 type 下结构不同为什么用 JSON 而不是自定义二进制协议Qt 自带 QJsonDocument解析和序列化都不需要引入第三方库排查问题时用文本协议一眼能看出哪里写错了。后期如果追求性能可以把心率、状态同步这类高频消息改成二进制体JSON 只做包头。服务端收到一条消息后先拆帧再按 type 分发到不同槽函数或处理类。拆帧逻辑放在一个公共类里客户端和服务端共用这是减少联调 bug 最有效的做法。2.3 心跳、超时与断线重连3 个必调参数长连接最容易出现的问题是“假死”客户端进程还活着但网络中间断了服务端也不知道订单状态推不过去用户那边点啥都没反应。解决方式是客户端定时发心跳包服务端超时未收到就主动断开该连接。三个参数我一般这样定参数推荐值依据心跳间隔10 秒低于 5 秒会造成无意义网络开销高于 30 秒会延迟发现断连超时判定30 秒取心跳间隔的 3 倍容忍一次丢包和一次延迟重连退避1s、2s、5s、10s、30s 封顶指数退避避免断线时客户端和服务端同时疯狂重连代码如下// 客户端的定时心跳 heartbeat_timer_ new QTimer(this); heartbeat_timer_-setInterval(10000); connect(heartbeat_timer_, QTimer::timeout, this, [this]() { if (socket_-state() QAbstractSocket::ConnectedState) { QJsonObject obj; obj[msg_id] QUuid::createUuid().toString(QUuid::WithoutBraces); obj[type] heartbeat; obj[table_no] table_no_; sendJson(obj); } }); heartbeat_timer_-start(); // 服务端的超时判定重载定时器的剩余时间判断 void ServerSession::handleHeartbeat(const QJsonObject msg) { last_heartbeat_ QDateTime::currentMSecsSinceEpoch(); }参数说明setInterval(10000)是毫秒对应心跳间隔 10 秒。last_heartbeat_在服务端每次收到任意消息时都更新而不只是心跳消息——因为正常业务数据同样能证明连接是活的。服务端每 5 秒扫一次所有 session如果当前时间 - last_heartbeat_ 30000就主动断开并清理资源。3. 客户端实现从下单到状态回显的 Qt Widgets 正确写法3.1 网络层必须和工作线程绑定GUI 线程不做阻塞Qt 客户端最常犯的错是在按钮点击槽函数里直接调用waitForConnected()或同步等待服务端响应。这会把 GUI 主线程卡死界面直接“转圈”用户体验非常差而且窗口管理器会提示程序无响应。正确做法是把 QTcpSocket 放到一个独立的 QThread 里运行网络事件通过信号槽转发到 GUI 线程GUI 线程不做任何会阻塞的操作。下面是一个简化但完整的客户端网络层骨架class NetworkWorker : public QObject { Q_OBJECT public: explicit NetworkWorker(QObject* parent nullptr) : QObject(parent) { socket_ new QTcpSocket(this); connect(socket_, QTcpSocket::readyRead, this, NetworkWorker::onReadyRead); connect(socket_, QTcpSocket::connected, this, NetworkWorker::onConnected); connect(socket_, QTcpSocket::disconnected, this, NetworkWorker::onDisconnected); } public slots: void connectToServer(const QString host, quint16 port) { socket_-connectToHost(host, port); } void sendMessage(const QJsonObject msg) { QByteArray payload QJsonDocument(msg).toJson(QJsonDocument::Compact); QByteArray frame; QDataStream stream(frame, QIODevice::WriteOnly); stream.setVersion(QDataStream::Qt_5_15); stream (quint32)payload.size(); frame.append(payload); socket_-write(frame); } signals: void messageReceived(const QJsonObject msg); void connectionLost(); private slots: void onReadyRead() { buffer_.append(socket_-readAll()); while (buffer_.size() (int)sizeof(quint32)) { QDataStream stream(buffer_, QIODevice::ReadOnly); stream.setVersion(QDataStream::Qt_5_15); quint32 blockSize 0; stream blockSize; if (buffer_.size() (int)(sizeof(quint32) blockSize)) return; QByteArray payload buffer_.mid(sizeof(quint32), blockSize); buffer_.remove(0, sizeof(quint32) blockSize); QJsonDocument doc QJsonDocument::fromJson(payload); emit messageReceived(doc.object()); } } private: QTcpSocket* socket_; QByteArray buffer_; };这段代码解决两个问题一是发送时用 4 字节长度前缀把 JSON 包粘包问题处理掉二是接收时把半包数据留在缓冲区等数据齐了再一次性解析。QDataStream直接写quint32长度服务端同样用QDataStream读取时不需要关心大小端问题因为 Qt 自己会处理。3.2 用订单状态机管理界面别让按钮回调当流程控制客户端界面再漂亮如果业务状态混乱点餐、加菜、结算、取消这几个动作就会互相打架。我习惯在下单流程里定义一个状态枚举界面上所有按钮的可用性、文案、颜色都从当前状态推导而不是分散在各回调里写 if-else。状态定义如下enum class OrderPhase { Selecting, // 浏览菜单、选菜中 Confirming, // 已点击“下单”等待服务端确认 Placed, // 服务端已确认订单生效 Preparing, // 后厨制作中 Served, // 已上菜 Finished, // 已结账 Cancelled // 已取消 };信号设计遵循“一个状态一个信号”的原则界面上对应控件在onOrderPhaseChanged槽函数里统一处理signals: void orderPhaseChanged(OrderPhase newPhase, const QString orderId); private slots: void onOrderPhaseChanged(OrderPhase phase, const QString orderId) { btn_confirm_-setEnabled(phase OrderPhase::Selecting); btn_cancel_-setEnabled(phase OrderPhase::Placed || phase OrderPhase::Preparing); btn_pay_-setEnabled(phase OrderPhase::Served); lbl_status_-setText(phaseText(phase)); // 这里统一触发进度条更新不需要各按钮去改进度条 progress_bar_-setValue(static_castint(phase) * 20); }3.3 菜单列表用增量更新订单区用独立 Model菜单数据从服务端拉取后如果每次有菜品售罄或价格变动就整体刷新 QTableWidget界面上会出现闪烁和滚动位置丢失。建议客户端在启动时拉取一次完整菜单之后只接收menu_update增量消息单独改对应行。菜品列表和订单明细区建议拆成两个独立的表格控件或 Model。订单区是用户操作密集区域每次服务端推送状态变化时只刷新该订单行不触碰菜单区。Qt 自带的 QTableWidget 够用如果后续菜单项要支持排序、筛选再把 QTableView QSqlTableModel 加上去初期不必过度设计。4. 服务端实现线程模型、SQLite 存储与接口自测4.1 并发模型怎么选每连接一线程还是线程池服务端最核心的决策是“一个客户端连接对应一个线程”还是“所有连接共享少量线程”。先说结论每连接一线程最省事线程池上限可控事件循环复用适合有经验的人。模型优点缺点每连接一线程思路直观一个 session 一个栈不互相干扰并发 100 时线程切换开销大线程池控制并发上限适合 IO 密集型需要自己维护 session 与线程的映射事件循环复用性能最好单线程处理所有 IO代码复杂度高锁和回调容易出问题Qt 的QTcpServer::nextPendingConnection()拿到每个连接的 socket 后常见做法是为每个连接创建一个ServerSession对象ServerSession内部持有自己的QTcpSocket并把它moveToThread到一个专门处理该连接的线程中。线程结束后由QThread::finished信号统一清理。void OrderServer::onNewConnection() { QTcpSocket* socket tcp_server_-nextPendingConnection(); QThread* thread new QThread(this); ServerSession* session new ServerSession(socket); session-moveToThread(thread); connect(thread, QThread::finished, session, QObject::deleteLater); connect(thread, QThread::finished, thread, QObject::deleteLater); connect(session, ServerSession::errorOccurred, this, OrderServer::onSessionError); connect(socket, QTcpSocket::disconnected, thread, QThread::quit); thread-start(); }4.2 价格必须在服务端重算不能信任客户端传的总价一个点餐系统最容易出安全问题的位置就是下单接口客户端把购物车明细和总价一起发给服务端服务端原样存储。这样用户只要能改请求体就能把 100 元的菜改成 0.1 元。正确做法是服务端收到订单时只从dish_id和count查数据库里的真实单价重新计算总价再落库。服务端代码示意如下bool OrderProcessor::submitOrder(const QJsonObject order, QJsonObject response) { const int tableNo order[table_no].toString().toInt(); const QJsonArray items order[data].toObject()[items].toArray(); if (items.isEmpty()) { response[error] empty_items; return false; } QSqlDatabase db QSqlDatabase::database(orders); if (!db.transaction()) { response[error] tx_begin_failed; return false; } double realTotal 0.0; int orderId -1; QSqlQuery query(db); for (const auto item : items) { const int dishId item.toObject()[dish_id].toInt(); const int count item.toObject()[count].toInt(); query.prepare(SELECT price FROM dishes WHERE id ?); query.addBindValue(dishId); if (!query.exec() || !query.next()) { db.rollback(); response[error] dish_not_found: QString::number(dishId); return false; } const double unitPrice query.value(0).toDouble(); realTotal unitPrice * count; } query.prepare(INSERT INTO orders (table_no, total, status, seq) VALUES (?, ?, ?, ?)); query.addBindValue(tableNo); query.addBindValue(realTotal); query.addBindValue(placed); query.addBindValue(order[seq].toInt()); if (!query.exec()) { db.rollback(); response[error] insert_failed; return false; } orderId query.lastInsertId().toInt(); if (!db.commit()) { db.rollback(); response[error] tx_commit_failed; return false; } response[order_id] orderId; response[total] realTotal; return true; }这里的关键参数是db.transaction()和db.commit()二者保证“先插入订单头再插入订单明细”整体成功或整体失败不会出现订单头存在但明细缺失的半截数据。seq字段在插入时用了唯一索引如果同一客户端重复提交同一个seq第二次插入会因唯一约束失败从而天然屏蔽重复下单。SQLite 在并发写方面有局限但在点餐系统这个规模下同时在线客户端几十个写频率低完全够用。如果你的场景是几百个客户端同时高频写订单再考虑换 PostgreSQL 或 MySQL使用 QSqlDatabase 封装时切换成本很低。4.3 不做接口文档也能自测用命令行发一个原始订单消息服务端写完最怕的是没写客户端就无从验证。实际上你用 Qt 写一个小发送工具或者直接用命令行工具就能完成大部分“服务端接口测试”。我一般会用最简单的方式启动服务端后用nc命令模拟客户端发送一条订单消息。发送内容要手动拼上 4 字节长度头所以更省事的是写一个 30 行的 Qt 控制台小工具把测试消息存成 JSON 文件后循环发送验证不同分支的响应。下面是用 bash nc 直接发送的示例对应 Linux 或 macOSWindows 下可以用 PowerShell 的 TcpClientmsg{msg_id:t001,type:submit_order,table_no:A1,seq:20240116002,data:{items:[{dish_id:3,count:2},{dish_id:7,count:1}]}} len$(printf %04x ${#msg}) printf \x$len$msg | nc 127.0.0.1 9000这条命令把len转成 4 字节十六进制的长度前缀再拼消息体发给本机 9000 端口。服务端收到后如果返回的 JSON 里error字段为空且order_id是一个自增整数说明下单链路是通的。如果没有 ncQt 自带的QTest也能做同样的事只是命令行更快。5. 联调期最值得提前踩的三个坑环境变量、编码和消息乱序这一章单独讲联调时最容易把时间耗干的三类问题每一类都是真实项目里遇到过的不是理论推演。5.1 QT_QPA_PLATFORM_PLUGIN_PATH最常见的启动崩溃之一客户端换到另一台 Windows 机器上运行时经常出现“程序启动后直接崩溃或提示找不到平台插件”的情况错误信息里会带qt_qpa_platform_plugin_path字样。原因通常是程序没有找到platforms/qwindows.dll。解决方式有两种。开发机里我一般这样处理在main.cpp里显式设置插件路径优先于环境变量。int main(int argc, char *argv[]) { QApplication app(argc, argv); // 插件目录按约定放在可执行文件相对路径的 platforms/ 下 QString pluginDir QCoreApplication::applicationDirPath() /plugins; QCoreApplication::addLibraryPath(pluginDir); // ... }部署到目标机器时把 Qt 安装目录下对应编译器的plugins/platforms整个目录复制到可执行文件旁边的platforms/目录即可。注意如果使用 MSVC 编译就要用 MSVC 对应的插件版本用 MinGW 编译则对应 MinGW 版本两者混用同样会导致加载失败。5.2 QString 与 UTF-8菜单名乱码的根源客户端显示菜单名出现乱码绝大多数情况不是字体问题而是在发送 JSON 时用了QJsonDocument::toJson()不带编码参数默认输出的 UTF-8 字节在接收端却被按 GBK 或 Latin1 解析了。严格统一编码的三句话代码文件保存为 UTF-8所有网络传输消息体固定 UTF-8服务端写入 SQLite 前不转码查询出来后也不转码让 Qt 按 UTF-8 处理。条件允许时在 Windows 平台的main()入口显式执行QTextCodec::setCodecForLocale(QTextCodec::codecForName(UTF-8));但这只影响本地字符串和文件读取不能解决网络收发的编码混用根治办法是让两端都明确指定字符集。5.3 订单状态推送乱序在服务端做顺序号校验服务端同时向同一个客户端推送多条状态更新时网络质量不好会产生乱序——客户端先收到“已上菜”再收到“制作中”界面上状态回退用户看到后厨把菜收回去体验非常怪。处理办法是服务端发往同一客户端的消息都携带自增的seq_no客户端只处理seq_no大于当前值的消息void ClientNetwork::onMessageReceived(const QJsonObject msg) { int seq msg[seq_no].toInt(); if (seq last_seq_no_) return; last_seq_no_ seq; emit dispatchMessage(msg); }last_seq_no_是每个连接私有的成员变量初始值为 0。这条过滤逻辑写在分发之前不管消息是订单状态还是菜单更新都先过这一关乱序导致的界面回退问题就能彻底挡住。这个技巧单独拿出来说是因为联调时它最难定位你不会看到报错只会在特定网络条件下复现状态错乱。本文还有配套的精品资源点击获取
返回列表