📝

RustでgRPCを実装するサンプルを書いてみた

2023/08/15に公開

はじめに

RustでgRPCを使用したサービスの実装をしてみたいと思い最初のとっかかりとしての簡単なサンプルを備忘録として残しておきます。なお著者はRustもgRPCもどちらも初心者です。最低限、自分が理解するための解説は入れていくようにします。

今回作成したソースはこちら。

https://github.com/kengo-k/rust-grpc-example

事前準備

実装を始める前に必要となる各種ツールをインストールします

protocをインストールする

protocは.protoファイルから各種プログラミング言語向けのソースコードを生成するためのコンパイラでコイツがなければ始まりません。今回protocコマンドを直接使うことはありません。後述するRustコードのビルド時に間接的に使われれます。

今回は(私の個人的な好みの問題で)asdfを使ってインストールを行いますが他の方法でインストールを行なっても全く問題はありません(例えばMacOSではhomebrewが使えます)。

下記のコマンドでprotocのインストールを行います。

$ asdf plugin add protoc
$ asdf list all protoc # インストール可能なprotocのバージョンをチェックする
$ asdf install protoc 3.20.3
$ asdf local protoc 3.20.3

grpcurlをインストールする

grpcurlはその名のとおりcurlのgrpc版です。起動したgRPCサーバの動作確認に使うクライアントです。grpcurlはCUIのツールですがBloomRPCなどのGUIもあるのでお好みでどうぞ。

$ asdf plugin add grpcurl
$ asdf list all grpcurl
$ asdf install grpcurl 1.8.7
$ asdf local grpcurl 1.8.7

実装する

新規プロジェクトを作成する

まずはcargoコマンドで新規プロジェクトを作成します。

$ cargo new rust_grpc_example

作成されたディレクトリに移動し、依存ライブラリをインストールします。

$ cd rust_grpc_example
$ cargo add tonic
$ cargo add prost
$ cargo add tokio --features=full
$ cargo add tonic-build --build
$ cargo add tonic-reflection

インストールが終了するとCargo.tomlファイルに依存ライブラリの記述が追加されます。使用したライブラリバージョンを記録する意味でCargo.tomlの内容もここに残しておきます。

[dependencies]
prost = "0.11.9"
tokio = { version = "1.31.0", features = ["full"] }
tonic = "0.9.2"
tonic-reflection = "0.9.2"

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

5つのライブラリをインストールしました。それぞれのライブラリの役割は次のとおりです。

  • tonic: RustでgRPCを実装するためのフレームワークです。
  • prost: Rust用のプロトコルバッファのライブラリです。tonicから利用されます。
  • tokio: Rustの非同期プログラミングのためのライブラリです。tonicから利用されます。
  • tonic-build: .protoファイルからRustのコードを生成するジェネレータです。
  • tonic-reflection: grpcurlからサーバにアクセスする際に必要です。

以上で必要なライブラリのインストールは完了です。

gRPCの定義を作成する

プロジェクトルート下にproto/helloworld/helloworld.protoを以下の内容で作成します。

syntax = "proto3";

package hello;

service HelloService {
    rpc SayHello (HelloRequest) returns (HelloResponse);
}

message HelloRequest {
    string name = 1;
}

message HelloResponse {
    string message = 1;
}

今回は「gRPCが動作すること」を目的としているので.protoファイルの記法などについて細かく触れるつもりはないので内容については割愛します。まあ、見れば雰囲気的にわかるでしょ程度の内容なので問題はないですよね?

build.rsを作成する

プロジェクトルート下に下記の内容でbuild.rsファイルを作成します。

use std::env;
use std::path::PathBuf;

fn main() -> Result<(), Box<dyn std::error::Error>> {
    tonic_build::configure()
        .build_server(true)
        .file_descriptor_set_path(
            PathBuf::from(env::var("OUT_DIR").expect("OUT_DIR is not set"))
                .join("hello_descriptor.bin"),
        )
        .compile(
            &["proto/helloworld/helloworld.proto"],
            &["proto/helloworld"],
        )?;
    Ok(())
}

build.rsはcargoが持つ仕組みです。cargo buildの実行時、このファイルが存在すると自動的に実行されます。ここではRustのビルド実行時に.protoをコンパイルして生成したRustのソースコードを規定の場所に出力するために使われています。なおRustコードの生成には、最初にインストールしたprotocを内部で使っています。

通常はtarget/debug/build/<プロジェクト名>-英数字の羅列/outのようなディレクトリに出力されているはずです。出力されるRustのソースファイル名は<.protoファイルのpackage名>.rsになっているはずです。つまり、今回の場合はhello.rsになります。

hello.rsの中身は以下のような感じになってます(長いので一部抜粋のみ)。

#[allow(clippy::derive_partial_eq_without_eq)]
#[derive(Clone, PartialEq, ::prost::Message)]
pub struct HelloRequest {
    #[prost(string, tag = "1")]
    pub name: ::prost::alloc::string::String,
}

#[allow(clippy::derive_partial_eq_without_eq)]
#[derive(Clone, PartialEq, ::prost::Message)]
pub struct HelloResponse {
    #[prost(string, tag = "1")]
    pub message: ::prost::alloc::string::String,
}
(...以下省略...)

リクエストやレスポンスの型がRustのstructで生成されていることがわかりますね(個々のアトリビュートの意味などはいちいち気にしない)。その他にService定義のためのトレイトなども生成されています。

build.rsの内容をもう少し詳しくみていきます。まずはこちらです。

.build_client(false)
.build_server(true)

今回はサーバ側のコードしか使用しないためbuild_client(false)を実行しクライアントコードの生成を無効にしています。続いてこちら。

.file_descriptor_set_path(
    PathBuf::from(env::var("OUT_DIR").expect("OUT_DIR is not set"))
        .join("hello_descriptor.bin"),
)

ここに出てくるfile_descriptorとはgRPCのメタ情報のことを意味します。ここではfile_descriptorを出力するパスを設定しています。この処理が必要になる理由は、この後の動作確認フェーズにてgrpcurlコマンドでメソッドの一覧をリストする際にメタ情報が必要になるためです(依存ライブラリでインストールしたtonic-reflectionが使用されています)。

最後がこちらです。

.compile(
    &["proto/helloworld/helloworld.proto"],
    &["proto/helloworld"],
)?;

compileに二つの引数を渡しています。最初の引数はコンパイル対象の.protoファイルのパスです。二番目の引数は.protoファイル内でimportを使用した際に参照するパスです。今回はimportを使用していませんので特に意味はないです。

main.rsを作成する

src/main.rsを以下の内容で作成します。

mod hello {
    tonic::include_proto!("hello");
}

use hello::{
    hello_service_server::{HelloService, HelloServiceServer},
    HelloRequest, HelloResponse,
};
use tonic::{Request, Response, Status};
use tonic_reflection::server::Builder;

pub struct MyHelloService {}

#[tonic::async_trait]
impl HelloService for MyHelloService {
    async fn say_hello(
        &self,
        request: Request<HelloRequest>,
    ) -> Result<Response<HelloResponse>, Status> {
        let res = HelloResponse {
            message: format!("Hello, {}!", request.into_inner().name),
        };
        Ok(Response::new(res))
    }
}

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let addr = "127.0.0.1:50051".parse()?;
    let hello_service = MyHelloService {};

    tonic::transport::Server::builder()
        .add_service(HelloServiceServer::new(hello_service))
        .add_service(
            Builder::configure()
                .register_encoded_file_descriptor_set(tonic::include_file_descriptor_set!(
                    "hello_descriptor"
                ))
                .build()
                .unwrap(),
        )
        .serve(addr)
        .await?;

    Ok(())
}

以下、ピンポイントで解説を入れていきます。

mod hello {
    tonic::include_proto!("hello");
}

build.rsが生成したRustコードをinclude_proto!マクロを使用して取り込んでいます。引数で指定しているのは.protoのパッケージ名です。

pub struct MyHelloService {}

#[tonic::async_trait]
impl HelloService for MyHelloService {
    async fn say_hello(
        &self,
        request: Request<HelloRequest>,
    ) -> Result<Response<HelloResponse>, Status> {
        let res = HelloResponse {
            message: format!("Hello, {}!", request.into_inner().name),
        };
        Ok(Response::new(res))
    }
}

HelloServiceは.protoで定義されたメソッドが定義されたトレイトでbuild.rsにより自動生成されたソースです。新しいstructであるMyHelloServiceを定義し、implでHelloServiceを実装します。

残りはmain関数ですが、ここでやっているのはHelloServiceを実装したstructを登録してサーバを起動しているだけです。下記の部分については、

.add_service(
    Builder::configure()
        .register_encoded_file_descriptor_set(tonic::include_file_descriptor_set!(
            "hello_descriptor"
        ))
        .build()
        .unwrap(),
)

リフレクションを有効にするための処理となります。build.rsで生成したfile_descriptorを指定しています。

動作確認

実装が完了したので動作確認をしていきます。まずはビルドしてサーバを起動します。

$ cargo build
$ cargo run

ビルドと起動が正常に終了すればOKです。grpcurlを使ってアクセスしてみましょう。まずはサービス名の一覧をリスト表示してみます。

$ grpcurl -plaintext localhost:50051 list 

grpc.reflection.v1alpha.ServerReflection
hello.HelloService

hello.HelloServiceが定義されていることがわかりました。ではこのサービスに定義されているメソッドをリスト表示してみます。

$ grpcurl -plaintext localhost:50051 list hello.HelloService
hello.HelloService.SayHello

SayHelloメソッドが定義されていることがわかりました。ではSayHelloを実行してみます。

$ grpcurl -plaintext -d '{"name": "World"}' localhost:50051 hello.HelloService/SayHello
{
  "message": "Hello, World!"
}

正常に実行されていることが確認できました。

Discussion