Приклад 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 розробника.

Похожие статьи:
Діма Малєєв — Engineering Manager у фінтех-компанії Affirm, ютуб-блогер та співавтор подкасту Shit I Know Live. У новому випуску рубрики «Що потім» Діма...
Отмечаем Международный день почты и вместе распаковываем посылку с ответами на актуальные вопросы PHP-мира на ThinkPHP #12. Вечер пятницы...
Реферальні програми в IT-компаніях передбачають, що спеціаліст рекомендує свого знайомого на ту чи іншу позицію і в разі найму...
The wardrobe of a man is always incomplete without suits. Men’s suits have always enjoyed immense reputation and popularity around the world. Men’s suits always show professionalism and distinctiveness and are also worn as a...
Компания GOTVIEW сообщила о начале продаж на российском рынке компактной модели гибридного ТВ-тюнера GOTVIEW USB 2.0 MasterHD 5, который...
Яндекс.Метрика