ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

gRPC C++项目生成与编译实战:从环境搭建到性能调优

gRPC C++项目生成与编译实战:从环境搭建到性能调优

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++ 生态虽然门槛高,但一旦跑通,后续的开发和维护效率是很高的,值得花时间把基础设施搭好。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进