gRPC w praktyce: biblioteki, protokoły i typowanie między usługami

0
36
Rate this post

Nawigacja:

Historia z produkcji: kiedy REST przestaje wystarczać

Wyobraź sobie mikroserwisową platformę e‑commerce: kilkanaście usług, każda grzecznie wystawia REST API. Gdy biznes rośnie, ruch też rośnie – nagle pojawia się lawina małych wywołań HTTP, rosną opóźnienia, a alerty o przekroczonych SLA nie mieszczą się w Slacku. Zespół zaczyna dławić się na pozornie prostych operacjach „policz koszyk” czy „sprawdź dostępność produktów”.

REST na wysokim obciążeniu – typowy scenariusz

REST w oparciu o HTTP/1.1 i JSON sprawdza się świetnie przy integracjach zewnętrznych i przy projektach, gdzie czytelność i prostota mają priorytet. Problem zaczyna się, gdy mikroserwisów jest kilkanaście lub kilkadziesiąt, a każdy request do API końcowego oznacza kaskadę kolejnych wywołań REST między usługami. Każde takie wołanie to osobne połączenie lub osobny request, osobne nagłówki, parsowanie JSON, serializacja, logowanie.

Pojawiają się symptomy:

  • Nadmiarowe dane w JSON – zwracane są duże obiekty, choć klient potrzebuje tylko kilku pól.
  • „Czatowanie” między usługami – kilka małych endpointów REST wywoływanych sekwencyjnie zamiast jednego, dobrze skrojonego wywołania.
  • Brak silnego typowania – przypadkowe zmiany w JSON (np. typ pola, nazwa) wywołują trudne do diagnozy błędy runtime.
  • Skomplikowane schematy – dokumentacja OpenAPI nie nadąża za zmianami, a kontrakty między zespołami rozjeżdżają się w czasie.

Koszt procesora rośnie, bo parsowanie dużych struktur JSON jest drogie. Czas odpowiedzi rośnie, bo każdy request oznacza kolejne handshake’y, kolejne nagłówki, kolejne kolejki w load balancerach.

Moment, w którym na horyzoncie pojawia się gRPC

W pewnym momencie ktoś z zespołu backendowego wrzuca do slackowego kanału link: „Może by tak gRPC?”. Binarny protokół, dużo lżejszy od JSON, oparcie o HTTP/2 z multiplexingiem, streaming, silne typowanie dzięki Protobuf. Na papierze wygląda jak panaceum, w praktyce to konkretny zestaw narzędzi do rozwiązania powtarzalnych problemów:

  • redukcja narzutu na każdy request (mniejsze payloady, mniej nagłówków),
  • utrzymanie jednego połączenia i wiele równoległych wywołań,
  • silny, generowany kontrakt między usługami (Protobuf),
  • łatwiejsza komunikacja polyglot – różne języki, ten sam kontrakt .proto.

W przejściu z REST na gRPC kluczowa jest zmiana myślenia: z „wysyłamy JSON po HTTP” na „mamy zdefiniowany kontrakt RPC, generujemy kod i korzystamy z silnego typowania na całej trasie”. Wtedy gRPC przestaje być modnym buzzwordem, a staje się konkretną odpowiedzią na problemy skalowalności i spójności komunikacji.

REST vs gRPC – sygnały, że czas na zmianę

Nie wszystkie systemy wymagają gRPC, ale w pewnych warunkach jego sens staje się oczywisty. Typowe sygnały, że REST wewnątrz systemu zaczyna ciążyć:

  • znaczna część ruchu to komunikacja wewnątrz klastra, a nie API publiczne,
  • duża liczba małych wywołań REST pomiędzy tymi samymi usługami (chattiness),
  • częsta zmiana kontraktów, problemy z kompatybilnością payloadów JSON,
  • wielojęzyczne zespoły (Go, Java, Node.js, Python) i powtarzalne bugi na styku serializacji/deserializacji.

W takich środowiskach gRPC pozwala zbić narzut komunikacji, uporządkować kontrakty i odzyskać kontrolę nad tym, co naprawdę „płynie po drucie” między usługami.

Wniosek z produkcji

gRPC nie zastąpi REST tam, gdzie potrzebna jest prosta integracja z przeglądarką czy zewnętrznymi partnerami. Jego siła leży wewnątrz systemu: w komunikacji między usługami, gdzie binarny protokół, HTTP/2 i silne typowanie amortyzują koszt rosnącej złożoności mikroserwisów.

Fundamenty gRPC – jak działa pod maską

Aby wyciągnąć z gRPC maksimum, trzeba zrozumieć jego warstwę transportu i model programowania RPC. Wtedy zachowanie systemu pod obciążeniem przestaje zaskakiwać, a konfiguracja przestaje być „czarną magią”.

HTTP/2 jako baza transportu

gRPC wykorzystuje HTTP/2 jako warstwę transportową. To fundamentalna różnica względem klasycznych API RESTowych korzystających z HTTP/1.1. Najważniejsze cechy HTTP/2 z punktu widzenia gRPC:

  • Multiplexing – wiele równoległych strumieni (requests) na jednym połączeniu TCP. Dzięki temu jedno połączenie między klientem a serwerem może obsłużyć kilkadziesiąt wywołań RPC naraz, bez blokowania „konektów”.
  • Utrzymywanie połączenia – klient gRPC typowo utrzymuje długotrwały kanał (channel) do serwera, zamiast tworzyć nowe połączenie dla każdego żądania.
  • Nagłówki binarne i kompresja HPACK – mniejsze, efektywniej kodowane metadane HTTP, co dodatkowo zmniejsza narzut.
  • Streamy dwukierunkowe – HTTP/2 wspiera strumienie w obie strony, co gRPC mapuje na streaming serwerowy, kliencki i dwukierunkowy.

W praktyce oznacza to niższe opóźnienia przy dużej liczbie małych wywołań, a także możliwość użycia streamingu zamiast „pętli” po kolejnych requestach HTTP.

Model RPC: klient, serwer, stuby i kanały

gRPC implementuje klasyczny model zdalnego wywołania procedur (Remote Procedure Call):

  • Serwer – implementuje interfejs wygenerowany z definicji .proto. Rejestruje usługi i metody, nasłuchuje na porcie, obsługuje przychodzące wywołania.
  • Klient – używa wygenerowanych stubów (proxy), które udostępniają metody odpowiadające definicji RPC. Dla programisty wygląda to jak zwykłe wywołanie metody.
  • Kanał (channel) – reprezentuje długotrwałe połączenie z serwerem gRPC. Przez ten kanał wysyłane są wszystkie wywołania, obsługiwane są retry, rozwiązywana jest nazwa hosta, TLS itp.

Stub klienta martwi się serializacją Protobuf, dodaniem metadanych, zarządzaniem timeoutami, retry. Programista widzi metodę, np. GetProduct(), i otrzymuje obiekt o typie zdefiniowanym w Protobuf.

Typy wywołań gRPC – kiedy który stosować

gRPC oferuje cztery podstawowe typy wywołań:

  • Unary RPC – klasyczny request/response, jeden komunikat wejściowy, jeden wyjściowy. Odpowiednik typowego endpointu REST: GetUser, CreateOrder.
  • Server streaming – klient wysyła jeden request, serwer strumieniuje wiele odpowiedzi. Przykład: subskrypcja logów, lista wyników generowana stopniowo.
  • Client streaming – klient strumieniuje wiele komunikatów do serwera, a na końcu otrzymuje pojedynczą odpowiedź. Przykład: wysyłanie partii danych telemetrycznych, a następnie zbiorcze podsumowanie.
  • Bidirectional streaming – oba kierunki są strumieniami; klient i serwer mogą wysyłać wiadomości niezależnie. Przykład: czat, realtime trading, interaktywne przetwarzanie.

Dobrze dobrany typ wywołania często redukuje liczbę requestów i opóźnienia. Zamiast wykonywać po kolei 100 zapytań unary, lepiej wysłać 100 komunikatów w jednym strumieniu i dać serwerowi możliwość odpowiadania na bieżąco.

Struktura pojedynczego wywołania gRPC

Na poziomie protokołu jedno wywołanie gRPC składa się z kilku elementów:

  • Metadane (metadata, headers) – para klucz–wartość, podobne do nagłówków HTTP. Mogą zawierać dane autoryzacyjne (np. token), trace ID, informacje o kliencie.
  • Payload – zakodowany binarnie komunikat Protobuf (lub inny format, jeśli zastosowano alternatywny codec).
  • Status – kod statusu gRPC (np. OK, NOT_FOUND, UNAVAILABLE) zwracany na końcu wywołania.
  • Trailing metadata – dodatkowe metadane zwracane po payloadzie, często z informacjami diagnostycznymi lub dodatkowymi danymi.

Dzięki wyraźnemu rozdzieleniu payloadu i statusu można elegancko modelować błędy, retry, a także dodatkowe informacje (np. szczegółowe opisy błędów).

Wniosek z warstwy transportu

Zrozumienie, jak gRPC opiera się na HTTP/2 i jak działa model streamów, pozwala projektować metody i konfiguracje tak, by skalowały się przewidywalnie. Gdy wiesz, że jedno połączenie może przenosić setki równoległych RPC, inaczej myślisz o poolach połączeń, timeoutach i mechanizmach retry.

Zbliżenie smartfona z ikonami aplikacji Google i Mail
Źródło: Pexels | Autor: Torsten Dettlaff

Protobuf jako kontrakt – definiowanie, rozwój i kompatybilność

Serce gRPC to nie tyle sam protokół RPC, co kontrakt Protobuf między usługami. To właśnie pliki .proto określają, jakie wiadomości są wymieniane, jak nazywają się metody i jakie typy danych przechodzą po drucie.

Składnia plików .proto – podstawy

Przykładowy plik Protobuf dla usługi katalogu produktów może wyglądać tak:

syntax = "proto3";

package catalog.v1;

option go_package = "github.com/myorg/catalog/proto;catalogpb";

message Product {
  int64 id = 1;
  string name = 2;
  string description = 3;
  double price = 4;
  repeated string tags = 5;
}

message GetProductRequest {
  int64 id = 1;
}

message GetProductResponse {
  Product product = 1;
}

service CatalogService {
  rpc GetProduct(GetProductRequest) returns (GetProductResponse);
}

Kluczowe elementy:

  • syntax – wersja języka Protobuf (najczęściej proto3).
  • package – przestrzeń nazw, istotna dla generowanego kodu i wersjonowania API.
  • message – definicja komunikatu (odpowiednik klasy / DTO).
  • service – interfejs RPC z listą metod.
  • option – dodatkowe informacje dla generatorów (np. docelowy pakiet w Go, Java).

Każde pole w message ma swój unikalny numer tagu, używany w binarnym kodowaniu. Nazwy pól można zmieniać bardziej swobodnie, ale tagów zmieniać nie wolno bez głębokich przemyśleń.

Numeracja pól i typy danych

Numeracja tagów w Protobuf jest kluczowa dla kompatybilności wstecznej. Podstawowe reguły:

  • tagi muszą być unikalne w obrębie jednego komunikatu,
  • tagów używa się do serializacji (nie nazw pól),
  • po usunięciu pola warto zarezerwować jego tag, aby nie został użyty ponownie.

Przykład zarezerwowania pola:

message Product {
  int64 id = 1;
  string name = 2;
  // field 3 removed
  reserved 3;
  double price = 4;
}

Protobuf oferuje wiele typów podstawowych: int32, int64, string, bool, bytes, double, float, a także kolekcje:

  • repeated – lista wartości tego samego typu,
  • map<K, V> – słownik (np. map<string, string> metadata = 10;).

Ważne, by trzymać się prostych typów i unikać niepotrzebnie zagnieżdżonych struktur, jeśli kontrakt ma być łatwy w utrzymaniu i współdzielony między zespołami.

Ewolucja schematu Protobuf – co wolno, a czego unikać

Największe problemy z gRPC i Protobuf pojawiają się podczas zmian w definicjach wiadomości. Schemat nie może być traktowany jak „kod, który się refaktoryzuje, jak chcemy”. Kilka zasad bezpiecznej ewolucji:

  • Dodawanie pól – można dodawać nowe pola z nowymi tagami. Starszy klient, który ich nie zna, po prostu je zignoruje.
  • Usuwanie pól – pole można usunąć, ale jego tag należy zarezerwować (reserved), aby nie został użyty ponownie.
  • Zmiana typu – zmiana typu pola (np. int32 na string) jest ryzykowna i w ogólności niezgodna wstecznie. Lepsze podejście: dodać nowe pole z nowym tagiem i wygasić stare.
  • Pola wymagane vs opcjonalne – w proto3 wszystkie pola są domyślnie opcjonalne; używa się wartości domyślnych. Jeśli wymagamy biznesowo danego pola, trzeba to egzekwować walidacją po stronie serwera.

Przykład „bezpiecznej” zmiany typu poprzez dodanie nowego pola:

Praktyczny przykład zmiany typu pola

Wyobraź sobie, że pole price w katalogu produktów zapisano jako double. Po kilku iteracjach biznes prosi o precyzyjne rozliczenia walutowe z kontrolą waluty, rabatów i podatków. Prosty typ zmiennoprzecinkowy przestaje wystarczać, a Ty musisz to naprawić, nie wywracając do góry całego ekosystemu usług.

Bezpieczniejsza strategia polega na równoległym wprowadzeniu nowego pola i stopniowej migracji:

message Money {
  string currency = 1; // np. "PLN", "EUR"
  int64 units = 2;     // część całkowita
  int32 nanos = 3;     // część ułamkowa (np. 1/10^9)
}

message Product {
  int64 id = 1;
  string name = 2;
  reserved 4;          // stare price = 4 (double)
  Money price_money = 5;
}

Serwer może początkowo wypełniać zarówno stare, jak i nowe pole (dla klientów, którzy jeszcze nie znają Money), a w nowszych wersjach SDK promować wyłącznie price_money. Z czasem, po zakończeniu migracji, implementacja po stronie serwera może przestać ustawiać pole historyczne, ale sam kontrakt nadal będzie kompatybilny.

Zmiany w usługach i metodach RPC

Scenariusz z produktem jest stosunkowo prosty, dopóki zmienia się wyłącznie struktura wiadomości. Trudniej robi się wtedy, gdy trzeba zmodyfikować same metody RPC albo całą usługę – np. połączyć dwa endpointy w jeden lub podzielić monolityczny serwis na kilka mniejszych.

Lista zasad, które znacznie zmniejszają ryzyko bolesnych migracji:

  • Nie zmieniaj sygnatur istniejących metod – argumentu wejściowego i typu odpowiedzi. Zamiast tego dodaj nową metodę (np. GetProductV2), a starą oznacz jako przestarzałą w dokumentacji i w kodzie (adnotacja @Deprecated/[Obsolete] itp.).
  • Nie usuwaj metod bez okresu przejściowego – nawet jeśli są „tylko dla nas”. W środowiskach z wieloma zespołami szybko pojawiają się nieudokumentowane zależności.
  • Unikaj łamania semantyki – nie zmieniaj radykalnie znaczenia istniejącej metody. Jeśli GetProduct zwracało zawsze jeden produkt albo błąd, niech po zmianie nie zacznie po cichu zwracać listy.

Jeżeli musi wejść dużo zmian naraz (np. redesign API), rozsądnie jest dodać nowy serwis z nowym package i nazwą, np. catalog.v2.CatalogServiceV2, a stary „zamrozić” na czas migracji. Zewnętrzni klienci dostają jasny sygnał, który kontrakt jest przyszłościowy, a który tylko utrzymaniowy.

Niejawne wymagania a walidacja kontraktu

W wielu zespołach największym źródłem błędów nie są zmiany typu czy nazwy pola, ale nieme oczekiwania: „to pole description zawsze będzie miało co najmniej 10 znaków”, „tags nigdy nie będzie puste”. Gdy kolejny zespół buduje klienta, interpretacja tych założeń szybko się rozjeżdża.

Część wymagań można (i warto) wyrażać wprost w kontrakcie, przy pomocy rozszerzeń lub walidatorów. Popularne podejścia:

  • używanie komentarzy w .proto jako źródła dokumentacji generowanej do kodu i portali API,
  • wykorzystanie rozszerzeń typu protoc-gen-validate, które pozwalają dodawać atrybuty walidacyjne (np. minimalna długość, zakres liczbowy),
  • definiowanie osobnych komunikatów request/response zamiast wykorzystywania jednego wielkiego DTO „do wszystkiego”.

Kiedy reguły walidacji stoją blisko kontraktu, łatwiej jest zauważyć, że zmiana typu lub semantyki pola wpływa na zachowanie klientów w innych usługach. To szczególnie ważne, gdy generuje się klienty w kilku językach jednocześnie.

Typowanie end-to-end – od Protobuf do kodu w wielu językach

W pewnym momencie zespoły backendowe dorzucają kolejne języki: obok Javy pojawia się Go, dołącza Node.js dla frontu BFF, a ktoś uruchamia analitykę w Pythonie. Każdy chce mieć „ładne” typy, najlepiej idealnie dopasowane do idiomów swojego języka, ale wszyscy muszą mówić tym samym kontraktem.

Generator kodu i narzędzia wokół protoc

Centralnym elementem jest kompilator Protobuf – protoc. Sam z siebie generuje tylko kod do obsługi wiadomości (serializacja/deserializacja), ale z wtyczkami produkuje również stuby gRPC. Typowy pipeline wygląda następująco:

protoc 
  --proto_path=./proto 
  --go_out=./gen/go --go_opt=paths=source_relative 
  --go-grpc_out=./gen/go --go-grpc_opt=paths=source_relative 
  --java_out=./gen/java 
  --grpc-java_out=./gen/java 
  proto/catalog/v1/catalog.proto

W praktyce proces ten jest opakowany w skrypty (Makefile, Gradle, npm scripts) lub narzędzia wyspecjalizowane (np. buf). Kluczowe jest, by generowanie było powtarzalne, zdeterministyczne i uruchamiane zarówno lokalnie, jak i w CI.

Mapowanie typów Protobuf na języki programowania

Każdy język mapuje typy Protobuf trochę inaczej. Czasami różnice są kosmetyczne, czasem przesądzają o tym, jak projektować schemat, by był wygodny w użyciu po obu stronach drutu.

  • Go – pola mapują się na struktury ze zwykłymi typami lub wskaźnikami (dla „optional”). repeated to slice ([]T), map<K,V> to mapa. Prostota kusi, żeby całe API projektować „pod Go”.
  • Java/Kotlin – wiadomości są klasami z builderami. repeated to listy, map to mapy. Wymóg mutowalności buildera wpływa czasem na sposób korzystania z typów (np. eksponowanie niezmiennych widoków na kolekcje).
  • TypeScript/JavaScript – zależnie od generatora: albo klasy z metodami, albo „zwykłe” interfejsy/typy. Tu szczególnie dotkliwe stają się rozbieżności między wartościami domyślnymi Protobuf a zachowaniem JS (np. undefined vs. wartości domyślne).

Jeśli te różnice są z tyłu głowy podczas projektowania .proto, unikniesz kontraktów, które są wygodne w jednym języku, a toporne w innym. Przykład: nadużywanie oneof może być czytelne w Go, a mało ergonomiczne w TypeScript, jeśli generator produkuje rozbudowane unie sumujące.

Konwencje nazewnicze i struktura pakietów

Chaos zaczyna się najczęściej od nazewnictwa. Jedna usługa używa product_id, inna id, a trzecia productId. Po roku debugowania integracji nikt już nie wie, co do czego pasuje. Konsekwentne nazewnictwo i struktura pakietów Protobuf to tania inwestycja, która się zwraca.

Kilka praktycznych reguł:

  • ujednolicone style nazw pól – w Protobuf typowo snake_case (product_id), niezależnie od stylu w języku docelowym,
  • pakiety z wersjonowaniem – np. catalog.v1, billing.v1, niekiedy z prefiksem organizacji (myorg.catalog.v1),
  • osobne pakiety dla kontraktów dzielonych między domenami, zamiast powielania tych samych komunikatów (np. common.types z Money, Address).

Wygodnym wzorcem jest wydzielenie katalogu proto/ w monorepo lub osobnego repozytorium z samymi .proto i generatami klienta. Wtedy każda usługa konsumuje ten sam artefakt (np. paczkę Maven, moduł Go, pakiet npm) i sama generacja kodu jest zcentralizowana.

Kontrakt jako osobny artefakt w CI/CD

W dojrzałych instalacjach gRPC kontrakt żyje własnym życiem. Nie jest wyłącznie „plikiem w katalogu”, ale produktem, który ma wersje, changelog i testy. Dobrym wzorcem jest pipeline, w którym:

  1. zmiana .proto przechodzi przez linting (np. buf lint) i testy kompatybilności (np. buf breaking),
  2. po akceptacji generowane są paczki dla poszczególnych języków: Javy, Go, TypeScriptu itp.,
  3. te paczki są publikowane do wewnętrznego rejestru (Maven, npm, GitHub Packages),
  4. poszczególne usługi zależą już od wersjonowanych artefaktów kontraktu, a nie od „lokalnych” kopii .proto.

Taki układ zwiększa przejrzystość: zmiana komunikatu staje się świadomą zmianą zależności w innych projektach. Nie pojawiają się niespodzianki w stylu „serwis A odświeżył generaty, serwis B jeszcze nie, a wszystko niby korzysta z tej samej gałęzi repozytorium”.

Zbliżenie kart SIM i kluczyka na białym tle
Źródło: Pexels | Autor: Pascal 📷

Rozszerzenia Protobuf i niestandardowe typy dziedzinowe

W wielu zespołach następuje moment, gdy typy podstawowe nie wystarczają. Pojawiają się domenowe konstrukcje: identyfikatory o określonym formacie, specyficzne kody statusów, bogate typy daty i czasu. Kontrakt gRPC może je odzwierciedlać na kilka sposobów – od prostych aliasów, po pełne rozszerzenia z własnym kodowaniem.

Wspólne typy bazowe: czas, pieniądze, identyfikatory

Dobrym pierwszym krokiem jest wydzielenie „klocków”, które powtarzają się wszędzie: Money, Timestamp, UUID. Można skorzystać z gotowych typów z ekosystemu Google Protobuf, np.:

  • google.protobuf.Timestamp – czas w formacie UTC z nanosekundami,
  • google.protobuf.Duration – przedział czasu,
  • google.protobuf.Any – „opakowanie” na dowolną wiadomość (użyteczne, ale łatwo je nadużyć).

Własne typy domenowe tworzy się jako zwykłe message, pakowane w osobnych pakietach, np.:

syntax = "proto3";

package common.types.v1;

message Uuid {
  string value = 1;
}

message Money {
  string currency = 1;
  int64 units = 2;
  int32 nanos = 3;
}

Wtedy każdy kontrakt domenowy odwołuje się do nich wprost:

import "common/types/v1/money.proto";
import "common/types/v1/uuid.proto";

message Product {
  common.types.v1.Uuid id = 1;
  string name = 2;
  common.types.v1.Money price = 3;
}

Po pewnym czasie taka „biblioteka typów” staje się fundamentem wszystkich usług. Znika problem powielonych definicji dat, kwot i identyfikatorów – a więc także możliwość rozjechania się formatu lub semantyki w różnych mikroserwisach.

Oneof – typy sumujące a kompatybilność

Zdarza się, że w ramach jednego pola trzeba przenieść różne warianty tego samego bytu. Klasyczny przykład: adres może być krajowy albo międzynarodowy, a informacje w nich zawarte różnią się szczegółami. Protobuf udostępnia konstrukcję oneof, która działa jak typ sumujący.

message DomesticAddress {
  string street = 1;
  string city = 2;
  string postal_code = 3;
}

message InternationalAddress {
  string address_line = 1;
  string country_code = 2;
}

message ShippingAddress {
  oneof kind {
    DomesticAddress domestic = 1;
    InternationalAddress international = 2;
  }
}

Taka definicja wymusza, że w danej instancji ustawione będzie co najwyżej jedno z pól. Z punktu widzenia kompatybilności oneof ma jednak specjalne reguły:

  • nie wolno przenosić istniejącego pola do oneof ani z oneof bez złamania kompatybilności,
  • pola typu oneof mogą być dodawane (z nowymi tagami), ale usuwanie wymaga rezerwacji tagów i najczęściej wprowadzenia nowego oneof lub nowej wersji wiadomości.

oneof bywa potężnym narzędziem, szczególnie gdy buduje się SDK z bogatym modelem dziedzinowym. Warto je jednak stosować tam, gdzie przewiduje się raczej dodawanie nowych wariantów niż agresywne zmiany istniejących.

Custom options i metadane kontraktu

Kiedy struktura danych stabilizuje się, na pierwszy plan wychodzą metadane. Trzeba oznaczyć pola jako PII, wskazać, które są indeksowane w wyszukiwarce, albo opisać mapowanie na tabele w bazie danych. Zamiast utrzymywać to w zewnętrznych dokumentach, można wykorzystać mechanizm custom options.

Protobuf pozwala definiować własne opcje, które potem są odczytywane przez generatory lub narzędzia pomocnicze. Przykładowo:

Opcje niestandardowe w praktyce: PII, indeksowanie, mapowanie

Podczas przeglądu schematów pod RODO jeden z zespołów odkrył, że nie jest w stanie szybko odpowiedzieć na pytanie: „które pola w gRPC zawierają dane osobowe?”. Odpowiedź była rozsiana po Confluence, komentarzach w kodzie i w głowach ludzi. Schematy Protobuf formalnie były poprawne, ale kompletnie ślepe na aspekt zgodności.

Opcje niestandardowe rozwiązują dokładnie ten problem: pozwalają związać metadane bezpośrednio z polem lub wiadomością. Definiuje się je raz, w osobnym pliku z rozszerzeniami:

syntax = "proto3";

package common.options.v1;

import "google/protobuf/descriptor.proto";

extend google.protobuf.FieldOptions {
  bool pii = 50001;
  bool searchable = 50002;
}

extend google.protobuf.MessageOptions {
  string db_table = 51001;
}

Następnie opcje można stosować we właściwych kontraktach:

syntax = "proto3";

package customer.v1;

import "common/options/v1/annotations.proto";

message Customer {
  option (common.options.v1.db_table) = "customers";

  string id = 1;
  string email = 2 [(common.options.v1.pii) = true,
                   (common.options.v1.searchable) = true];
  string phone = 3 [(common.options.v1.pii) = true];
}

Generator kodu lub osobne narzędzie (np. mały program w Go/Node, który parsuje FileDescriptorSet) może teraz:

  • zbudować listę pól z PII, która trafi do rejestru ochrony danych,
  • wygenerować konfigurację indeksów dla wyszukiwarki,
  • sprawdzić, czy każda wiadomość mapowana na tabelę ma klucz główny.

W praktyce taki plik z rozszerzeniami staje się „językiem meta” dla całej organizacji. Jeśli pojawia się nowy wymóg (np. oznaczenie pól szyfrowanych w spoczynku), dodaje się kolejną opcję i wdraża ją w schematach – bez konieczności wprowadzania dodatkowych adnotacji w każdym języku docelowym.

Generatory świadome opcji niestandardowych

Sama obecność opcji w .proto niewiele daje, jeśli nikt z nich nie korzysta. Największy zysk jest wtedy, gdy generatory klientów i serwerów potrafią czytać te metadane i coś z nimi zrobić.

Możliwości są różne, zależnie od ekosystemu:

  • w Go można pisać własne protoc plugins, które bazując na opcjach generują dodatkowy kod (np. validatory, logowanie z maskowaniem PII),
  • w Javie i Kotlinie można użyć refleksji na deskryptorach Protobuf (np. Descriptors.FieldDescriptor), aby dynamicznie oznaczyć pola jako wrażliwe i przekazać tę informację do warstwy logów lub ORM,
  • w TypeScript/Node często powstają osobne narzędzia, które zamieniają FileDescriptorSet w konfiguracje (np. JSON z metadanymi dla UI).

Jeśli generatory i runtime korzystają z tych samych opcji, to kontrakt zaczyna naprawdę „rządzić” zachowaniem systemu, a nie tylko strukturą danych. Modyfikacja pola z PII na nie-PII staje się zmianą kodu o konkretnych konsekwencjach: przepływy maskowania, audyt, dostęp w narzędziach analitycznych.

Walidacja danych po obu stronach drutu

Przy jednej z kampanii marketingowych backend przyjmował tysiące błędnych żądań, bo formularz frontendowy przepuszczał niepoprawne adresy e-mail. Backend miał walidację rozproszoną po kilku serwisach, w różnych językach. Zgranie tego w całość zajęło kilka sprintów – głównie dlatego, że walidacja nie była częścią kontraktu.

Opis walidacji w schemacie

Najprostsza droga do spójnej walidacji to opisanie jej w .proto w postaci opcji niestandardowych, a następnie wygenerowanie z nich logiki w usługach. Przykładowy plik z regułami walidacji:

syntax = "proto3";

package common.validation.v1;

import "google/protobuf/descriptor.proto";

extend google.protobuf.FieldOptions {
  int64 min_length = 60001;
  int64 max_length = 60002;
  string pattern = 60003;
  bool required = 60004;
}

Użycie w kontrakcie:

syntax = "proto3";

package customer.v1;

import "common/validation/v1/rules.proto";

message CreateCustomerRequest {
  string email = 1 [
    (common.validation.v1.required) = true,
    (common.validation.v1.pattern) = "^[^@]+@[^@]+$",
    (common.validation.v1.max_length) = 254
  ];

  string first_name = 2 [
    (common.validation.v1.required) = true,
    (common.validation.v1.max_length) = 100
  ];

  string last_name = 3 [
    (common.validation.v1.required) = true,
    (common.validation.v1.max_length) = 100
  ];
}

Po takiej adnotacji można:

  • wygenerować walidatory po stronie serwera (np. plugin do protoc-gen-go czy biblioteka w Javie, która patrzy w deskryptory),
  • wygenerować walidację do frontendu – choćby w postaci JSON-a z regułami czy fragmentów TypeScriptu dla formularza.

Dużą korzyścią jest to, że reguły walidacji są wersjonowane razem z kontraktem. Zmiana dopuszczalnej długości pola staje się zmianą w .proto, a nie w czterech różnych modułach rozrzuconych po systemie.

Walidacja na poziomie wiadomości vs pól

Nie wszystkie zasady dają się opisać na pojedynczych polach. Często walidacja zależy od relacji między polami: albo jedno, albo drugie; oba albo żadne; pole B wymagane, gdy pole A ma konkretną wartość.

Można to rozwiązać dwoma sposobami:

  • przy użyciu dodatkowych opcji na poziomie wiadomości (np. message_rules z listą prostych reguł),
  • poprzez wygenerowanie „szkieletu” walidatora z komentarzem, a pełną logikę dopisuje programista.

Przykład prostego podejścia z opcją na wiadomości:

syntax = "proto3";

package common.validation.v1;

import "google/protobuf/descriptor.proto";

message MessageRule {
  string expression = 1;
  string message = 2;
}

extend google.protobuf.MessageOptions {
  repeated MessageRule rules = 61001;
}
message ScheduleCampaignRequest {
  google.protobuf.Timestamp start_time = 1;
  google.protobuf.Timestamp end_time = 2;

  option (common.validation.v1.rules) = {
    expression: "end_time > start_time"
    message: "end_time must be after start_time"
  };
}

Po stronie serwera plugin może zamienić takie reguły na odpowiednie sprawdzenia (np. DSL do silnika reguł), a na froncie posłużą choćby jako komunikaty walidacyjne.

Kobieta w domowym biurze na wideokonferencji, notuje przy laptopie
Źródło: Pexels | Autor: Ivan S

Kontrakty a ewolucja usług w czasie

W jednej z firm katalog produktów powiększał się wolniej niż lista wersji API. Zanim skończono rollout catalog.v3, część klientów wciąż siedziała na v1, a integratorzy pytali, która wersja jest „prawdziwa”. gRPC formalnie dawało kompatybilność, ale brak strategii wersjonowania doprowadził do bałaganu.

Wersjonowanie pakietów i punktów końcowych

W gRPC wersjonowanie najczęściej odbywa się w nazwie pakietu Protobuf, a nie w adresie URL. Dobrze przemyślana konwencja potrafi uchronić zespół przed eksplozją wariantów.

Sprawdza się układ:

  • pakiet zawiera wersję, np. catalog.v1, catalog.v2,
  • serwer może jednocześnie wystawić kilka wersji tej samej usługi (kilka implementacji zarejestrowanych na jednym serwerze gRPC),
  • nowe metody trafiają do nowej wersji, a stare pozostają stabilne, aż klienci zakończą migrację.

Przykład dwóch wersji w jednym serwerze (Go):

grpcServer := grpc.NewServer()

catalogv1.RegisterCatalogServiceServer(
  grpcServer,
  catalogv1.NewCatalogServiceV1Impl(deps),
)

catalogv2.RegisterCatalogServiceServer(
  grpcServer,
  catalogv2.NewCatalogServiceV2Impl(deps),
)

Wspólne typy bazowe (np. common.types.v1.Money) pomagają uniknąć zduplikowanych definicji, a jednocześnie pozwalają zmieniać sam kontrakt usług bez dotykania podstawowych bytów dziedzinowych.

Deprecation i komunikacja zmian

Schemat Protobuf pozwala oznaczyć pola i metody jako przestarzałe przy użyciu opcji deprecated. Można to rozciągnąć także na metadane specyficzne dla organizacji.

service CatalogService {
  rpc ListProducts (ListProductsRequest) returns (ListProductsResponse) {
    option deprecated = true;
  }

  rpc ListProductsV2 (ListProductsV2Request) returns (ListProductsV2Response);
}

Gdy generatory obsługują tę opcję, IDE potrafi ostrzec programistę korzystającego z przestarzałej metody. Dodatkowo, własna opcja rozszerzająca może wprowadzić np. datę planowanego usunięcia:

extend google.protobuf.MethodOptions {
  string sunset_date = 70001;
}
rpc ListProducts (ListProductsRequest) returns (ListProductsResponse) {
  option deprecated = true;
  option (common.options.v1.sunset_date) = "2025-06-30";
}

Na bazie takich oznaczeń można zbudować prosty raport techniczny lub dashboard, który pokazuje, ile usług i klientów nadal korzysta z funkcji przeznaczonych do wyłączenia.

Strategie wprowadzania niekompatybilnych zmian

Czasem nie da się uniknąć złamania kompatybilności: architektura zmienia się na tyle, że stary kontrakt nie ma sensu. W gRPC zwykle oznacza to pojawienie się nowego pakietu z wyższą wersją.

Najbezpieczniejszy scenariusz:

  1. nowa wersja kontraktu powstaje w pakiecie *.v2 (lub *.v1beta1).
  2. serwer implementuje równolegle starą i nową wersję.
  3. SDK klienta obsługuje oba kontrakty, dając prosty „adapter” migracyjny.
  4. klienci stopniowo przełączają się na nowe metody.
  5. po pełnej migracji stara wersja jest wyłączana zgodnie z wcześniej ogłoszonym terminem.

Głównym wyzwaniem jest tu zachowanie spójności typów dziedzinowych: jeśli w v2 dochodzą nowe pola (np. atrybuty produktu), warto je dodawać do istniejących typów wspólnych lub wprowadzić nowe typy z jasno zakomunikowaną relacją do starych (np. ProductV2, a nie inny byt o tej samej nazwie w innym pakiecie).

Typowanie między usługami a model dziedzinowy

W jednym z projektów zespół księgowy chciał raportować przychody „tak jak w ERP”, z dokładnością do grosza i w odpowiedniej walucie. Mikroserwisy natomiast używały trzech różnych typów na kwoty: double, int64 i własnego Money, w kilku wariantach. Integracja z hurtownią danych szybko ujawniła, jak bolesny bywa brak spójnego modelu dziedzinowego na poziomie kontraktów.

Model dziedzinowy jako wspólna biblioteka Protobuf

Kluczem do mocnego typowania między usługami jest jeden wspólny model, wokół którego wszystko się kręci. Nie chodzi o globalny monolit kontraktów, ale o zestaw dobrze zdefiniowanych bytów dziedzinowych, używanych tam, gdzie ma to sens.

Przykładowy podział:

  • common.types.v1 – prymitywy dziedzinowe: Money, Uuid, Locale,
  • catalog.types.v1 – wspólne typy katalogowe: Product, Category,
  • billing.types.v1 – typy rozliczeniowe: Invoice, LineItem.

Usługi gRPC wówczas z tych typów korzystają, zamiast wymyślać swoje odpowiedniki:

syntax = "proto3";

package billing.v1;

import "catalog/types/v1/product.proto";
import "common/types/v1/money.proto";

message CreateInvoiceRequest {
  repeated catalog.types.v1.Product products = 1;
  common.types.v1.Money total_amount = 2;
}

Taki układ utrudnia przypadkowe „rozjechanie się” definicji. Jeśli cena produktu ma być zawsze kwotą z dokładnością do grosza i kodem waluty ISO, to wszystkie usługi widzą ten sam Money, a nie własną interpretację.

Granice między kontraktem a kodem domenowym

Niektóre zespoły próbują bezpośrednio używać wygenerowanych typów Protobuf w całym kodzie aplikacji jako „modelu domenowego”. Na krótką metę to przyspiesza prace, ale po kilku iteracjach rozwój zaczyna być bolesny.

Rozsądniejszy kompromis:

  • typy Protobuf są warstwą kontraktu – reprezentacją na drucie,
  • wewnętrzny model domenowy (encje, agregaty) może mieć nieco inne kształty: relacje, metody biznesowe, niektóre pola tylko na potrzeby obliczeń,
  • mapowanie między modelem domenowym a typami Protobuf jest jawne (np. przez funkcje konwersji lub adaptery).

Dzięki temu kontrakt pozostaje stabilny i przewidywalny, a domena może ewoluować we własnym tempie. W szczególności daje to możliwość:

  • ukrycia pól technicznych (np. wewnętrznych identyfikatorów bazodanowych),
  • agregowania danych z wielu źródeł w jednej odpowiedzi API,
  • Najczęściej zadawane pytania (FAQ)

    Kiedy gRPC ma sens zamiast REST w mikroserwisach?

    Obciążenie rośnie, a ty widzisz lawinę małych requestów REST między tymi samymi usługami i coraz dłuższe czasy odpowiedzi – to pierwszy sygnał, że klasyczny JSON po HTTP zaczyna ciążyć. Szczególnie boli to w systemach, gdzie większość ruchu to komunikacja wewnątrz klastra, a nie publiczne API dla przeglądarek.

    gRPC opłaca się wtedy, gdy:

    • masz dużo małych, częstych wywołań między mikroserwisami (tzw. chattiness),
    • kontrakty często się zmieniają i łapiesz bugi na poziomie serializacji JSON,
    • system jest polyglot (np. Go + Java + Node.js + Python) i chcesz jeden spójny kontrakt .proto,
    • walczysz z opóźnieniami i CPU zjadanym przez parsowanie dużych JSON-ów.

    REST nadal świetnie sprawdza się na brzegu systemu (przeglądarka, partnerzy zewnętrzni), a gRPC najlepiej „czuje się” w środku – między usługami.

    Na czym dokładnie polega różnica między REST a gRPC?

    Przy REST zazwyczaj wysyłasz JSON po HTTP/1.1: każde wywołanie to osobny request, nagłówki, parsowanie tekstu. Przy małej skali jest to wygodne, czytelne i łatwe do debugowania, lecz przy dziesiątkach usług i tysiącach wywołań zaczyna być kosztowne.

    gRPC:

    • korzysta z HTTP/2 (multiplexing wielu wywołań na jednym połączeniu),
    • domyślnie używa binarnego Protobuf zamiast JSON (mniejsze, szybsze payloady),
    • opiera się na kontraktach .proto i generowanym kodzie, więc masz silne typowanie po obu stronach,
    • od razu wspiera streaming (serwerowy, kliencki, dwukierunkowy).

    W praktyce REST wygrywa na prostocie i dostępności narzędzi dla świata HTTP/1.1/JSON, a gRPC na wydajności i spójności wewnętrznej komunikacji.

    Co daje Protobuf i silne typowanie w komunikacji między usługami?

    Gdy dwa zespoły dogadują się „na JSON”, często kończy się to niespodziankami: ktoś zmieni nazwę pola, ktoś inny typ z int na string i dopiero monitoring pokazuje błąd produkcyjny. Protobuf wymusza wspólny, wersowany kontrakt – zmiany są jawne i od razu widoczne przy generowaniu kodu.

    Silne typowanie przekłada się na:

    • błędy łapane na etapie kompilacji, a nie dopiero w logach produkcyjnych,
    • łatwiejsze refaktoryzacje (zmieniasz definicję w .proto, generujesz kod w kilku językach),
    • spójne modele danych w polyglot stacku bez ręcznego „mapowania JSON-ów”.

    Krótko: mniej „magicznych” bugów na styku usług i większa odwaga przy zmianach kontraktów.

    Jak HTTP/2 pomaga gRPC przy dużym obciążeniu?

    Wyobraź sobie usługę, która musi wykonać 20 wywołań do innych mikroserwisów, żeby policzyć koszyk. W REST każde z nich to osobne połączenie lub osobny request, własne nagłówki i kolejka w load balancerze – opóźnienia sumują się błyskawicznie.

    HTTP/2, na którym opiera się gRPC, rozwiązuje kilka z tych problemów:

    • multiplexing – wiele strumieni na jednym połączeniu TCP, bez „blokowania” konektów,
    • długotrwałe kanały (channel) – klient nie musi na nowo zestawiać połączenia przy każdym RPC,
    • binarne nagłówki i kompresja – mniejszy narzut na metadane.

    W efekcie pojedynczy channel między usługami może obsłużyć równolegle dziesiątki wywołań, co mocno zbija latency przy „gęstej” komunikacji.

    Jakie typy wywołań gRPC istnieją i kiedy ich używać?

    Jeśli do tej pory miałeś tylko REST, możesz myśleć głównie w kategoriach request/response. W gRPC dochodzi do tego model strumieniowy, który często pozwala uprościć protokoły między usługami i zmniejszyć liczbę requestów.

    Podstawowe typy:

    • Unary RPC – jeden request, jedna odpowiedź. Dobry zamiennik klasycznych endpointów REST (np. GetUser, CreateOrder).
    • Server streaming – klient prosi raz, serwer strumieniuje wiele odpowiedzi. Sprawdza się przy listach wyników, logach, subskrypcjach.
    • Client streaming – klient wysyła strumień komunikatów, na końcu dostaje jedną odpowiedź. Użyteczne przy wysyłce paczek danych telemetrycznych czy plików w kawałkach.
    • Bidirectional streaming – obie strony streamują niezależnie, np. czat, interaktywne przetwarzanie, kanały „realtime”.

    Dobrze dobrany typ wywołania często zastępuje wiele drobnych endpointów REST i usuwa „czatowanie” między usługami.

    Czy gRPC nadaje się do publicznych API i integracji z przeglądarką?

    Jeżeli twoim głównym klientem jest przeglądarka lub partner zewnętrzny, REST lub GraphQL zwykle nadal będą wygodniejszym wyborem. Świat HTTP/1.1 + JSON ma ogromne wsparcie narzędziowe, a debugowanie requestów w devtools przeglądarki jest banalne.

    gRPC błyszczy głównie wewnątrz systemu. Można je oczywiście wystawiać na zewnątrz (np. przez gRPC-Web + proxy albo translację REST ↔ gRPC), ale typowy, praktyczny wzorzec wygląda tak:

    • zewnętrzne API – REST/GraphQL, zorientowane na potrzeby frontendu lub partnerów,
    • wewnętrzna komunikacja mikroserwisów – gRPC na HTTP/2, z Protobufem i streamingiem.

    Dzięki temu łączysz prostotę integracji na brzegu z wydajnością i spójnością w środku architektury.

    Jak zacząć migrować z REST na gRPC w istniejącej architekturze?

    Najgorsze, co można zrobić, to spróbować „przepisać wszystko” w jednym kroku. Lepiej wybrać wąski, ale mocno obciążony fragment – np. komunikację między serwisem zamówień a magazynem – i tam wprowadzić gRPC jako pierwszy eksperyment.

    Praktyczny plan to:

    • zidentyfikować najbardziej „gadatliwe” ścieżki REST między usługami,
    • zdefiniować kontrakt .proto dla tych interakcji i wygenerować klienta/serwer,
    • uruchomić oba kanały równolegle (REST + gRPC) i stopniowo przełączać ruch,
    • zmierzyć realne zyski: latency, CPU, liczba requestów, stabilność kontraktów.

    Po takim pilotażu masz dane zamiast przeczucia i możesz świadomie zdecydować, które kolejne fragmenty systemu przenieść na gRPC.

    Kluczowe Wnioski

  • Gdy mikroserwisów przybywa, a każde proste żądanie (np. przeliczenie koszyka) wywołuje kaskadę małych REST-owych requestów z JSON, rosną opóźnienia, koszt CPU i chaos w kontraktach między zespołami.
  • REST oparty na HTTP/1.1 i JSON świetnie sprawdza się jako publiczne API, ale przy intensywnej komunikacji wewnątrz klastra zaczyna ciążyć: pojawia się „czatowanie” między usługami, nadmiarowe payloady i trudne do wykrycia błędy typów w runtime.
  • gRPC rozwiązuje te problemy, łącząc binarny protokół Protobuf z HTTP/2: zmniejsza narzut na request (mniejszy payload, mniej nagłówków), pozwala utrzymywać jedno długotrwałe połączenie i wykonywać wiele równoległych wywołań RPC.
  • Silny, generowany kontrakt .proto porządkuje komunikację między usługami i językami – zmiany w modelu są kontrolowane, a wygenerowane stuby eliminują dużą część błędów na styku serializacji/deserializacji.
  • HTTP/2 daje gRPC kluczowe przewagi: multiplexing wielu strumieni na jednym TCP, binarne i kompresowane nagłówki (HPACK), utrzymywane kanały oraz strumienie dwukierunkowe, co realnie obniża latency przy dużej liczbie małych wywołań.
  • Przesiadka z REST na gRPC wymaga zmiany myślenia: zamiast „wysyłamy JSON po HTTP” pracuje się na jasno zdefiniowanych kontraktach RPC, generowanym kodzie i silnym typowaniu end-to-end.
  • Opracowano na podstawie

  • gRPC: A High-Performance, Open-Source Universal RPC Framework. Google – Oficjalna dokumentacja gRPC: model RPC, typy wywołań, kanały, stuby
  • Protocol Buffers Language Guide. Google – Specyfikacja Protobuf: definicja kontraktów, typowanie, generowanie kodu
  • Hypertext Transfer Protocol Version 2 (HTTP/2). Internet Engineering Task Force (2015) – RFC 7540: specyfikacja HTTP/2, multiplexing, strumienie, nagłówki
  • RESTful Web Services. O’Reilly Media (2007) – Klasyczne omówienie REST, zasoby, reprezentacje, zastosowania HTTP/1.1
  • Microservices Patterns: With examples in Java. Manning Publications (2019) – Wzorce komunikacji synchronicznej i asynchronicznej, kontrakty usług
  • The gRPC Async Architecture. Cloud Native Computing Foundation – Opis architektury gRPC, kanałów, zarządzania połączeniami i wydajności
  • JSON Data Interchange Syntax. Ecma International (2017) – Standard ECMA‑404: definicja JSON, struktury danych, charakterystyka
  • Service Mesh: The Ultimate Guide. Red Hat – Omówienie komunikacji usług, HTTP/2, gRPC i problemów w mikroserwisach