gRPC, czyli mikrousługi po nowemu (staremu)! – część 1
Rozwijając temat – czyli czym jest protobuf i gRPC. Spróbujemy przejść od modelu “unary” do “bidirectional streaming”. Odkryjemy odkryte i wrócimy do “wywołania metod” porzucając “udostępnianie zasobów”.
Pokolenie HTTP/1.1
W 1997 r. ukazuje się RFC 2068 Hypertext Transfer Protocol -- HTTP/1.1. To kontynuacja prac nad rozwojem protokołu zainicjowana przez “wynalazcę www”, za którego powszechnie uważany jest sir Tim Berners-Lee. Między innymi za te zasługi został mu nadany brytyjski tytuł szlachecki. Co potem? Długo, długo nic i… w maju 2015 r. pojawia się RFC 7540 Hypertext Transfer Protocol Version 2 (HTTP/2) – zbudowany jako kontynuacja i na doświadczeniach projektu Google SPDY (“speedy”). W tym okresie zmieniliśmy fryzury i proporcje ekranów telewizyjnych. W 97 r. bezołowiową 98 można było kupić na cepeenie za 1,77 zł/l. I wszystko to na protokole HTTP/1.1.
HTTP/1.1 vs. HTTP/2
Jest nowa wersja protokołu i co możemy z nią zrobić? Jaka jest podstawowa różnica (bez wchodzenia w niuanse)? Oczywiście mówimy o komunikacji między klientem i serwerem. Zawsze coś jest klientem (czasami ktoś i czasami “tego pana nie obsługujemy”) i coś jest serwerem.
W HTTP/1.1, w uproszczeniu, nawiązujemy połączenie TCP, wysyłamy żądanie (wraz z nagłówkami i być może innymi “cudami”), otrzymujemy odpowiedź. Takich żądań, żeby zrealizować jakieś sensowne zadanie, często potrzebujemy wiele, więc historia się powtarza: nawiązujemy kolejne połączenie, wysyłamy żądanie wraz z…, otrzymujemy odpowiedź, po czym nawiązujemy połączenie…
HTTP/2 działa inaczej! Nawiązujemy stałe połączenie TCP, które będzie komunikacyjną “rurą” między klientem a serwerem. W ramach tego połączenia przesyłamy wiele żądań i odbieramy wiele odpowiedzi. Mamy pełen “multiplexing” i jeśli do tego dodamy tzw. “binary framing layer” to zaczyna wyglądać obiecująco. Wyobraźnia szaleje: strumienie danych, wydajność, znikomy narzut na komunikację w (jakże modnym) świecie mikroserwisów.
Protocol Buffers
Cytując, w wolnym tłumaczeniu, protocol buffers to niezależny od języka i platformy rozszerzalny mechanizm do serializacji ustrukturyzowanych danych. Samo rozwiązanie nie jest jakoś nadzwyczaj oryginalne w swojej koncepcji, ale dla nas jest o tyle istotne, że gRPC używa protocol buffers do komunikacji.
Musimy wyjść od pliku .proto. Pierwszym istotnym elementem takiego pliku będzie definicja komunikatów (message), czyli naszych struktur danych wykorzystywanych w komunikacji między klientem a serwerem. Każdy message składa się z pól, a każde pole zawiera co najmniej typ danych, nazwę pola i… kolejność pola w komunikacie/strukturze. Jest to o tyle istotne, że w przypadku protobuf/gRPC przesyłamy dane binarnie w uporządkowanym strumieniu bajtów. Skoro znamy strukturę, którą określiliśmy w naszym kontrakcie .proto nie ma potrzeby przesyłania w każdym komunikacie nazwy pola w postaci znacznika (XML) czy klucza (JSON) jak to robimy, przesyłając dane tekstowo.
Drugim kluczowym elementem pliku będzie definicja usługi (service) składającej się z zestawu zdalnych metod/procedur (rpc). Każda metoda ma komunikat wejściowy i wyjściowy oraz definicję czy komunikat jest elementem strumienia (stream), co jednoznacznie określa nam jeden z czterech rodzajów/trybów wywołania (o czym poniżej).
Skoro już mamy plik .proto kolejnym krokiem będzie wygenerowanie kodu źródłowego dla jednego z dostępnych języków programowania. Kod taki będzie zawierał zarówno odwzorowanie komunikatów (klasy, struktury, typy) jak i szkielet wywołania zdalnych procedur. W tym celu należy wywołać kompilator protocol buffer (protoc) z właściwymi przełącznikami określającymi m.in. docelowy język programowania i przekazać nasz kontrakt - plik .proto.
new [==,!=] old
“Ja ją gdzieś widziałem” – mówi Maks o szefowej “Archeo”...
W 1991 r. rusza CORBA = Common Object Request Broker Architecture. Pierwszym krokiem do stworzenia aplikacji jest specyfikacja obiektów i interfejsów w pliku .idl (IDL = Interface Definition Language). W kolejnym należy wywołać kompilator, np. idlj (IDL-to-Java compiler), który wygeneruje… A potem była Java RMI, Remote EJB… A może coś bardziej uniwersalnego, niezależnego od języka, opartego na powszechnym protokole? SOAP i XML na HTTP! Definiujemy kontrakt w postaci WSDL (znowu XML), generujemy szkielet aplikacji (klienta lub serwera). Czasami pojawiają się problemy na styku różnych języków i frameworków, ale prawie zawsze działa. To może coś lżejszego? REST i JSON! Jest zwinniej. Kontrakt możemy udostępnić w postaci OpenAPI/Swagger a do wywołania wystarczy curl. Standardu de facto nie ma, rodzą się dobre praktyki. Pojawia się zdefiniowany przez Richardsona model dojrzałości - od poziomu 0 do poziomu 3. Na drugim używamy zasobów (resources) w powiązaniu z właściwymi metodami HTTP (HTTP verbs).
Pojawia się gRPC ze swoim .proto. Pętla się domyka… Programista z włosami pokrytymi szlachetną patyną zamienia się biurkiem z kolegą z fryzurą samuraja!
4 rodzaje/tryby w gRPC
Komunikacja przebiega oczywiście między klientem i serwerem. Mając do dyspozycji kombinacje pojedynczego żądania i strumienia otrzymujemy cztery rodzaje/tryby metod gRPC.
Unary
Klient wysyła pojedyncze żądanie do serwera i otrzymuje zwrotnie pojedynczą odpowiedź. Przypomina to klasyczne wywołanie metody, która przyjmuje na wejściu parametr (być może wiele) i zwraca wynik. Będzie to też odpowiednik wywołania SOAP/REST. Wysyłamy pojedyncze żądanie, otrzymujemy pojedynczą odpowiedź.
Przykładem może być funkcja Add, która dodaje dwie liczby. Klient wysyła pojedyncze żądanie (komunikat, który zawiera dwie liczby) i otrzymuje pojedynczą odpowiedź (komunikat, który zawiera sumę liczb).
Server streaming
Klient wysyła pojedyncze żądanie do serwera i otrzymuje zwrotnie strumień. Klient może czytać ze strumienia tak długo, jak serwer wysyła dane. gRPC gwarantuje kolejność komunikatów w strumieniu w ramach pojedynczego wywołania metody.
Przykładem może być funkcja PrimeNumberDecomposition. Klient wysyła pojedyncze żądanie (komunikat, który zawiera jedną liczbę) i otrzymuje zwrotnie strumień liczb. Elementy tego strumienia serwer przesyła na bieżąco, w momencie kiedy wyznaczy kolejną liczbę. Klient nie musi czekać na zakończenie działania metody. A może wyszukiwanie i udostępnianie dużych zbiorów danych? Wysyłamy zapytanie i, zamiast stronicować wyniki, serwer na bieżąco w strumieniu dosyła wyniki tak długo, jak może coś wyszukać albo klient powie dość.
Client streaming
Klient “otwiera” strumień – wysyła sekwencję komunikatów do serwera. Kiedy zakończy wysyłanie danych, czeka na pojedynczą odpowiedź serwera.
Przykładem może być funkcja ComputeAverage. Klient wysyła dowolnie długi strumień liczb, w odpowiedzi otrzymuje pojedynczą wartość.
Bidirectional streaming
Obie strony komunikacji, zarówno klient jak i serwer, wykorzystują strumienie do wysyłania komunikatów. Strumienie funkcjonują niezależnie, co oznacza, że klient i serwer mogą dokonywać zapisu/odczytu w kolejności w jakiej chcą. Inaczej - jeden komunikat w strumieniu “od serwera” nie musi być odpowiedzią na jeden komunikat ze strumienia “od klienta”.
Przykładem może być funkcja FindMaximum. Klient wysyła dowolnie długi strumień liczb i równocześnie zaczyna odbierać komunikaty z serwera. Komunikat zwrotny zawiera aktualną największą wartość z ciągu liczb, który wysyłamy. Jeśli wysyłamy ciąg rosnący po każdym wysłanym elemencie, otrzymamy komunikat zwrotny, jeśli wysyłamy wartości losowe tylko wtedy gdy wartość jest większa od aktualnego maksimum.
Zakończenie wstępu
Duży generalnie może więcej (albo przynajmniej tak mu się wydaje). Mrówki są małe, ale dzięki dużej mocy obliczeniowej i dobremu protokołowi komunikacji systemy składające się z setek tysięcy mikroserwisów (mikromrówek) po prostu działają. Jak to mawiał dr Marciniak: “Ten przedmiot nazywa się metody numeryczne i tak macie wpisane w indeksach. Ale to nie są metody numeryczne! To jest wstęp do podstaw elementów metod numerycznych”.
Mamy zatem wstęp do gRPC. W kolejnej części, “na zaliczenie”, zdefiniujemy usługę (utworzymy plik .proto, wywołamy kompilator protoc lub użyjemy pluginu dla Gradle), zaimplementujemy część serwerową w języku Go (żeby było “fancy”) i klienta w języku Java (żeby udowodnić, że to może działać “na poważnie/na produkcji”).
Przejdź do 2 części artykułu poświęconej usłudze. Współnie utworzymy plik .proto, wywołamy kompilator protoc i zaimplementujemy część serwerową w języku Go.