Приклад 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-файлу були додаткові службові ssert.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 розробника.