В статье будет показано как создать gRPC сервер и клиент на Rust. Для большей наглядности клиент будет также Telegram ботом. В итоге будет получена следующая архитектура:

Статья является не всеобъемлющим руководством по gRPC в Rust, а скорее практическим гайдом, демонстрирующим основы и как создать приложение на основе gRPC.
Доменная модель включает данные о планетах Солнечной системы и их спутниках.
Имплементация
Существует несколько реализаций gRPC на Rust. В этом проекте был использован tonic.
Проект включает следующие модули:
- gRPC сервер
- gRPC клиент (также является Telegram ботом)
- общий модуль rpc
Последний модуль содержит определение gRPC сервиса и отвечает за генерацию gRPC кода необходимого и для сервера, и для клиента.
Определение сервиса и генерация кода
Определение сервиса написано на версии proto3 Protocol Buffers и находится в .proto файле:
syntax = "proto3"; package solar_system_info; import "google/protobuf/timestamp.proto"; import "google/protobuf/empty.proto"; service SolarSystemInfo { rpc GetPlanetsList (google.protobuf.Empty) returns (PlanetsListResponse); rpc GetPlanet (PlanetRequest) returns (PlanetResponse); rpc GetPlanets (google.protobuf.Empty) returns (stream PlanetResponse); } message PlanetsListResponse { repeated string list = 1; } message PlanetRequest { string name = 1; } message PlanetResponse { Planet planet = 1; } message Planet { uint64 id = 1; string name = 2; Type type = 3; float meanRadius = 4; float mass = 5; repeated Satellite satellites = 6; bytes image = 7; } enum Type { TERRESTRIAL_PLANET = 0; GAS_GIANT = 1; ICE_GIANT = 2; DWARF_PLANET = 3; } message Satellite { uint64 id = 1; string name = 2; google.protobuf.Timestamp first_spacecraft_landing_date = 3; }
Здесь определены простые (unary) RPC (GetPlanetsList и GetPlanet), server-side streaming RPC (GetPlanets) и структуры для передачи необходимых данных. Структуры содержат поля как некоторых обычных типов (uint64, string, etc.), так и:
- перечисление (
Planet.type) - список (
Planet.satellites) - бинарные данные (
Planet.image) - тип date/timestamp (
Satellite.first_spacecraft_landing_date)
Для настройки генерации серверного и клиентского gRPC кода сначала добавим следующие зависимости:
[package] name = "solar-system-info-rpc" version = "0.1.0" edition = "2018" [dependencies] tonic = "0.4.2" # Rust gRPC implementation prost = "0.7.0" # Rust Protocol Buffers implementation prost-types = "0.7.0" # Contains definitions of Protocol Buffers well-known types [build-dependencies] tonic-build = "0.4.2"
Библиотека prost-types позволяет использовать некоторые из well-known типов Protobuf, такие как Empty и Timestamp.
В корне модуля должно быть расположено следующее:
fn main() -> Result<(), Box<dyn std::error::Error>> { tonic_build::compile_protos("proto/solar-system-info/solar-system-info.proto")?; Ok(()) }
Создадим модуль, который будет содержать сгенерированный код и будет использован серверным и клиентским приложениями:
pub mod solar_system_info { tonic::include_proto!("solar_system_info"); }
После запуска сервера или клиента вы можете найти сгенерированный код в файле /target/debug/build/solar-system-info-rpc/out/solar_system_info.rs. Например, для создания сервера вам нужно будет имплементировать сгенерированный трейт SolarSystemInfo:
Сгенерированный трейт SolarSystemInfo
#[doc = r" Generated server implementations."] pub mod solar_system_info_server { #![allow(unused_variables, dead_code, missing_docs)] use tonic::codegen::*; #[doc = "Generated trait containing gRPC methods that should be implemented for use with SolarSystemInfoServer."] #[async_trait] pub trait SolarSystemInfo: Send + Sync + 'static { async fn get_planets_list( &self, request: tonic::Request<()>, ) -> Result<tonic::Response<super::PlanetsListResponse>, tonic::Status>; async fn get_planet( &self, request: tonic::Request<super::PlanetRequest>, ) -> Result<tonic::Response<super::PlanetResponse>, tonic::Status>; #[doc = "Server streaming response type for the GetPlanets method."] type GetPlanetsStream: futures_core::Stream<Item = Result<super::PlanetResponse, tonic::Status>> + Send + Sync + 'static; async fn get_planets( &self, request: tonic::Request<()>, ) -> Result<tonic::Response<Self::GetPlanetsStream>, tonic::Status>; } #[derive(Debug)] pub struct SolarSystemInfoServer<T: SolarSystemInfo> { inner: _Inner<T>, } }
Сгенерированные структуры, используемые функцией get_planet, выглядят так:
Сгенерированные структуры для функции get_planet
#[derive(Clone, PartialEq, ::prost::Message)] pub struct PlanetRequest { #[prost(string, tag = "1")] pub name: ::prost::alloc::string::String, } #[derive(Clone, PartialEq, ::prost::Message)] pub struct PlanetResponse { #[prost(message, optional, tag = "1")] pub planet: ::core::option::Option<Planet>, } #[derive(Clone, PartialEq, ::prost::Message)] pub struct Planet { #[prost(uint64, tag = "1")] pub id: u64, #[prost(string, tag = "2")] pub name: ::prost::alloc::string::String, #[prost(enumeration = "Type", tag = "3")] pub r#type: i32, #[prost(float, tag = "4")] pub mean_radius: f32, #[prost(float, tag = "5")] pub mass: f32, #[prost(message, repeated, tag = "6")] pub satellites: ::prost::alloc::vec::Vec<Satellite>, #[prost(bytes = "vec", tag = "7")] pub image: ::prost::alloc::vec::Vec<u8>, } #[derive(Clone, PartialEq, ::prost::Message)] pub struct Satellite { #[prost(uint64, tag = "1")] pub id: u64, #[prost(string, tag = "2")] pub name: ::prost::alloc::string::String, #[prost(message, optional, tag = "3")] pub first_spacecraft_landing_date: ::core::option::Option<::prost_types::Timestamp>, } #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, ::prost::Enumeration)] #[repr(i32)] pub enum Type { TerrestrialPlanet = 0, GasGiant = 1, IceGiant = 2, DwarfPlanet = 3, }
gRPC сервер
Функция main сервера представлена ниже:
#[tokio::main] async fn main() -> Result<(), Box<dyn std::error::Error>> { dotenv().ok(); env_logger::init(); info!("Starting Solar System info server"); let addr = std::env::var("GRPC_SERVER_ADDRESS")?.parse()?; let pool = create_connection_pool(); run_migrations(&pool); let solar_system_info = SolarSystemInfoService { pool }; let svc = SolarSystemInfoServer::new(solar_system_info); Server::builder().add_service(svc).serve(addr).await?; Ok(()) }
Имплементация трейта SolarSystemInfo (был показан в предыдущем разделе) выглядит так:
Имплементация gRPC сервера
struct SolarSystemInfoService { pool: PgPool, } #[tonic::async_trait] impl SolarSystemInfo for SolarSystemInfoService { type GetPlanetsStream = Pin<Box<dyn Stream<Item = Result<PlanetResponse, Status>> + Send + Sync + 'static>>; async fn get_planets_list( &self, request: Request<()>, ) -> Result<Response<PlanetsListResponse>, Status> { debug!("Got a request: {:?}", request); let names_of_planets = persistence::repository::get_names(&get_connection(&self.pool)) .expect("Can't get names of the planets"); let reply = PlanetsListResponse { list: names_of_planets, }; Ok(Response::new(reply)) } async fn get_planets( &self, request: Request<()>, ) -> Result<Response<Self::GetPlanetsStream>, Status> { debug!("Got a request: {:?}", request); let (tx, rx) = mpsc::channel(4); let planets: Vec<Planet> = persistence::repository::get_all(&get_connection(&self.pool)) .expect("Can't load planets") .into_iter() .map(|p| { PlanetWrapper { planet: p.0, satellites: p.1, } .into() }) .collect(); tokio::spawn(async move { let mut stream = tokio_stream::iter(&planets); while let Some(planet) = stream.next().await { tx.send(Ok(PlanetResponse { planet: Some(planet.clone()), })) .await .unwrap(); } }); Ok(Response::new(Box::pin( tokio_stream::wrappers::ReceiverStream::new(rx), ))) } async fn get_planet( &self, request: Request<PlanetRequest>, ) -> Result<Response<PlanetResponse>, Status> { debug!("Got a request: {:?}", request); let planet_name = request.into_inner().name; let planet = persistence::repository::get_by_name(&planet_name, &get_connection(&self.pool)); match planet { Ok(planet) => { let planet = PlanetWrapper { planet: planet.0, satellites: planet.1, } .into(); let reply = PlanetResponse { planet: Some(planet), }; Ok(Response::new(reply)) } Err(e) => { error!( "There was an error while getting a planet {}: {}", &planet_name, e ); match e { Error::NotFound => Err(Status::not_found(format!( "Planet with name {} not found", &planet_name ))), _ => Err(Status::unknown(format!( "There was an error while getting a planet {}: {}", &planet_name, e ))), } } } } }
Здесь определена кастомная SolarSystemInfoService структура, которая имеет доступ к БД с помощью Diesel ORM.
Напомню, что get_planets_list и get_planet являются примерами простых RPC, а get_planets — server-side streaming RPC.
Изображения планет включаются в бинарник приложения во время компиляции с помощью библиотеки rust_embed (при разработке они загружаются из файловой системы).
gRPC клиент
gRPC клиент в модуле bot создаётся так:
Создание gRPC клиента
async fn create_grpc_client() -> SolarSystemInfoClient<tonic::transport::Channel> { let channel = tonic::transport::Channel::from_static(&GRPC_SERVER_ADDRESS) .connect() .await .expect("Can't create a channel"); SolarSystemInfoClient::new(channel) }
Далее он может быть использован так:
Использование gRPC клиента
let response = get_planets_list(grpc_client).await?;
Telegram бот
Как было отмечено ранее, для большей наглядности gRPC клиент является также и Telegram ботом. Для имплементации бота использована библиотека teloxide.
Перейдём сразу к main.rs:
#[tokio::main] async fn main() { dotenv().ok(); teloxide::enable_logging!(); log::info!("Starting Solar System info bot"); let api_url = std::env::var("TELEGRAM_API_URL").expect("Can't get Telegram API URL"); let api_url = Url::parse(&api_url).expect("Can't parse Telegram API URL"); let bot = Bot::from_env() .set_api_url(api_url) .parse_mode(Html) .auto_send(); let bot = Arc::new(bot); let grpc_client = create_grpc_client().await; teloxide::commands_repl(bot, "solar-system-info-bot", move |cx, command| { answer(cx, command, grpc_client.clone()) }) .await; }
Для упрощения настройки SSL/TLS в проект включён модуль nginx. Он действует как forward proxy, который получает HTTP запросы от бота и перенаправляет их на серверы Telegram API.
Запуск и тестирование
Вы можете запустить проект локально двумя способами:
- используя Docker Compose (
docker-compose.yml):
docker-compose up - без Docker
Запустите gRPC сервер и клиент с помощьюcargo run
Запросы к серверу можно выполнять используя какой-либо gRPC клиент (например, BloomRPC):

или делать это косвенно с помощью Telegram бота:

Соответствие между командами бота и RPC следующее:
/list→GetPlanetsList/planets→GetPlanets/planet→GetPlanet
Для тестирования приложения с помощью бота вам нужен Telegram аккаунт и собственный бот (введение в эту тему здесь). В зависимости от выбранного способа запуска, токен бота должен быть указан здесь или здесь.
CI/CD
CI/CD сконфигурировано с использованием GitHub Actions (workflow), который собирает Docker образы gRPC сервера и клиента (то есть Telegram бота) и разворачивает их на Google Cloud Platform.
Бота можно протестировать здесь.
Заключение
В статье я показал как создать gRPC сервер и клиент на Rust и привёл пример использования клиента как источника данных для Telegram бота. Не стесняйтесь написать мне, если нашли какие-либо ошибки в статье или исходном коде. Спасибо за внимание!
Полезные ссылки
ссылка на оригинал статьи https://habr.com/ru/post/557600/
Добавить комментарий