1. 为什么值得从零手写一次 gRPC C++ 项目
gRPC 这套东西,第一次接触的人很容易被它绕晕。Python、Go 那边几行代码就能跑起来,到了 C++ 这边,光是把项目生成、编译跑通,就能卡掉一大半人。我自己最早接触 gRPC C++ 的时候,也是被 CMake 报错、protobuf 版本冲突、链接找不到符号这几件事轮番教育过。所以这篇东西,我不打算讲什么高深原理,就聚焦一件事:一个 gRPC C++ 项目,从 proto 文件到能跑起来的可执行程序,中间到底发生了什么,每一步为什么这么写。
先把结论摆前面:gRPC C++ 项目的生成和编译,本质上是三件事的串联——proto 文件通过 protoc 生成 C++ 桩代码,CMake 把这些桩代码和 gRPC 运行库链接起来,最后编译出客户端和服务端两个可执行文件。听起来简单,但每一环都有坑。这篇文章适合两类人:一类是刚接手一个 gRPC C++ 项目、被构建系统搞得头大的开发者;另一类是想自己从零搭一个 gRPC C++ 骨架、但不确定目录该怎么组织的人。我会把目录结构、CMake 写法、protoc 参数、编译顺序、常见报错全部拆开讲,并且给出可以直接抄的配置。
需要提前说明的是,下面涉及的具体版本号、路径、参数,都是基于常见工程实践给出的合理方案,不是唯一解。你完全可以根据自己的环境调整,但调整之前,最好先理解每个参数在干什么,否则改完出问题会更难排查。
2. 项目整体设计与目录结构拆解
2.1 为什么 gRPC C++ 项目要单独设计目录结构
很多人写 C++ 项目习惯把所有文件堆在一个目录里,源文件、头文件、构建脚本混在一起。小项目无所谓,但 gRPC 项目不行,因为它有一个特殊产物——由 proto 文件自动生成的代码。这部分代码你不该手写,也不该提交到版本库,但它又必须参与编译。如果不把它单独隔离出来,你的源码目录很快就会被生成文件污染,改一次 proto 就多一堆文件,根本分不清哪些是人写的、哪些是机器生成的。
所以一个清晰的 gRPC C++ 项目,目录结构至少要分成四层:协议层(proto)、生成层(generated)、业务层(src)、构建层(CMake)。这四层各司其职,proto 只放接口定义,generated 只放自动生成的东西,src 放你真正写的服务实现和客户端逻辑,CMake 负责把前三者串起来。这样做的直接好处是:清理生成文件只需要删一个目录,proto 改动的影响范围一目了然,团队协作时也不会有人误改生成代码。
我见过一种反面案例,有人把.pb.cc、.grpc.pb.cc这些生成文件直接提交进了仓库,结果两个人本地 protoc 版本不一样,生成的代码有差异,合并的时候冲突一大堆。这就是目录结构没设计好带来的连锁反应。把生成层独立出来,并且在.gitignore里排除掉,这类问题从根上就避免了。
2.2 一套可直接复用的目录骨架
下面这套结构是我自己用了很久、也在多个项目里验证过的,你可以直接照搬:
grpc-demo/ ├── proto/ │ └── demo.proto ├── generated/ │ ├── demo.pb.h │ ├── demo.pb.cc │ ├── demo.grpc.pb.h │ └── demo.grpc.pb.cc ├── src/ │ ├── server/ │ │ └── main.cpp │ └── client/ │ └── main.cpp ├── cmake/ │ └── FindGRPC.cmake ├── CMakeLists.txt └── .gitignore这里有几个设计决策值得说清楚。proto 单独一个目录,是因为一个项目可能有多个 proto 文件,甚至多个子模块各有各的 proto,集中管理方便统一生成。generated 目录不提交,.gitignore里写一行generated/就够了,每个开发者本地生成自己的。server 和 client 分开,是因为它们虽然共享同一份 proto 生成的桩代码,但业务逻辑完全不同,混在一起编译容易互相干扰,分开后可以独立构建、独立调试。
提示:generated 目录建议在构建时自动创建,而不是手动建好空目录提交。CMake 里用
file(MAKE_DIRECTORY ...)就能做到,后面会讲。
2.3 生成层与业务层的边界怎么划
这是新手最容易模糊的地方。简单说,generated 目录里的东西你一个字都不要改,哪怕它生成的代码看起来有点冗余、命名有点丑。因为下次重新生成,你的修改就全没了。业务层要做的事情只有一件:继承生成的服务基类,实现里面的虚函数。
举个例子,proto 里定义了一个SayHello方法,protoc 会生成一个DemoService::Service基类,里面有个纯虚函数SayHello。你在src/server/main.cpp里写一个类继承它,把SayHello实现掉,这就完事了。客户端那边则是用生成的DemoService::Stub去调用。整个过程中,业务代码只依赖生成的头文件,不依赖生成的具体实现细节,这个边界守住了,项目就不会乱。
3. proto 文件与代码生成的核心细节
3.1 proto 文件到底该怎么写才规范
proto 文件是 gRPC 项目的源头,它写得好不好,直接决定了后面生成的代码好不好用。先看一个最小可用的例子:
syntax = "proto3"; package demo; service DemoService { rpc SayHello (HelloRequest) returns (HelloReply) {} } message HelloRequest { string name = 1; } message HelloReply { string message = 1; }这里有几个细节必须强调。第一,syntax = "proto3"不能省,不写默认是 proto2,语法差异很大,生成的代码也不一样。第二,package建议加上,它会影响生成代码的命名空间,不加的话所有符号都在全局,多个 proto 一起用容易撞名。第三,字段编号(= 1)一旦确定就不要改,它是序列化时的唯一标识,改了会导致新旧版本不兼容。
还有一个容易被忽略的点:proto 文件的命名。建议用下划线风格,比如user_service.proto,生成的文件会是user_service.pb.h、user_service.grpc.pb.h。不要用大写或者驼峰,某些平台的构建系统对大小写敏感,容易出问题。
3.2 protoc 生成 C++ 代码的完整命令
proto 写好了,接下来就是生成代码。gRPC C++ 需要两个插件配合:protoc 本体负责生成消息类的序列化代码,grpc_cpp_plugin 负责生成服务桩代码。完整命令长这样:
protoc \ --proto_path=./proto \ --cpp_out=./generated \ --grpc_out=./generated \ --plugin=protoc-gen-grpc=$(which grpc_cpp_plugin) \ ./proto/demo.proto逐段解释一下。--proto_path告诉 protoc 去哪里找 import 的文件,这里指向 proto 目录。--cpp_out指定消息类代码的输出目录,会生成demo.pb.h和demo.pb.cc。--grpc_out指定服务桩代码的输出目录,会生成demo.grpc.pb.h和demo.grpc.pb.cc。--plugin指定 grpc 插件的位置,which grpc_cpp_plugin会自动找到它的路径。
注意:
--cpp_out和--grpc_out必须指向同一个目录,否则生成的头文件互相 include 的时候会找不到对方。这个坑我踩过,报错信息是fatal error: demo.pb.h: No such file or directory,排查半天才发现是两个输出目录不一致。
生成完成后,generated 目录里应该有四个文件。如果只有两个(缺了.grpc.pb.*),说明 grpc 插件没生效,大概率是grpc_cpp_plugin没装或者路径不对。这时候用which grpc_cpp_plugin确认一下,如果没有输出,说明需要先安装 gRPC 的 C++ 开发包。
3.3 生成代码里到底有什么
很多人生成完代码就急着编译,从来没看过生成的文件长什么样。其实花五分钟看一眼,对理解整个 gRPC 的工作机制帮助极大。demo.pb.h里主要是消息类,HelloRequest会变成一个 C++ 类,有set_name()、name()这样的访问器,还有序列化、反序列化的方法。demo.grpc.pb.h里则是服务相关的类,包括DemoService::Service(服务端基类)、DemoService::Stub(客户端桩)、以及各种Call相关的辅助类。
理解这一点很关键:你写的服务端代码,本质上是继承DemoService::Service并重写虚函数;你写的客户端代码,本质上是持有DemoService::Stub并调用它的方法。生成代码把网络通信、序列化这些脏活全包了,你只需要关心业务逻辑。这也是 gRPC 相比手写 socket 通信最大的价值所在。
4. CMake 构建系统的完整实现
4.1 为什么 gRPC C++ 项目强烈建议用 CMake
C++ 的构建系统选择很多,Makefile、Bazel、Meson 都能用,但 gRPC C++ 项目我强烈建议用 CMake。原因有三个。第一,gRPC 官方就是用 CMake 构建的,它提供的gRPCConfig.cmake能让你用find_package(gRPC CONFIG REQUIRED)一行搞定依赖查找。第二,CMake 对 protobuf 生成代码的支持很成熟,有现成的protobuf_generate_cpp之类的函数可以复用。第三,跨平台,Windows、Linux、macOS 一套配置基本通用,团队协作时不用每人维护一份 Makefile。
当然,CMake 本身也有学习成本,语法有点反人类。但对于 gRPC 这种依赖复杂的项目,用 CMake 省下来的时间远超学习成本。下面我给的配置,你可以直接抄,改改项目名就能用。
4.2 一份可直接抄的 CMakeLists.txt
先看完整配置,然后我逐段拆解:
cmake_minimum_required(VERSION 3.16) project(grpc-demo CXX) set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) # 查找依赖 find_package(Protobuf REQUIRED) find_package(gRPC CONFIG REQUIRED) # 生成代码输出目录 set(GENERATED_DIR ${CMAKE_CURRENT_SOURCE_DIR}/generated) file(MAKE_DIRECTORY ${GENERATED_DIR}) # proto 文件 set(PROTO_FILE ${CMAKE_CURRENT_SOURCE_DIR}/proto/demo.proto) # 自定义命令:生成 C++ 代码 add_custom_command( OUTPUT ${GENERATED_DIR}/demo.pb.cc ${GENERATED_DIR}/demo.pb.h ${GENERATED_DIR}/demo.grpc.pb.cc ${GENERATED_DIR}/demo.grpc.pb.h COMMAND ${Protobuf_PROTOC_EXECUTABLE} ARGS --proto_path=${CMAKE_CURRENT_SOURCE_DIR}/proto --cpp_out=${GENERATED_DIR} --grpc_out=${GENERATED_DIR} --plugin=protoc-gen-grpc=$<TARGET_FILE:gRPC::grpc_cpp_plugin> ${PROTO_FILE} DEPENDS ${PROTO_FILE} COMMENT "Generating C++ code from demo.proto" ) # 生成代码编译成库 add_library(demo_proto ${GENERATED_DIR}/demo.pb.cc ${GENERATED_DIR}/demo.grpc.pb.cc ) target_include_directories(demo_proto PUBLIC ${GENERATED_DIR}) target_link_libraries(demo_proto PUBLIC gRPC::grpc++ protobuf::libprotobuf) # 服务端 add_executable(server src/server/main.cpp) target_link_libraries(server PRIVATE demo_proto) # 客户端 add_executable(client src/client/main.cpp) target_link_libraries(client PRIVATE demo_proto)4.3 关键配置逐段拆解
find_package(gRPC CONFIG REQUIRED)这行是核心。CONFIG模式表示用 gRPC 自带的配置文件查找,而不是 CMake 自带的 Find 模块。这样找到的 gRPC 会提供gRPC::grpc++这样的 imported target,链接的时候直接写 target 名就行,不用手动指定一堆库路径。
add_custom_command是生成代码的关键。它定义了"当 proto 文件变化时,执行 protoc 生成代码"这条规则。OUTPUT列出所有生成的文件,CMake 靠这个判断是否需要重新生成。DEPENDS指定依赖的 proto 文件,proto 一改,命令就会重新执行。这里有个细节:$<TARGET_FILE:gRPC::grpc_cpp_plugin>是 CMake 的生成器表达式,它会自动解析出 grpc_cpp_plugin 的完整路径,比手动写路径可靠得多。
add_library(demo_proto ...)把生成的两个.cc文件编译成一个静态库。这样做的好处是,服务端和客户端都链接这个库,生成代码只编译一次,构建速度更快。target_include_directories把 generated 目录暴露出去,这样 server 和 client 的源码里就能#include "demo.grpc.pb.h"了。
target_link_libraries(demo_proto PUBLIC gRPC::grpc++ protobuf::libprotobuf)这行把 gRPC 和 protobuf 的库链接进来。用PUBLIC是因为链接 demo_proto 的目标也需要这些库,CMake 会自动传递依赖。
4.4 编译顺序与依赖关系梳理
CMake 最强大的地方就是自动处理依赖顺序,但前提是你把依赖关系写对了。这个项目里的依赖链是这样的:proto 文件 → 生成代码 → demo_proto 库 → server/client 可执行文件。CMake 会根据add_custom_command的 OUTPUT 和add_library的源文件列表,自动推导出正确的构建顺序。
但有一个坑要注意:add_custom_command的 OUTPUT 必须和add_library的源文件完全一致。如果 OUTPUT 里写了demo.pb.cc,但 add_library 里写的是${GENERATED_DIR}/demo.pb.cc,CMake 可能认不出这是同一个文件,导致生成命令不执行,编译时报"找不到文件"。所以路径写法要统一,我上面统一用了${GENERATED_DIR}/xxx的形式。
5. 服务端与客户端代码实现要点
5.1 服务端最小实现
服务端代码的核心就是继承生成的服务基类,实现业务方法:
#include <grpcpp/grpcpp.h> #include "demo.grpc.pb.h" class DemoServiceImpl final : public demo::DemoService::Service { grpc::Status SayHello(grpc::ServerContext* context, const demo::HelloRequest* request, demo::HelloReply* reply) override { std::string prefix("Hello, "); reply->set_message(prefix + request->name()); return grpc::Status::OK; } }; int main() { std::string address("0.0.0.0:50051"); DemoServiceImpl service; grpc::ServerBuilder builder; builder.AddListeningPort(address, grpc::InsecureServerCredentials()); builder.RegisterService(&service); std::unique_ptr<grpc::Server> server(builder.BuildAndStart()); std::cout << "Server listening on " << address << std::endl; server->Wait(); return 0; }这里有几个点值得说。grpc::ServerBuilder是服务端的核心,它负责配置监听地址、注册服务、启动服务器。AddListeningPort的第二个参数是凭证,示例里用的是不加密的InsecureServerCredentials,生产环境要换成 TLS 凭证。RegisterService把我们的实现注册进去,注意传的是指针,所以service对象的生命周期要覆盖整个服务器运行期。server->Wait()会阻塞主线程,直到服务器被关闭。
5.2 客户端最小实现
客户端代码的核心是创建 Stub 并发起调用:
#include <grpcpp/grpcpp.h> #include "demo.grpc.pb.h" int main() { std::string address("localhost:50051"); auto channel = grpc::CreateChannel(address, grpc::InsecureChannelCredentials()); auto stub = demo::DemoService::NewStub(channel); demo::HelloRequest request; request.set_name("World"); demo::HelloReply reply; grpc::ClientContext context; grpc::Status status = stub->SayHello(&context, request, &reply); if (status.ok()) { std::cout << "Reply: " << reply.message() << std::endl; } else { std::cout << "RPC failed: " << status.error_message() << std::endl; } return 0; }客户端的流程是:创建 channel → 创建 stub → 构造请求 → 发起调用 → 处理响应。grpc::CreateChannel建立到服务端的连接,NewStub基于 channel 创建桩对象。调用的时候,ClientContext用来传递元数据、超时等控制信息,请求和响应对象分别传入。返回的grpc::Status一定要检查,status.ok()为 false 时说明调用失败,error_message()里有具体原因。
5.3 编译与运行验证
配置和代码都写好后,编译就三步:
mkdir build && cd build cmake .. make -j4编译成功后,build 目录下会有server和client两个可执行文件。先启动服务端:
./server再开一个终端运行客户端:
./client如果一切正常,客户端会输出Reply: Hello, World。看到这行字,说明整个 gRPC C++ 项目从生成到编译到运行,全链路打通了。
提示:如果服务端启动时报
Address already in use,说明 50051 端口被占用了。换个端口,或者用lsof -i:50051找到占用进程处理掉。
6. 常见问题与排查技巧实录
6.1 编译期常见报错速查
gRPC C++ 的报错信息有时候很隐晦,我整理了一份常见问题对照表,遇到问题可以先查这个:
| 报错信息 | 根本原因 | 解决方法 |
|---|---|---|
fatal error: demo.pb.h: No such file | 生成目录没加入 include 路径 | 检查target_include_directories是否包含 generated |
undefined reference to grpc::... | 没链接 gRPC 库 | 确认target_link_libraries里有gRPC::grpc++ |
protoc-gen-grpc: program not found | grpc 插件没装或路径不对 | 用which grpc_cpp_plugin确认,重新安装 gRPC |
This file was generated by an older version of protoc | protoc 版本和运行库版本不一致 | 统一 protoc 和 libprotobuf 的版本 |
cannot find -lgrpc++ | 库路径没配置 | 检查 gRPC 安装路径,必要时设置CMAKE_PREFIX_PATH |
6.2 版本冲突这个坑怎么绕
gRPC C++ 最恶心的问题就是版本冲突。protobuf 有版本,gRPC 有版本,两者之间还有兼容性要求。我踩过最惨的一次是:系统里装了一个 protobuf,项目又通过包管理器装了另一个,结果 protoc 生成代码用的是 A 版本,链接的时候用的是 B 版本,运行时报序列化错误,排查了一整天才定位到。
避免这个坑的办法是:统一依赖来源。要么全部用系统包管理器装,要么全部用源码编译,不要混着来。如果项目用 CMake,可以在配置阶段打印一下找到的版本:
message(STATUS "Protobuf version: ${Protobuf_VERSION}") message(STATUS "gRPC version: ${gRPC_VERSION}")这样每次配置的时候都能看到实际用的是哪个版本,出问题好定位。
6.3 生成代码不更新的排查思路
有时候改了 proto 文件,重新编译却发现行为没变。这通常是生成命令没触发。排查步骤是:第一,确认 proto 文件的修改时间确实比生成文件新;第二,检查add_custom_command的 DEPENDS 是否包含了 proto 文件;第三,手动删掉 generated 目录重新编译,如果这样能好,说明是依赖检测的问题。
最彻底的办法是在 CMake 里加一个自定义 target,需要的时候手动触发重新生成:
add_custom_target(regenerate_proto COMMAND ${CMAKE_COMMAND} -E remove_directory ${GENERATED_DIR} COMMAND ${CMAKE_COMMAND} --build . --target demo_proto )这样执行make regenerate_proto就能强制重新生成,比手动删目录方便。
6.4 几个我踩过的实操心得
心得一:proto 文件改动后,一定要重新生成再编译。CMake 的依赖检测大部分时候靠谱,但偶尔会抽风,尤其是跨平台的时候。养成改完 proto 先make regenerate_proto的习惯,能省很多事。
心得二:生成目录不要提交到版本库。前面说过,但值得再强调一次。.gitignore里加上generated/和build/,团队协作时能避免大量无意义的冲突。
心得三:服务端对象的生命周期要管好。RegisterService传的是指针,如果 service 对象在服务器运行期间被销毁,会出大问题。最简单的做法是在 main 函数里用栈对象,让它的生命周期覆盖整个server->Wait()。
心得四:客户端 channel 可以复用。每次调用都CreateChannel开销很大,正确做法是创建一个 channel,多个 stub 共享。channel 是线程安全的,可以放心在多线程环境里用。
7. 项目扩展与工程化建议
7.1 从单 proto 到多 proto 的演进
实际项目里不可能只有一个 proto 文件。当 proto 多起来之后,生成命令和 CMake 配置都要相应调整。最直接的做法是用file(GLOB ...)收集所有 proto 文件,然后循环生成:
file(GLOB PROTO_FILES ${CMAKE_CURRENT_SOURCE_DIR}/proto/*.proto) foreach(PROTO ${PROTO_FILES}) get_filename_component(PROTO_NAME ${PROTO} NAME_WE) add_custom_command( OUTPUT ${GENERATED_DIR}/${PROTO_NAME}.pb.cc ${GENERATED_DIR}/${PROTO_NAME}.grpc.pb.cc COMMAND ${Protobuf_PROTOC_EXECUTABLE} ARGS --proto_path=${CMAKE_CURRENT_SOURCE_DIR}/proto --cpp_out=${GENERATED_DIR} --grpc_out=${GENERATED_DIR} --plugin=protoc-gen-grpc=$<TARGET_FILE:gRPC::grpc_cpp_plugin> ${PROTO} DEPENDS ${PROTO} ) list(APPEND GENERATED_SRCS ${GENERATED_DIR}/${PROTO_NAME}.pb.cc ${GENERATED_DIR}/${PROTO_NAME}.grpc.pb.cc) endforeach() add_library(demo_proto ${GENERATED_SRCS})注意:
file(GLOB ...)在 CMake 里不太推荐,因为新增文件不会自动触发重新配置。但对于 proto 这种相对稳定的文件,用起来问题不大。如果追求严谨,可以手动列出所有 proto 文件。
7.2 引入 TLS 与生产环境考量
示例里用的是不加密的连接,这在生产环境是不可接受的。gRPC C++ 支持 TLS,服务端用grpc::SslServerCredentials,客户端用grpc::SslCredentials。配置的时候需要准备证书和私钥,服务端加载服务端证书,客户端加载 CA 证书用于验证服务端。这部分内容比较多,但核心就是替换掉InsecureServerCredentials和InsecureChannelCredentials这两个调用。
生产环境还要考虑超时设置、重试策略、负载均衡、健康检查这些。gRPC C++ 都提供了对应的 API,但配置起来比 Python、Go 那边繁琐不少。建议先把基础链路跑通,再逐步加这些高级特性,不要一上来就全堆上去,否则出问题很难定位是哪一层的问题。
7.3 构建速度优化的一些思路
gRPC C++ 项目编译慢是出了名的,尤其是生成代码那部分。几个优化思路:第一,把生成代码编译成静态库,前面已经这么做了,服务端和客户端共享,避免重复编译。第二,开启 ccache,CMake 里配置CMAKE_CXX_COMPILER_LAUNCHER=ccache,第二次编译能快很多。第三,用 Ninja 替代 Make,cmake -G Ninja ..,Ninja 的并行调度比 Make 更高效。第四,减少不必要的头文件包含,生成的头文件很大,能前置声明就前置声明。
我自己实测下来,一个中等规模的 gRPC C++ 项目,用上 ccache 和 Ninja 之后,增量编译时间能从几分钟降到几十秒。这个提升在频繁改代码的时候非常明显。
7.4 关于测试与调试的补充
gRPC C++ 的调试比普通 C++ 程序麻烦,因为涉及网络通信。我的经验是:先用同步 API 把逻辑跑通,再考虑异步。同步 API 代码直观,出问题好定位。异步 API 性能好但复杂度高,新手容易写出死锁或者回调地狱。
调试的时候,可以在服务端方法入口和出口打日志,客户端调用前后也打日志,这样能快速判断问题出在哪一侧。如果连接都建立不起来,检查端口和防火墙;如果连接建立但调用失败,检查 proto 定义是否一致;如果调用成功但数据不对,检查序列化相关的字段编号。
另外,gRPC 自带一个反射功能,配合一些通用工具可以在不依赖 proto 文件的情况下调试接口。这个在排查线上问题时很有用,但配置稍微复杂,建议先把基础调试手段用熟再说。
整个项目从 proto 到可执行文件,核心就是理解"生成"和"链接"这两件事。生成靠 protoc 加插件,链接靠 CMake 的 target 机制。把这两条线理顺,剩下的就是业务逻辑的事了。我个人的体会是,第一次搭的时候慢一点没关系,把每个参数、每个依赖都搞清楚,后面再遇到类似项目,基本就是复制粘贴改改名字的功夫。