1. Wprowadzenie
W tym laboratorium wykorzystasz gRPC-Rust do utworzenia klienta i serwera, które będą stanowić podstawę aplikacji do mapowania tras napisanej w języku Rust.
Po ukończeniu tego samouczka będziesz mieć klienta, który łączy się z serwerem zdalnym za pomocą gRPC, aby uzyskiwać informacje o funkcjach na trasie klienta, tworzyć podsumowanie trasy klienta i wymieniać informacje o trasie, takie jak aktualizacje ruchu, z serwerem i innymi klientami.
Usługa jest zdefiniowana w pliku Protocol Buffers, który będzie używany do generowania kodu szablonowego dla klienta i serwera, aby mogły się ze sobą komunikować. Dzięki temu zaoszczędzisz czas i wysiłek potrzebny na wdrożenie tej funkcji.
Wygenerowany kod obsługuje nie tylko złożoność komunikacji między serwerem a klientem, ale także serializację i deserializację danych.
Czego się nauczysz
- Jak używać buforów protokołu do definiowania interfejsu API usługi.
- Jak utworzyć klienta i serwer oparte na gRPC na podstawie definicji Protocol Buffers za pomocą automatycznego generowania kodu.
- znajomość komunikacji strumieniowej klient-serwer za pomocą gRPC;
Te ćwiczenia z programowania są przeznaczone dla programistów języka Rust, którzy dopiero zaczynają korzystać z gRPC lub chcą sobie przypomnieć jego działanie, a także dla wszystkich innych osób zainteresowanych tworzeniem systemów rozproszonych. Nie musisz mieć wcześniejszego doświadczenia z gRPC.
2. Zanim zaczniesz
Wymagania wstępne
Sprawdź, czy masz zainstalowane te elementy:
- GCC. Postępuj zgodnie z instrukcjami tutaj
- Rust, najnowsza wersja. Postępuj zgodnie z instrukcjami instalacji, które znajdziesz tutaj.
Pobierz kod
Aby nie trzeba było zaczynać od zera, w tym ćwiczeniu znajdziesz szkielet kodu źródłowego aplikacji, który możesz uzupełnić. Z tych instrukcji dowiesz się, jak dokończyć aplikację, w tym jak użyć wtyczek kompilatora buforów protokołów do wygenerowania kodu gRPC.
Najpierw utwórz katalog roboczy codelab i przejdź do niego:cd
mkdir streaming-grpc-rust-getting-started && cd streaming-grpc-rust-getting-started
Pobierz i rozpakuj ćwiczenia z programowania:
curl -sL https://github.com/grpc-ecosystem/grpc-codelabs/archive/refs/heads/v1.tar.gz \ | tar xvz --strip-components=4 \ grpc-codelabs-1/codelabs/grpc-rust-streaming/start_here
Możesz też pobrać plik ZIP zawierający tylko katalog z instrukcjami i rozpakować go ręcznie.
Jeśli nie chcesz wpisywać implementacji, gotowy kod źródłowy jest dostępny na GitHubie.
3. Określanie komunikatów i usług
Pierwszym krokiem jest zdefiniowanie usługi gRPC aplikacji, jej metod RPC oraz typów wiadomości żądań i odpowiedzi za pomocą buforów protokołu. Twoja usługa będzie zapewniać:
- Metody RPC wywoływane przez serwer i klienta:
ListFeatures
,RecordRoute
iRouteChat
. - Typy wiadomości
Point
,Feature
,Rectangle
,RouteNote
iRouteSummary
, które są strukturami danych wymienianymi między klientem a serwerem podczas wywoływania powyższych metod.
Te metody RPC i ich typy wiadomości będą zdefiniowane w pliku proto/routeguide.proto
podanego kodu źródłowego.
Protokoły buforów są powszechnie znane jako protobufy. Więcej informacji o terminologii gRPC znajdziesz w artykule Podstawowe koncepcje, architektura i cykl życia.
Definiowanie typów wiadomości
Najpierw zdefiniujmy wiadomości, które będą używane przez nasze wywołania RPC. W routeguide/route_guide.proto
pliku kodu źródłowego najpierw zdefiniuj Point
typ wiadomości. Symbol Point
reprezentuje parę współrzędnych szerokości i długości geograficznej na mapie. W tym ćwiczeniu używaj liczb całkowitych jako współrzędnych:
message Point {
int32 latitude = 1;
int32 longitude = 2;
}
Numery 1
i 2
to unikalne identyfikatory poszczególnych pól w strukturze message
.
Następnie określ Feature
typ wiadomości. Feature
używa pola string
na nazwę lub adres pocztowy czegoś w lokalizacji określonej przez Point
:
message Feature {
// The name or address of the feature.
string name = 1;
// The point where the feature is located.
Point location = 2;
}
Następnie Rectangle
, czyli wiadomość reprezentująca prostokąt szerokości i długości geograficznej, przedstawiony jako 2 przeciwległe punkty „lo” i „hi”.
message Rectangle {
// One corner of the rectangle.
Point lo = 1;
// The other corner of the rectangle.
Point hi = 2;
}
Jest to też wiadomość RouteNote
, która reprezentuje wiadomość wysłaną w danym momencie.
message RouteNote {
// The location from which the message is sent.
Point location = 1;
// The message to be sent.
string message = 2;
}
Wymagamy też wiadomości RouteSummary
. Ten komunikat jest odbierany w odpowiedzi na wywołanie RPC RecordRoute
, które opisujemy w następnej sekcji. Zawiera liczbę otrzymanych punktów, liczbę wykrytych cech i całkowity przebyty dystans jako sumę odległości między poszczególnymi punktami.
message RouteSummary {
// The number of points received.
int32 point_count = 1;
// The number of known features passed while traversing the route.
int32 feature_count = 2;
// The distance covered in metres.
int32 distance = 3;
// The duration of the traversal in seconds.
int32 elapsed_time = 4;
}
Określanie metod usługi
Najpierw zdefiniujmy usługę, a potem wiadomości. Aby zdefiniować usługę, w pliku .proto
podaj jej nazwę. Plik proto/routeguide.proto
ma strukturę service
o nazwie RouteGuide
, która definiuje co najmniej 1 metodę udostępnianą przez usługę aplikacji.
Zdefiniuj metody RPC w definicji usługi, określając typy żądań i odpowiedzi. W tej części ćwiczenia zdefiniujemy:
ListFeatures
Pobiera Feature
dostępne w danym Rectangle
. Wyniki są przesyłane strumieniowo, a nie zwracane od razu (np. w wiadomości z odpowiedzią zawierającej pole powtarzane), ponieważ prostokąt może obejmować duży obszar i zawierać ogromną liczbę elementów.
Odpowiednim typem tego wywołania RPC jest strumieniowe wywołanie RPC po stronie serwera: klient wysyła żądanie do serwera i otrzymuje strumień, z którego może odczytywać sekwencję komunikatów. Klient odczytuje zwrócony strumień, dopóki nie będzie już żadnych wiadomości. Jak widać w naszym przykładzie, metodę przesyłania strumieniowego po stronie serwera określa się, umieszczając słowo kluczowe stream
przed typem odpowiedzi.
rpc ListFeatures(Rectangle) returns (stream Feature) {}
RecordRoute
Akceptuje strumień obiektów Point
na pokonywanej trasie i zwraca obiekt RouteSummary
po zakończeniu przejazdu.
W tym przypadku odpowiednie wydaje się wywołanie RPC strumieniowania po stronie klienta: klient zapisuje sekwencję wiadomości i wysyła je na serwer, ponownie używając udostępnionego strumienia. Gdy klient skończy pisać wiadomości, czeka, aż serwer je wszystkie odczyta i zwróci odpowiedź. Metodę przesyłania strumieniowego po stronie klienta określa się, umieszczając słowo kluczowe stream
przed typem żądania.
rpc RecordRoute(stream Point) returns (RouteSummary) {}
RouteChat
Akceptuje strumień RouteNote
wysyłanych podczas pokonywania trasy, a także odbiera inne RouteNote
(np. od innych użytkowników).
To jest właśnie przypadek użycia strumieniowania dwukierunkowego. Dwukierunkowe przesyłanie strumieniowe RPC polega na tym, że obie strony wysyłają sekwencję wiadomości za pomocą strumienia odczytu i zapisu. Oba strumienie działają niezależnie, więc klienci i serwery mogą odczytywać i zapisywać dane w dowolnej kolejności.
Na przykład serwer może poczekać na otrzymanie wszystkich wiadomości od klienta, zanim napisze odpowiedzi, lub może odczytać wiadomość, a następnie napisać odpowiedź albo zastosować inną kombinację odczytów i zapisów.
Kolejność wiadomości w każdym strumieniu jest zachowana. Ten typ metody określa się, umieszczając słowo kluczowe stream
przed żądaniem i odpowiedzią.
rpc RouteChat(stream RouteNote) returns (stream RouteNote) {}
4. Generowanie kodu klienta i serwera
Wygenerowany kod z pliku .proto
w wygenerowanym katalogu został już Ci przekazany.
Jeśli chcesz dowiedzieć się, jak samodzielnie wygenerować kod z pliku .proto
lub wprowadzić w nim zmiany i je przetestować, zapoznaj się z tymi instrukcjami..proto
Wygenerowany kod zawiera:
- Definicje struktur dla typów wiadomości
Point
,Feature
,Rectangle
,RouteNote
iRouteSummary
. - Cechę usługi, którą musimy wdrożyć:
route_guide_server::RouteGuide
. - Typ klienta, którego użyjemy do wywołania serwera:
route_guide_client::RouteGuideClient<T>
.
Następnie zaimplementujemy metody po stronie serwera, aby gdy klient wyśle żądanie, serwer mógł odpowiedzieć.
5. Wdrażanie usługi
Najpierw zobaczmy, jak utworzyć RouteGuide
serwerRouteGuide
. Aby usługa RouteGuide
działała prawidłowo, musisz wykonać 2 czynności:
- Implementacja interfejsu usługi wygenerowanego na podstawie definicji usługi: wykonywanie rzeczywistej „pracy” usługi.
- Uruchomienie serwera gRPC, który nasłuchuje żądań od klientów i przekazuje je do odpowiedniej implementacji metody.
W src/server/server.rs
możemy uwzględnić wygenerowany kod za pomocą makra include_generated_proto!
gRPC i zaimportować cechę RouteGuide
oraz Point
.
mod grpc_pb {
grpc::include_generated_proto!("generated", "routeguide");
}
pub use grpc_pb::{
route_guide_server::{RouteGuideServer, RouteGuide},
Point, Feature, Rectangle, RouteNote, RouteSummary
};
Zacznijmy od zdefiniowania struktury reprezentującej naszą usługę. Obecnie możemy to zrobić w przypadku tych urządzeń: src/server/server.rs
.
#[derive(Debug)]
pub struct RouteGuideService {
features: Vec<Feature>,
}
Teraz musimy zaimplementować cechę route_guide_server::RouteGuide
z wygenerowanego kodu.
Implementacja RouteGuide
Musimy wdrożyć wygenerowany interfejs RouteGuide
. Tak wyglądałaby implementacja. Jest to już w szablonie.
#[tonic::async_trait] impl RouteGuide for RouteGuideService { async fn list_features( &self, request: Request<Rectangle>, ) -> Result<Response<ListFeaturesStream>, Status> { ... } async fn record_route( &self, request: Request<tonic::Streaming<Point>>, ) -> Result<Response<RouteSummary>, Status> { ... } async fn route_chat( &self, request: Request<tonic::Streaming<RouteNote>>, ) -> Result<Response<RouteChatStream>, Status> { ... } }
Przyjrzyjmy się szczegółowo każdej implementacji RPC.
Strumieniowe wywołanie RPC po stronie serwera
Zacznijmy od ListFeatures
. Jest to strumieniowe wywołanie RPC po stronie serwera, więc musimy odesłać do klienta wiele obiektów Feature
.
async fn list_features(
&self,
request: Request<Rectangle>,
) -> Result<Response<ListFeaturesStream>, Status> {
println!("ListFeatures = {:?}", request);
let (tx, rx) = mpsc::channel(4);
let features = self.features.clone();
tokio::spawn(async move {
for feature in &features[..] {
if in_range(&feature.location().to_owned(), request.get_ref()) {
println!(" => send {feature:?}");
tx.send(Ok(feature.clone())).await.unwrap();
}
}
println!(" /// done sending");
});
let output_stream = ReceiverStream::new(rx);
Ok(Response::new(Box::pin(output_stream)))
}
Jak widać, otrzymujemy obiekt żądania (Rectangle
, w którym klient chce znaleźć Features
). Tym razem musimy zwrócić strumień wartości. Tworzymy kanał i uruchamiamy nowe zadanie asynchroniczne, w którym przeprowadzamy wyszukiwanie i wysyłamy do kanału funkcje spełniające nasze ograniczenia. Połowa kanału Stream jest zwracana do elementu wywołującego, opakowana w tonic::Response
.
RPC przesyłania strumieniowego po stronie klienta
Przyjrzyjmy się teraz nieco bardziej skomplikowanej metodzie przesyłania strumieniowego po stronie klienta RecordRoute
, w której otrzymujemy strumień Points
od klienta i zwracamy pojedynczy RouteSummary
z informacjami o jego podróży. Otrzymuje strumień jako dane wejściowe, którego serwer może używać do odczytywania i zapisywania wiadomości. Może iterować wiadomości klientów za pomocą metody next()
i zwracać pojedynczą odpowiedź.
async fn record_route(
&self,
request: Request<tonic::Streaming<Point>>,
) -> Result<Response<RouteSummary>, Status> {
println!("RecordRoute");
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?;
println!(" ==> Point = {point:?}");
// Increment the point count
summary.set_point_count(summary.point_count() + 1);
// Find features
for feature in &self.features[..] {
if feature.location().latitude() == point.latitude() {
if feature.location().longitude() == point.longitude(){
summary.set_feature_count(summary.feature_count() + 1);
}
}
}
// Calculate the distance
if let Some(ref last_point) = last_point {
let new_dist = summary.distance() + calc_distance(last_point, &point);
summary.set_distance(new_dist);
}
last_point = Some(point);
}
summary.set_elapsed_time(now.elapsed().as_secs() as i32);
Ok(Response::new(summary))
}
W treści metody używamy metody next()
strumienia, aby wielokrotnie odczytywać żądania klienta do obiektu żądania (w tym przypadku Point
), dopóki nie będzie już więcej wiadomości. Jeśli jest to None, strumień jest nadal prawidłowy i można kontynuować odczyt.
Dwukierunkowe przesyłanie strumieniowe RPC
Na koniec przyjrzyjmy się dwukierunkowemu strumieniowemu wywołaniu RPC RouteChat()
.
async fn route_chat(
&self,
request: Request<tonic::Streaming<RouteNote>>,
) -> Result<Response<RouteChatStream>, Status> {
println!("RouteChat");
let mut notes: HashMap<(i32, i32), Vec<RouteNote>> = 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();
let key = (location.latitude(), location.longitude());
let location_notes = notes.entry(key).or_insert(vec![]);
location_notes.push(note);
for note in location_notes {
yield note.clone();
}
}
};
Ok(Response::new(Box::pin(output)))
}
Tym razem otrzymujemy strumień, który, podobnie jak w przykładzie przesyłania strumieniowego po stronie klienta, może służyć do odczytywania i zapisywania wiadomości. Tym razem jednak zwracamy wartości za pomocą strumienia metody, gdy klient nadal pisze wiadomości do swojego strumienia wiadomości. Składnia odczytu i zapisu jest tu bardzo podobna do naszej metody przesyłania strumieniowego po stronie klienta, z tym wyjątkiem, że serwer zwraca RouteChatStream
. Chociaż każda ze stron zawsze będzie otrzymywać wiadomości drugiej strony w kolejności, w jakiej zostały napisane, zarówno klient, jak i serwer mogą odczytywać i zapisywać dane w dowolnej kolejności – strumienie działają całkowicie niezależnie.
Strumień output
tworzymy za pomocą funkcji try_stream!
, co oznacza, że strumień może zwracać błędy.
Uruchom serwer
Po wdrożeniu tej metody musimy też uruchomić serwer gRPC, aby klienci mogli korzystać z naszej usługi. Wypełnij main()
.
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let addr = "[::1]:10000".parse().unwrap();
println!("RouteGuideServer listening on: {addr}");
let route_guide = RouteGuideService {
features: load(),
};
let svc = RouteGuideServer::new(route_guide);
Server::builder().add_service(svc).serve(addr).await?;
Ok(())
}
Oto, co dzieje się w main()
krok po kroku:
- Określ port, którego mamy używać do nasłuchiwania żądań klientów
- Utwórz
RouteGuideService
z wczytanymi funkcjami. - Utwórz instancję serwera gRPC za pomocą
RouteGuideServer::new()
, korzystając z utworzonej usługi. - Zarejestruj implementację usługi na serwerze gRPC.
- Wywołaj funkcję
serve()
na serwerze, podając szczegóły portu, aby zablokować oczekiwanie do momentu zakończenia procesu.
6. Tworzenie klienta
W tej sekcji przyjrzymy się tworzeniu klienta Rust dla naszej usługi RouteGuide w src/client/client.rs
.
Najpierw umieść wygenerowany kod w zakresie.
mod grpc_pb {
grpc::include_generated_proto!("generated", "routeguide");
}
use grpc_pb::route_guide_client::RouteGuideClient;
use grpc_pb::{Point, Rectangle, RouteNote};
Metody usługi połączeń
Zobaczmy teraz, jak wywołujemy metody usługi. W gRPC-Rust wywołania RPC działają w trybie blokującym/synchronicznym, co oznacza, że wywołanie RPC czeka na odpowiedź serwera i zwraca odpowiedź lub błąd.
Strumieniowe wywołanie RPC po stronie serwera
W tym miejscu wywołujemy metodę strumieniowania po stronie serwera ListFeatures
, która zwraca strumień obiektów geograficznych Feature
.
async fn print_features(client: &mut RouteGuideClient<Channel>) -> Result<(), Box<dyn Error>> {
let rectangle = proto!(Rectangle {
lo: proto!(Point {
latitude: 400_000_000,
longitude: -750_000_000,
}),
hi: proto!(Point {
latitude: 420_000_000,
longitude: -730_000_000,
}),
});
let mut stream = client
.list_features(Request::new(rectangle))
.await?
.into_inner();
while let Some(feature) = stream.message().await? {
println!("FEATURE: Name = \"{}\", Lat = {}, Lon = {}",
feature.name(),
feature.location().latitude(),
feature.location().longitude());
}
Ok(())
}
Przekazujemy do metody żądanie i otrzymujemy instancję elementu ListFeaturesStream
. Klient może używać strumienia ListFeaturesStream
do odczytywania odpowiedzi serwera. Używamy metody ListFeaturesStream
message()
, aby wielokrotnie odczytywać odpowiedzi serwera do obiektu bufora protokołu odpowiedzi (w tym przypadku Feature
), dopóki nie będzie już więcej wiadomości.
RPC przesyłania strumieniowego po stronie klienta
W przypadku record_route
przekształcamy wektor punktów w strumień. Następnie przekazujemy ten strumień do record_route()
jako żądanie i po pełnym przetworzeniu strumienia przez serwer otrzymujemy pojedynczą odpowiedź RouteSummary
.
async fn run_record_route(client: &mut RouteGuideClient<Channel>) -> Result<(), Box<dyn Error>> {
let mut rng = rand::rng();
let point_count: i32 = rng.random_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) => {
let response = response.into_inner();
println!("SUMMARY: Feature Count = {}, Distance = {}", response.feature_count(), response.distance())},
Err(e) => println!("something went wrong: {e:?}"),
}
Ok(())
}
Dwukierunkowe przesyłanie strumieniowe RPC
Na koniec przyjrzyjmy się dwukierunkowemu strumieniowemu wywołaniu RPC RouteChat()
. Przekazujemy do metody żądanie strumienia, do którego zapisujemy dane, i otrzymujemy strumień, z którego możemy odczytywać wiadomości. Tym razem zwracamy wartości za pomocą strumienia metody, gdy serwer nadal zapisuje wiadomości w swoim strumieniu wiadomości.
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));
for _ in 0..10 {
let time = interval.tick().await;
let elapsed = time.duration_since(start);
let note = proto!(RouteNote {
location: proto!(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: Latitude = {}, Longitude = {}, Message = \"{}\"",
note.location().latitude(),
note.location().longitude(),
note.message());
}
Ok(())
}
Chociaż każda ze stron zawsze będzie otrzymywać wiadomości drugiej strony w kolejności, w jakiej zostały napisane, zarówno klient, jak i serwer mogą odczytywać i zapisywać dane w dowolnej kolejności – strumienie działają całkowicie niezależnie.
Wywoływanie metod pomocniczych
Aby wywołać metody usługi, musimy najpierw utworzyć kanał do komunikacji z serwerem. Najpierw tworzymy punkt końcowy, łączymy się z nim i przekazujemy utworzony w trakcie połączenia kanał do RouteGuideClient::new()
w ten sposób:
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
// Create endpoint to connect to
let endpoint = Endpoint::new("http://[::1]:10000")?;
let channel = endpoint.connect().await?;
// Create a new client
let mut client = RouteGuideClient::new(channel);
Ok(())
}
W main()
wykonaj utworzone przed chwilą metody.
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
// Create endpoint to connect to
let endpoint = Endpoint::new("http://[::1]:10000")?;
let channel = endpoint.connect().await?;
// Create a new client
let mut client = RouteGuideClient::new(channel);
println!("\n*** SERVER STREAMING ***");
print_features(&mut client).await?;
println!("\n*** CLIENT STREAMING ***");
run_record_route(&mut client).await?;
println!("\n*** BIDIRECTIONAL STREAMING ***");
run_route_chat(&mut client).await?;
Ok(())
}
7. Wypróbuj
Najpierw, aby uruchomić klienta i serwer, dodajmy je jako binarne środowiska docelowe do naszego pakietu. Musimy odpowiednio zmodyfikować plik Cargo.toml:
[[bin]]
name = "routeguide-server"
path = "src/server/server.rs"
[[bin]]
name = "routeguide-client"
path = "src/client/client.rs"
Podobnie jak w przypadku każdego projektu musimy też pomyśleć o zależnościach, które są niezbędne do działania naszego kodu. W przypadku projektów w Rust zależności będą się znajdować w pliku Cargo.toml
. Wymagane zależności zostały już wymienione w pliku Cargo.toml
.
Następnie uruchom te polecenia z naszych katalogów roboczych:
- Uruchom serwer w jednym terminalu:
RUSTFLAGS="-Awarnings" cargo run --bin routeguide-server
- Uruchom klienta w innym terminalu:
RUSTFLAGS="-Awarnings" cargo run --bin routeguide-client
Zobaczysz dane wyjściowe podobne do tych:
*** SERVER STREAMING *** FEATURE: Name = "Patriots Path, Mendham, NJ 07945, USA", Lat = 407838351, Lon = -746143763 FEATURE: Name = "101 New Jersey 10, Whippany, NJ 07981, USA", Lat = 408122808, Lon = -743999179 FEATURE: Name = "U.S. 6, Shohola, PA 18458, USA", Lat = 413628156, Lon = -749015468 ... *** CLIENT STREAMING *** Traversing 86 points SUMMARY: Feature Count = 0, Distance = 803709356 *** BIDIRECTIONAL STREAMING *** Note: Latitude = 409146138, Longitude = -746188906, Message = "at 112.45µs" Note: Latitude = 409146139, Longitude = -746188906, Message = "at 1.00011245s" Note: Latitude = 409146140, Longitude = -746188906, Message = "at 2.00011245s"
8. Co dalej?
- Dowiedz się, jak działa gRPC, z artykułów Wprowadzenie do gRPC i Podstawowe pojęcia.
- Zapoznaj się z samouczkiem dotyczącym podstaw.