Как создать приложение на Go с gRPC

В архитектуре REST («передача состояния представления»)  —  основной технологии построения веб-приложений и микросервисов  —  есть несколько способов создания API. Обычно используется протокол HTTP.

API считается RESTful, когда он:

  • имеет единый интерфейс и клиент-серверную независимость;
  • не сохраняет состояние;
  • допускает кеширование;
  • многоуровневый.

REST был призван устранить недостатки SOAPлегковесным RPC под капотом), но возвращал много метаданных и поэтому не смог его заменить. Это привело к появлению новых или улучшенных технологий типа gRPC и GraphQL.

gRPC  —  это высокопроизводительный универсальный фреймворк RPC с открытым исходным кодом для скоростного обмена данными между микросервисами.

По умолчанию в gRPC есть Protobuf (буферы протокола), которые форматируют или сериализовывают сообщения в определенный формат с плотноупакованными высокоэффективными данными.

Очевидно, что легковесный RPC типа gRPC будет идеальным в ряде сценариев, например при создании приложения на Go  —  популярном языке для микросервисов.

Что такое RPC?

RPC (удаленный вызов процедур)  —  одна из старейших архитектур. Позволяет вызывать функцию на удаленном сервере и получать ответ в том же формате.

Концепции RPC API и REST API чем-то похожи. Интерфейсы RPC определяют правила и методы, с которыми взаимодействует клиент. Чтобы вызывать эти методы, он отправляет вызовы с аргументами, которые находятся в строке запроса. У вызова RPC POST /deletePokemon будет такая строка запроса: {“id”: 2 }, а у типичного REST  —  DELETE /pokemon/delete/2.

Что такое gRPC?

Это легковесный преемник RPC. Разработан для обмена данными между микросервисами и другими взаимодействующими системами.

Преимущества gRPC:

  • буферы протокола (Protobuf) вместо JSON;
  • HTTP 2 вместо HTTP 1.1;
  • встроенная генерация кода;
  • высокая производительность;
  •  SSL-защита.

Также gRPC улучшает дизайн приложения: в отличие от REST, он ориентирован на API, а не на ресурсы.

А еще gRPC асинхронен по умолчанию: не блокирует поток при поступлении запроса и обслуживает миллионы запросов параллельно, обеспечивая высокую масштабируемость.

В gRPC есть четыре типа API:

  • Унарный.
    Очень похож на традиционный API (REST API): клиент делает запрос, а сервер отправляет ответ.
  • Потоковой передачи данных с сервера.
    Здесь клиент делает один запрос, а сервер отправляет несколько или поток данных.
  • Потоковой передачи данных от клиента.
    То же, что предыдущий, но наоборот: клиент отправляет поток данных, а сервер  —  один ответ.
  • Двунаправленной потоковой передачи.
    Здесь клиент и сервер отправляют потоки данных.

Что особенного в буферах протокола?

Буферы протокола  —  это расширяемый механизм сериализации данных с открытым исходным кодом. Они используются для языка описания интерфейсов и для формата обмена сообщениями.

В отличие от JSON (нотация объектов JavaScript), буферы протокола не зависят от языка, меньше по размеру полезных данных, быстрее, проще и производительнее.

В gRPC они применяются для определения:

  • сообщений (данные, запрос и ответ);
  • сервиса (имя сервиса и конечные точки RPC).

Остается лишь определить структурированные данные, и компилятор protoc буфера протокола сгенерирует код на основе выбранного языка. Последний protoc (версия 3) поддерживает C#, C++, Dart, Go, Java, Kotlin, Node, Objective-C, PHP, Python и Ruby.

Когда стоит использовать gRPC?

gRPC идеально подойдет для внутренних задач:

  • связывания микросервисов;
  • потоковой передачи данных в режиме реального времени;
  • многоязычных систем.

gRPC  —  это будущее для API микросервисов и мобильных серверов. Является частью Cloud Native Computing Foundation и технологических гигантов типа Google, Netflix, Square и CoackroachDB.

Приступаем к работе с gRPC

Создадим небольшое приложение на Golang с gRPC потоковой передачи данных с сервера. Для этого с помощью gRPC/protoc (компилятора буферов протокола) определим сообщения и сервисы. Потом сгенерируем для них интерфейс и добавим в него функциональность, например MongoDB (для уровня базы данных), чтобы постоянного хранить данные.

Необходимые условия

  • Последняя версия Go (1.0 или новее).
  • Последняя версия protoc (protoc 3).

Сначала установим компилятор protoc и добавим его в пути доступа из любого места операционной системы. Процесс установки для своей ОС см. здесь.

Инициализируем проект, создав каталог и запустив эти команды:

go mod init github.com/username/grpc-pokemon

go get -u google.golang.org/grpc
go get -u google.golang.org/protobuf/protoc-gen-go

Первой командой создается файл go.mod для отслеживания зависимостей кода. А двумя другими  —  устанавливаются зависимости для grpc и protoc-gen-go в Golang, которые понадобятся в файле .proto.

Перейдем к самому интересному  —  созданию файлов .proto, в которых определяется сервис и соответствующие функции/сообщения. А с помощью компилятора protoc здесь генерируются необходимые файлы буферов протокола.

В каталоге pokemon создаем этот файл proto:

syntax = "proto3";

package pokemon;

option go_package = "github.com/TRomesh/grpc-pokemon;pokemonpc";

message Pokemon {
string id = 1;
string pid = 2;
string name = 3;
string power = 4;
string description = 5;
}

message CreatePokemonRequest {
Pokemon pokemon = 1;
}

message CreatePokemonResponse {
Pokemon pokemon = 1; // будет иметь идентификатор покемона
}

message ReadPokemonRequest {
string pid = 1;
}

message ReadPokemonResponse {
Pokemon pokemon = 1;
}

message UpdatePokemonRequest {
Pokemon pokemon = 1;
}

message UpdatePokemonResponse {
Pokemon pokemon = 1;
}

message DeletePokemonRequest {
string pid = 1;
}

message DeletePokemonResponse {
string pid = 1;
}

message ListPokemonRequest {

}

message ListPokemonResponse {
Pokemon pokemon = 1;
}

service PokemonService {
rpc CreatePokemon (CreatePokemonRequest) returns (CreatePokemonResponse);
rpc ReadPokemon (ReadPokemonRequest) returns (ReadPokemonResponse); // возвращает NOT_FOUND, если не найдено
rpc UpdatePokemon (UpdatePokemonRequest) returns (UpdatePokemonResponse); // возвращает NOT_FOUND, если не найдено
rpc DeletePokemon (DeletePokemonRequest) returns (DeletePokemonResponse); // возвращает NOT_FOUND, если не найдено
rpc ListPokemon (ListPokemonRequest) returns (stream ListPokemonResponse); // Для потоковой передачи с сервера
}

С помощью компилятора protoc. сгенерируем код для Golang, выполнив следующую команду из корневого каталога проекта:

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

Внимание: при компилировании используем относительные пути и задаем путь к файлу pokemon.proto.

Этой командой генерируются два файла: pokemon.pb.go для сериализации сообщений с помощью буферов протокола и pokemon_grpc.pb.go, состоящий из кода для клиента gRPC и серверного кода, где они потом будут реализовываться:

Файлы Go, сгенерированные компилятором protoc

В файле pokemon_grpc.pb.go для клиент-серверной реализации генерируются структуры и интерфейсы.

Создание сервера и клиента

Реализацию сервера начнем с создания файла server.go. Для постоянного хранения данных установим зависимость MongoDB:

go get go.mongodb.org/mongo-driver/mongo

Создаем сервер с подключением MongoDB. Сначала импортируем определения буферов протокола и создаем структуру с помощью PokemonServiceServer, а затем реализуем функции в файле .proto (createPokemon, ReadPokemon и т. д.):

import (
"context"
"fmt"
"log"
"net"
"os"
"os/signal"

pokemonpc "github.com/TRomesh/grpc-pokemon/pokemon"
"github.com/joho/godotenv"
"go.mongodb.org/mongo-driver/bson"
"go.mongodb.org/mongo-driver/bson/primitive"
"go.mongodb.org/mongo-driver/mongo"
"go.mongodb.org/mongo-driver/mongo/options"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/reflection"
"google.golang.org/grpc/status"
)

var collection *mongo.Collection

type server struct {
  pokemonpc.PokemonServiceServer
}

type pokemonItem struct {
 ID          primitive.ObjectID `bson:"_id,omitempty"`
 Pid         string             `bson:"pid"`
 Name        string             `bson:"name"`
 Power       string             `bson:"power"`
 Description string             `bson:"description"`
}

func getPokemonData(data *pokemonItem) *pokemonpc.Pokemon {
 return &pokemonpc.Pokemon{
  Id:          data.ID.Hex(),
  Pid:         data.Pid,
  Name:        data.Name,
  Power:       data.Power,
  Description: data.Description,
 }
}

func (*server) CreatePokemon(ctx context.Context, req *pokemonpc.CreatePokemonRequest) (*pokemonpc.CreatePokemonResponse, error) {
fmt.Println("Create Pokemon")
pokemon := req.GetPokemon()
data := pokemonItem{
 Pid:         pokemon.GetPid(),
 Name:        pokemon.GetName(),
 Power:       pokemon.GetPower(),
 Description: pokemon.GetDescription(),
}

res, err := collection.InsertOne(ctx, data)

if err != nil {
 return nil, status.Errorf(
  codes.Internal,
  fmt.Sprintf("Internal error: %v", err),
 )
}

oid, ok := res.InsertedID.(primitive.ObjectID)
  if !ok {
   return nil, status.Errorf(
    codes.Internal,
    fmt.Sprintf("Cannot convert to OID"),
  )
}

return &pokemonpc.CreatePokemonResponse{
  Pokemon: &pokemonpc.Pokemon{
  Id:          oid.Hex(),
  Pid:         pokemon.GetPid(),
  Name:        pokemon.GetName(),
  Power:       pokemon.GetPower(),
  Description: pokemon.GetDescription(),
  },
}, nil

}

PokemonServiceServer и функции, а также структуры для каждого запроса (*pokemonpc.CreatePokemonRequest) и ответа (*pokemonpc.CreatePokemonResponse) сгенерированы компилятором protoc.

Так же реализуются и остальные функции (чтения, обновления и удаления покемонов).

Переходим к клиентской части. Но это CLI-приложение, поэтому выполняем каждую функцию с одной попытки, например createPokemon, ReadPokemon, UpdatePokemon и т. д.:

package main

import (
"context"
"fmt"
"io"
"log"
"os"

pokemonpc "github.com/TRomesh/grpc-pokemon/pokemon"
"github.com/joho/godotenv"
"google.golang.org/grpc"
)

const defaultPort = "4041"

func main() {

fmt.Println("Pokemon Client")

err := godotenv.Load(".env")
if err != nil {
log.Fatal("Error loading .env file")
}

port := os.Getenv("PORT")
if port == "" {
port = defaultPort
}

opts := grpc.WithInsecure()

cc, err := grpc.Dial("localhost:4041", opts)
if err != nil {
log.Fatalf("could not connect: %v", err)
}
defer cc.Close() // Может, в отдельной функции ошибка будет обработана?

c := pokemonpc.NewPokemonServiceClient(cc)

// создаем покемона
fmt.Println("Creating the pokemon")
pokemon := &pokemonpc.Pokemon{
Pid: "Poke01",
Name: "Pikachu",
Power: "Fire",
Description: "Fluffy",
}
createPokemonRes, err := c.CreatePokemon(context.Background(), &pokemonpc.CreatePokemonRequest{Pokemon: pokemon})
if err != nil {
log.Fatalf("Unexpected error: %v", err)
}
fmt.Printf("Pokemon has been created: %v", createPokemonRes)
pokemonID := createPokemonRes.GetPokemon().GetId()

// считываем покемона
fmt.Println("Reading the pokemon")
readPokemonReq := &pokemonpc.ReadPokemonRequest{Pid: pokemonID}
readPokemonRes, readPokemonErr := c.ReadPokemon(context.Background(), readPokemonReq)
if readPokemonErr != nil {
fmt.Printf("Error happened while reading: %v \n", readPokemonErr)
}

fmt.Printf("Pokemon was read: %v \n", readPokemonRes)

// обновляем покемона
newPokemon := &pokemonpc.Pokemon{
Id: pokemonID,
Pid: "Poke01",
Name: "Pikachu",
Power: "Fire Fire Fire",
Description: "Fluffy",
}
updateRes, updateErr := c.UpdatePokemon(context.Background(), &pokemonpc.UpdatePokemonRequest{Pokemon: newPokemon})
if updateErr != nil {
fmt.Printf("Error happened while updating: %v \n", updateErr)
}
fmt.Printf("Pokemon was updated: %v\n", updateRes)

// удаляем покемона
deleteRes, deleteErr := c.DeletePokemon(context.Background(), &pokemonpc.DeletePokemonRequest{Pid: pokemonID})

if deleteErr != nil {
fmt.Printf("Error happened while deleting: %v \n", deleteErr)
}
fmt.Printf("Pokemon was deleted: %v \n", deleteRes)

// выводим список покемонов

stream, err := c.ListPokemon(context.Background(), &pokemonpc.ListPokemonRequest{})
if err != nil {
log.Fatalf("error while calling ListPokemon RPC: %v", err)
}
for {
res, err := stream.Recv()
if err == io.EOF {
break
}
if err != nil {
log.Fatalf("Something happened: %v", err)
}
fmt.Println(res.GetPokemon())
}
}

Вот и все. Мы создали клиент и сервер, использовав интерфейсы и структуры, сгенерированные компилятором protoc.

Запустим сервер следующей командой:

$ go run server/server.go
Запуск сервера Pokemon

А теперь клиентское приложение. Оно вызовет методы, определенные на сервере для CRUD-операций с Pokemon:

Выполнение клиента

Когда клиент вызовет CRUD-операции с покемоном, со стороны сервера логи будут такие:

Журнал сервера после выполнения клиента

Полный код примера см. по ссылке.

Заключение

gRPC  —  это одна из лучших технологий создания микросервисов и служб потоковой передачи данных в реальном времени.

Буферы протокола, HTTP/2 и SSL-защита делают gRPC надежной, безопасной и более производительной архитектурой, чем REST.

Кроме того, gRPC поддерживает все самые известные языки. Дополнительная информация доступна в документации по gRPC. Спасибо за внимание.

Читайте также:

Читайте нас в TelegramVK и Яндекс.Дзен


Перевод статьи Tharaka Romesh: Let’s “Go” and build an Application with gRPC

Предыдущая статья7 критериев выбора подходящего фреймворка для глубокого обучения
Следующая статьяPython PyQt5: современные графические интерфейсы для Windows, MacOS и Linux