在 Rust 中构建 gRPC 服务端与客户端
文章概述
- 标题:在 Rust 中构建 gRPC 服务端与客户端(使用自定义
tonic_build配置) - 作者:Rust 技术教练
- 发布日期:2024 年 11 月 25 日
- 链接地址:待发布
- 摘要:本教程将指导你使用
tonic_build::configure自定义生成 gRPC 的 Rust 代码,并完成服务端和客户端的实现,探索如何灵活配置生成路径及相关功能。
技术背景与原理
技术背景:
- gRPC 和 Protocol Buffers 是构建分布式系统的标准技术栈。
- Rust 的异步编程模型(基于
tokio和async/await)非常适合 gRPC 的非阻塞通信需求。
关键技术或概念:
- **自定义
tonic_build**:通过tonic_build::configure,可以指定生成代码的路径和是否生成服务端/客户端代码。 - 自定义输出目录:可以将生成的
.rs文件放在项目的特定目录中(如src/proto),便于管理。
- **自定义
详细步骤
1. 环境准备
- 安装
protoc
protoc release 页面
1 | |
2. 创建 Rust 项目
1 | |
项目结构:
1 | |
3. 添加依赖
在 Cargo.toml 中添加以下内容:
1 | |
4. 定义 .proto 文件
在 proto/message.proto 中定义 gRPC 服务和消息:
1 | |
5. 配置 build.rs
在 build.rs 中自定义生成 proto 代码路径和服务端/客户端代码生成选项:
1 | |
运行以下命令生成代码:
1 | |
生成的 message.rs 文件将放在 src/proto/ 中。
6. 实现服务端
在 src/server.rs 中实现服务端逻辑:
1 | |
7. 实现客户端
在 src/client.rs 中实现客户端逻辑:
1 | |
8. 运行服务端和客户端
启动服务端:
1 | |
启动客户端:
1 | |
问题解决与常见问题
生成代码路径未更新:
- 确保
build.rs中out_dir("src/proto")配置正确。 - 确保运行
cargo build以触发代码生成。
- 确保
**找不到生成的
message.rs**:- 确保
src/proto文件夹存在,或者在运行cargo build后检查是否生成代码。
- 确保
protoc未安装或路径错误:- 确保系统正确安装
protoc,并将其路径加入PATH。
- 确保系统正确安装
附加资源
知识点
1. tonic::include_proto! 的默认行为
默认情况下,
tonic_build在编译.proto文件后生成的.rs文件会保存在OUT_DIR环境变量指定的目录中。如果没有显式设置
OUT_DIR环境变量,则该目录通常位于 Cargo 的构建输出目录。例如:1
<target_dir>/debug/build/<crate_name>-<hash>/out/<proto_package_name>.rs示例路径:
1
target/debug/build/proto_usage-4e6f1dc6a899518f/out/voting.rs在这种默认情况下,可以使用
tonic::include_proto!宏直接导入生成的.rs文件。例如:1
2
3pub mod voting {
tonic::include_proto!("voting");
}其中
"voting"对应.proto文件中定义的package voting;。
2. 修改 OUT_DIR 环境变量的影响
如果修改了
OUT_DIR环境变量的值,或在tonic_build配置中指定了输出路径,则不能再使用tonic::include_proto!导入.rs文件。在这种情况下,需要使用 Rust 提供的
include!宏明确指定.rs文件的路径。例如:1
2
3pub mod voting {
include!("../protos/voting.rs");
}这里假设
voting.rs被生成到了项目的protos/目录中。
3. 通过 tonic_build 自定义输出路径
tonic_build提供了configure方法,用于修改.proto文件编译后的.rs文件保存路径。配置输出路径时,需注意:
- 使用
.out_dir("<path>")指定保存路径。 - 若使用非默认路径,则必须使用
include!宏来导入生成的.rs文件。
- 使用
示例代码:
1
2
3
4
5
6
7
8
9
10use std::io::Result;
fn main() -> Result<()> {
tonic_build::configure()
.build_server(true) // 生成服务端代码
.build_client(true) // 生成客户端代码
.out_dir("protos") // 将编译后的 .rs 文件保存到项目根目录的 protos/ 目录
.compile(&["protos/voting.proto"], &["protos"])?; // 编译 .proto 文件
Ok(())
}
4. tonic_build 配置详解
build_server(true):是否生成服务端的代码(实现服务端逻辑所需的接口)。build_client(true):是否生成客户端的代码(调用服务端所需的客户端接口)。.out_dir("<path>"):自定义输出目录,生成的.rs文件会被存放在指定路径中。.compile(<proto_files>, <proto_include_paths>):<proto_files>:需要编译的.proto文件路径列表。<proto_include_paths>:用于提供 Protobuf 的扩展功能路径列表。如果没有额外扩展,直接使用.proto文件所在目录即可。
5. 导入生成的 .rs 文件
默认路径(使用
OUT_DIR):1
2
3pub mod voting {
tonic::include_proto!("voting");
}自定义路径(使用
include!):1
2
3pub mod voting {
include!("../protos/voting.rs");
}
6. 参考文档
思路
定义 gRPC 服务的 .proto 文件
.proto 文件是 gRPC 的基础,包含以下关键部分:
- 文件的语法版本:声明使用的 Protobuf 版本。
- 命名空间(package):定义服务和消息的命名空间。
- 服务定义:描述 gRPC 服务及其方法。
- 消息结构:定义请求和响应的数据格式。
示例 .proto 文件:
1 | |
定义 .proto 文件的主要思路
1. 确定服务的职责
- 思考:服务需要完成哪些功能?
- 每个功能都可以抽象为一个
rpc方法。 - 例如:
MessageService的职责是处理消息通信,其中一个方法是SendMessage。
- 每个功能都可以抽象为一个
- 实践:
- 为服务定义一个清晰的名称,如
MessageService。 - 服务可以包含一个或多个方法,每个方法表示一项功能。
- 为服务定义一个清晰的名称,如
2. 设计消息传递的接口
- 方法设计原则:
方法签名遵循以下格式:
1
rpc MethodName (RequestType) returns (ResponseType);每个方法包含:
- 方法名称:如
SendMessage。 - 请求消息类型:如
MessageRequest。 - 响应消息类型:如
MessageResponse。
- 方法名称:如
- 实践:
- 确定方法的输入和输出类型。
- 考虑是否需要扩展(如双向流式通信)。
3. 定义消息结构
- 思考:通信双方需要交换哪些数据?
- 每条消息的数据结构应尽量简单、易扩展。
- 使用明确的字段类型和编号。
- 实践:
- 使用
message关键字定义消息。 - 每个字段需要:
- 类型:如
string,int32,bool。 - 名称:如
content。 - 编号:字段编号在同一消息中必须唯一,通常从 1 开始。
- 类型:如
- 使用
4. 关注扩展性
- 思考:如何应对未来需求的变化?
- 为可能扩展的字段预留字段编号。
- 使用枚举、嵌套消息等方式提高可扩展性。
- 实践:
在定义中保留一定的编号间隔,以便未来添加字段。
使用
reserved关键字标记废弃的字段编号或名称:1
2
3
4message Example {
reserved 3, 5 to 7; // 废弃编号 3 和 5 到 7
reserved "old_field"; // 废弃字段名称
}
注意事项
1. 字段编号的重要性
字段编号是 Protobuf 的核心,用于序列化和反序列化。
规则:
- 编号在同一
message中必须唯一。 - 建议为关键字段使用连续编号,为可选字段预留编号。
- 编号在同一
示例:
1
2
3
4
5message MessageRequest {
string content = 1;
int32 priority = 2;
bool is_urgent = 3; // 为新字段预留编号
}
2. 类型选择
使用 Protobuf 提供的基本类型(如
int32,string,bool)。如果字段可以为空,建议使用
optional声明:1
2
3message Example {
optional string description = 1;
}
3. 命名空间的作用
package定义模块路径,避免命名冲突。- 生成的代码中,
package会映射到 Rust 的模块路径。 - 示例:
.proto文件:1
package message;Rust 模块路径:
1
tonic::include_proto!("message");
4. 多文件组织
- 如果服务复杂,可以将
.proto文件拆分为多个:- 核心服务定义在主文件中。
- 消息和共享类型放在独立文件中,使用
import导入。 - 示例:
主文件:
1
2
3
4
5
6
7
8syntax = "proto3";
package message;
import "common.proto";
service MessageService {
rpc SendMessage (MessageRequest) returns (MessageResponse);
}消息文件(
common.proto):1
2
3
4
5
6
7
8
9
10
11syntax = "proto3";
package message;
message MessageRequest {
string content = 1;
}
message MessageResponse {
string content = 1;
}
5. 服务扩展与版本控制
- 通过新增方法扩展服务。
- 避免直接修改已有字段的类型或编号,确保兼容性。
总结
定义 .proto 文件时,需要明确以下结构:
- 服务接口:
- 每个服务的职责和方法。
- 方法的输入/输出类型。
- 消息结构:
- 每条消息需要包含哪些字段。
- 字段的类型和编号是否合理。
- 命名空间:
- 使用
package明确模块路径,避免冲突。
- 使用
- 扩展性:
- 为未来的扩展预留字段编号。
- 使用
import管理复杂的服务和消息。
通过合理设计 .proto 文件,可以确保 gRPC 服务的接口清晰、易于维护,同时为未来的扩展做好准备。