从零手写gRPC C++项目:proto生成、CMake构建与编译实战

📅 发布时间:2026/10/12 20:18:36
从零手写gRPC C++项目:proto生成、CMake构建与编译实战
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 \ --pluginprotoc-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 项目强烈建议用 CMakeC 的构建系统选择很多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} --pluginprotoc-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.ccCMake 可能认不出这是同一个文件导致生成命令不执行编译时报找不到文件。所以路径写法要统一我上面统一用了${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_ptrgrpc::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是否包含 generatedundefined reference to grpc::...没链接 gRPC 库确认target_link_libraries里有gRPC::grpcprotoc-gen-grpc: program not foundgrpc 插件没装或路径不对用which grpc_cpp_plugin确认重新安装 gRPCThis file was generated by an older version of protocprotoc 版本和运行库版本不一致统一 protoc 和 libprotobuf 的版本cannot find -lgrpc库路径没配置检查 gRPC 安装路径必要时设置CMAKE_PREFIX_PATH6.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} --pluginprotoc-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 项目编译慢是出了名的尤其是生成代码那部分。几个优化思路第一把生成代码编译成静态库前面已经这么做了服务端和客户端共享避免重复编译。第二开启 ccacheCMake 里配置CMAKE_CXX_COMPILER_LAUNCHERccache第二次编译能快很多。第三用 Ninja 替代 Makecmake -G Ninja ..Ninja 的并行调度比 Make 更高效。第四减少不必要的头文件包含生成的头文件很大能前置声明就前置声明。我自己实测下来一个中等规模的 gRPC C 项目用上 ccache 和 Ninja 之后增量编译时间能从几分钟降到几十秒。这个提升在频繁改代码的时候非常明显。7.4 关于测试与调试的补充gRPC C 的调试比普通 C 程序麻烦因为涉及网络通信。我的经验是先用同步 API 把逻辑跑通再考虑异步。同步 API 代码直观出问题好定位。异步 API 性能好但复杂度高新手容易写出死锁或者回调地狱。调试的时候可以在服务端方法入口和出口打日志客户端调用前后也打日志这样能快速判断问题出在哪一侧。如果连接都建立不起来检查端口和防火墙如果连接建立但调用失败检查 proto 定义是否一致如果调用成功但数据不对检查序列化相关的字段编号。另外gRPC 自带一个反射功能配合一些通用工具可以在不依赖 proto 文件的情况下调试接口。这个在排查线上问题时很有用但配置稍微复杂建议先把基础调试手段用熟再说。整个项目从 proto 到可执行文件核心就是理解生成和链接这两件事。生成靠 protoc 加插件链接靠 CMake 的 target 机制。把这两条线理顺剩下的就是业务逻辑的事了。我个人的体会是第一次搭的时候慢一点没关系把每个参数、每个依赖都搞清楚后面再遇到类似项目基本就是复制粘贴改改名字的功夫。