Основы gRPC: Tonic

Это руководство, адаптированное из grpc-go, предоставляет базовое введение в работу с gRPC и Tonic. Изучая этот пример, вы научитесь:

  • Определять сервис в файле .proto.
  • Генерировать код сервера и клиента.
  • Писать простой клиент и сервер для вашего сервиса.

Предполагается, что вы знакомы с Protocol Buffers и основами Rust. Обратите внимание, что пример в этом руководстве использует версию proto3 языка Protocol Buffers. Подробнее об этом можно узнать в руководстве по языку proto3.

Зачем использовать gRPC?

Наш пример — простое приложение для построения маршрутов, которое позволяет клиентам получать информацию об особенностях на их маршруте, создавать сводку маршрута и обмениваться информацией о маршруте, такой как обновления о дорожном движении, с сервером и другими клиентами.

С gRPC мы можем один раз определить наш сервис в файле .proto и реализовать клиенты и серверы на любом из поддерживаемых gRPC языков, которые, в свою очередь, могут работать в средах от серверов внутри Google до вашего собственного планшета — вся сложность общения между разными языками и средами обрабатывается за вас gRPC. Мы также получаем все преимущества работы с Protocol Buffers, включая эффективную сериализацию, простой IDL и легкое обновление интерфейсов.

Предварительные требования

Для запуска примеров кода и прохождения руководства единственным предварительным требованием является сам Rust. rustup — удобный инструмент для его установки, если у вас его ещё нет.

Запуск примера

Клонируйте или скачайте репозиторий Tonic:

$ git clone https://github.com/hyperium/tonic.git

Перейдите в корневую директорию репозитория Tonic:

$ cd tonic

Запустите сервер

$ cargo run --bin routeguide-server

В отдельной оболочке запустите клиент

$ cargo run --bin routeguide-client

Вы должны увидеть быстрый вывод логов в обоих окнах терминала. В оболочке, где вы запустили клиентский бинарник, вы должны увидеть вывод двунаправленного потокового RPC, печатающий по одной строке в секунду:

NOTE = RouteNote { location: Some(Point { latitude: 409146139, longitude: -746188906 }), message: "at 1.000319208s" }

Если прокрутите вверх, вы должны увидеть вывод других 3 типов запросов: простой RPC, серверный поток и клиентский поток.

Настройка проекта

Мы будем разрабатывать наш пример с нуля в новом крейте:

$ cargo new routeguide
$ cd routeguide

Определение сервиса

Наш первый шаг — определить gRPC-сервис и типы запросов и ответов методов, используя Protocol Buffers. Мы будем хранить наши .proto-файлы в директории в корне нашего крейта. Обратите внимание, что Tonic не особо важен путь, по которому лежат наши .proto-определения. Позже в руководстве мы увидим, как использовать различную конфигурацию генерации кода.

$ mkdir proto && touch proto/route_guide.proto

Полный .proto-файл можно посмотреть в examples/proto/routeguide/route_guide.proto.

Чтобы определить сервис, вы указываете именованный service в вашем .proto-файле:

service RouteGuide {
   ...
}

Затем вы определяете rpc-методы внутри определения вашего сервиса, указывая их типы запросов и ответов. gRPC позволяет определять четыре вида методов сервиса, и все они используются в сервисе RouteGuide:

  • Простой RPC, где клиент отправляет запрос на сервер и ждет ответа, прямо как при обычном вызове функции.
   // Получает объект (Feature) в заданной позиции.
   rpc GetFeature(Point) returns (Feature) {}
  • Серверный потоковый RPC, где клиент отправляет запрос на сервер и получает поток для чтения последовательности сообщений. Клиент читает из возвращенного потока, пока сообщения не закончатся. Как видно в нашем примере, серверный потоковый метод указывается путем размещения ключевого слова stream перед типом ответа.
  // Получает объекты (Features), доступные в пределах заданного Прямоугольника (Rectangle). Результаты передаются потоком, а не возвращаются сразу (например, в сообщении ответа с повторяющимся полем), так как прямоугольник может покрывать большую площадь и содержать огромное количество объектов.
  rpc ListFeatures(Rectangle) returns (stream Feature) {}
  • Клиентский потоковый RPC, где клиент записывает последовательность сообщений и отправляет их на сервер. После того как клиент закончил записывать сообщения, он ждет, пока сервер прочитает их все и вернет свой ответ. Клиентский потоковый метод указывается путем размещения ключевого слова stream перед типом запроса.
  // Принимает поток Точек (Points) на traversed маршруте, возвращая RouteSummary после завершения обхода.
  rpc RecordRoute(stream Point) returns (RouteSummary) {}
  • Двунаправленный потоковый RPC, где обе стороны отправляют последовательность сообщений. Два потока работают независимо, поэтому клиенты и серверы могут читать и писать в любом порядке: например, сервер может ждать получения всех клиентских сообщений перед записью своих ответов, или он может поочередно читать сообщение, а затем писать сообщение, или какая-либо другая комбинация чтения и записи. Порядок сообщений в каждом потоке сохраняется. Этот тип метода указывается путем размещения ключевого слова stream как перед запросом, так и перед ответом.
  // Принимает поток RouteNotes, отправляемых во время прохождения маршрута, одновременно получая другие RouteNotes (например, от других пользователей).
  rpc RouteChat(stream RouteNote) returns (stream RouteNote) {}

Наш .proto-файл также содержит определения типов сообщений Protocol Buffers для всех типов запросов и ответов, используемых в наших методах сервиса — например, вот тип сообщения Point:

// Точки представлены как пары широта-долгота в представлении E7 (градусы, умноженные на 10**7 и округленные до ближайшего целого числа).
// Широты должны быть в диапазоне +/- 90 градусов, а долготы должны быть в диапазоне +/- 180 градусов (включительно).
message Point {
  int32 latitude = 1;
  int32 longitude = 2;
}

Генерация кода клиента и сервера

Tonic можно настроить для генерации кода как часть обычного процесса сборки Cargo. Это очень удобно, потому что после настройки всего не требуется дополнительных шагов для поддержания синхронизации сгенерированного кода и наших .proto-определений.

За кулисами Tonic использует PROST! для обработки сериализации Protocol Buffers и генерации кода.

Отредактируйте Cargo.toml и добавьте все зависимости, которые нам понадобятся для этого примера:

[dependencies]
tonic = "*"
prost = "0.14"
tokio = { version = "1.0", features = ["rt-multi-thread", "macros", "sync", "time"] }
tokio-stream = "0.1"

async-stream = "0.2"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
rand = "0.8"

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

Создайте файл build.rs в корне вашего крейта:

fn main() {
    tonic_build::compile_protos("proto/route_guide.proto")
        .unwrap_or_else(|e| panic!("Failed to compile protos {:?}", e));
}
$ cargo build

Вот и всё. Сгенерированный код содержит:

  • Определения структур для типов сообщений Point, Rectangle, Feature, RouteNote, RouteSummary.
  • Трейт сервиса, который нам нужно реализовать: route_guide_server::RouteGuide.
  • Тип клиента, который мы будем использовать для вызова сервера: route_guide_client::RouteGuideClient<T>.

Если вам интересно, где находятся сгенерированные файлы, читайте дальше. Скоро тайна будет раскрыта! Теперь мы можем перейти к интересной части.

Создание сервера

Сначала давайте посмотрим, как мы создаем сервер RouteGuide. Если вас интересует только создание gRPC-клиентов, вы можете пропустить этот раздел и перейти прямо к Созданию клиента (хотя вам всё равно может быть интересно!).

Есть две части, чтобы заставить наш сервис RouteGuide выполнять свою работу:

  • Реализация трейта сервиса, сгенерированного из нашего определения сервиса.
  • Запуск gRPC-сервера для прослушивания запросов от клиентов.

Вы можете найти наш пример сервера RouteGuide в examples/src/routeguide/server.rs.

Реализация трейта сервера RouteGuide

Мы можем начать с определения структуры для представления нашего сервиса, пока мы можем сделать это в main.rs:

#![allow(unused)]
fn main() {
#[derive(Debug)]
struct RouteGuideService;
}

Далее нам нужно реализовать трейт route_guide_server::RouteGuide, который генерируется на этапе нашей сборки. Сгенерированный код помещается внутрь нашей целевой директории, в местоположение, определяемое переменной окружения OUT_DIR, которую устанавливает Cargo. Для нашего примера это означает, что вы можете найти сгенерированный код по пути, похожему на target/debug/build/routeguide/out/routeguide.rs.

Вы можете узнать больше о build.rs и переменной окружения OUT_DIR в книге Cargo.

Мы можем использовать макрос include_proto от Tonic, чтобы внести сгенерированный код в область видимости:

#![allow(unused)]
fn main() {
pub mod routeguide {
    tonic::include_proto!("routeguide");
}

use routeguide::route_guide_server::{RouteGuide, RouteGuideServer};
use routeguide::{Feature, Point, Rectangle, RouteNote, RouteSummary};
}

Примечание: Лексема, передаваемая макросу include_proto (в нашем случае "routeguide"), — это имя пакета, объявленного в нашем .proto-файле, а не имя файла, например "routeguide.rs".

После этого мы можем набросать заглушку для нашей реализации сервиса:

#![allow(unused)]
fn main() {
use std::pin::Pin;
use std::sync::Arc;
use tokio::sync::mpsc;
use tonic::{Request, Response, Status};
use tokio_stream::{wrappers::ReceiverStream, Stream};
}
#![allow(unused)]
fn main() {
#[tonic::async_trait]
impl RouteGuide for RouteGuideService {
    async fn get_feature(&self, _request: Request<Point>) -> Result<Response<Feature>, Status> {
        unimplemented!()
    }

    type ListFeaturesStream = ReceiverStream<Result<Feature, Status>>;

    async fn list_features(
        &self,
        _request: Request<Rectangle>,
    ) -> Result<Response<Self::ListFeaturesStream>, Status> {
        unimplemented!()
    }

    async fn record_route(
        &self,
        _request: Request<tonic::Streaming<Point>>,
    ) -> Result<Response<RouteSummary>, Status> {
        unimplemented!()
    }

    type RouteChatStream = Pin<Box<dyn Stream<Item = Result<RouteNote, Status>> + Send  + 'static>>;

    async fn route_chat(
        &self,
        _request: Request<tonic::Streaming<RouteNote>>,
    ) -> Result<Response<Self::RouteChatStream>, Status> {
        unimplemented!()
    }
}
}

Примечание: Атрибутный макрос tonic::async_trait добавляет поддержку асинхронных функций в трейтах. Внутри он использует async-trait. Вы можете узнать больше об async fn в трейтах в Async Book.

Состояние сервера

Нашему сервису нужен доступ к неизменяемому списку объектов (features). При запуске сервера мы собираемся десериализовать их из json-файла и хранить как наше единственное общее состояние:

#![allow(unused)]
fn main() {
#[derive(Debug)]
pub struct RouteGuideService {
    features: Arc<Vec<Feature>>,
}
}

Создайте json-файл с данными и вспомогательный модуль для чтения и десериализации наших объектов.

$ mkdir data && touch data/route_guide_db.json
$ touch src/data.rs

Вы можете найти наш пример json-данных в examples/data/route_guide_db.json и соответствующий модуль data для загрузки и десериализации в examples/routeguide/data.rs.

Примечание: Если вы следуете руководству, вам нужно будет изменить путь к файлу данных с examples/data/route_guide_db.json на data/route_guide_db.json.

Наконец, нам нужно реализовать две вспомогательные функции: in_range и calc_distance. Мы будем использовать их при поиске объектов. Вы можете найти их в examples/src/routeguide/server.rs.

Типы запросов и ответов

Все наши методы сервиса получают tonic::Request<T> и возвращают Result<tonic::Response<T>, tonic::Status>. Конкретный тип T зависит от того, как наши методы объявлены в нашем сервисном определении .proto. Это может быть:

  • Единичное значение, например Point, Rectangle, или даже тип сообщения, включающий повторяющееся поле.
  • Поток значений, например impl Stream<Item = Result<Feature, tonic::Status>>.

Простой RPC

Давайте сначала рассмотрим самый простой метод, get_feature, который просто получает tonic::Request<Point> от клиента и пытается найти объект (Feature) в данной Point. Если объект не найден, возвращается пустой.

#![allow(unused)]
fn main() {
async fn get_feature(&self, request: Request<Point>) -> Result<Response<Feature>, Status> {
    for feature in &self.features[..] {
        if feature.location.as_ref() == Some(request.get_ref()) {
            return Ok(Response::new(feature.clone()));
        }
    }

    Ok(Response::new(Feature::default()))
}
}

Серверный потоковый RPC

Теперь давайте посмотрим на один из наших потоковых RPC. list_features — это серверный потоковый RPC, поэтому нам нужно отправить обратно несколько Feature нашему клиенту.

#![allow(unused)]
fn main() {
type ListFeaturesStream = ReceiverStream<Result<Feature, Status>>;

async fn list_features(
    &self,
    request: Request<Rectangle>,
) -> Result<Response<Self::ListFeaturesStream>, Status> {
    let (tx, rx) = mpsc::channel(4);
    let features = self.features.clone();

    tokio::spawn(async move {
        for feature in &features[..] {
            if in_range(feature.location.as_ref().unwrap(), request.get_ref()) {
                tx.send(Ok(feature.clone())).await.unwrap();
            }
        }
    });

    Ok(Response::new(ReceiverStream::new(rx)))
}
}

Как и get_feature, входные данные list_features — это одно сообщение, в данном случае Rectangle. Однако на этот раз нам нужно вернуть поток значений, а не одно. Мы создаем канал и порождаем новую асинхронную задачу, в которой выполняем поиск, отправляя объекты, удовлетворяющие нашим ограничениям, в канал.

Половина Stream канала возвращается вызывающей стороне, обернутая в tonic::Response.

Клиентский потоковый RPC

Теперь давайте рассмотрим что-то немного более сложное: клиентский потоковый метод record_route, где мы получаем поток Point от клиента и возвращаем одно значение RouteSummary с информацией об их поездке. Как вы можете видеть, на этот раз метод получает tonic::Request<tonic::Streaming<Point>>.

#![allow(unused)]
fn main() {
use std::time::Instant;
use tokio_stream::StreamExt;
}
#![allow(unused)]
fn main() {
async fn record_route(
    &self,
    request: Request<tonic::Streaming<Point>>,
) -> Result<Response<RouteSummary>, Status> {
    let mut stream = request.into_inner();

    let mut summary = RouteSummary::default();
    let mut last_point = None;
    let now = Instant::now();

    while let Some(point) = stream.next().await {
        let point = point?;
        summary.point_count += 1;

        for feature in &self.features[..] {
            if feature.location.as_ref() == Some(&point) {
                summary.feature_count += 1;
            }
        }

        if let Some(ref last_point) = last_point {
            summary.distance += calc_distance(last_point, &point);
        }

        last_point = Some(point);
    }

    summary.elapsed_time = now.elapsed().as_secs() as i32;

    Ok(Response::new(summary))
}
}

record_route концептуально прост: мы получаем поток Point и сворачиваем его в RouteSummary. Другими словами, мы строим суммарное значение по мере обработки каждого Point в нашем потоке, один за другим. Когда в нашем потоке больше нет Point, мы возвращаем RouteSummary, обернутый в tonic::Response.

Двунаправленный потоковый RPC

Наконец, давайте рассмотрим наш двунаправленный потоковый RPC route_chat, который получает поток RouteNote и возвращает либо другой поток RouteNote, либо ошибку.

#![allow(unused)]
fn main() {
use std::collections::HashMap;
}
#![allow(unused)]
fn main() {
type RouteChatStream =
    Pin<Box<dyn Stream<Item = Result<RouteNote, Status>> + Send  + 'static>>;


async fn route_chat(
    &self,
    request: Request<tonic::Streaming<RouteNote>>,
) -> Result<Response<Self::RouteChatStream>, Status> {
    let mut notes = HashMap::new();
    let mut stream = request.into_inner();

    let output = async_stream::try_stream! {
        while let Some(note) = stream.next().await {
            let note = note?;

            let location = note.location.unwrap();

            let location_notes = notes.entry(location).or_insert(vec![]);
            location_notes.push(note);

            for note in location_notes {
                yield note.clone();
            }
        }
    };

    Ok(Response::new(Box::pin(output)
        as Self::RouteChatStream))

}
}

route_chat использует крейт async-stream для выполнения асинхронного преобразования из одного (входного) потока в другой (выходной) поток. По мере обработки входных данных каждое значение вставляется в карту notes, при этом создается клон исходного RouteNote. Результирующий поток затем возвращается вызывающей стороне. Аккуратно.

Примечание: Забавное приведение as необходимо из-за ограничения в компиляторе Rust. Ожидается, что это скоро будет исправлено.

Запуск сервера

После того как мы реализовали все наши методы, нам также нужно запустить gRPC-сервер, чтобы клиенты могли фактически использовать наш сервис. Вот как выглядит наша функция main:

#![allow(unused)]
fn main() {
mod data;
use tonic::transport::Server;
}
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let addr = "[::1]:10000".parse().unwrap();

    let route_guide = RouteGuideService {
        features: Arc::new(data::load()),
    };

    let svc = RouteGuideServer::new(route_guide);

    Server::builder().add_service(svc).serve(addr).await?;

    Ok(())
}

Для обработки запросов Tonic внутренне использует Tower и hyper. Что это значит, среди прочего, так это то, что у нас есть гибкий и компонуемый стек, на котором мы можем строить. Мы можем, например, добавить интерцептор для обработки запросов до того, как они достигнут наших методов сервиса.

Создание клиента

В этом разделе мы рассмотрим создание клиента Tonic для нашего сервиса RouteGuide. Вы можете увидеть наш полный пример клиентского кода в examples/src/routeguide/client.rs.

Наш крэйт будет иметь две бинарные цели: routeguide-client и routeguide-server. Нам нужно соответствующим образом отредактировать наш Cargo.toml:

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

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

Переименуйте main.rs в server.rs и создайте новый файл client.rs.

$ mv src/main.rs src/server.rs
$ touch src/client.rs

Чтобы вызывать методы сервиса, нам сначала нужно создать gRPC-клиент для связи с сервером. Как и в случае с сервером, мы начнем с внесения сгенерированного кода в область видимости:

pub mod routeguide {
    tonic::include_proto!("routeguide");
}

use routeguide::route_guide_client::RouteGuideClient;
use routeguide::{Point, Rectangle, RouteNote};


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

     Ok(())
}

Как и в реализации сервера, мы начинаем с внесения нашего сгенерированного кода в область видимости. Затем мы создаем клиента в нашей основной функции, передавая полный URL сервера в RouteGuideClient::connect. Наш клиент теперь готов делать сервисные вызовы. Обратите внимание, что client является изменяемым, это потому, что ему нужно управлять внутренним состоянием.

Вызов методов сервиса

Теперь давайте посмотрим, как мы вызываем наши методы сервиса. Обратите внимание, что в Tonic RPC являются асинхронными, что означает, что вызовы RPC нужно .awaitить.

Простой RPC

Вызов простого RPC get_feature так же прост, как вызов локального метода:

#![allow(unused)]
fn main() {
use tonic::Request;
}
#![allow(unused)]
fn main() {
let response = client
    .get_feature(Request::new(Point {
        latitude: 409146138,
        longitude: -746188906,
    }))
    .await?;

println!("RESPONSE = {:?}", response);
}

Мы вызываем клиентский метод get_feature, передавая единственное значение Point, обернутое в tonic::Request. Мы получаем обратно Result<tonic::Response<Feature>, tonic::Status>.

Серверный потоковый RPC

Вот где мы вызываем серверный потоковый метод list_features, который возвращает поток географических Feature.

#![allow(unused)]
fn main() {
use tonic::transport::Channel;
use std::error::Error;
}
#![allow(unused)]
fn main() {
async fn print_features(client: &mut RouteGuideClient<Channel>) -> Result<(), Box<dyn Error>> {
    let rectangle = Rectangle {
        lo: Some(Point {
            latitude: 400000000,
            longitude: -750000000,
        }),
        hi: Some(Point {
            latitude: 420000000,
            longitude: -730000000,
        }),
    };

    let mut stream = client
        .list_features(Request::new(rectangle))
        .await?
        .into_inner();

    while let Some(feature) = stream.message().await? {
        println!("FEATURE = {:?}", feature);
    }

    Ok(())
}
}

Как и в простом RPC, мы передаем запрос с одним значением. Однако вместо того, чтобы получить одно значение обратно, мы получаем поток Feature.

Мы используем метод message() из структуры tonic::Streaming, чтобы повторно читать ответы сервера в объект protobuf ответа (в данном случае Feature), пока в потоке не останется больше сообщений.

Клиентский потоковый RPC

Клиентский потоковый метод record_route принимает поток Point и возвращает одно значение RouteSummary.

#![allow(unused)]
fn main() {
use rand::rngs::ThreadRng;
use rand::Rng;
}
#![allow(unused)]
fn main() {
async fn run_record_route(client: &mut RouteGuideClient<Channel>) -> Result<(), Box<dyn Error>> {
    let mut rng = rand::thread_rng();
    let point_count: i32 = rng.gen_range(2..100);

    let mut points = vec![];
    for _ in 0..=point_count {
        points.push(random_point(&mut rng))
    }

    println!("Traversing {} points", points.len());
    let request = Request::new(tokio_stream::iter(points));

    match client.record_route(request).await {
        Ok(response) => println!("SUMMARY: {:?}", response.into_inner()),
        Err(e) => println!("something went wrong: {:?}", e),
    }

    Ok(())
}
}
#![allow(unused)]
fn main() {
fn random_point(rng: &mut ThreadRng) -> Point {
    let latitude = (rng.gen_range(0..180) - 90) * 10_000_000;
    let longitude = (rng.gen_range(0..360) - 180) * 10_000_000;
    Point {
        latitude,
        longitude,
    }
}
}

Мы строим вектор случайного количества значений Point (от 2 до 100), а затем преобразуем его в Stream с помощью функции tokio_stream::iter. Это дешевый и простой способ получить поток, подходящий для передачи в наш метод сервиса. Полученный поток затем оборачивается в tonic::Request.

Двунаправленный потоковый RPC

Наконец, давайте рассмотрим наш двунаправленный потоковый RPC. Метод route_chat принимает поток RouteNote и возвращает либо другой поток RouteNote, либо ошибку.

#![allow(unused)]
fn main() {
use std::time::Duration;
use tokio::time;
}
#![allow(unused)]
fn main() {
async fn run_route_chat(client: &mut RouteGuideClient<Channel>) -> Result<(), Box<dyn Error>> {
    let start = time::Instant::now();

    let outbound = async_stream::stream! {
        let mut interval = time::interval(Duration::from_secs(1));

        while let time = interval.tick().await {
            let elapsed = time.duration_since(start);
            let note = RouteNote {
                location: Some(Point {
                    latitude: 409146138 + elapsed.as_secs() as i32,
                    longitude: -746188906,
                }),
                message: format!("at {:?}", elapsed),
            };

            yield note;
        }
    };

    let response = client.route_chat(Request::new(outbound)).await?;
    let mut inbound = response.into_inner();

    while let Some(note) = inbound.message().await? {
        println!("NOTE = {:?}", note);
    }

    Ok(())
}
}

В этом случае мы используем крейт async-stream для генерации нашего исходящего потока, создавая значения RouteNote с интервалом в одну секунду. Затем мы перебираем поток, возвращенный сервером, печатая каждое значение в потоке.

Попробуйте!

Запустите сервер

$ cargo run --bin routeguide-server

Запустите клиент

$ cargo run --bin routeguide-client

Приложение

Конфигурация tonic_build

Конфигурация генерации кода по умолчанию в Tonic удобна для самодостаточных примеров и небольших проектов. Однако есть случаи, когда нам нужен немного другой рабочий процесс. Например:

  • При сборке Rust-клиентов и серверов в разных крейтах.
  • При сборке Rust-клиента или сервера (или обоих) в рамках более крупного многопользовательского проекта.
  • Когда мы хотим поддержку редактора для сгенерированного кода, а наш редактор не индексирует сгенерированные файлы в местоположении по умолчанию.

Более общо, когда мы хотим хранить наши .proto-определения в центральном месте и генерировать код для разных крейтов или разных языков, конфигурации по умолчанию недостаточно.

К счастью, tonic_build можно настроить в соответствии с любыми нашими потребностями. Вот всего две возможности:

  1. Мы можем хранить наши .proto-определения в отдельном крейте и генерировать наш код по требованию, в отличие от времени сборки, помещая результирующие модули туда, где они нам нужны.

main.rs

fn main() {
    tonic_build::configure()
        .build_client(false)
        .out_dir("another_crate/src/pb")
        .compile(&["path/my_proto.proto"], &["path"])
        .expect("failed to compile protos");
}

При cargo run это сгенерирует код только для сервера и поместит результирующий файл в another_crate/src/pb.

  1. Аналогично, мы также можем хранить .proto-определения в отдельном крейте, а затем использовать этот крэйт как прямую зависимость везде, где он нам нужен.