これは株式会社POL テックカレンダー 2021 17日目の記事です。
株式会社POLでエンジニアをしている高橋です。
昨日の記事は @w40141 さんによるRustとCI/CDの記事でした。
おかわりで何を書くか悩みましたが、社内でRustの採用を推進している立場なので、今回はRustのgRPCサーバーを作成して別のWebサーバーからgRPCサーバーを呼び出すコードのサンプルを作って、以下で解説してみようと思います。
コードはに公開しているので、コードだけ見たい方はこちらから。tonic, actix-webを利用しています。
プロジェクトの構成
├── Cargo.lock
├── Cargo.toml
├── client
│ ├── Cargo.toml
│ ├── build.rs
│ ├── src
├── proto
│ ├── fuga
│ ├── hoge
│ └── service.proto
├── server
│ ├── Cargo.toml
│ ├── build.rs
│ ├── src
gRPCサーバー(以下server)とgRPCを呼び出すサーバー(以下client)をcargoのワークスペースで管理するようにしてます。
protoファイルを元にserver, clientのコード生成をしたいので、protoディレクトリをclient, serverと同じ階層に作成し、その中にprotoファイルを配置しています。
protoファイルによるメッセージ、サービス定義
protoファイルは肥大化しないようにファイル分割して作成しています。(hogeディレクトリ、fugaディレクトリ配下にメッセージをそれぞれ定義しています、実際の開発ではドメインで分割してあげると良いのかなあと思いながら適当な粒度になるようにファイル分割してます)
// proto/fuga/message.proto
syntax = "proto3";
package fuga;
enum Fuga {
FUGA_UNSPECIFIED = 0;
FUGA_FOO = 1;
FUGA_BAR = 2;
FUGA_BAZ = 3;
}
// proto/hoge/message.proto
syntax = "proto3";
package hoge;
import "fuga/message.proto";
message Hoge {
string id = 1;
string name = 2;
fuga.Fuga fuga = 3;
}
message GetHogeRequest {
string id = 1;
}
// proto/service.proto
// サービスファイルで各メッセージファイルの定義をインポートし、サービスを定義する
syntax = "proto3";
package sample_service;
import "google/protobuf/empty.proto";
import "hoge/message.proto";
service SampleService {
rpc GetHoge(hoge.GetHogeRequest) returns (hoge.Hoge) {}
}
server側のクレート
上記で作成したprotoファイルを元にgRPCサーバーのコードを生成します。
主要なRustのgRPCサーバーのクレートとして、grpc-rs、grpc-rust、tonicがありますが、今回はtonicを利用しています。 (star数やIssueのメンテナンス度合いを比較した程度で採用してますが、async, awaitで書けるので特に不満なく使えています。サンプルも多く存在しているので、簡易な実装についても困ることはあまりなかったです)
tonicで必要なクレート(prost
, tokio
) + ロギング系(log
, env_logger
) + 便利クレート(getset
)辺りをdependency
に追加し、tonic-build
を build-dependencies
に追加しています。
# server/Cargo.toml
[package]
name = "sample-server"
version = "0.1.0"
edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
dotenv = "0.15.0"
log = "0.4.0"
env_logger = "0.8.4"
tonic = "0.6"
prost = "0.9"
tokio = { version = "1.0", features = ["macros", "rt-multi-thread"] }
getset = "0.1.2"
[build-dependencies]
tonic-build = "0.6"
tonicによるコード生成
tonic_build
クレートを使って、protoファイルからRustのgRPCのコードを自動生成します。
build.rs
に以下のような生成処理を書くとout_dir
で指定した配下にコード生成されます。
※サーバーのコードなので、オプションで build_server
をtrue, build_client
をfalseしてサーバー側のコードだけ生成するようにしてます。
// server/build.rs
fn main() -> Result<(), Box<dyn std::error::Error>> {
tonic_build::configure()
.include_file("mod.rs")
.build_client(false)
.build_server(true)
.out_dir("src/generated")
.compile(&["../proto/service.proto"], &["../proto"])?;
Ok(())
}
serverの実装
コード生成で作成されたtraitを実装し、main.rsで起動してgRPCサーバーとして起動するコードが完成します(結構少ないコードで動くものが作れちゃいます)。
※今回はサンプルなのでid=1〜4を指定すると、値を取得できるような実装をしています。取得できない場合はwarningログを出力するようにしてます(エラーハンドリングはまだ試行錯誤中です…)
// server/lib.rs
use crate::generated::sample_service::sample_service_server::{SampleService, SampleServiceServer};
use crate::generated::{fuga::Fuga, hoge::GetHogeRequest, hoge::Hoge};
use std::collections::HashMap;
use tonic::{Code, Request, Response, Status};
mod generated;
pub struct Service;
#[tonic::async_trait]
impl SampleService for Service {
async fn get_hoge(&self, request: Request<GetHogeRequest>) -> Result<Response<Hoge>, Status> {
let hoges: HashMap<&str, (&str, Fuga)> = HashMap::from([
("1", ("hoge1", Fuga::Foo)),
("2", ("hoge2", Fuga::Bar)),
("3", ("hoge3", Fuga::Baz)),
("4", ("hoge4", Fuga::Unspecified)),
]);
let id: &str = &request.get_ref().id;
let hoge = hoges.get(id);
match hoge {
Some(r) => Ok(Response::new(Hoge {
id: id.to_string(),
name: r.0.to_string(),
fuga: r.1.clone() as i32,
})),
None => {
log::warn!("cannot find {}", id);
Err(Status::new(Code::NotFound, "hoge is not found."))
}
}
}
}
pub fn create_server() -> SampleServiceServer<Service> {
let service = Service {};
SampleServiceServer::new(service)
}
// server/main.rs
extern crate sample_server;
use dotenv::dotenv;
use std::env;
use tonic::transport::Server;
use sample_server::create_server;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
dotenv().ok();
env_logger::init();
let addr = env::var("GRPC_SERVER_ADDRESS")
.expect("Can't get service address")
.parse()
.unwrap();
log::info!("server listening on: {}", addr);
let svc = create_server();
Server::builder().add_service(svc).serve(addr).await?;
Ok(())
}
client側のクレート
server側と同様にprotoファイルを元にgRPCサーバーのコードを生成します。
実際の開発の構成と近い構成のサンプルにしたかったので、actix-webでWebサーバーを立ち上げて、リクエストのidに対応するデータをレスポンスで返却するような構成にしています。(toincがhyperコミュニティによって開発されているので、サンプルではhyperを使えばよかったと後で後悔しました…)
tonicでは非同期処理にtokioを利用しており、server, clientどちらでもtokioによるサーバー起動が必要です。actix-webではactix-rtを利用することでtokioベースでのサーバー起動ができるので、今回はactix-rtを使っています。
※tokioの0.2系と1.0系で仕様が大きく変わったのか、tonicで利用するtokioのバージョンとactix-rtで利用するtokioのバージョンは0.2系か1.0系のどちらかに合わせる必要があります。actix-rtの2.x系でtokioの1.0系に対応されているようなので、そちらのバージョンを使います(バージョン不一致によるエラーとビルドエラーが今回のサンプルで一番苦労しました…)
// client/Cargo.toml
[package]
name = "sample-client"
version = "0.1.0"
edition = "2021"
[dependencies]
dotenv = "0.15.0"
log = "0.4.0"
env_logger = "0.8.4"
serde = "1.0.130"
serde_derive = "1.0.130"
actix-web = { version = "^4.0.0-beta.11", default-features = false, features = ["openssl"] }
actix-rt = {version = "2.3.0"}
tonic = {version="0.6", features=["tls-roots"]}
prost = "0.9"
tokio = {version = "1.0", features = ["time"]}
[build-dependencies]
tonic-build = "0.6"
tonicによるコード生成
serverと同じ様に build.rs
を実装してgRPCを呼び出しするクライアントのコードを生成します
// client/build.rs
fn main() -> Result<(), Box<dyn std::error::Error>> {
tonic_build::configure()
.include_file("mod.rs")
.build_client(true)
.build_server(false)
.out_dir("src/generated")
.compile(&["../proto/service.proto"], &["../proto"])?;
Ok(())
}
actix-webで簡単なサーバーの実装
クエリパラメータから取得したいidの値を解決し、gRPCサーバーに問い合わせする簡易コードを実装します。
// client/lib.rs
use std::env;
use actix_web::web::Query;
use serde::Deserialize;
use tonic::Code;
use generated::hoge::GetHogeRequest;
use generated::sample_service::sample_service_client::SampleServiceClient;
mod generated;
#[derive(Deserialize)]
pub struct Info {
id: String,
}
pub async fn index(info: Query<Info>) -> String {
let id = &info.id.clone();
let url = env::var("GRPC_SERVER_ADDRESS").expect("cannot get env");
let mut client = SampleServiceClient::connect::<String>(url).await.unwrap();
let result = client.get_hoge(GetHogeRequest { id: id.clone() }).await;
match result {
Ok(r) => {
format!("id={} hoge is: {}", id, r.get_ref().name)
}
Err(status) => match status.code() {
Code::NotFound => {
log::debug!("hoge is not found: id={}", id);
format!("hoge is notfound: id={}", id)
}
_ => {
log::error!("get hoge error from grpc server. message:{}", &status);
format!("get hoge error id={}, error={}", id, &status)
}
},
}
}
// client/main.rs
extern crate sample_client;
use std::env;
use actix_web::{web, App, HttpServer};
use dotenv::dotenv;
use sample_client::index;
#[actix_rt::main]
async fn main() -> std::io::Result<()> {
dotenv().ok();
env_logger::init();
let address = env::var("SERVICE_ADDRESS").expect("Can't get service address");
log::info!("server listening on: {}", address);
HttpServer::new(move || App::new().service(web::resource("/").to(index)))
.bind(address)?
.run()
.await
}
起動
cargo run
でserver, clientを起動してhttp://localhost/?id=1 にアクセスすると、gRPCサーバーにデータを問い合わせてレスポンスを返却してくれるサーバーが起動します。
終わり
以上、RustのgRPCサーバーを作成し、別のWebサーバーからgRPCサーバーを呼び出すコードのサンプルでした。
明日の記事は @yiwi さんです。よろしくお願いします。