Приклад gRPC-мікросервісу на Go

Привіт, мене звуть Ярослав, я працюю в компанії Evrius. Прийшовши в проект, отримав завдання: розробити мікросервіс для збереження статистики. Тому розпочав вивчати gRPC.

Фреймворк gRPC (remote procedure call) — продукт Google, розроблений для стандартизації взаємодії між сервісами й зменшення обсягу трафіку. gRPC розглядаю як хорошу заміну REST під час взаємодії між мікросервісами. У gRPC лаконічний формат опису, порівняно з Swagger є backward і forward compatibility, а також автогенерація коду популярними мовами програмування (у Swagger автогенерація теж є).

Тому стаття буде цікава тим, хто вже щось чув хороше про gRPC і хоче впровадити його в проект. У статті описано просте завдання й докладне його розв’язання.

Якщо вже є досвід з gRPC, то можете завантажити репозиторій і запустити проект.

Завдання і налаштування проекту

Розробити сервіс для збереження рецептів і пошук рецепта за інгредієнтами. Наприклад, зберегти рецепти салатів і знайти рецепт, де є моцарела. Працюю з тестами, тому створю проект і запущу простий тест. Вибрав пакетний менеджер dep (бо його використовуємо в основному проекті):

dep init

Команда створює файли Gopkg.toml, Gopkg.lock у корені проекту:

~/go/src/gitlab.com/go-yp/grpc-recipes
├── Gopkg.lock
└── Gopkg.toml

А ще під’єднуємо пакет для assert-ів:

dep ensure -add github.com/stretchr/testify/assert

Напишемо й запустимо тест:

package tests

import (
	"github.com/stretchr/testify/assert"
	"testing"
)

func TestDefault(t *testing.T) {
	assert.Equal(t, 1, 1)
}

go test ./components/... -v

=== RUN   TestDefault
--- PASS: TestDefault (0.00s)
PASS
ok  	gitlab.com/go-yp/grpc-recipes/components/tests	0.002

Опишемо proto-файли для сервісу рецептів:

# protos/services/recipes/recipes.proto
syntax = "proto3";

package recipes;

message Empty {
}

message Ingredient {
    uint32 code = 1;
    string name = 2;
}

message Recipe {
    uint32 code = 1;
    string title = 2;
    repeated Ingredient ingredients = 3;
}

message Recipes {
    repeated Recipe recipes = 1;
}

message IngredientsFilter {
    repeated uint32 codes = 1;
}

service RecipesService {
    rpc Store (Recipes) returns (Empty);
    rpc FindByIngredients (IngredientsFilter) returns (Recipes);
}

Отже, є сервіс RecipesService з методами Store й FindByIngredients, де методи отримують і повертають повідомлення.

На основі proto-файлу protos/services/recipes/recipes.proto можемо згенерувати Go-файл, що міститиме структури message, RecipesService-клієнт й інтерфейс сервера RecipesService.

Для генерації потрібно protoc compiler, за посиланням інструкція з налаштування. Після того як встановили protoc, можемо запустити команду для генерації Go-файлу:

mkdir models
protoc -I . protos/services/recipes/*.proto --go_out=plugins=grpc:models

Отже, проаналізуймо згенерований Go-файл models/protos/services/recipes/recipes.pb.go:

// Code generated by protoc-gen-go. DO NOT EDIT.
// source: protos/services/recipes/recipes.proto

package recipes

type Empty struct {
	XXX_NoUnkeyedLiteral struct{} `json:"-"`
	XXX_unrecognized     []byte   `json:"-"`
	XXX_sizecache        int32    `json:"-"`
}

type Ingredient struct {
	Code                 uint32   `protobuf:"varint,1,opt,name=code,proto3" json:"code,omitempty"`
	Name                 string   `protobuf:"bytes,2,opt,name=name,proto3" json:"name,omitempty"`
	XXX_NoUnkeyedLiteral struct{} `json:"-"`
	XXX_unrecognized     []byte   `json:"-"`
	XXX_sizecache        int32    `json:"-"`
}

type Recipe struct {
	Code                 uint32        `protobuf:"varint,1,opt,name=code,proto3" json:"code,omitempty"`
	Title                string        `protobuf:"bytes,2,opt,name=title,proto3" json:"title,omitempty"`
	Ingredients          []*Ingredient `protobuf:"bytes,3,rep,name=ingredients,proto3" json:"ingredients,omitempty"`
	XXX_NoUnkeyedLiteral struct{}      `json:"-"`
	XXX_unrecognized     []byte        `json:"-"`
	XXX_sizecache        int32         `json:"-"`
}

type Recipes struct {
	Recipes              []*Recipe `protobuf:"bytes,1,rep,name=recipes,proto3" json:"recipes,omitempty"`
	XXX_NoUnkeyedLiteral struct{}  `json:"-"`
	XXX_unrecognized     []byte    `json:"-"`
	XXX_sizecache        int32     `json:"-"`
}

type IngredientsFilter struct {
	Codes                []uint32 `protobuf:"varint,1,rep,packed,name=codes,proto3" json:"codes,omitempty"`
	XXX_NoUnkeyedLiteral struct{} `json:"-"`
	XXX_unrecognized     []byte   `json:"-"`
	XXX_sizecache        int32    `json:"-"`
}

Ті ж структури, що описано в proto-файлі:

type RecipesServiceClient interface {
	Store(ctx context.Context, in *Recipes, opts ...grpc.CallOption) (*Empty, error)
	FindByIngredients(ctx context.Context, in *IngredientsFilter, opts ...grpc.CallOption) (*Recipes, error)
}

type recipesServiceClient struct {
	cc *grpc.ClientConn
}

func NewRecipesServiceClient(cc *grpc.ClientConn) RecipesServiceClient {
	return &recipesServiceClient{cc}
}

func (c *recipesServiceClient) Store(ctx context.Context, in *Recipes, opts ...grpc.CallOption) (*Empty, error) {
	out := new(Empty)
	err := c.cc.Invoke(ctx, "/recipes.RecipesService/Store", in, out, opts...)
	if err != nil {
		return nil, err
	}
	return out, nil
}

func (c *recipesServiceClient) FindByIngredients(ctx context.Context, in *IngredientsFilter, opts ...grpc.CallOption) (*Recipes, error) {
	out := new(Recipes)
	err := c.cc.Invoke(ctx, "/recipes.RecipesService/FindByIngredients", in, out, opts...)
	if err != nil {
		return nil, err
	}
	return out, nil
}

Готовий для використання клієнт, що його створюють через NewRecipesServiceClient:

type RecipesServiceServer interface {
	Store(context.Context, *Recipes) (*Empty, error)
	FindByIngredients(context.Context, *IngredientsFilter) (*Recipes, error)
}

func RegisterRecipesServiceServer(s *grpc.Server, srv RecipesServiceServer) {
	s.RegisterService(&_RecipesService_serviceDesc, srv)
}

func _RecipesService_Store_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
	in := new(Recipes)
	if err := dec(in); err != nil {
		return nil, err
	}
	if interceptor == nil {
		return srv.(RecipesServiceServer).Store(ctx, in)
	}
	info := &grpc.UnaryServerInfo{
		Server:     srv,
		FullMethod: "/recipes.RecipesService/Store",
	}
	handler := func(ctx context.Context, req interface{}) (interface{}, error) {
		return srv.(RecipesServiceServer).Store(ctx, req.(*Recipes))
	}
	return interceptor(ctx, in, info, handler)
}

func _RecipesService_FindByIngredients_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
	in := new(IngredientsFilter)
	if err := dec(in); err != nil {
		return nil, err
	}
	if interceptor == nil {
		return srv.(RecipesServiceServer).FindByIngredients(ctx, in)
	}
	info := &grpc.UnaryServerInfo{
		Server:     srv,
		FullMethod: "/recipes.RecipesService/FindByIngredients",
	}
	handler := func(ctx context.Context, req interface{}) (interface{}, error) {
		return srv.(RecipesServiceServer).FindByIngredients(ctx, req.(*IngredientsFilter))
	}
	return interceptor(ctx, in, info, handler)
}

var _RecipesService_serviceDesc = grpc.ServiceDesc{
	ServiceName: "recipes.RecipesService",
	HandlerType: (*RecipesServiceServer)(nil),
	Methods: []grpc.MethodDesc{
		{
			MethodName: "Store",
			Handler:    _RecipesService_Store_Handler,
		},
		{
			MethodName: "FindByIngredients",
			Handler:    _RecipesService_FindByIngredients_Handler,
		},
	},
	Streams:  []grpc.StreamDesc{},
	Metadata: "protos/services/recipes/recipes.proto",
}

Інтерфейс RecipesServiceServer треба зреалізувати й під’єднати через RegisterRecipesServiceServer. Після реалізації RecipesServiceServer і запуску gRPC матимемо готовий сервіс RecipesServer.

В інший сервіс, хай буде Core, ми додамо proto-файл, згенеруємо Go-файл з клієнтом RecipesServiceClient, і сервіс Core зможе робити запити на RecipesServer. Тепер структура проекту така:

~/go/src/gitlab.com/go-yp/grpc-recipes
├── components
│   └── tests
│       └── grpc_test.go
├── Gopkg.lock
├── Gopkg.toml
├── models
│   └── protos
│       └── services
│           └── recipes
│               └── recipes.pb.go
├── protos
│   └── services
│       └── recipes
│           └── recipes.proto
└── vendor

Реалізація Recipes-сервісу

Створимо файл components/server/server.go:

package server

type Server struct {
}

Згенеруємо методи інтерфейсу RecipesServiceServer за допомогою Goland IDE, натискаємо комбінацію Ctrl+I ― з’являється поле, де вводимо назву RecipesServiceServer, тоді отримуємо:

package server

import (
	"context"
	"gitlab.com/go-yp/grpc-recipes/models/protos/services/recipes"
)

type Server struct {
}

func (Server) Store(context.Context, *recipes.Recipes) (*recipes.Empty, error) {
	panic("implement me")
}

func (Server) FindByIngredients(context.Context, *recipes.IngredientsFilter) (*recipes.Recipes, error) {
	panic("implement me")
}

Тепер допишемо логіку додавання рецептів і пошуку за інгредієнтами:

package server

import (
	"context"
	"gitlab.com/go-yp/grpc-recipes/models/protos/services/recipes"
	"sync"
)

var (
	empty = &recipes.Empty{}
)

type Server struct {
	mu   sync.RWMutex
	data []*recipes.Recipe
}

func (s *Server) Store(ctx context.Context, recipes *recipes.Recipes) (*recipes.Empty, error) {
	s.mu.Lock()
	s.data = append(s.data, recipes.Recipes...)
	s.mu.Unlock()

	return empty, nil
}

func (s *Server) FindByIngredients(ctx context.Context, filter *recipes.IngredientsFilter) (*recipes.Recipes, error) {
	result := make([]*recipes.Recipe, 0)

	s.mu.RLock()
	data := s.data
	s.mu.RUnlock()

	codeMap := make(map[uint32]bool, len(filter.Codes))
	for _, code := range filter.Codes {
		codeMap[code] = true
	}

	for _, recipe := range data {
		for _, ingredient := range recipe.Ingredients {
			if codeMap[ingredient.Code] {
				result = append(result, recipe)

				break
			}
		}
	}

	return &recipes.Recipes{
		Recipes: result,
	}, nil
}

Напишемо тест і перевіримо, чи працює. Додамо два пакети protobuf і grpc, які використовують у файлі models/protos/services/recipes/recipes.pb.go в Gopkg.toml.

[[constraint]]
  name = "google.golang.org/grpc"
  version = "1.18.0"

[[constraint]]
  branch = "master"
  name = "github.com/golang/protobuf"

Виконаємо команду:

dep ensure

Версії конфліктують між собою, тому й використовую master branch для пакета github.com/golang/protobuf.

Для тестування gRPC є пакет google.golang.org/grpc/test/bufconn, готування до тестування має такий вигляд:

# components/tests/grpc_test.go
package tests

import (
	"context"
	"github.com/juju/errors"
	"github.com/stretchr/testify/assert"
	"gitlab.com/go-yp/grpc-recipes/components/server"
	"gitlab.com/go-yp/grpc-recipes/models/protos/services/recipes"
	"google.golang.org/grpc"
	"google.golang.org/grpc/test/bufconn"
	"log"
	"net"
	"testing"
)

const (
	bufferSize = 1024 * 1024
)

func TestStoreAndFindByIngredients(t *testing.T) {
	connection, err := mockServerConnect(context.Background())
	if !assert.NoError(t, err) {
		return
	}
	defer connection.Close()

	client := recipes.NewRecipesServiceClient(connection)

	_ = client
}

func mockServerConnect(ctx context.Context) (conn *grpc.ClientConn, err error) {
	lis := bufconn.Listen(bufferSize)
	s := grpc.NewServer()

	recipes.RegisterRecipesServiceServer(
		s,
		new(server.Server),
	)

	go func() {
		if err := s.Serve(lis); err != nil {
			log.Fatalf("[CRITICAL] Server exited with error: %+v", errors.Trace(err))
		}
	}()

	return grpc.DialContext(
		ctx,
		"bufnet",
		grpc.WithContextDialer(func(context.Context, string) (net.Conn, error) {
			return lis.Dial()
		}),
		grpc.WithInsecure(),
	)
}

А тепер протестуємо додавання рецепта й пошук:

func TestStoreAndFindByIngredients(t *testing.T) {
	connection, err := mockServerConnect(context.Background())
	if !assert.NoError(t, err) {
		return
	}
	defer connection.Close()

	client := recipes.NewRecipesServiceClient(connection)

	recipe1 := &recipes.Recipe{
		Code:  10001,
		Title: "Борщ",
		Ingredients: []*recipes.Ingredient{
			{
				Code: 625,
				Name: "Буряк",
			},
			{
				Code: 725,
				Name: "Квасоля",
			},
			{
				Code: 675,
				Name: "Помідори",
			},
		},
	}

	recipe2 := &recipes.Recipe{
		Code:  10002,
		Title: "Вінегрет з печерицями",
		Ingredients: []*recipes.Ingredient{
			{
				Code: 625,
				Name: "Буряк",
			},
			{
				Code: 825,
				Name: "Печериці",
			},
		},
	}

	mainRecipes := &recipes.Recipes{
		Recipes: []*recipes.Recipe{
			recipe1,
			recipe2,
		},
	}

	storeResponse, err := client.Store(context.Background(), mainRecipes)
	if !assert.NoError(t, err) {
		return
	}
	assert.Equal(t, &recipes.Empty{}, storeResponse)

	recipesBy625, err := client.FindByIngredients(context.Background(), &recipes.IngredientsFilter{
		Codes: []uint32{625},
	})
	if !assert.NoError(t, err) {
		return
	}
	assert.Equal(t, mainRecipes, recipesBy625)
}

А пам’ятаєте, що в згенерованих структурах з proto-файлу були додаткові службові XXX-поля? То ось через XXX-поля тест провалився на assert.Equal(t, mainRecipes, recipesBy625). Допишемо порівняння (звісно, є припущення, що хтось уже написав автогенерацію таких порівнянь).

func TestStoreAndFindByIngredients(t *testing.T) {
	// ...
	assertEqualRecipes(t, mainRecipes.Recipes, recipesBy625.Recipes)
}

func assertEqualRecipes(t *testing.T, expect, actual []*recipes.Recipe) bool {
	t.Helper()

	if !assert.Equal(t, len(expect), len(actual)) {
		return false
	}

	for i := range expect {
		if !assert.Equal(t, expect[i].Code, actual[i].Code) {
			return false
		}

		if !assert.Equal(t, expect[i].Title, actual[i].Title) {
			return false
		}

		if !assertEqualIngredient(t, expect[i].Ingredients, actual[i].Ingredients) {
			return false
		}
	}

	return true
}

func assertEqualIngredient(t *testing.T, expect, actual []*recipes.Ingredient) bool {
	t.Helper()

	if !assert.Equal(t, len(expect), len(actual)) {
		return false
	}

	for i := range expect {
		if !assert.Equal(t, expect[i].Code, actual[i].Code) {
			return false
		}

		if !assert.Equal(t, expect[i].Name, actual[i].Name) {
			return false
		}
	}

	return true
}

Вітаю, тепер тести виконано.

Перевіримо localhost

Ми запустимо сервер на localhost і за допомогою тестів перевіримо, що так само працює як і через bufconn.

Створимо файл main.go:

package main

import (
	"github.com/juju/errors"
	"gitlab.com/go-yp/grpc-recipes/components/server"
	"gitlab.com/go-yp/grpc-recipes/models/protos/services/recipes"
	"google.golang.org/grpc"
	"log"
	"net"
)

func main() {
	lis, err := net.Listen("tcp", ":32625")
	if err != nil {
		log.Fatalf("[CRITICAL] failed to listen: %+v", errors.Trace(err))
	}
	defer lis.Close()

	s := grpc.NewServer()

	recipes.RegisterRecipesServiceServer(
		s,
		new(server.Server),
	)

	if err := s.Serve(lis); err != nil {
		log.Fatalf("[CRITICAL] Server exited with error: %+v", errors.Trace(err))
	}
}

Оновимо тест і побачимо, що змінилося:

func TestStoreAndFindByIngredients(t *testing.T) {
	// connection, err := mockServerConnect(context.Background())
	connection, err := localhostServerConnect("localhost:32625")
	if !assert.NoError(t, err) {
		return
	}
	defer connection.Close()
	// ...
	// same
}

func localhostServerConnect(address string) (conn *grpc.ClientConn, err error) {
	return grpc.Dial(address, grpc.WithInsecure())
}

В окремому вікні терміналу запустимо:

go run main.go

Знову запускаю тести ― усе вдалося.

Епілог

Готовий репозиторій з прикладом можна переглянути на GitLab-і. Під час написання фокус робив саме на gRPC. Сподіваюся, що все вдалося.

А ще ми до себе в команду шукаємо Go розробника.

Похожие статьи:
Многие IT-специалисты переезжают за границу с семьями. Мы уже писали о том, насколько сложно адаптироваться женам программистов...
Привет, меня зовут Владимир Поло. Я основатель AcademyOcean — SaaS-продукта для онбординга и обучения сотрудиков. В течение последних...
Привіт. Мене звати Андрій. Я керівник невеликої вебстудії, проте зараз найбільше часу приділяю власним проєктам. Така зміна...
Когда слишком много гео-объектов (точек, маркеров, меток) расположены рядом на карте, они сливаются в одно большое, едва...
Длительность курса: 120 академических часов (3 месяца): 3 занятия по 3 часа в неделюГрафик занятий: вторник, четверг —...
Яндекс.Метрика