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