Pierwsze kroki z gRPC-Rust – przesyłanie strumieniowe

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, RecordRouteRouteChat.
  • Typy wiadomości Point, Feature, Rectangle, RouteNoteRouteSummary, 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 12 to unikalne identyfikatory poszczególnych pól w strukturze message.

Następnie określ Featuretyp 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, RouteNoteRouteSummary.
  • 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ć RouteGuideserwerRouteGuide. 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.

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:

  1. Określ port, którego mamy używać do nasłuchiwania żądań klientów
  2. Utwórz RouteGuideService z wczytanymi funkcjami.
  3. Utwórz instancję serwera gRPC za pomocą RouteGuideServer::new(), korzystając z utworzonej usługi.
  4. Zarejestruj implementację usługi na serwerze gRPC.
  5. 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 ListFeaturesStreammessage(), 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:

  1. Uruchom serwer w jednym terminalu:
RUSTFLAGS="-Awarnings" cargo run --bin routeguide-server 
  1. 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?