在 Rust 中构建 gRPC 服务端与客户端


文章概述

  • 标题:在 Rust 中构建 gRPC 服务端与客户端(使用自定义 tonic_build 配置)
  • 作者:Rust 技术教练
  • 发布日期:2024 年 11 月 25 日
  • 链接地址:待发布
  • 摘要:本教程将指导你使用 tonic_build::configure 自定义生成 gRPC 的 Rust 代码,并完成服务端和客户端的实现,探索如何灵活配置生成路径及相关功能。

技术背景与原理

  • 技术背景

    • gRPC 和 Protocol Buffers 是构建分布式系统的标准技术栈。
    • Rust 的异步编程模型(基于 tokioasync/await)非常适合 gRPC 的非阻塞通信需求。
  • 关键技术或概念

    1. **自定义 tonic_build**:通过 tonic_build::configure,可以指定生成代码的路径和是否生成服务端/客户端代码。
    2. 自定义输出目录:可以将生成的 .rs 文件放在项目的特定目录中(如 src/proto),便于管理。

详细步骤

1. 环境准备

1
2
3
4
5
6

wget $protoc_release_url -o protoc_zip
mkdir -p /opt/software/protoc_dir
cd /opt/software/protoc_dir
sudo ln -s $PWD/bin/protoc /usr/local/bin/protoc
sudo ln -s $PWD/include/* /usr/local/include/

2. 创建 Rust 项目

1
2
cargo new dam --bin
cd dam

项目结构:

1
2
3
4
5
6
7
8
9
10
grpc_demo/
├── Cargo.toml
├── build.rs
├── proto/
│ └── message.proto
└── src/
├── main.rs
├── server.rs
├── client.rs
└── proto/ # 将生成的 Rust 代码存放在此目录

3. 添加依赖

Cargo.toml 中添加以下内容:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
[package]
name = "dam"
version = "0.1.0"
edition = "2021"

[dependencies]
prost = "0.13.3"
tokio = { version = "1.41.1", features = ["rt-multi-thread", "macros"]}
tonic = { version = "0.12.3", features = ["server", "codegen"] }
prost-types = "0.13.3"
tonic-build = "0.12.3"

[build-dependencies]
tonic-build = "0.12.3"

[[bin]]
name = "server"
path = "src/server.rs"

[[bin]]
name = "client"
path = "src/client.rs"

4. 定义 .proto 文件

proto/message.proto 中定义 gRPC 服务和消息:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
syntax = "proto3";

package message;

service MessageService {
rpc SendMessage (MessageRequest) returns (MessageResponse);
}

message MessageRequest {
string content = 1;
}

message MessageResponse {
string content = 1;
}

5. 配置 build.rs

build.rs 中自定义生成 proto 代码路径和服务端/客户端代码生成选项:

1
2
3
4
5
6
7
8
fn main() -> Result<(), Box<dyn std::error::Error>> {
tonic_build::configure()
.build_server(true) // 生成服务端代码
.build_client(true) // 生成客户端代码
.out_dir("src/proto") // 将生成的文件放到 src/proto
.compile_protos(&["proto/message.proto"], &["proto"])?; // 定义 proto 路径
Ok(())
}

运行以下命令生成代码:

1
cargo build

生成的 message.rs 文件将放在 src/proto/ 中。


6. 实现服务端

src/server.rs 中实现服务端逻辑:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
use tonic::{transport::Server, IntoRequest, Request, Response, Status};
use message::{MessageRequest, MessageResponse};
use message::message_service_server::{MessageService, MessageServiceServer};

pub mod message {
include!("proto/message.rs");
}

#[derive(Debug, Default)]
pub struct MyMessageService;

#[tonic::async_trait]
impl MessageService for MyMessageService {
async fn send_message(
&self,
request: Request<MessageRequest>,
) -> Result<Response<MessageResponse>, Status> {
println!("Got a request: {:?}", request);
let reply = MessageResponse {
content: format!("Echo: {}", request.into_inner().content),
};
Ok(Response::new(reply))
}
}

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let addr = "[::1]:50051".parse().unwrap();
let service = MyMessageService::default();

println!("Server listening on {}", addr);

Server::builder()
.add_service(MessageServiceServer::new(service))
.serve(addr)
.await?;

Ok(())
}


7. 实现客户端

src/client.rs 中实现客户端逻辑:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
use message::{MessageRequest, MessageResponse};
use message::message_service_client::MessageServiceClient;

pub mod message {
//tonic::include_proto!("message");
include!("proto/message.rs");
}

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let mut client = MessageServiceClient::connect("http://[::1]:50051").await?;

let request = tonic::Request::new(MessageRequest {
content: String::from("Hello, server!"),
});

let response = client.send_message(request).await?;

println!("Response: {:?}", response.into_inner());

Ok(())
}

8. 运行服务端和客户端

启动服务端:

1
cargo run --bin server

启动客户端:

1
cargo run --bin client

问题解决与常见问题

  1. 生成代码路径未更新

    • 确保 build.rsout_dir("src/proto") 配置正确。
    • 确保运行 cargo build 以触发代码生成。
  2. **找不到生成的 message.rs**:

    • 确保 src/proto 文件夹存在,或者在运行 cargo build 后检查是否生成代码。
  3. 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
    3
    pub 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
    3
    pub mod voting {
    include!("../protos/voting.rs");
    }

    这里假设 voting.rs 被生成到了项目的 protos/ 目录中。


3. 通过 tonic_build 自定义输出路径

  • tonic_build 提供了 configure 方法,用于修改 .proto 文件编译后的 .rs 文件保存路径。

  • 配置输出路径时,需注意:

    1. 使用 .out_dir("<path>") 指定保存路径。
    2. 若使用非默认路径,则必须使用 include! 宏来导入生成的 .rs 文件。
  • 示例代码:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    use 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
    3
    pub mod voting {
    tonic::include_proto!("voting");
    }
  • 自定义路径(使用 include!):

    1
    2
    3
    pub mod voting {
    include!("../protos/voting.rs");
    }

6. 参考文档

思路

定义 gRPC 服务的 .proto 文件

.proto 文件是 gRPC 的基础,包含以下关键部分:

  1. 文件的语法版本:声明使用的 Protobuf 版本。
  2. 命名空间(package):定义服务和消息的命名空间。
  3. 服务定义:描述 gRPC 服务及其方法。
  4. 消息结构:定义请求和响应的数据格式。

示例 .proto 文件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
syntax = "proto3";  // 声明 Protobuf 使用的版本

package message; // 定义命名空间,用于生成代码时确定模块路径

service MessageService { // 定义 gRPC 服务
rpc SendMessage (MessageRequest) returns (MessageResponse); // 定义方法
}

message MessageRequest { // 定义请求消息
string content = 1; // 消息字段,类型为 string,字段编号为 1
}

message MessageResponse { // 定义响应消息
string content = 1; // 消息字段,类型为 string,字段编号为 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
      4
      message Example {
      reserved 3, 5 to 7; // 废弃编号 3 和 5 到 7
      reserved "old_field"; // 废弃字段名称
      }

注意事项

1. 字段编号的重要性

  • 字段编号是 Protobuf 的核心,用于序列化和反序列化。

  • 规则

    • 编号在同一 message 中必须唯一。
    • 建议为关键字段使用连续编号,为可选字段预留编号。
  • 示例

    1
    2
    3
    4
    5
    message MessageRequest {
    string content = 1;
    int32 priority = 2;
    bool is_urgent = 3; // 为新字段预留编号
    }

2. 类型选择

  • 使用 Protobuf 提供的基本类型(如 int32, string, bool)。

  • 如果字段可以为空,建议使用 optional 声明:

    1
    2
    3
    message 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
        8
        syntax = "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
        11
        syntax = "proto3";

        package message;

        message MessageRequest {
        string content = 1;
        }

        message MessageResponse {
        string content = 1;
        }

5. 服务扩展与版本控制

  • 通过新增方法扩展服务。
  • 避免直接修改已有字段的类型或编号,确保兼容性。

总结

定义 .proto 文件时,需要明确以下结构:

  1. 服务接口
    • 每个服务的职责和方法。
    • 方法的输入/输出类型。
  2. 消息结构
    • 每条消息需要包含哪些字段。
    • 字段的类型和编号是否合理。
  3. 命名空间
    • 使用 package 明确模块路径,避免冲突。
  4. 扩展性
    • 为未来的扩展预留字段编号。
    • 使用 import 管理复杂的服务和消息。

通过合理设计 .proto 文件,可以确保 gRPC 服务的接口清晰、易于维护,同时为未来的扩展做好准备。


在 Rust 中构建 gRPC 服务端与客户端
https://abrance.github.io/2024/11/25/mdstorage/domain/rust/在 Rust 中构建 gRPC 服务端与客户端/
Author
xiaoy
Posted on
November 25, 2024
Licensed under