开篇先说清楚,这篇是接着上一篇环境准备的,上一篇把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 + Protobuf | Protobuf二进制,体积小、解析快 | .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.21,gRPC和Protobuf通过vcpkg安装,gRPC版本是1.54.0,Protobuf跟随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存一段可读的信息,比如除零了就把错误原因写在这里,方便界面直接弹给用户看。
需要留神的是消息字段的编号,a=1、b=2、op=3。字段编号不是随便给的,它会进到Protobuf的二进制编码中,同一个消息里不能重复,而且一旦发布出去,尽量永远不要改。如果你上线后某天想把a和b调个位置,没问题,但把字段编号改了,旧数据就解析不出来了。这点在真正的项目里很关键,提前养成习惯。
2.3 CMakeLists.txt整合Qt、gRPC和Protobuf
这个工程我依然用CMake组织,因为Qt也推荐CMake,gRPC官方同样推荐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.txt:
cmake_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} --plugin=protoc-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_FILE=D:/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:STRING=D:/vcpkg/installed/x64-windows,这个变量和toolchain二选一用就行,我一般两个都加上,省得来回试。
第一次构建大概率需要等一会,因为gRPC和Protobuf的头文件多,编译生成代码也要时间。构建完成后你应该能在build目录里看到三个exe:calculator_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_ptr<grpc::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()会阻塞当前线程,直到进程被终止,Ctrl+C或者关闭窗口后它才会返回。
启动成功后,控制台会打印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 QFutureWatcher<RpcResult>(this); connect(m_watcher, &QFutureWatcher<RpcResult>::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_ptr<grpc::Channel> m_channel; QFutureWatcher<RpcResult>* m_watcher = nullptr; };注意两个细节。一是channel我在构造函数里创建了一次并保存成成员变量,而不是每次点按钮都创建。grpc的Channel是线程安全的,而且创建期间要解析地址、可能要建立连接,成本并不低,复用它是基本操作。二是RpcResult这个结构体在绑定到QFutureWatcher模板之前,要调用Q_DECLARE_METATYPE让它成为Qt元类型系统认识的类型,不然编译期可能报和信号槽参数相关的错误。当然你也可以退回用std::pair<bool,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_ptr<grpc::Channel> channel = m_channel; QFuture<RpcResult> 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的人都掉进去过。
还有一个相关的问题:即使都是MSVC,Debug和Release配置也可能撞车。vcpkg默认会同时安装debug和release版本的grpc库,CMake里find_package会自动选择,但如果你手动指定了库路径,很可能会link错版本。保险做法是交给CMake和vcpkg去管理,别自己手动指定grpc++.lib的完整路径。
5.3 CMake找不到gRPC/Protobuf
find_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-windows,cmake文件位于D:/vcpkg/installed/x64-windows/share/grpc,而CMake恰好需要知道去那里找。最简单的解决办法就是在Qt Creator的CMake配置里加上一句话:
-DCMAKE_TOOLCHAIN_FILE=D:/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那堆DLL,windeployqt不会管,得手动把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.1,Windows防火墙有没有放行,服务端绑定的是0.0.0.0还是127.0.0.1。这些问题里最容易忽略的是防火墙,第一次跑的时候Windows弹窗如果点了“取消”,后面每次连都是连接拒绝,但代码看起来毫无问题。
真想看详细的通信日志,设置两个环境变量就够:
GRPC_VERBOSITY=debug GRPC_TRACE=all设置后再运行客户端,控制台会刷出大量连接握手、HTTP/2帧的信息,虽然信息量大,但你能看到它到底卡在哪一步,比如是DNS解析失败还是连接被拒。调试完记得关掉,不然性能会有损耗。线上环境千万别开GRPC_TRACE=all,日志会把磁盘刷爆。
5.6 界面卡顿的排查顺序
如果你把第4章的线程改掉了还觉得卡,先按这个顺序查:看RPC调用是不是真的在工作线程,可以在Lambda里打印线程ID,和主线程ID对照;看m_watcher是不是只挂了一份future,如果按钮点了多次,前一份还没结束就又setFuture新future,可能造成上次任务成了僵尸;看是不是在回调里做了比较重的同步操作,比如读写数据库、网络请求,这些同样会卡主线程。gRPC调用这条链路的延迟如果短时间内达不到预期,还可以在服务端日志里打耗时,定位是网络问题还是业务处理问题。
| 症状 | 可能原因 | 快速排查 |
|---|---|---|
| 编译期找不到头文件 | CMAKE_PREFIX_PATH未配置 | 检查CMake配置,加vcpkg路径 |
| 运行时报Qt版本混用 | PATH里有多个Qt版本 | 修正运行环境,发布前用windeployqt |
| 大量unresolved external symbol | MinGW和MSVC库混用 | 切换MSVC套装 |
| 双击exe缺DLL | gRPC/Protobuf DLL未复制 | 用Dependencies查缺失项 |
| 错误码14 | 服务未启动/防火墙/端口错 | 依次检查服务端和防火墙 |
| 界面点击后卡死 | RPC阻塞了主线程 | 改成QtConcurrent或线程调用 |
| 中文乱码 | proto的string或QString编码问题 | 统一UTF-8,保证Qt文件带BOM |
再分享一个我自己用着非常顺手的排查习惯:每次改完proto,先不碰Qt界面,用命令行客户端跑一遍,确认服务端和proto生成代码没问题之后,再回来动Qt。这不是绕远路,而是把gRPC链路和Qt界面这两件最容易互相干扰的事情彻底分开。另一个经验是,Channel对象创建和销毁的开销比想象中大,尤其后面接口变多、调用频繁之后,千万别在每次按钮点击里都创建Channel,自己维护一个全局Channel或者连接池,客户端响应速度会明显不一样。这个小计算器跑顺之后,按同样的套路把流式返回、文件传输加进去,你对gRPC的理解会比看十篇文章都深刻。