28.02.20228 min

Włodzimierz KozłowskiMenedżer obszaru projektowegoAsseco Poland S.A.

gRPC, czyli mikrousługi po nowemu (staremu)! – część 2

Zdefiniujemy usługę (utworzymy plik .proto, wywołamy kompilator protoc) i zaimplementujemy część serwerową w języku Go.

gRPC, czyli mikrousługi po nowemu (staremu)! – część 2

Przechodzimy do najciekawszej, ale równocześnie najbardziej bolesnej części dla każdego programisty, kiedy próbuje przekuć (“przestukać”) zjawiskowe slajdy z mega interesującej konferencji na działający kod. Albo inaczej, kiedy spędza siódmy dzień nad rozwiązaniem problemu, na który potrzeba piętnastu minut kodowania i dziesięciu zmian na bazie – jak to mawiał jeden dyrektor P., który sam programował, w zaokrągleniu 100 lat temu.

W pierwszej części zrobiliśmy “wstęp do gRPC” (“wstęp do podstaw elementów”).

Teoretycznie zatem mamy wszystko poukładane. Mamy solidne, stabilne podstawy, czujemy się panami sytuacji i nie pozostaje nam nic innego jak zostać ojcami, albo matkami sukcesu.

Kontynuujemy, co zaplanowaliśmy w części 1. Dzisiaj zdefiniujemy usługę (utworzymy plik .proto, wywołamy kompilator protoc) i zaimplementujemy część serwerową w języku Go.

Omawiana przykładowa implementacja oparta jest na zadaniach z dwóch kursów na Udemy: “gRPC [Golang] Master Class: Build Modern API & Microservices” i “gRPC [Java] Master Class: Build Modern API & Micro services” , których autorem jest Stephane Maarek.


Utworzymy plik .proto

Mamy tu klasyczne podejście contract first. Bez pliku .proto nie ruszymy dalej, niezależnie od języka programowania, w którym będziemy implementować serwer czy klienta.

syntax = "proto3";

 
option java_package = "pl.cafebabe.calculator";

option java_multiple_files = true;

option go_package = "cafebabe.pl/calculator;calculator";

 
message AddRequest {

  int64 a = 1;

  int64 b = 2;

}

 
message AddResponse {

  int64 result = 1;

}

 
message SumRequest {

  repeated int64 x = 1;

}

 
message SumResponse {

  int64 result = 1;

}

 
message PrimeNumberDecompositionRequest {

  int64 number = 1;

}

 
message PrimeNumberDecompositionResponse {

  int64 number = 1;

}

 
message ComputeAverageRequest {

  int32 number = 1;

}

 
message ComputeAverageResponse {

  double average = 1;

}

 
message FindMaximumRequest {

  int32 number = 1;

}

 
message FindMaximumResponse {

  int32 maximum = 1;

}

 
message SquareRootRequest {

  int32 number = 1;

}

 
message SquareRootResponse {

  double value = 1;

}

 
service CalculatorService {

  rpc Add(AddRequest) returns (AddResponse);

  rpc Sum(SumRequest) returns (SumResponse);

  rpc PrimeNumberDecomposition(PrimeNumberDecompositionRequest) returns (stream PrimeNumberDecompositionResponse);

  rpc ComputeAverage(stream ComputeAverageRequest) returns (ComputeAverageResponse);

  rpc FindMaximum(stream FindMaximumRequest) returns (stream FindMaximumResponse);

  rpc SquareRoot(SquareRootRequest) returns (SquareRootResponse);
}


I krótko, zwięźle jak dla technicznych. Pierwsze pole syntax. Używamy najnowszej wersji proto3. Jest jeszcze dostępna wersja proto2. Potem zestaw opcji dedykowanych dla języków programowania. Sam plik proto jest niezależny od języka implementacji, to przecież “tylko” kontrakt, ale w końcu nasze stuby zostaną wygenerowane w jakimś konkretnym nowożytnym języku.

Możemy zatem umieścić dodatkowe flagi, które zostaną użyte przez kompilator protoc w kontekście danego języka. Mogą się tu znaleźć, wiadomo np. java_package, java_multiple_files czy go_package, ale i csharp_namespace! Dalej komunikaty message.

Komunikaty oczywiście mogą być bardziej skomplikowane, zawierać zagnieżdżone typy (też komunikaty), wyliczenia itd. Każde pole ma swój typ, w dokumentacji można znaleźć mapowanie typów protobuf na typy języków programowania, przy czym są to w zasadzie tzw. typy proste.

Jeśli zostaliśmy przyzwyczajeni do wyuzdanej reprezentacji czasu (data lokalna albo ze strefą czasową, albo data z czasem i offset strefy), albo UUID jako typ to takich farmazonów tutaj nie znajdziemy. Pewne dodatkowe typy dostępne są w pakiecie google.protobuf.

I w końcu definicja usługi service, która tutaj została dumnie nazwana CalculatorService. Ta nazwa będzie się przewijać potem w wygenerowanym kodzie niezależnie od języka. “Serwajs” (brrr!) to zestaw metod = zdalnie wywoływanych procedur rpc, a każda z nich ma jednoznaczną nazwę (nie ma tutaj przeciążania, przesłaniania czy innych bezeceństw), przyjmuje komunikat (wejście) i odpowiada jakimś komunikatem (wyjście).


Wywołamy kompilator protoc

Żeby go wywołać oczywiście trzeba go mieć. Jeśli wywołanie protoc “u mnie nie działa” trzeba go zainstalować w ten czy inny sposób.

Dla języka Go wymagana będzie jeszcze instalacja pluginu:

go install google.golang.org/protobuf/cmd/protoc-gen-go@latest


Dokumentację dla wszystkich wspieranych języków znajdziemy tutaj.

Samego kompilatora będziemy używać w kontekście języka programowania, w którym chcemy zaimplementować serwer lub klienta.


Zaimplementujemy część serwerową w języku Go

A teraz bez zbędnego gadania. Zakładamy, że mamy Go (no jak!), tworzymy nowy projekt:

mkdir grpc-go-course

cd grpc-go-course

go mod init grpc-go-course


tworzymy podkatalog calculator, “wrzucamy” tam nasz kontrakt, czyli plik calculator.proto i wywołujemy w końcu nasz kompilator protoc

protoc --go_out=. --go_opt=paths=source_relative --go-grpc_out=. --go-grpc_opt=paths=source_relative calculator/calculator.proto


Kluczowy jest parametr --go_out - to on informuje kompilator, że… generujemy kod dla języka Go. Jak łatwo się domyśleć inni użyją tutaj --java_out a jeszcze inni od tych innych… --csharp_out. Oprócz języka musimy jeszcze określić miejsce naszego kontraktu tutaj:

calculator/calculator.proto


Jeśli wszystko poszło dobrze w katalogu calculator pojawiły się dwa nowe pliki z rozszerzeniem .go. I to jest programowanie! W skrócie, w jednym znajdziemy definicję i obsługę naszych komunikatów, w drugim szkielety dla klienta i serwera.

package main

 
import (

	"context"

	"fmt"

	"google.golang.org/grpc"

	"google.golang.org/grpc/codes"

	"google.golang.org/grpc/reflection"

	"google.golang.org/grpc/status"

	pb "grpc-go-course/calculator"

	"io"

	"log"

	"math"

	"net"

)

 
const (

	port = ":50052"

)

 
type CalculatorServiceServer struct {

	pb.UnimplementedCalculatorServiceServer

}

 
func (*CalculatorServiceServer) Add(_ context.Context, req *pb.AddRequest) (*pb.AddResponse, error) {

	log.Printf("Received: %v", req)

	result := req.A + req.B

	return &pb.AddResponse{Result: result}, nil

}

 
func (*CalculatorServiceServer) Sum(_ context.Context, req *pb.SumRequest) (*pb.SumResponse, error) {

	log.Printf("Received: %v", req)

	var sum int64

	for _, v := range req.X {

		sum += v

	}

	return &pb.SumResponse{Result: sum}, nil

}

 
func (*CalculatorServiceServer) PrimeNumberDecomposition(req *pb.PrimeNumberDecompositionRequest, stream pb.CalculatorService_PrimeNumberDecompositionServer) error {

	fmt.Printf("Received PrimeNumberDecomposition: %v\n", req)

	k := int64(2)

	N := req.Number

	for N > 1 {

		if N%k == 0 {

			err := stream.Send(&pb.PrimeNumberDecompositionResponse{Number: k})

			if err != nil {

				return err

			}

			N /= k

		} else {

			k++

		}

	}

	return nil

}

 
func (*CalculatorServiceServer) ComputeAverage(stream pb.CalculatorService_ComputeAverageServer) error {

	fmt.Println("ComputeAverage function invoked")

 
	sum := int32(0)

	count := 0

	for {

		req, err := stream.Recv()

		if err == io.EOF {

			fmt.Println("Received EOF. Calculating and sending response")

			average := float64(sum) / float64(count)

			err := stream.SendAndClose(&pb.ComputeAverageResponse{Average: average})

			if err != nil {

				return err

			}

			break

		}

		if err != nil {

			log.Fatalf("Something fatal: %v", err)

		}

		sum += req.Number

		count++

	}

	return nil

}

 
func (*CalculatorServiceServer) FindMaximum(stream pb.CalculatorService_FindMaximumServer) error {

	fmt.Println("FindMaximum function invoked")

 
	max := int32(math.MinInt32)

	for {

		req, err := stream.Recv()

		if err == io.EOF {

			return nil

		}

		if err != nil {

			log.Fatalf("Error during receive: %v", err)

		}

		number := req.Number

		fmt.Printf("Received number: %v\n", number)

		if number > max {

			max = number

			fmt.Printf("Sending maximum value: %v\n", max)

			err := stream.Send(&pb.FindMaximumResponse{Maximum: max})

			if err != nil {

				log.Fatalf("Error while sending data to client: %v", err)

				return err

			}

		}

	}

	return nil

}

 
func (*CalculatorServiceServer) SquareRoot(_ context.Context, req *pb.SquareRootRequest) (*pb.SquareRootResponse, error) {

	fmt.Printf("SquareRoot function received: %v", req)

	number := req.Number

	if number < 0 {

		return nil, status.Errorf(codes.InvalidArgument, "Received negative number")

	}

	return &pb.SquareRootResponse{Value: math.Sqrt(float64(number))}, nil

}

 
func main() {

	fmt.Println("Hello gRPC (go CalculatorServiceServer)!")

 
	lis, err := net.Listen("tcp", port)

	if err != nil {

		log.Fatalf("Failed to listen: %v", err)

	}

 
	s := grpc.NewServer()

	pb.RegisterCalculatorServiceServer(s, &CalculatorServiceServer{})

	reflection.Register(s)

 
	if err := s.Serve(lis); err != nil {

		log.Fatalf("Failed to serve %v", err)

	}

}


I tutaj dosłownie kilka zdań komentarza. Z samego gRPC mamy tutaj tylko kilka linii.

Pierwsza rzecz to definicja struct, która będzie implementacją naszej usługi. Samą usługę w pliku proto nazwaliśmy CalculatorService, kompilator protoc w pliku calculator_grpc.pb.go wygenerował nam typ UnimplementedCalculatorServiceServer oraz metody dla tego typu (metody, czyli funkcje, które mają zdefiniowanych odbiorców), np.

func (UnimplementedCalculatorServiceServer) Add(context.Context, *AddRequest) (*AddResponse, error) {

	return nil, status.Errorf(codes.Unimplemented, "method Add not implemented")

}

Jest to domyślna implementacja, która będzie nam zwracała błąd, że metoda nie została zaimplementowana.

Nasza usługa musi w sobie osadzać tę wersję “niezaimplementowaną”.

type CalculatorServiceServer struct {

pb.UnimplementedCalculatorServiceServer

}


Druga rzecz to funkcja main, w której tworzymy instancję serwera gRPC, rejestrujemy implementację naszej usługi – możemy ich mieć oczywiście wiele i… startujemy. Linia reflection.Register(s) i niezbędny dla niej import "google.golang.org/grpc/reflection" są opcjonalne.

Powiedzieliśmy sobie, że mamy podejście contract first. Zatem zarówno serwer, jak i klient “znają” plik proto: struktury komunikatów i zestaw metod. Żadna dodatkowa dokumentacja usługi, żaden “swagger” nie jest nam niezbędny. To prawda.

Jeśli jednak chcemy, aby nasza usługa była bardziej ekshibicjonistyczna (w drugim tego słowa znaczeniu wg SJP), wystarczy zarejestrować usługę, która przejawia “skłonność do ujawniania spraw” i z jej wykorzystaniem możemy uzyskać pełen opis usługi.

Cała reszta to już nasza logika biznesowa. Technicznie to implementacja metod dla naszej struktury, które “przysłonią” domyślną implementację.

Zakładam, że na tym etapie używamy już jakiegoś IDE więc wystarczy “odpalić” plik. Jeśli walimy w notatniku czy vi:

go get google.golang.org/grpc

go build


Niezależnie od środowiska, systemu operacyjnego powinniśmy otrzymać, niezbyt pokaźnych rozmiarów – w końcu to Go, plik wykonywalny – nasz serwer z gotową usługą!

I teraz najciekawsza część wpisu – analiza własna pozostałych metod. Znamy już 4 rodzaje/tryby w gRPC: od najprostszej unary do bidirectional streaming. Zwróćmy uwagę, jak zmieniają się nagłówki funkcji w zależności od trybu.

Jeśli implementujemy metodę typu unary na wejściu i wyjściu mamy wprost nasze komunikaty (message z pliku proto). W pozostałych przypadkach dojdzie nam obsługa strumienia: od pobrania komunikatu stream.Recv(), wysłania pojedynczego komunikatu stream.Send(...), wysłania i zamknięcia strumienia stream.SendAndClose(...), ewentualnie zakończenie strumienia przez wysłanie nil lub err.

Przykład w języku Go wydaje się być, w moim odczuciu, nadzwyczaj przejrzysty, łatwy w czytaniu niezależnie od języka programowania, którym posługujemy się na co dzień, pozbawiony konstrukcji silnie zorientowanych na język czy framework.

Dowieźliśmy naszą usługę! W kolejnych częściach zaimplementujemy klienta w języku Java, a jeśli znajdą się chętni może powtórzymy stronę serwerową z wykorzystaniem frameworków Spring Boot czy Quarkus . Dla tych, którzy nie mogą się doczekać polecam instalację narzędzia gRPCurl i wywołanie polecenia:

grpcurl -plaintext -d '{ "a": 5, "b": 7 }' localhost:50052 CalculatorService/Add


Jeśli dostaniemy, np. w naszym PowerShell, błąd:

Error invoking method "CalculatorService/Add": error getting request data: invalid character 'a' looking for beginning of object key string


po uzupełnieniu o \”

grpcurl -plaintext -d '{ \"a\": 5, \"b\": 7 }' localhost:50052 CalculatorService/Add


powinien zakończyć się pełnym sukcesem

{

"result": "12"

}


<p>Loading...</p>