1. 为什么值得折腾 gRPC 的 C++ 项目生成与编译
如果你写过一段时间后端服务,大概率听说过 gRPC 这个名词。它本质上是一个高性能、跨语言的远程过程调用框架,底层跑在 HTTP/2 之上,用 Protobuf 做接口描述和序列化。说人话就是:你定义好一份接口文件,工具帮你生成客户端和服务端的代码骨架,你只管填业务逻辑,剩下的网络传输、序列化、连接管理它全包了。
那为什么单独把 C++ 拎出来讲?因为 Java、Go、Python 这些语言用 gRPC 基本是“开箱即用”——装个包、跑条命令就完事了。但 C++ 不一样,C++ 的 gRPC 项目生成和编译是整个生态里门槛最高、坑最多的一环。你需要面对 CMake 配置、Protobuf 编译器版本匹配、Abseil 依赖、链接顺序、动态库与静态库的选择等一系列问题。很多人在这一步就被劝退了,明明接口定义写得没问题,就是编译不过,报错信息还特别长,看一眼就头大。
这篇内容适合哪些人看?第一类是对 gRPC 有兴趣但还没动手跑通过 C++ 项目的开发者;第二类是在项目里已经用了 gRPC,但每次编译环境出问题都要折腾半天的工程师;第三类是想把 gRPC 集成到已有 C++ 工程里,但不确定怎么组织目录结构和构建脚本的技术负责人。不管你是哪种,我都会从项目生成到编译落地,把每一步的操作、参数含义、常见报错和排查思路讲清楚。
我自己的经历是,第一次搭 gRPC 的 C++ 环境花了整整两天,大部分时间不是在写代码,而是在跟编译器和链接器较劲。后来跑通了几次,总结出一套比较稳定的流程,这篇文章就是把这套流程和踩过的坑完整记录下来。
2. 整体思路与方案选型:为什么这样组织项目
2.1 两种主流构建方式的选择逻辑
gRPC 的 C++ 项目搭建,目前主流有两种路径。第一种是直接用 CMake 的FetchContent或者add_subdirectory把 gRPC 源码拉进来一起编译;第二种是先在本机安装好 gRPC 和 Protobuf 的库,然后项目里通过find_package来引用。
这两种方式各有适用场景。第一种方式的好处是版本完全可控,不依赖系统环境,换一台机器只要网络能拉到源码就能编译。坏处是编译时间非常长,gRPC 加上它的依赖(Abseil、Protobuf、re2、c-ares、zlib 等),首次全量编译在普通开发机上可能要二三十分钟甚至更久。第二种方式编译快,但要求每台开发机都正确安装了对应版本的 gRPC 和 Protobuf,版本不匹配时会出现各种奇怪的链接错误。
我的建议是:如果你是在做团队项目,且团队有统一的开发环境管理(比如容器镜像),用第二种方式,把 gRPC 预装到基础镜像里,项目编译会快很多。如果你是个人学习或者想快速验证一个想法,用第一种方式,虽然首次编译慢,但省去了环境配置的麻烦。
注意:不管选哪种方式,Protobuf 的版本和 gRPC 的版本必须匹配。gRPC 每个 release 版本都对 Protobuf 有最低版本要求,版本差距太大时生成的代码可能不兼容。
2.2 项目目录结构的设计考量
一个清晰的项目目录结构能帮你省下大量排查问题的时间。我通常会把项目组织成下面这样:
my-grpc-project/ ├── proto/ # 存放 .proto 接口定义文件 │ └── hello.proto ├── src/ │ ├── server/ # 服务端业务代码 │ │ └── main.cpp │ └── client/ # 客户端业务代码 │ └── main.cpp ├── generated/ # 生成的代码输出目录(构建时自动创建) ├── cmake/ # 自定义 CMake 模块 │ └── FindGRPC.cmake ├── third_party/ # 第三方依赖(可选) ├── CMakeLists.txt # 顶层构建脚本 └── README.md把proto单独放一个目录,是因为接口定义是客户端和服务端共享的,独立出来方便管理。generated目录不纳入版本控制,每次构建时重新生成,避免生成代码和 proto 文件不同步。src下面按 server 和 client 分目录,是因为实际项目中这两边的依赖和编译选项可能不同。
2.3 Protobuf 代码生成的核心机制
理解 Protobuf 代码生成的机制,对排查问题非常关键。整个流程分两步:第一步,protoc编译器读取.proto文件,生成消息类的 C++ 代码(*.pb.h和*.pb.cc);第二步,gRPC 的插件grpc_cpp_plugin基于同一份 proto 文件,生成服务存根代码(*.grpc.pb.h和*.grpc.pb.cc)。
这两步是独立的。protoc负责消息序列化部分,grpc_cpp_plugin负责服务接口部分。很多人编译报错说找不到Service相关的符号,往往是因为只跑了protoc没跑 gRPC 插件,或者插件路径没配对。
在 CMake 里,这两个步骤通常通过自定义命令(add_custom_command)来触发,指定.proto文件作为输入,生成的.pb.cc和.grpc.pb.cc作为输出,然后把输出文件加入目标源文件列表。这样 CMake 就能自动处理依赖关系,proto 文件改了会自动重新生成。
3. 核心细节解析与实操要点
3.1 环境准备:编译器与依赖版本
先说编译器。gRPC 的 C++ 实现大量使用了 C++11 及以上的特性,较新的版本(1.50 以后)开始要求 C++14 甚至 C++17。GCC 建议 7.5 以上,Clang 建议 6.0 以上。如果你用的是比较老的系统自带编译器,建议先升级,否则会遇到各种语法不支持的问题。
CMake 的版本要求也要注意。gRPC 本身在构建时要求 CMake 3.13 以上,但你的项目如果用了一些较新的 CMake 特性(比如target_link_options),可能需要 3.16 以上。我一般建议直接用 3.20 以上的版本,省心。
Protobuf 的版本匹配是个大坑。举个例子,gRPC 1.54 要求 Protobuf 3.21 以上,如果你系统里装的是 Protobuf 3.6,编译时就会报一堆找不到符号的错误。最稳妥的做法是查一下你用的 gRPC 版本的CMakeLists.txt或者官方文档,确认它要求的 Protobuf 最低版本。
实操心得:在 Ubuntu 上,系统自带的
libprotobuf-dev和protobuf-compiler往往版本偏旧。如果你要用较新的 gRPC,建议从源码编译 Protobuf,或者用包管理器安装较新的版本。编译 Protobuf 时记得开-Dprotobuf_BUILD_SHARED_LIBS=ON,否则默认只生成静态库,后续链接 gRPC 时可能出问题。
3.2 proto 文件编写要点
proto 文件是整个项目的接口契约,写的时候有几个细节需要注意。首先是syntax声明,现在统一用proto3,语法更简洁,也不用处理 required/optional 的复杂性。其次是package声明,它决定了生成代码的命名空间,建议用项目名或者组织名做前缀,避免和其他项目的类型冲突。
一个典型的 proto 文件长这样:
syntax = "proto3"; package myproject.hello; service Greeter { rpc SayHello (HelloRequest) returns (HelloReply) {} rpc SayHelloStream (HelloRequest) returns (stream HelloReply) {} } message HelloRequest { string name = 1; int32 age = 2; } message HelloReply { string message = 1; }这里定义了一个Greeter服务,包含一个普通 RPC 和一个服务端流式 RPC。字段后面的数字是字段编号,一旦发布就不能随意更改,否则会导致序列化不兼容。
注意:proto 文件的
package和 C++ 的命名空间不是一回事。package影响的是 Protobuf 层面的类型标识,C++ 命名空间默认由文件路径和 package 共同决定。如果你想让生成的 C++ 代码用特定的命名空间,可以在 proto 里加option cpp_namespace = "xxx";。
3.3 CMake 中集成代码生成的关键配置
在 CMake 里集成 gRPC 代码生成,核心是找到protoc和grpc_cpp_plugin这两个可执行文件,然后用add_custom_command触发生成。下面是一段我常用的配置:
find_package(Protobuf REQUIRED) find_package(gRPC REQUIRED) set(PROTO_FILE ${CMAKE_CURRENT_SOURCE_DIR}/proto/hello.proto) set(GENERATED_DIR ${CMAKE_CURRENT_BINARY_DIR}/generated) file(MAKE_DIRECTORY ${GENERATED_DIR}) set(PB_SRC ${GENERATED_DIR}/hello.pb.cc) set(PB_HDR ${GENERATED_DIR}/hello.pb.h) set(GRPC_SRC ${GENERATED_DIR}/hello.grpc.pb.cc) set(GRPC_HDR ${GENERATED_DIR}/hello.grpc.pb.h) add_custom_command( OUTPUT ${PB_SRC} ${PB_HDR} ${GRPC_SRC} ${GRPC_HDR} COMMAND ${Protobuf_PROTOC_EXECUTABLE} ARGS --grpc_out=${GENERATED_DIR} --cpp_out=${GENERATED_DIR} -I ${CMAKE_CURRENT_SOURCE_DIR}/proto --plugin=protoc-gen-grpc=$<TARGET_FILE:gRPC::grpc_cpp_plugin> ${PROTO_FILE} DEPENDS ${PROTO_FILE} COMMENT "Generating gRPC C++ code from hello.proto" )这段配置里,Protobuf_PROTOC_EXECUTABLE是find_package(Protobuf)提供的变量,指向 protoc 的路径。$<TARGET_FILE:gRPC::grpc_cpp_plugin>是 CMake 的生成器表达式,能自动解析出 gRPC 插件的完整路径,比手动拼路径可靠得多。
生成的.pb.cc和.grpc.pb.cc文件需要加入你的目标源文件列表,同时把GENERATED_DIR加入include_directories,这样代码里才能#include "hello.pb.h"。
3.4 链接库的选择与顺序问题
链接阶段是 C++ gRPC 项目最容易出问题的地方。你需要链接的库包括gRPC::grpc++、gRPC::grpc++_reflection(如果需要反射)、Protobuf::libprotobuf,以及 gRPC 的底层依赖如gRPC::gpr、gRPC::address_sorting等。如果用find_package(gRPC)找到的是官方提供的 CMake 配置文件,这些依赖通常会自动传递,你只需要链接gRPC::grpc++就行。
但如果你是自己编译安装的 gRPC,或者用的是系统包管理器安装的版本,可能没有提供 CMake 配置文件,这时候就需要手动指定库路径和链接顺序。链接顺序的原则是:依赖别人的库放在前面,被依赖的库放在后面。比如你的代码依赖grpc++,grpc++依赖grpc和protobuf,那链接顺序就是-lgrpc++ -lgrpc -lprotobuf。
踩过的坑:有一次在 Ubuntu 上用 apt 安装了
libgrpc++-dev,编译时一直报undefined reference to grpc::Channel::Channel(...)。排查了半天发现是链接顺序问题,把-lgrpc++放到了-lprotobuf后面,导致链接器找不到符号。调整顺序后问题解决。
4. 完整实操流程:从零跑通一个 gRPC C++ 项目
4.1 环境搭建与依赖安装
假设你在 Ubuntu 22.04 上操作,先安装基础工具:
sudo apt update sudo apt install -y build-essential cmake git pkg-config然后安装 gRPC 和 Protobuf。如果你选择源码编译的方式,流程大致如下:
git clone --recurse-submodules -b v1.54.0 --depth 1 https://github.com/grpc/grpc cd grpc mkdir -p cmake/build cd cmake/build cmake ../.. -DgRPC_INSTALL=ON \ -DgRPC_BUILD_TESTS=OFF \ -DCMAKE_INSTALL_PREFIX=/usr/local make -j$(nproc) sudo make install这里-DgRPC_BUILD_TESTS=OFF是为了跳过测试代码的编译,能省不少时间。-DCMAKE_INSTALL_PREFIX=/usr/local指定安装路径,安装后头文件在/usr/local/include,库文件在/usr/local/lib。
编译完成后,可能需要更新动态库缓存:
sudo ldconfig这一步很关键,否则运行时会报找不到.so文件的错误。
4.2 编写 proto 文件与 CMakeLists
按照前面说的目录结构,创建proto/hello.proto,内容用 3.2 节的示例。然后写顶层CMakeLists.txt:
cmake_minimum_required(VERSION 3.16) project(my-grpc-project LANGUAGES CXX) set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) find_package(Protobuf REQUIRED) find_package(gRPC REQUIRED) set(GENERATED_DIR ${CMAKE_CURRENT_BINARY_DIR}/generated) file(MAKE_DIRECTORY ${GENERATED_DIR}) set(PROTO_FILE ${CMAKE_CURRENT_SOURCE_DIR}/proto/hello.proto) add_custom_command( OUTPUT ${GENERATED_DIR}/hello.pb.cc ${GENERATED_DIR}/hello.pb.h ${GENERATED_DIR}/hello.grpc.pb.cc ${GENERATED_DIR}/hello.grpc.pb.h COMMAND ${Protobuf_PROTOC_EXECUTABLE} ARGS --grpc_out=${GENERATED_DIR} --cpp_out=${GENERATED_DIR} -I ${CMAKE_CURRENT_SOURCE_DIR}/proto --plugin=protoc-gen-grpc=$<TARGET_FILE:gRPC::grpc_cpp_plugin> ${PROTO_FILE} DEPENDS ${PROTO_FILE} COMMENT "Generating gRPC code" ) add_library(hello_proto STATIC ${GENERATED_DIR}/hello.pb.cc ${GENERATED_DIR}/hello.grpc.pb.cc ) target_include_directories(hello_proto PUBLIC ${GENERATED_DIR}) target_link_libraries(hello_proto PUBLIC gRPC::grpc++ Protobuf::libprotobuf) add_executable(server src/server/main.cpp) target_link_libraries(server PRIVATE hello_proto) add_executable(client src/client/main.cpp) target_link_libraries(client PRIVATE hello_proto)把生成的代码封装成一个静态库hello_proto,server 和 client 都链接它,这样代码生成只跑一次,编译效率更高。
4.3 服务端与客户端代码实现
服务端src/server/main.cpp的核心逻辑:
#include <grpcpp/grpcpp.h> #include "hello.grpc.pb.h" class GreeterServiceImpl final : public myproject::hello::Greeter::Service { grpc::Status SayHello(grpc::ServerContext* context, const myproject::hello::HelloRequest* request, myproject::hello::HelloReply* reply) override { std::string prefix("Hello, "); reply->set_message(prefix + request->name()); return grpc::Status::OK; } }; int main() { std::string server_address("0.0.0.0:50051"); GreeterServiceImpl 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; }客户端src/client/main.cpp:
#include <grpcpp/grpcpp.h> #include "hello.grpc.pb.h" int main() { auto channel = grpc::CreateChannel("localhost:50051", grpc::InsecureChannelCredentials()); auto stub = myproject::hello::Greeter::NewStub(channel); myproject::hello::HelloRequest request; request.set_name("World"); myproject::hello::HelloReply reply; grpc::ClientContext context; grpc::Status status = stub->SayHello(&context, request, &reply); if (status.ok()) { std::cout << "Response: " << reply.message() << std::endl; } else { std::cout << "RPC failed: " << status.error_message() << std::endl; } return 0; }4.4 编译与运行验证
在项目根目录执行:
mkdir build && cd build cmake .. make -j$(nproc)编译成功后,先启动服务端:
./server再开一个终端运行客户端:
./client如果看到Response: Hello, World,说明整个流程跑通了。
实操心得:首次编译时,如果 gRPC 是源码编译安装的,CMake 的
find_package可能找不到配置文件。这时候需要手动指定gRPC_DIR和Protobuf_DIR,指向安装目录下的lib/cmake/grpc和lib/cmake/protobuf。可以在 cmake 命令里加-DgRPC_DIR=/usr/local/lib/cmake/grpc来指定。
5. 常见问题与排查技巧实录
5.1 编译阶段典型报错与解决
报错一:fatal error: grpcpp/grpcpp.h: No such file or directory
这个错误说明编译器找不到 gRPC 的头文件。原因通常是 gRPC 没有正确安装,或者 CMake 没有把 gRPC 的 include 路径传递给目标。检查find_package(gRPC)是否成功,可以在 CMakeLists 里加message(STATUS "gRPC found: ${gRPC_FOUND}")来确认。如果 gRPC 装在非标准路径,需要设置CMAKE_PREFIX_PATH。
报错二:undefined reference to google::protobuf::...
这是链接错误,说明 Protobuf 库没有链接上,或者链接顺序不对。检查target_link_libraries里是否包含了Protobuf::libprotobuf。如果是手动指定库路径,确保-lprotobuf在-lgrpc++之后。
报错三:protoc: error while loading shared libraries: libprotoc.so.xx: cannot open shared object file
protoc 本身运行时找不到动态库。这通常是因为 Protobuf 安装后没有运行ldconfig,或者安装路径不在动态库搜索路径里。执行sudo ldconfig或者设置LD_LIBRARY_PATH可以解决。
报错四:生成的代码里报This file was generated by an older version of protoc
proto 文件生成时用的 protoc 版本和链接时用的 Protobuf 库版本不一致。检查Protobuf_PROTOC_EXECUTABLE指向的 protoc 版本,和Protobuf::libprotobuf的版本是否一致。可以用protoc --version查看编译器版本,用pkg-config --modversion protobuf查看库版本。
5.2 运行时问题排查
问题:客户端连接服务端时报failed to connect to all addresses
先确认服务端是否正常启动并监听了端口。用netstat -tlnp | grep 50051检查端口监听状态。如果服务端在容器里运行,确认端口映射是否正确。另外,客户端连接地址如果是localhost,在某些环境下可能解析到 IPv6 地址,而服务端只监听了 IPv4,可以改成127.0.0.1:50051试试。
问题:RPC 调用返回StatusCode::UNIMPLEMENTED
说明请求的方法在服务端没有注册。检查服务端是否调用了builder.RegisterService(&service),以及 proto 文件里定义的方法名和实现类里的方法签名是否完全一致。方法名大小写错误也会导致这个问题。
问题:程序运行时崩溃,报pure virtual method called
这通常是因为服务实现类的对象在 RPC 完成前被销毁了。确保服务对象的生命周期覆盖整个服务运行期间,不要把它定义在局部作用域里。
5.3 常见问题速查表
| 问题现象 | 可能原因 | 排查方向 |
|---|---|---|
| 找不到 grpcpp 头文件 | gRPC 未安装或路径未传递 | 检查 find_package 结果,设置 CMAKE_PREFIX_PATH |
| 链接时 undefined reference | 库未链接或顺序错误 | 检查 target_link_libraries,调整库顺序 |
| protoc 运行时报库缺失 | 动态库路径未配置 | 执行 ldconfig 或设置 LD_LIBRARY_PATH |
| 生成代码版本不匹配 | protoc 与库版本不一致 | 统一 protoc 和 libprotobuf 版本 |
| 客户端连接失败 | 服务端未启动或地址错误 | 检查端口监听,确认连接地址 |
| RPC 返回 UNIMPLEMENTED | 方法未注册或签名不匹配 | 检查 RegisterService 和方法签名 |
| 运行时 pure virtual 崩溃 | 服务对象生命周期问题 | 确保服务对象存活到服务结束 |
避坑技巧:在 CMake 里加
set(CMAKE_EXPORT_COMPILE_COMMANDS ON),生成compile_commands.json,配合编辑器的语言服务器可以快速定位头文件路径问题。另外,编译时加VERBOSE=1可以看到完整的编译和链接命令,排查链接问题时非常有用。
6. 工程化扩展与性能调优建议
6.1 多 proto 文件的管理策略
实际项目里不可能只有一个 proto 文件。当 proto 文件多起来之后,建议按业务模块分目录,每个模块一个子目录,CMake 里用file(GLOB_RECURSE)收集所有 proto 文件,然后循环生成。但要注意file(GLOB)在 CMake 里不推荐用于源文件收集,因为新增文件时不会自动触发重新配置。更稳妥的做法是手动列出 proto 文件,或者用CONFIGURE_DEPENDS选项。
生成代码的库也可以按模块拆分,每个模块一个静态库,上层按需链接。这样修改某个模块的 proto 时,只需要重新编译该模块,不用全量重编。
6.2 编译速度优化
gRPC 项目的编译速度是个痛点。几个优化方向:第一,用ccache,它能缓存编译结果,重复编译时大幅提速。在 CMake 里设置CMAKE_CXX_COMPILER_LAUNCHER=ccache即可启用。第二,用ninja替代make,并行编译效率更高。第三,把不常变动的 proto 生成代码预编译成静态库,避免每次全量编译。
如果团队有条件,可以搭建分布式编译环境,但这属于进阶话题,个人项目用 ccache 加 ninja 已经够用了。
6.3 生产环境的注意事项
生产环境用 gRPC,有几个点需要特别注意。首先是安全凭证,开发阶段用InsecureServerCredentials没问题,但生产环境必须用 TLS。gRPC 提供了SslServerCredentials和SslChannelCredentials,配置好证书和密钥即可。
其次是超时和重试策略。gRPC 默认没有超时,一个卡住的 RPC 可能永远不返回。建议在客户端设置 deadline,比如context.set_deadline(std::chrono::system_clock::now() + std::chrono::seconds(5))。重试策略可以通过服务配置来设置,但要注意幂等性,非幂等的方法不要盲目重试。
最后是监控和日志。gRPC 提供了拦截器机制,可以在拦截器里统一处理日志、监控指标、链路追踪等横切关注点。建议在项目初期就把拦截器框架搭好,后续加功能会方便很多。
我在实际项目里遇到过一次因为没设 deadline 导致线程池被耗尽的事故,后来所有 RPC 调用都强制要求设超时。这个教训分享出来,希望大家引以为戒。gRPC 的 C++ 生态虽然门槛高,但一旦跑通,后续的开发和维护效率是很高的,值得花时间把基础设施搭好。