在 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 服务的接口清晰、易于维护,同时为未来的扩展做好准备。