ARTICLE DETAIL

资讯详情

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

Qt与gRPC实战:远程计算器从proto到界面完整实现

Qt与gRPC实战:远程计算器从proto到界面完整实现 开篇先说清楚这篇是接着上一篇环境准备的上一篇把gRPC在Windows下怎么装、CMake怎么找包这些基础讲完了但这东西光看概念不行得真上手写一个能跑的例子才算完。我周末自己改了一个特别简单的服务用Qt写了个带界面的客户端通过gRPC调服务端做一个四则运算远程计算器。例子很小但gRPC编程的整条链路——proto定义、代码生成、CMake整合、客户端同步调用、界面卡顿的线程改造——全都走了一遍。如果你刚把gRPC环境折腾好、手里缺个练手项目或者已经会跑官方helloworld、想加一点真实业务逻辑进去这篇可以直接照着抄。1. 为什么选“远程计算器”当练手项目1.1 一个能跑通全链路的最小业务练手项目最怕两件事一是太大还没等跑起来人就放弃了二是太小像官方helloworld那样只返回一个固定字符串跑完一点感觉都没有。计算器这个业务恰好卡在中间请求里有真实数据两个double和一个操作符响应里也有真实数据一个double结果错误处理也有空间比如除零、不认识的运算符。这些要素对应到实际项目里就是结构化的请求参数、响应结果和异常分支换成什么业务逻辑思路都一样。把计算器跑通了后面换成一个真正的用户服务、订单服务代码结构几乎不用动改改proto和业务方法就行。而且这个例子能同时把两端都练到你既要写服务端怎么接收请求、处理逻辑、回填响应也要写客户端怎么组装请求、发起调用、解析响应。大多数新手一开始只关注一头要么只顾着看服务端怎么启动要么只管客户端怎么调结果真联调的时候两边各说各话字段都对不上。计算器把两头强制绑在一起逼你打开两个程序互相通信这一套流程走下来比单独看十篇教程都管用。1.2 先理清客户端和服务端的调用关系动手写代码之前脑子里先要有这么一张图CalculatorService是服务端监听一个固定端口负责收请求、做运算、返结果Qt的那个计算器窗口是客户端用户填完参数点按钮把请求发过去等在原地收返回值。这俩进程在本机调试的时候通过127.0.0.1通信将来部署到生产环境只要把客户端创建Channel时用的地址从localhost换成服务器的局域网IP或者域名就行业务代码一行都不用改。端口我用了50051因为这是gRPC官方示例的默认端口好记也避免总被占用。需要说明的是50051不是gRPC强制指定的理论上任何未被占用的端口都可以但练手阶段选一个约定俗成的端口好处是从官方文档、社区文章里抄例子时不需要来回改端口号。服务端的监听地址我写的是0.0.0.0:50051意思是本机所有网卡都监听客户端用127.0.0.1或局域网IP都能连上如果只想本机访问写127.0.0.1:50051也行。Windows上第一次启动服务端时系统会弹防火墙提示直接允许就好不然后面客户端会连不上。1.3 为什么不用HTTP/JSON或者自己写TCP不较真地说计算器这种本地练手服务用三种方式都能做出来。但既然这篇文章的标题是qt下使用grpc编程那就得把选型的逻辑说透。方案序列化方式接口约束适用场景裸TCP自定义协议自己定义比如用分隔符拼字符串没有全靠口头约定极少数需要极致性能且有专业团队维护的内部系统HTTP/JSON接口JSON人和机器都能读没有严格schema接口文档维护靠自觉对外提供接口、前后端分离、浏览器环境调用gRPC ProtobufProtobuf二进制体积小、解析快.proto文件就是契约自动生成代码字段类型编译期就校验微服务内部调用、多语言协作、长连接流式场景可能有人觉得一个计算器而已HTTP/JSON也能写甚至更简单为什么偏要上gRPC我的理解是计算器只是壳真正要练的是跨语言、跨进程的RPC服务怎么组织代码。打个比方HTTP/JSON就像随手写在便签纸上的事项方便是方便时间一长没人更新文档字段该传number还是string全靠猜Protobuf则像一份固定格式的表格列是哪些、什么类型都写死了填错类型编译都过不去。练手项目的意义就是让你提前踩一遍这些坑后面真在Qt里接一个内部RPC服务时你不需要临时学三件套。2. 工程搭建从proto文件到CMake工程2.1 前期准备环境、依赖、版本匹配先说我这边的环境方便你对照Windows 10 64位Qt用的是5.15.2 MSVC2019 64位套件CMake 3.21gRPC和Protobuf通过vcpkg安装gRPC版本是1.54.0Protobuf跟随vcpkg默认版本。这组版本是我实测过的能跑通后面我也会专门讲版本不匹配的坑但先给你一个安全的起点。这里要特别强调一个方向性选择一定要用MSVC的Qt套件不要用MinGW的套件来编gRPC工程。gRPC的官方预编译库、vcpkg编译出来的库默认都是MSVC工具链的产物你用MinGW的Qt Creator套件去链接会撞上一堆unresolved external symbol之类的链接错误。这不是代码问题是ABI不兼容你换代码写法是绕不过去的。在Qt Creator里新建或打开工程时Kit一栏老老实实选“Desktop Qt 5.15.2 MSVC2019 64bit”同时确认Qt安装时勾选了MSVC组件和CMake相关组件。2.2 写proto文件把接口契约定下来我在工程下建了一个proto目录里面放calculator.proto。文件很小但每个字段都是后面代码生成的基础别为了省事跳过。内容如下syntax proto3; package calc; message CalcRequest { double a 1; double b 2; string op 3; } message CalcResponse { double result 1; string message 2; } service Calculator { rpc Calculate(CalcRequest) returns (CalcResponse); }syntax指定proto3语法和proto2最大的区别是字段默认值逻辑不同、枚举首值必须是0、不需要再写required/optional。这里用proto3就行了gRPC默认推荐。package calc是C命名空间生成代码后所有的类都在calc名字空间里。请求里有a、b两个double一个操作符字符串op我故意没有为加减乘除各写一个RPC方法而是用一个op字段在服务端里做分发这样proto里只定义一个服务方法客户端调用逻辑也简洁适合练手。响应里result存运算结果message存一段可读的信息比如除零了就把错误原因写在这里方便界面直接弹给用户看。需要留神的是消息字段的编号a1、b2、op3。字段编号不是随便给的它会进到Protobuf的二进制编码中同一个消息里不能重复而且一旦发布出去尽量永远不要改。如果你上线后某天想把a和b调个位置没问题但把字段编号改了旧数据就解析不出来了。这点在真正的项目里很关键提前养成习惯。2.3 CMakeLists.txt整合Qt、gRPC和Protobuf这个工程我依然用CMake组织因为Qt也推荐CMakegRPC官方同样推荐CMake两边合流最省心。核心思路是先让protoc和grpc_cpp_plugin根据calculator.proto生成四个文件calculator.pb.h、calculator.pb.cc、calculator.grpc.pb.h、calculator.grpc.pb.cc再把生成的文件和手写的main.cpp一起编译成三个目标服务端calculator_server、命令行客户端calculator_client、Qt界面客户端calc_widget。直接贴我实测能用的CMakeLists.txtcmake_minimum_required(VERSION 3.16) project(qt_grpc_demo LANGUAGES CXX) set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) set(CMAKE_AUTOMOC ON) find_package(Qt5 REQUIRED COMPONENTS Widgets Concurrent) find_package(Protobuf CONFIG REQUIRED) find_package(gRPC CONFIG REQUIRED) set(PROTO_FILE ${CMAKE_CURRENT_SOURCE_DIR}/proto/calculator.proto) set(PROTO_SRC_DIR ${CMAKE_CURRENT_SOURCE_DIR}/proto) set(PROTO_GEN_DIR ${CMAKE_CURRENT_BINARY_DIR}/generated) file(MAKE_DIRECTORY ${PROTO_GEN_DIR}) get_target_property(GRPC_CPP_PLUGIN gRPC::grpc_cpp_plugin IMPORTED_LOCATION) add_custom_command( OUTPUT ${PROTO_GEN_DIR}/calculator.pb.cc ${PROTO_GEN_DIR}/calculator.grpc.pb.cc COMMAND protobuf::protoc ARGS --grpc_out${PROTO_GEN_DIR} --cpp_out${PROTO_GEN_DIR} -I ${PROTO_SRC_DIR} --pluginprotoc-gen-grpc${GRPC_CPP_PLUGIN} ${PROTO_FILE} DEPENDS ${PROTO_FILE} COMMENT Generate gRPC source files ) add_executable(calculator_server server/main.cpp ${PROTO_GEN_DIR}/calculator.pb.cc ${PROTO_GEN_DIR}/calculator.grpc.pb.cc ) target_include_directories(calculator_server PRIVATE ${PROTO_GEN_DIR} ${PROTO_SRC_DIR} ) target_link_libraries(calculator_server PRIVATE gRPC::grpc protobuf::libprotobuf ) add_executable(calculator_client client/main.cpp ${PROTO_GEN_DIR}/calculator.pb.cc ${PROTO_GEN_DIR}/calculator.grpc.pb.cc ) target_include_directories(calculator_client PRIVATE ${PROTO_GEN_DIR} ${PROTO_SRC_DIR} ) target_link_libraries(calculator_client PRIVATE gRPC::grpc protobuf::libprotobuf ) add_executable(calc_widget ui/main_window.cpp ${PROTO_GEN_DIR}/calculator.pb.cc ${PROTO_GEN_DIR}/calculator.grpc.pb.cc ) target_include_directories(calc_widget PRIVATE ${PROTO_GEN_DIR} ${PROTO_SRC_DIR} ) target_link_libraries(calc_widget PRIVATE Qt5::Widgets Qt5::Concurrent gRPC::grpc protobuf::libprotobuf )有三处需要额外解释。第一CMAKE_AUTOMOC必须开因为Qt的QObject派生类和信号槽依赖moc来处理元对象系统不开的话报错非常莫名。第二add_custom_command里用protobuf::protoc和gRPC::grpc_cpp_plugin这两个CMake target来定位可执行文件比手动写死“D:/vcpkg/installed/x64-windows/tools/protobuf/protoc.exe”更不容易踩路径坑。第三如果你用Qt Creator手动选套件时找不到Protobuf或者gRPC的包多半是没把vcpkg的toolchain传给CMake常见解决办法是在CMake配置参数里加一句-DCMAKE_TOOLCHAIN_FILED:/vcpkg/scripts/buildsystems/vcpkg.cmake如果vcpkg安装在别的盘把路径换成自己的就行。这一步是环境问题吗算但它属于最典型的CMake配置问题十个人里至少有四个卡在这一关。2.4 在Qt Creator里导入工程并完成构建文件夹结构我建议和我保持一致这样代码里包含路径好理解你以后换项目也好迁移qt_grpc_demo/ ├── CMakeLists.txt ├── proto/ │ └── calculator.proto ├── server/ │ └── main.cpp ├── client/ │ └── main.cpp └── ui/ └── main_window.cpp在Qt Creator里点“打开项目”选中CMakeLists.txt它会自动识别CMake工程。然后记得在Projects-Kit一栏选MSVC2019 64bit那个套件CMake的Generator建议选“Ninja”或者“CodeBlocks - NMake Makefiles”Qt Creator会自动帮你匹配。如果CMake Configuration里没有CMAKE_PREFIX_PATH指向vcpkg就手动加一行CMAKE_PREFIX_PATH:STRINGD:/vcpkg/installed/x64-windows这个变量和toolchain二选一用就行我一般两个都加上省得来回试。第一次构建大概率需要等一会因为gRPC和Protobuf的头文件多编译生成代码也要时间。构建完成后你应该能在build目录里看到三个execalculator_server.exe、calculator_client.exe、calc_widget.exe。注意Qt Creator默认构建目录是build-qt_grpc_demo-Desktop_Qt_5_15_2_MSVC2019_64bit-Debug这种长了很长的目录生成的四个proto文件也会在这里面的generated子目录下别去源代码目录里找找不到的。3. 服务端实现先让后端的gRPC服务跑起来3.1 实现CalculatorService服务端核心代码写在server/main.cpp里。思路很简单写一个类继承proto生成的calc::Calculator::Service重写Calculate方法。方法签名是编译器定死的三个参数分别是服务上下文、请求消息、响应消息返回值是一个grpc::Status用来告诉gRPC框架这通调用处理得成不成功。#include iostream #include memory #include string #include grpcpp/grpcpp.h #include calculator.grpc.pb.h #include calculator.pb.h class CalculatorServiceImpl final : public calc::Calculator::Service { public: grpc::Status Calculate(grpc::ServerContext* context, const calc::CalcRequest* request, calc::CalcResponse* reply) override { double a request-a(); double b request-b(); const std::string op request-op(); (void)context; if (op ) { reply-set_result(a b); reply-set_message(ok); } else if (op -) { reply-set_result(a - b); reply-set_message(ok); } else if (op *) { reply-set_result(a * b); reply-set_message(ok); } else if (op /) { if (b 0.0) { reply-set_message(divide by zero); return grpc::Status(grpc::INVALID_ARGUMENT, divide by zero); } reply-set_result(a / b); reply-set_message(ok); } else { reply-set_message(unsupported operator); return grpc::Status(grpc::INVALID_ARGUMENT, unsupported operator); } return grpc::Status::OK; } };这个类有三个值得细看的点。一request和reply都是protobuf自动生成的类读取字段用request-a()这种接口设置字段用reply-set_result()。这些接口由proto文件生成你不用自己实现但需要养成习惯去读自动生成的头文件了解有哪些方法。二除零这种情况虽然也能返回OK然后把message置成“divide by zero”但让它作为业务数据返回还是作为RPC错误返回是有讲究的。真正的业务判断应该留在业务逻辑层而RPC层的错误应该交给grpc::Status所以我这里返回了grpc::INVALID_ARGUMENT客户端可以根据错误码和错误消息做区分。三我在开头把context强制转成了(void)context因为暂时用不到它但方法必须接收它告诉编译器“我故意没用”避免警告刷屏。以后要做鉴权、超时、查元数据全靠这个ServerContext。3.2 服务端启动与端口绑定main函数里用grpc::ServerBuilder启动服务代码很短int main(int argc, char** argv) { std::string server_address 0.0.0.0:50051; CalculatorServiceImpl service; grpc::ServerBuilder builder; builder.AddListeningPort(server_address, grpc::InsecureServerCredentials()); builder.RegisterService(service); std::unique_ptrgrpc::Server server(builder.BuildAndStart()); std::cout Server listening on server_address std::endl; server-Wait(); return 0; }ServerBuilder用起来很像Qt里组装QWidget布局先创建构建器一项一项把要监听什么端口、注册什么服务都填进去最后BuildAndStart一下服务就跑起来了。AddListeningPort里我传的是不加密凭据也就是无TLS本机练手完全够用。以后要是上生产环境、客户端要从外网连那就必须换成TLS凭据否则数据裸奔在网络上。RegisterService把刚才写的CalculatorServiceImpl实例注册进去多个Service可以在同一个进程里注册只要端口不同或者路径不同就行。最后的server-Wait()会阻塞当前线程直到进程被终止CtrlC或者关闭窗口后它才会返回。启动成功后控制台会打印Server listening on 0.0.0.0:50051。记住这是个常驻进程后面跑Qt客户端之前得先保证它还在跑不然客户端会报连接不上。我见过不止一个同事跑RPC程序客户端先启动了服务端没启动然后怀疑代码有bug查了半天。3.3 一个不带Qt的命令行客户端冒烟测试这一步看着有点多余但实际上非常重要。为什么因为gRPC链路本身是否通、proto生成代码是否正确和Qt界面是否正常是两个完全独立的问题。如果你上来就写Qt界面一旦出问题你会同时在两个方向上排查非常痛苦。先写一个几十行的命令行客户端把gRPC这条链路跑通后面Qt界面出问题时你可以迅速判断问题出在界面还是出在RPC调用。命令行客户端client/main.cpp#include iostream #include grpcpp/grpcpp.h #include calculator.grpc.pb.h #include calculator.pb.h int main(int argc, char** argv) { auto channel grpc::CreateChannel(127.0.0.1:50051, grpc::InsecureChannelCredentials()); auto stub calc::Calculator::NewStub(channel); calc::CalcRequest request; request.set_a(10); request.set_b(5); request.set_op(); calc::CalcResponse response; grpc::ClientContext context; grpc::Status status stub-Calculate(context, request, response); if (status.ok()) { std::cout result: response.result() std::endl; std::cout message: response.message() std::endl; } else { std::cout RPC failed, error code: status.error_code() , message: status.error_message() std::endl; } return 0; }grpc::CreateChannel创建到服务端的连接通道第二个参数是不加密凭据和前面服务端要配对着来。NewStub创建了一个调用桩这个stub本质上就是一系列RPC方法的封装在它上面调用Calculate相当于把request塞进HTTP/2流里发出去。整个调用是同步阻塞的当前线程会一直等着服务端返回。测试时可以改改op字段和两个数字看看加减乘除和除零异常的输出。我第一次跑的时候算了个10加5看到打印result: 15整条链路才算真正打通那种感觉和光看教程完全不一样。4. Qt客户端实现从界面卡顿到流畅体验4.1 做一个最简单的计算器界面Qt客户端我用一个QWidget搞定没有走复杂的Model/View框架因为练手项目越简单越容易看懂。界面上放两个QLineEdit分别输入a和b一个QComboBox选加减乘除一个QPushButton点计算一个QLabel显示结果。全部代码写在一个main_window.cpp里构造函数里把控件new出来、放进QGridLayout、连好信号槽这部分和普通Qt程序没什么区别。#include QtWidgets #include QtConcurrent #include grpcpp/grpcpp.h #include calculator.grpc.pb.h #include calculator.pb.h struct RpcResult { bool ok false; double value 0.0; QString error; }; Q_DECLARE_METATYPE(RpcResult) class MainWindow : public QWidget { Q_OBJECT public: explicit MainWindow(QWidget* parent nullptr) : QWidget(parent) { m_aEdit new QLineEdit(10, this); m_bEdit new QLineEdit(5, this); m_opCombo new QComboBox(this); m_opCombo-addItems({, -, *, /}); m_callButton new QPushButton(Calculate, this); m_resultLabel new QLabel(result: --, this); m_resultLabel-setMinimumWidth(200); QGridLayout* layout new QGridLayout(this); layout-addWidget(new QLabel(a:), 0, 0); layout-addWidget(m_aEdit, 0, 1); layout-addWidget(new QLabel(b:), 1, 0); layout-addWidget(m_bEdit, 1, 1); layout-addWidget(new QLabel(op:), 2, 0); layout-addWidget(m_opCombo, 2, 1); layout-addWidget(m_callButton, 3, 0, 1, 2); layout-addWidget(m_resultLabel, 4, 0, 1, 2); connect(m_callButton, QPushButton::clicked, this, MainWindow::onCalcClicked); m_channel grpc::CreateChannel(127.0.0.1:50051, grpc::InsecureChannelCredentials()); m_watcher new QFutureWatcherRpcResult(this); connect(m_watcher, QFutureWatcherRpcResult::finished, this, MainWindow::onRpcFinished); } public slots: void onCalcClicked() { /* 待填充 */ } void onRpcFinished() { /* 待填充 */ } private: QLineEdit* m_aEdit nullptr; QLineEdit* m_bEdit nullptr; QComboBox* m_opCombo nullptr; QPushButton* m_callButton nullptr; QLabel* m_resultLabel nullptr; std::shared_ptrgrpc::Channel m_channel; QFutureWatcherRpcResult* m_watcher nullptr; };注意两个细节。一是channel我在构造函数里创建了一次并保存成成员变量而不是每次点按钮都创建。grpc的Channel是线程安全的而且创建期间要解析地址、可能要建立连接成本并不低复用它是基本操作。二是RpcResult这个结构体在绑定到QFutureWatcher模板之前要调用Q_DECLARE_METATYPE让它成为Qt元类型系统认识的类型不然编译期可能报和信号槽参数相关的错误。当然你也可以退回用std::pairbool,double但结构体可读性好很多。4.2 按钮槽里同步调用为什么界面会卡先写一个看起来最顺理成章的版本onCalcClicked里直接读界面控件、调用同步RPC、把结果塞回界面。代码大概是void MainWindow::onCalcClicked() { double a m_aEdit-text().toDouble(); double b m_bEdit-text().toDouble(); QString op m_opCombo-currentText(); auto stub calc::Calculator::NewStub(m_channel); calc::CalcRequest request; request.set_a(a); request.set_b(b); request.set_op(op.toStdString()); calc::CalcResponse response; grpc::ClientContext context; grpc::Status status stub-Calculate(context, request, response); if (status.ok()) { m_resultLabel-setText(QString(result: %1).arg(response.result())); } else { m_resultLabel-setText(QString(error: %1).arg( QString::fromStdString(status.error_message()))); } }单看这个函数逻辑是正确的跑起来也确实能算出来。但只要你给RPC加一点延迟或者在服务端没启动的情况下点按钮界面马上病给你看窗口拖不动、点其他按钮没反应、好像整个程序死了。原因是gRPC的同步调用会阻塞当前线程直到收到响应或超时而Qt的界面刷新、鼠标事件、键盘事件全都要依靠主线程的事件循环。主线程被阻塞之后事件循环转不起来用户的所有操作都被堵在门口。你可以做个实验在服务端的Calculate方法里加一行sleep(5)再点Qt客户端立刻就能看到界面冻结5秒后才恢复。这也是所有GUI程序里的经典规则耗时操作绝不能放在UI线程里执行。4.3 用QtConcurrent把gRPC调用挪到工作线程把RPC调用移出主线程方案不止一种可以直接new一个std::thread跑Lambda也可以用QtConcurrent::run把任务丢进Qt全局线程池。我在这个例子里用QtConcurrent因为代码量小和QFutureWatcher配合起来像接力跑子线程跑请求主线程通过watcher的finished信号等结果结果拿回来后安全地更新界面。改完之后onCalcClicked和回调是这样的void MainWindow::onCalcClicked() { m_callButton-setEnabled(false); m_resultLabel-setText(calculating...); double a m_aEdit-text().toDouble(); double b m_bEdit-text().toDouble(); std::string op m_opCombo-currentText().toStdString(); std::shared_ptrgrpc::Channel channel m_channel; QFutureRpcResult future QtConcurrent::run([a, b, op, channel]() - RpcResult { auto stub calc::Calculator::NewStub(channel); calc::CalcRequest request; request.set_a(a); request.set_b(b); request.set_op(op); calc::CalcResponse response; grpc::ClientContext context; context.set_deadline(std::chrono::system_clock::now() std::chrono::seconds(5)); grpc::Status status stub-Calculate(context, request, response); RpcResult result; if (status.ok()) { result.ok true; result.value response.result(); } else { result.ok false; result.error QString::fromStdString(status.error_message()); } return result; }); m_watcher-setFuture(future); } void MainWindow::onRpcFinished() { RpcResult result m_watcher-result(); if (result.ok) { m_resultLabel-setText(QString(result: %1).arg(result.value)); } else { m_resultLabel-setText(QString(error: %1).arg(result.error)); } m_callButton-setEnabled(true); }看起来变化不大但关键点全变了。耗时网络调用放进QtConcurrent::run的Lambda里立刻不再阻塞UI线程m_callButton在点击后先置灰防止调用还没回来时用户又点一次造成重复提交计算按钮在finished信号里恢复这个状态机虽然简单却把“处理中”和“空闲”两个状态分开了。另外在ClientContext上显式设置了一个5秒的deadline网络不通或服务端挂掉时客户端不会无限等下去。真实项目中建议把deadline放到配置项里不要写死。这里有一个很隐蔽的易错点Lambda里我捕获了channel的shared_ptr而不只捕获this因为线程执行时界面可能已经关了继续调用stub会悬空崩溃。捕获channel的拷贝让这个连接对象至少活到Lambda执行结束是保命的操作。新建一个函数级stub也完全可以它很轻量本质只是channel对象上的一个视图没必要缓存。4.4 加一点细节错误提示、Channel复用和超时控制上面代码里RpcResult这个结构体承担了错误信息传递的职责除了ok和value之外我还塞了一个error字段这样除零、未知操作符这类业务错误能够清晰显示到界面上而不是只显示一个NaN或者空字符串。界面上的错误信息用的是QString所以你如果以后要接真项目RpcResult里可以再扩展状态码、耗时等字段。如果后面接口变多了同一个服务Server里可能会有多个Service那客户端一个Channel就够了一个Channel对应一个服务端地址可以new出多个Stub来调不同Service。真正高并发的场景还需要关注连接池、负载均衡那就不是练手项目讨论的范围了。但至少在你把计算器改成文件上传、流式日志这些需求之前这个客户端结构是足够你撑过几个项目的。5. 踩坑实录版本、路径、链接和运行问题速查5.1 Qt库版本不匹配运行Qt程序时最著名的一个报错就是cannot mix incompatible Qt library (5.15.3) with this library (5.15.2)。我一开始也被这个错误恶心了半天。原因基本只有一个程序运行时加载到了和编译期不同版本的Qt动态库。比如你机器上装了Qt 5.15.2和5.15.3两个版本Qt Creator里选的是5.15.2套件但可执行文件运行时PATH环境变量里偏偏先找到了5.15.3的bin目录它就把更高版本的Qt5Core.dll加载进来了两边一比对版本号不一致直接崩溃。排查办法其实不复杂。在Qt Creator的“运行”设置里把“将构建目录加入PATH”和Qt的bin目录顺序调对发布程序前用windeployqt统一拷贝不要手动从系统PATH依赖。如果你用命令行运行程序确保cmd窗口里没有其他Qt版本的bin目录被排在前面。这类问题最大的麻烦在于它不是编译期报错而是运行期崩溃新手很容易怀疑是自己代码写错了。5.2 编译期链接错误与编译器不一致gRPC库的链接错误报错形式通常是大量unresolved external symbol而且符号名长得像乱码一样。最常见的根因是编译器不匹配你用MinGW套件去链接用MSVC编译出来的gRPC库。Qt Creator里看到的错误可能是undefined reference to或者cannot find -lgrpc但真正的原因就是两个工具链的静态库格式不同Windows上是.a和.lib互不相通。解决方案前面已经提过切换Qt套件到MSVC 2019 64位。这个坑踩一次记一辈子我身边几乎所有第一次在Qt里集成gRPC的人都掉进去过。还有一个相关的问题即使都是MSVCDebug和Release配置也可能撞车。vcpkg默认会同时安装debug和release版本的grpc库CMake里find_package会自动选择但如果你手动指定了库路径很可能会link错版本。保险做法是交给CMake和vcpkg去管理别自己手动指定grpc.lib的完整路径。5.3 CMake找不到gRPC/Protobuffind_package(gRPC CONFIG REQUIRED)失败报Could not find a package configuration file provided by gRPC十有八九是CMAKE_PREFIX_PATH或CMAKE_TOOLCHAIN_FILE没传给CMake。vcpkg安装后的包在D:/vcpkg/installed/x64-windowscmake文件位于D:/vcpkg/installed/x64-windows/share/grpc而CMake恰好需要知道去那里找。最简单的解决办法就是在Qt Creator的CMake配置里加上一句话-DCMAKE_TOOLCHAIN_FILED:/vcpkg/scripts/buildsystems/vcpkg.cmake加上之后再重新运行CMake问题立刻消失。如果你习惯用命令行那就在cmake后面手动拼同一个参数。protoc和grpc_cpp_plugin版本不匹配也会造成一个问题就是生成出来的.pb.cc和.grpc.pb.cc在编译时出现一些非常奇怪的字段名或API报错。别犹豫大概率是protoc.exe和grpc_cpp_plugin.exe不是同一个版本。检查一下你PATH里有没有别的protoc或者vcpkg的gRPC和Protobuf包是否版本差异过大用protoc --version和grpc_cpp_plugin --version分别查一下能省很多时间。5.4 运行时缺DLL与启动崩溃编译过了、链接过了双击exe却弹窗说找不到某些DLL这类问题在Windows上太习以为常了。Qt相关的库还好说用windeployqt处理但gRPC和Protobuf那堆DLLwindeployqt不会管得手动把vcpkg安装目录里bin下的grpc.dll、grpc.dll、protobuf.dll等复制到exe同目录或者把它们加进PATH。我最后干脆写了个批处理发布前把整个输出目录补全。排查DLL依赖可以用Dependencies这个工具打开exe它能列出所有依赖项红色标记的就是缺的。跑不起来先用它看比瞎猜强。另外有一种弹窗是0xc000007b这个错误码非常误导人很多情况下它不是纯粹的缺DLL而是加载的DLL位数不对比如64位程序去加载了32位库。把Qt套件和gRPC库都锁定在64位就能排除大多数情况。5.5 gRPC连接失败错误码14及调试开关启动服务端之后客户端调用时报错如果status.error_code()是14对应grpc::StatusCode::UNAVAILABLE大体意思是服务不可达。排查顺序我建议是服务端有没有在跑端口是不是50051客户端Channel地址写的是不是127.0.0.1Windows防火墙有没有放行服务端绑定的是0.0.0.0还是127.0.0.1。这些问题里最容易忽略的是防火墙第一次跑的时候Windows弹窗如果点了“取消”后面每次连都是连接拒绝但代码看起来毫无问题。真想看详细的通信日志设置两个环境变量就够GRPC_VERBOSITYdebug GRPC_TRACEall设置后再运行客户端控制台会刷出大量连接握手、HTTP/2帧的信息虽然信息量大但你能看到它到底卡在哪一步比如是DNS解析失败还是连接被拒。调试完记得关掉不然性能会有损耗。线上环境千万别开GRPC_TRACEall日志会把磁盘刷爆。5.6 界面卡顿的排查顺序如果你把第4章的线程改掉了还觉得卡先按这个顺序查看RPC调用是不是真的在工作线程可以在Lambda里打印线程ID和主线程ID对照看m_watcher是不是只挂了一份future如果按钮点了多次前一份还没结束就又setFuture新future可能造成上次任务成了僵尸看是不是在回调里做了比较重的同步操作比如读写数据库、网络请求这些同样会卡主线程。gRPC调用这条链路的延迟如果短时间内达不到预期还可以在服务端日志里打耗时定位是网络问题还是业务处理问题。症状可能原因快速排查编译期找不到头文件CMAKE_PREFIX_PATH未配置检查CMake配置加vcpkg路径运行时报Qt版本混用PATH里有多个Qt版本修正运行环境发布前用windeployqt大量unresolved external symbolMinGW和MSVC库混用切换MSVC套装双击exe缺DLLgRPC/Protobuf DLL未复制用Dependencies查缺失项错误码14服务未启动/防火墙/端口错依次检查服务端和防火墙界面点击后卡死RPC阻塞了主线程改成QtConcurrent或线程调用中文乱码proto的string或QString编码问题统一UTF-8保证Qt文件带BOM再分享一个我自己用着非常顺手的排查习惯每次改完proto先不碰Qt界面用命令行客户端跑一遍确认服务端和proto生成代码没问题之后再回来动Qt。这不是绕远路而是把gRPC链路和Qt界面这两件最容易互相干扰的事情彻底分开。另一个经验是Channel对象创建和销毁的开销比想象中大尤其后面接口变多、调用频繁之后千万别在每次按钮点击里都创建Channel自己维护一个全局Channel或者连接池客户端响应速度会明显不一样。这个小计算器跑顺之后按同样的套路把流式返回、文件传输加进去你对gRPC的理解会比看十篇文章都深刻。
返回列表