拓冰建站拓冰建站
首页 / 资讯中心 / 正文

基于Qt 5.4的客户自助点餐系统:C/S架构、网络协议与部署指南

简介这是一份面向C课程设计与毕业设计场景的完整项目——基于Qt5.4的客户自助点餐系统包含客户端与服务端两大部分覆盖菜品浏览、在线点餐、订单提交、支付结算等餐厅点餐全流程适用于希望掌握Qt跨平台桌面开发、TCP/IP网络通信以及SQLite/MySQL数据库联动的学生与开发者。压缩包共201个文件体积27.28MB内含39个C源码、23个头文件、9个UI界面文件、51张PNG图标资源、2个QRC资源管理文件以及2个pro工程配置同时提供已编译的exe可执行程序便于直接运行体验和对照源码分析。目前已有400人学习或下载该资源项目从界面布局、信号槽事件处理到服务端并发请求与数据一致性保障均给出了完整的工程实现。通过阅读代码与实际运行读者可以系统掌握Qt5.4项目架构、socket通信、多线程处理、数据库事务等关键技能也能学习错误处理、日志记录和调试测试等真实开发环节为独立完成同类系统奠定扎实基础。1. 基于Qt 5.4的客户自助点餐系统课程设计如何从“能跑”做到“像样”客户自助点餐系统几乎是C课程设计里出现频率最高的题目之一但大部分提交版本停留在“界面画了几个按钮、数据全写死在代码里”的阶段。而这个标题拆开来看有三层含义基于Qt 5.4意味着要考虑该版本对C11的支持程度、对旧式信号槽语法SIGNAL/SLOT宏的兼容性以及发布部署时缺失DLL的经典问题客户端服务端意味着要处理TCP网络通信、自定协议、多客户端并发这几道绕不开的坎自助点餐则要求业务闭环——菜单浏览、下单、服务端确认、状态回传缺一环都不算完整。这篇文章适合正在做课程设计的学生以及想用Qt快速搭一个C/S架构demo的工程师。我不会给你贴一个完整项目源码而是把最常踩的坑、协议设计思路、关键代码片段和调试手段讲清楚。目标是让你手上的实物能从“演示能过”提升到“答辩有东西可讲”。2. 架构设计与Qt 5.4选型的理由别一上来就写代码2.1 为什么客户端/服务端模式适合点餐系统而不是纯本地数据库点餐系统如果只在本机跑用QSqlTableModel直接绑定本地SQLite就够了完全不需要网络层。但课程设计题目明确写了“客户端服务端”这意味着要展现的是网络编程能力。服务端统一管理菜单数据和订单状态客户端负责交互展示这种模式的价值在于多个客户端同时下单时服务端是唯一的数据源不会出现菜单不一致或者订单冲突的问题。从评分角度看网络模块是拉开差距的地方。大多数同学提交的作品客户端和服务端都跑在同一台机器上通信走127.0.0.1回环地址这完全没问题。但你要让评委老师看明白“为什么需要服务端”最简单的说法是菜单更新只需要改服务端数据所有客户端下次拉取时自动同步订单汇总在服务端集中处理后续如果接打印机或者大屏显示直接对接服务端即可。2.2 Qt 5.4的关键特性C11、信号槽新语法、模块划分Qt 5.4是2015年1月发布的版本属于Qt 5.4系列的第一个补丁版本。它支持C11标准意味着你可以用auto、lambda表达式、nullptr来写代码不需要为了兼容老编译器而畏手畏脚。但要注意Qt 5.4对C14的支持有限std::make_unique这类C14特性不要用。信号槽方面Qt 5.4虽然已经支持新语法connect(sender, Sender::signal, receiver, Receiver::slot)但老式的connect(sender, SIGNAL(signal()), receiver, SLOT(slot()))依然可用。我建议你在课程设计里用新语法因为编译期就能检查信号和槽是否存在减少运行时莫名连接不上的问题——这在答辩现场是加分项。模块划分上Qt 5.4的QtWidgets是GUI模块QtNetwork负责TCP/UDP通信QtSql用于数据库操作。核心模块自然都带第三方模块里QtSerialPort串口、QtMultimedia多媒体默认不包含需要在.pro文件里手动加。对于点餐系统来说你只需要QT core gui network widgets greaterThan(QT_MAJOR_VERSION, 4): QT widgets TARGET ordering TEMPLATE app CONFIG c11注意greaterThan(QT_MAJOR_VERSION, 4)这一行的作用Qt 5.x里widgets模块被分离出去不再包含在gui里这行是为了兼容Qt 4的工程文件写法。如果你只用Qt 5.4直接写QT widgets就行但保留这行对老师提问“当年我在Qt 4里写的工程能不能直接编译”是一个很好的回答。2.3 数据存储选型SQLite嵌入 vs JSON文件Qt自带的QSQLITE驱动提供了嵌入式数据库支持不需要单独安装数据库服务端非常适合课程设计场景。默认情况下Qt 5.4是从源码编译的话SQLite驱动是编译进去的如果是官方安装包需要验证插件是否存在。验证方法很简单在程序里跑一句qDebug() QSqlDatabase::isDriverAvailable(QSQLITE);如果输出true说明SQLite驱动可用。有些精简版安装包可能没带这个插件解决方案是在[Qt安装目录]/[版本号]/[编译器目录]/plugins/sqldrivers下检查有没有qsqlite.dll或libqsqlite.so没有就去Qt安装器里补装。菜品数据量不大几十个SKU用SQLite完全够。但表格设计不要偷懒至少要三张表菜品分类表、菜品表、订单表。订单表和订单明细表分开建因为一个订单可能包含多个菜品。CREATE TABLE category ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL UNIQUE ); CREATE TABLE dish ( id INTEGER PRIMARY KEY AUTOINCREMENT, category_id INTEGER NOT NULL, name TEXT NOT NULL, price REAL NOT NULL, status INTEGER DEFAULT 1, FOREIGN KEY(category_id) REFERENCES category(id) ); CREATE TABLE orders ( id INTEGER PRIMARY KEY AUTOINCREMENT, table_no TEXT NOT NULL, status INTEGER DEFAULT 0, created_time TEXT DEFAULT (datetime(now,localtime)) ); CREATE TABLE order_detail ( id INTEGER PRIMARY KEY AUTOINCREMENT, order_id INTEGER NOT NULL, dish_id INTEGER NOT NULL, quantity INTEGER NOT NULL, price REAL NOT NULL, FOREIGN KEY(order_id) REFERENCES orders(id), FOREIGN KEY(dish_id) REFERENCES dish(id) );订单状态用整数表示0待处理1已确认2已完成3已取消。这样在客户端和服务端之间传递状态时直接传数字前端映射成文本避免中文编码问题在网络上传输时搞出幺蛾子。3. 网络通信层核心实现自定协议比用什么库更重要3.1 基于QTcpServer和QTcpSocket的服务端骨架Qt的QTcpServer负责监听端口、接受连接每个新连接在newConnection信号里获取一个QTcpSocket*。这个socket在断开连接时需要手动delete否则会造成内存泄漏。我一般会在服务端维护一个QHashQTcpSocket*, ClientInfo来管理在线客户端。class OrderServer : public QTcpServer { Q_OBJECT public: explicit OrderServer(QObject *parent 0); protected: void incomingConnection(qintptr socketDescriptor) override; private slots: void onReadyRead(); void onDisconnected(); private: QHashQTcpSocket*, QByteArray m_buffers; void handleRequest(QTcpSocket* client, const QByteArray data); };incomingConnection是重写QTcpServer的关键这个虚函数在TCP三次握手完成后被调用参数socketDescriptor是套接字描述符int型。在里面new一个QTcpSocket然后setSocketDescriptor(socketDescriptor)把套接字描述符交给QTcpSocket管理。void OrderServer::incomingConnection(qintptr socketDescriptor) { QTcpSocket *socket new QTcpSocket(this); if (!socket-setSocketDescriptor(socketDescriptor)) { delete socket; return; } connect(socket, QTcpSocket::readyRead, this, OrderServer::onReadyRead); connect(socket, QTcpSocket::disconnected, this, OrderServer::onDisconnected); m_buffers.insert(socket, QByteArray()); }这里有一个值得跟答辩老师讲清楚的点为什么重写incomingConnection而不是直接connectQTcpServer::newConnection之后再调用nextPendingConnection()两种做法效果类似但重写虚函数的方式更直接不需要先接受再等待信号减少了事件循环的一层调度。对于课程设计来说两者都行但你要能说出区别这体现你对Qt网络模块的理解深度。3.2 自定义协议格式解决粘包和半包问题TCP是流式协议没有消息边界。客户端连续发两条消息服务端可能一次性收到两段数据粘在一起粘包也可能只收到半条半包。解决方案有几种固定长度消息头、特殊分隔符、长度字段内容体。我推荐第三种——4字节长度头加JSON内容体。帧格式定义如下偏移长度字段说明01版本号当前固定为0x0111消息类型0x01查询菜单0x02提交订单0x03确认订单0x04取消订单22数据长度小端序表示后续JSON字节数4NJSON数据UTF-8编码版本号字段虽然现在没用但保留它意味着协议可升级。在答辩时你可以说如果以后要增加新功能只需要改消息类型老版本客户端看到未知类型就忽略这是一个向后兼容的设计。服务端收数据时不能直接处理因为可能收到的是半包或者粘包。需要把数据先追加到缓冲区然后循环检查缓冲区内数据是否满足“至少4字节头”和“头里声明的数据长度一点不差”。void OrderServer::onReadyRead() { QTcpSocket *socket qobject_castQTcpSocket*(sender()); if (!socket) return; QByteArray buffer m_buffers[socket]; buffer.append(socket-readAll()); while (buffer.size() 4) { // 解析消息头 quint8 version static_castquint8(buffer.at(0)); quint8 type static_castquint8(buffer.at(1)); quint16 len static_castquint16((buffer.at(2) 0xFF) | ((buffer.at(3) 0xFF) 8)); if (buffer.size() 4 len) { break; // 数据还没收完整等下一次readyRead } QByteArray payload buffer.mid(4, len); buffer.remove(0, 4 len); // 处理完从缓冲区移除 handleRequest(socket, payload, type); } }一个容易犯的错是quint16转换时忘记按小端序组装直接buffer.at(2) * 256 buffer.at(3)在网络上传输的字节序如果和本机不同长度解析就是错的。Qt提供的qFromLittleEndian可以更安全地解决这个问题。3.3 并发处理单线程事件循环够用吗服务端如果直接在主线程处理所有socket遇到耗时操作比如写数据库、查询菜单卡顿会阻塞事件循环导致其他客户端无响应。Qt的解决方案有两种一是在处理请求时用QtConcurrent::run把任务丢到线程池二是反序列化、逻辑处理、序列化响应这几个步骤都在工作线程完成。课程设计的量级用第一种就够了。点餐系统的请求都是短事务JSON解析加SQLite插入在毫秒级阻塞事件循环的概率很低。但为了体现并发意识你可以这样做把handleRequest里的耗时部分交给QtConcurrent。#include QtConcurrent/QtConcurrent #include QFuture void OrderServer::handleRequest(QTcpSocket *socket, const QByteArray payload, quint8 type) { // 把业务逻辑丢给线程池避免阻塞事件循环 QFuturevoid future QtConcurrent::run([this, socket, payload, type]() { QByteArray response processBusinessLogic(payload, type); // 跨线程写socket需要QueuedConnection到主线程 QMetaObject::invokeMethod(this, sendResponse, Qt::QueuedConnection, Q_ARG(void*, socket), Q_ARG(QByteArray, response)); }); }重点说下QMetaObject::invokeMethod这一行。socket是主线程创建的对象在工作线程里直接socket-write()是不安全的因为Qt对象的事件循环在主线程。invokeMethod配合Qt::QueuedConnection把发送操作移交到主线程执行这是跨线程操作的规范做法。如果你偷懒直接在工作线程写socket运气好没问题但答辩时老师一问“多线程访问同一个socket对象”就会露馅。4. 客户端实现与交互流程从界面设计到联调4.1 客户端模块划分菜单位、购物车、订单状态三块功能区客户端的界面布局直接决定演示效果。用QMainWindow作为主窗口中央区域放QSplitter左侧是菜品列表QListWidget分类导航右侧上方是QTableWidget菜品明细右侧下方是购物车区域QTableWidget和结算按钮。整体效果是点左侧分类右侧菜品跟着过滤双击菜品或者点“加入购物车”按钮菜品进入下方购物车。控件类选择上有个细节菜品列表用QListWidget还是QTreeWidget点餐系统只有两级结构分类-菜品用QTreeWidget天然合适但课程设计里面很多人只会用QListWidget。我用QTreeWidget的原因是它自带层级展开双击二级节点直接加购物车一层节点做分类切换。// 菜品树初始化 QTreeWidget *dishTree new QTreeWidget(this); dishTree-setColumnCount(2); QStringList headers; headers 菜品 价格; dishTree-setHeaderLabels(headers);菜品数据来自服务端客户端本地不存每次启动程序时先请求菜单数据。这样保证服务端改了价格客户端重启后就是新价格。价格的数据类型用double还是int以分为单位老练的开发者会用int存分避免浮点精度问题。但课程设计里价格会出现12.5元这种用double配合QString::number(price, f, 2)格式化显示也说得过去。如果你想在答辩时秀一下可以说“我们内部以int存储价格的最小单位分显示时再除以100避免浮点累积误差。”4.2 TCP客户端封装连接管理、心跳、断线重连客户端不能只写一个按钮触发一次连接因为用户可能长时间停留在点餐界面。服务端如果重启所有客户端socket会收到disconnected信号此时客户端要自动尝试重连。class OrderClient : public QObject { Q_OBJECT public: explicit OrderClient(QObject *parent 0); void connectToServer(const QString host, quint16 port); signals: void connected(); void disconnected(); void menuReceived(const QJsonArray menu); void orderResult(int orderId, bool success, const QString message); private: QTcpSocket *m_socket; QTimer *m_reconnectTimer; QByteArray m_recvBuffer; void parseFrame(const QByteArray data); };心跳机制很容易被忽视。点餐系统虽然不像IM那样需要实时推送但如果客户端开着半小时不动服务端可能已经因为超时把socket断掉了路由器NAT映射失效、服务端设置了read timeout客户端再点“提交订单”就发送失败。解决办法是客户端每隔30秒发一个心跳包消息类型0x91服务端收到心跳包后不做业务处理直接回一个心跳响应或者在服务端记录最后活跃时间。重连逻辑用QTimer::singleShot配合指数退避void OrderClient::onDisconnected() { // 3秒后重试连续失败则间隔递增到最多30秒 int interval qMin(m_failedCount * 3000, 30000); QTimer::singleShot(interval, this, [this]() { connectToServer(m_host, m_port); }); m_failedCount; }m_failedCount在成功连接后重置为0。这个细节体现你考虑过网络不稳定的场景。4.3 下单到确认的完整时序客户端如何保证不丢单完整的业务流程应该是客户端提交订单 - 服务端写入本地SQLite生成订单号 - 服务端返回订单号和状态到客户端。这里的“写入SQLite成功”是确认订单有效的唯一标准而不是“客户端发出去就成功”。客户端在发订单前要做本地校验购物车不能为空桌号必须是数字菜品数量不能为0。校验通过后组装JSON{ table_no: A12, items: [ {dish_id: 3, quantity: 2}, {dish_id: 7, quantity: 1} ] }服务端收到后解析启动一个事务往orders表和order_detail表分别插入数据事务提交成功才返回成功。如果中间任何一步失败回滚并返回错误信息。注意QSqlDatabase的默认提交模式是QSqlDatabase::Open后处于手动提交autocommit开启要改成事务控制QSqlDatabase db QSqlDatabase::database(order_conn); db.transaction(); QSqlQuery query(db); query.prepare(INSERT INTO orders (table_no, status) VALUES (?, ?)); query.addBindValue(tableNo); query.addBindValue(0); if (!query.exec()) { db.rollback(); sendError(socket, 订单创建失败); return; } int orderId query.lastInsertId().toInt(); // 循环插入order_detail... db.commit();这里有个常见错误用同一个QSqlQuery对象循环执行insert第二次执行前需要query.clear()清空上一轮的绑定值否则addBindValue会重复添加参数导致SQL语句里问号个数和参数个数对不上。正确做法是每次insert重新prepare或者复用prepare但用query.bindValue(0, value)显式绑定位置。超时处理也很关键客户端发订单后启动一个5秒的QTimer如果没收到服务端响应提示“网络超时请检查服务端状态”同时把订单状态标记为“本地待提交”留到下次连接成功后补发。课程设计能做到这一步已经超出一般水平了。5. 服务端业务逻辑与数据库操作这块是答辩的重点5.1 菜单更新流程服务端下发JSON客户端解析渲染服务端启动时从SQLite加载全部菜品数据到内存一个QJsonArray收到“查询菜单”请求时直接把内存数据序列化下发。不需要每次请求都查数据库因为菜单变更频率极低一天几次内存缓存足够。服务端的管理员接口可以做添加菜品、修改价格、上下架。课程设计没有后台管理界面也没关系直接操作SQLite数据库或者用一个简单的控制台命令。但你要在文档里写清楚“数据维护方式”否则老师觉得你这个系统只能看不能改数据。菜品JSON的组织方式QJsonArray buildMenuJson() { QJsonArray categories; QSqlQuery query; query.exec(SELECT id, name FROM category ORDER BY id); while (query.next()) { QJsonObject catObj; catObj[id] query.value(0).toInt(); catObj[name] query.value(1).toString(); QJsonArray dishes; QSqlQuery dishQuery; dishQuery.prepare(SELECT id, name, price FROM dish WHERE category_id ? AND status 1); dishQuery.addBindValue(query.value(0).toInt()); dishQuery.exec(); while (dishQuery.next()) { QJsonObject dishObj; dishObj[id] dishQuery.value(0).toInt(); dishObj[name] dishQuery.value(1).toString(); dishObj[price] dishQuery.value(2).toDouble(); dishes.append(dishObj); } catObj[dishes] dishes; categories.append(catObj); } return categories; }客户端拿到JSON后解析并填充QTreeWidget或QTableWidget。不要嵌套三层循环在UI线程里做耗时操作JSON解析在这种数据量下几十条完全不影响直接同步解析即可。5.2 订单查询与状态机客户端怎么知道自己的订单做到哪一步订单要经历“已提交 - 已确认 - 已完成”三个状态。服务端收到订单后立即返回“订单号待处理”状态厨师端如果有确认菜品开始制作后订单状态改为“已确认”。客户端的订单列表轮询查询状态变化。这里没有用WebSocket或服务端主动推送因为课程设计里做的TCP客户端与服务端是长连接服务端可以直接在状态变更后主动发消息给相关客户端。但如果你做的是短连接模式每次请求新建连接那客户端只能靠轮询。轮询间隔设5秒一次比较合理太频繁浪费带宽太慢用户感知不到状态变化。状态机的核心逻辑bool transitionOrderState(int orderId, int fromState, int toState) { QSqlQuery query; query.prepare(UPDATE orders SET status ? WHERE id ? AND status ?); query.addBindValue(toState); query.addBindValue(orderId); query.addBindValue(fromState); if (!query.exec()) { return false; } return query.numRowsAffected() 1; }用WHERE status ?的目的是防止状态乱跳。比如客户端连续点了两次“取消”第二次取消应该失败因为状态已经从“待处理”变成了“已取消”。带条件的更新语句保证状态变迁的原子性用一个update实现了并发控制不需要加锁。5.3 多客户端并发时的数据一致性SQLite是否扛得住SQLite在写入时会对整个数据库文件加锁多个客户端同时提交订单写操作会排队。对于点餐系统这种写并发极低的场景一个餐厅同时下单的人不超过几十个SQLite完全够用不会出现“database is locked”的错误前提是你把每次事务的耗时控制在几十毫秒内。学院派的说法是“SQLite支持多读单写”即多个连接可以同时读但写需要独占。这在课程设计里确实会考。QSQLITE驱动的默认行为是每次写操作自动开启事务并提交这样写性能会受影响。如果你用一个连接处理所有请求并且开启了QSQLITE_BUSY_TIMEOUT可以通过db.exec(PRAGMA busy_timeout 5000)设置那并发写时连接等待5秒获取写锁基本不会出问题。如果真出现database is locked报错排查步骤一是检查有没有连接忘记close导致锁没释放二是看看是不是在事务里执行了太多写操作超过默认锁超时时间SQLite默认是0立刻报错。课程设计里加一句PRAGMA busy_timeout 3000就够了。6. 部署发布与Qt 5.4的隐藏坑打包、调试和答辩加分项6.1 Windows下打包发布windeployqt怎么用Qt 5.4发布程序最头疼的是DLL依赖。好在新版本Qt提供了windeployqt工具。编译完Release版本后在命令行执行cd C:\build-ordering-Desktop_Qt_5_4_1_MinGW_32bit-Release\release C:\Qt\Qt5.4.1\5.4\mingw491_32\bin\windeployqt.exe ordering.exe这条命令会自动把ordering.exe依赖的Qt模块DLL和插件复制到当前目录。但它只能复制Qt自己的运行库MinGW的C运行时libgcc_s_dw2-1.dll、libstdc-6.dll、libwinpthread-1.dll需要手动从MinGW的bin目录复制。检查完整性的方法是用Dependencies工具或者Process Explorer打开exe看缺少哪些DLL。最简单的自查方式把release文件夹拷到一台干净的Windows机器上没有装过Qt的双击exe看能不能启动。能启动就说明没问题。数据库插件需要额外注意SQLite驱动插件在sqldrivers子目录下windeployqt会自动处理但如果你手动拷贝DLL就容易漏掉。验证SQLite可用的方法是设置环境变量QT_DEBUG_PLUGINS1再运行程序终端会打印插件加载日志一目了然。6.2 中文乱码的三大源头源码编码、JSON传输、数据库存储Qt 5.4在Windows上默认使用本地代码页GBK读取源文件如果源码文件是UTF-8编码中文字符串会直接乱码。解决办法是在.pro文件里加msvc { QMAKE_CXXFLAGS /utf-8 }如果是MinGW编译器Qt 5.4提供的方案是加QTextCodec::setCodecForLocale或者干脆统一用QStringLiteral宏包住中文字符串。我建议源文件统一存为UTF-8编码可以在Qt Creator里设置然后在main函数里设置locale编码#include QTextCodec int main(int argc, char *argv[]) { QApplication app(argc, argv); QTextCodec::setCodecForLocale(QTextCodec::codecForName(UTF-8)); // ... }网络传输的JSON必须用UTF-8编码QJsonDocument的toJson方法默认输出UTF-8QJsonDocument::fromJson输入也要求UTF-8。跨端传中文不会乱码。数据库端SQLite存储UTF-16或UTF-8都由Qt驱动自己处理你不需要干预。真正容易乱码的只有源码里的字符串字面量这个问题用上面的设置就能解决。6.3 答辩前要验证的三个场景和一个加分技巧第一个场景客户端断开重连。启动服务端后启动客户端然后手动关掉服务端观察客户端的心跳重连日志是否正常当服务端重新启动后客户端能否自动恢复连接。第二个场景两个客户端同时启动分别下单验证服务端能区分出两个连接并各自返回正确的订单号。第三个场景服务端先关闭数据库文件或者把SQLite文件改个名再启动验证程序是否给出友好的错误提示而不是直接崩溃。加分技巧是用QLoggingCategory做分模块日志。在代码里定义Q_LOGGING_CATEGORY(lcNetwork, app.network) Q_LOGGING_CATEGORY(lcDatabase, app.database) qCInfo(lcNetwork) Client connected: socket-peerAddress().toString(); // 运行时开启调试输出 // 设置环境变量 QT_LOGGING_RULESapp.network.debugtrue答辩时把日志输出打开老师问“你这个系统跑起来什么状态”直接把日志窗口展示出来比空口解释强得多。QLoggingCategory还能按模块过滤日志哪块出了问题就开哪块的debug级别不用全局都打开刷屏。这也是Qt 5.4提供的能力代码量很小但显得你认真设计了可观测性。本文还有配套的精品资源点击获取
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门