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

Все про українське ІТ в телеграмі — підписуйтеся на канал DOU

👍ПодобаєтьсяСподобалось0
До обраногоВ обраному3
LinkedIn

Схожі статті




39 коментарів

Підписатись на коментаріВідписатись від коментарів Коментарі можуть залишати тільки користувачі з підтвердженими акаунтами.

кому-то приходилось реализовывать собственный механизм аутентификации с пробросом данных в обработчики? например сделать свой net.Listener/net.Conn, передавать в grp server, который после установки соединения будет производить нужную аутентификацию и передавать дальше данные о пользователе который аутентифицировался в контекст запроса
год назад смотрел доку, бегло по коду пробегался, не нашел способа сделать это нормально через public api в golang grpc

s.mu.RLock(); data := s.data; s.mu.RUnlock();
Насколько я понимаю переменная data в go — это указатель на map. В этом случае здесь необходимо снимать блокировку ПОСЛЕ того, как закончился range-цикл по data?

В текущем виде race condition таки да. Прийдется использовать какой-то из видов deepcopy или копировать самостоятельно.

В текущем виде race condition таки да

А тести показали, що без race

package tests

import (
  "sync"
  "testing"
)

type recipe struct {
  value int
}

type tRWMutexSlice struct {
  mu   sync.RWMutex
  data []*recipe
}

func (s *tRWMutexSlice) add(recipe *recipe) {
  s.mu.Lock()
  s.data = append(s.data, recipe)
  s.mu.Unlock()
}

func (s *tRWMutexSlice) max() int {
  s.mu.RLock()
  data := s.data
  s.mu.RUnlock()

  max := 0

  for _, recipe := range data {
    if recipe.value > 0 {
      max = recipe.value
    }
  }

  return max
}

func TestRWMutexSliceRace(t *testing.T) {
  service := new(tRWMutexSlice)

  wg := new(sync.WaitGroup)

  wg.Add(1000)
  for i := 0; i < 1000; i++ {
    go func(value int) {
      service.add(&recipe{
        value: value,
      })
      wg.Done()
    }(i)
  }

  wg.Add(1000)
  for i := 0; i < 1000; i++ {
    go func(value int) {
      _ = service.max()
      wg.Done()
    }(i)
  }

  wg.Wait()
}
go test ./... -v -race

Прошу прощения, недосмотрел — если бы был мап то был бы рейс, а со слайсом нет проблем.

data це slice з якого в циклі читаю тому достатньо

	s.mu.Lock()
	s.data = append(s.data, recipes.Recipes...)
	s.mu.Unlock()
	s.mu.RLock()
	data := s.data
	s.mu.RUnlock()
якщо прибрати RLock & RUnlock то буде race

У випадку map треба було б блокувати на весь час читання, у випадку slice під капотом масив, який залишається без змін від 0 до length

Сколько я не видел кодешников людей на go, все делают поля структур экспортируемыми. Это для облегчения примера или go программисты забивают на инкапсуляцию?

Ось те що писав:

type Server struct {
	mu   sync.RWMutex
	data []*recipes.Recipe
}
ще тести, інше кодогенерація
все делают поля структур экспортируемыми

Якщо DTO структура то всі поля відкриті, без getter-ів та setter-ів
В сервісах поля закривають

А какое отношение имеют неэкспортируемые поля к инкапсуляции?)

Думаю, Вы смешивание понятие инкапсуляции с сокрытием данных. Они часто идут бок о бок, при этом это не эквивалентные понятия.

Да, понятие инкапсуляции более широкое, но в нормальных ЯП эти понятия можно считать почти эквивалентными. И в основном, говоря об инкапсуляции имеют ввиду именно сокрытие данных. Ну и как бы ответ автора на мой вопрос это доказывает)

Могу посоветовать почитать о table driven testing in Go, а еще использовать Godoc для просмотра генеренных файлов и не только.

assert.NoError(t, err) {

- очень странно выглядит, не очень понятно чем вас if err != nil {} не устроило. Вместо return внутри тестов используется t.Fatal().

є припущення, що хтось уже написав автогенерацію таких порівнянь

godoc.org/...​ns/godebug/pretty#Compare

Мені «return» більш зрозуміліший ніж t.Fatal(err)
в терміналі майже однаково, з:

	if !assert.NoError(t, err) {
		return
	}
буде:
=== RUN   TestStoreAndFindByIngredients
--- FAIL: TestStoreAndFindByIngredients (0.00s)
    grpc_test.go:22: 
        	Error Trace:	grpc_test.go:22
        	Error:      	Received unexpected error:
        	            	gitlab.com/go-yp/grpc-recipes/components/tests/grpc_test.go:86: some test error
        	Test:       	TestStoreAndFindByIngredients

А з:

	if err != nil {
		t.Fatal(err)
	}
=== RUN   TestStoreAndFindByIngredients
--- FAIL: TestStoreAndFindByIngredients (0.00s)
    grpc_test.go:23: some test error

Что делать, если нужен контекст err? t.Fatalf() - ok, а как через return?

використовую github.com/juju/errors щоб позначити де сталась помилка, і потім «assert.NoError(t, err)» це виводить

Видно ось тут:

        	
Error Trace:	grpc_test.go:22
Error:      	Received unexpected error: gitlab.com/go-yp/grpc-recipes/components/tests/grpc_test.go:86: some test error
Могу посоветовать почитать о table driven testing in Go

Комфортніше через функції, ось як тут github.com/valyala/fastjson

func TestParseUint64BestEffort(t *testing.T) {
	f := func(s string, expectedNum uint64) {
		t.Helper()

		num := ParseUint64BestEffort(s)
		if num != expectedNum {
			t.Fatalf("unexpected umber parsed from %q; got %v; want %v", s, num, expectedNum)
		}
	}

	// Invalid first char
	f("", 0)

	// Invalid suffix
	f("1foo", 0)

	// Int
	f("1", 1)
}

1) Работает только потому что количество параметров 2, когда их будет десяток такой подход не скейлится. 2) Весь тест вылетит после первой невыполненой функции и остальные тесты не проверятся.

Так і в прикладах Introducing table driven tests (dave.cheney.net):

func TestSplit(t *testing.T) {
    type test struct {
        input string
        sep   string
        want  []string
    }

    tests := []test{
        {input: "a/b/c", sep: "/", want: []string{"a", "b", "c"}},
        {input: "a/b/c", sep: ",", want: []string{"a/b/c"}},
        {input: "abc", sep: "/", want: []string{"abc"}},
    }

    for _, tc := range tests {
        got := Split(tc.input, tc.sep)
        if !reflect.DeepEqual(tc.want, got) {
            t.Fatalf("expected: %v, got: %v", tc.want, got)
        }
    }
}
Весь тест вылетит после первой невыполненой функции и остальные тесты не проверятся

Не зовсім так. Якщо прокрутити статтю нижче цього прикладу, то проводиться наступна модифікація — добавляється t.Run, який дозволяє запускати окремі кейси.

І як написали вище метод з функціями не скейлиться дуже добре:
1. Кожного разу вам прийдеться копіювати цілу стрічку і є великий шанс, що в одному місці ви переплутаєте параметри.
2. Якщо тест слід зробити трохи складнішим, наприклад передати не обовязкову помилку, яку ваша функція повертає, то у вашому випадку прийдеться модифікувати кожну стрічку. Якщо користуватися структурами, то помилка додається тільки ті кейси в яких вона необхідна.

В основному користуюсь функціями коли треба покрити більше випадків

З аргументами використання таблиць згоден у випадках складних тестів

є припущення, що хтось уже написав автогенерацію таких порівнянь

godoc.org/...​ns/godebug/pretty#Compare

Подивився, там відсутня можливість прибрати з порівняння конкретні поля

protobuf сравнивает корректно, что еще нужно? Второй вариант godoc.org/...​m/google/go-cmp/cmp#Equal

protobuf сравнивает корректно, что еще нужно?

Потрібно проігнорувати поля:

	XXX_NoUnkeyedLiteral struct{}  `json:"-"`
	XXX_unrecognized     []byte    `json:"-"`
	XXX_sizecache        int32     `json:"-"`
Второй вариант godoc.org/...​m/google/go-cmp/cmp#Equal

Повертає тільки true або false

Вручну написав порівняння за 5 хвилин, якщо колись знайду бібліотеку яка вміє ігнорувати і порівнювати з повертанням різниці то напишу в цій темі

Варіант, в лоб це порівнювати серіалізовані дані

Дякую, спрацював:

import (
	"github.com/google/go-cmp/cmp"
	"github.com/google/go-cmp/cmp/cmpopts"
)
ignoreFields := []string{
	"XXX_NoUnkeyedLiteral",
	"XXX_unrecognized",
	"XXX_sizecache",
}

println(cmp.Diff(
	mainRecipes.Recipes,
	recipesBy625.Recipes,
	cmpopts.IgnoreFields(
		recipes.Recipe{},
		ignoreFields...,
	),
	cmpopts.IgnoreFields(
		recipes.Ingredient{},
		ignoreFields...,
	),
))

А теперь попробуйте без

ignoreFields

с темже результатом для protobuf

Варіанти:

println(cmp.Diff(
	mainRecipes.Recipes,
	recipesBy625.Recipes,
))
та
println(cmp.Diff(
mainRecipes.Recipes,
recipesBy625.Recipes,
cmpopts.IgnoreFields(recipes.Recipe{}),
cmpopts.IgnoreFields(recipes.Ingredient{}),
))
дають однаковий результат:
  []*recipes.Recipe{
  	&{
  		Code:  0x2711,
  		Title: "Борщ",
  		Ingredients: []*recipes.Ingredient{
  			&{
  				... // 2 identical fields
  				XXX_NoUnkeyedLiteral: struct{}{},
  				XXX_unrecognized:     nil,
- 				XXX_sizecache:        15,
+ 				XXX_sizecache:        0,
  			},
  			&{
  				... // 2 identical fields
  				XXX_NoUnkeyedLiteral: struct{}{},
  				XXX_unrecognized:     nil,
- 				XXX_sizecache:        19,
+ 				XXX_sizecache:        0,
  			},
  			&{
  				... // 2 identical fields
  				XXX_NoUnkeyedLiteral: struct{}{},
  				XXX_unrecognized:     nil,
- 				XXX_sizecache:        21,
+ 				XXX_sizecache:        0,
  			},
  		},
  		XXX_NoUnkeyedLiteral: struct{}{},
  		XXX_unrecognized:     nil,
- 		XXX_sizecache:        74,
+ 		XXX_sizecache:        0,
  	},
  	&{
  		Code:  0x2712,
  		Title: "Вінегрет з печерицями",
  		Ingredients: []*recipes.Ingredient{
  			&{
  				... // 2 identical fields
  				XXX_NoUnkeyedLiteral: struct{}{},
  				XXX_unrecognized:     nil,
- 				XXX_sizecache:        15,
+ 				XXX_sizecache:        0,
  			},
  			&{
  				... // 2 identical fields
  				XXX_NoUnkeyedLiteral: struct{}{},
  				XXX_unrecognized:     nil,
- 				XXX_sizecache:        21,
+ 				XXX_sizecache:        0,
  			},
  		},
  		XXX_NoUnkeyedLiteral: struct{}{},
  		XXX_unrecognized:     nil,
- 		XXX_sizecache:        85,
+ 		XXX_sizecache:        0,
  	},
  }

Для коректного порівняння proto, необхідно передати додатковий параметр в Equal або Diff: cmp.Comparer(proto.Equal))

Так набагато краще, дякую

import (
	"github.com/golang/protobuf/proto"
	"github.com/google/go-cmp/cmp"
)
println(cmp.Diff(
	mainRecipes.Recipes,
	recipesBy625.Recipes,
	cmp.Comparer(proto.Equal),
))

Спасибо за статью! Попробую gRPC =)

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

Почему сразу не писать на go этот файл? Я просто на go не пишу, поэтому интересно. Зачем нам еще создавать proto файл и генерировать Go-файл?
Это норм, что DTO-ки, сервисы и тд в одном файле?

Ти описуєш proto файли, які можна скопіювати в інший сервіс в іншому Git-репозиторію і там згенерувати Go клієнт і структури

Это норм, что DTO-ки, сервисы и тд в одном файле?

це спрощення для статті, звісно, краще їх зберігати в різних файлах

А ще gRPC використовується для взаємодії сервісів які можуть бути написані на різних мовах програмування, а proto файли без залежності від мови програмування

Я б відділив моделі від реквестів/респонзів:

message StoreRequest {
     repeated Recipe recipes = 1;
}

Для empty є спеціальний gist.github.com/...​4e8dfb28e31fb5a8bf2f8282c

Для empty є спеціальний

Вже для наступних статей

Дякую, пів року тому шукав:

import "google/protobuf/empty.proto";

service SomeService {
    rpc SomeOperation (google.protobuf.Empty) returns (google.protobuf.Empty) {}
}
переведу в проекті

Підписатись на коментарі