Сегодня я хотел бы поделиться особенностью разработки сервисов на Golang вместе с протоколом gRPC. В этой статье я не буду рассказывать, что такое gRPC, protobuf и для чего они нужны, вместо этого я сосредоточусь на технической части.

Мы напишем простое приложение на Golang, который в качестве транспортного протокола будет использовать gRPC, а так же с помощью gRPC Gateway мы подключим поддержку RESTful API. У нашего сервиса будет всего два ендпоинта, а именно:

  • Создать пользователя

  • Получить пользователя по идентификатору

Давайте определим интерфейс для нашего сервиса, для этого нам нужно создать два protobuf файла, один для моделей, а другой для сервисов. Хорошей практикой является разделение моделей и сервисов в разные protobuf файлы, таким образом мы можем легко переиспользовать модели в других сервисах.

user_model.proto
syntax = "proto3";
package com.example.user.model.v1;
option go_package = "com.example/usersvcapi/v1";

message UserWrite {
    string name = 1;
    UserType type = 2;
}

message UserRead {
    string id = 1;
    string name = 2;
    UserType type = 3;
}

enum UserType {
    USER_TYPE_UNKNOWN = 0;
    USER_TYPE_ADMIN = 1;
    USER_TYPE_USER = 2;
}

Я намеренно разделил модель пользователя, на запись и чтение, чтобы показать как на стороне сервиса мы можем сгенерировать уникальный идентификатор.

Обратите внимание на тип пользователя, который является перечислением. Перечисления в protobuf/syntax3 имеют ряд особенностей. Из интересного, например - нулевое значение должно быть первым элементом для совместимости с семантикой proto2, где первое значение перечисления всегда используется по умолчанию.

Так же, рекомендуется, чтобы имя элемента перечисления начиналось с типа перечисления + имя элемента. Например при следующем определении возникнет конфликт имен пространств элементов перечисления:

enum UserType {
    UNKNOWN = 0;
    ADMIN = 1;
    USER = 2;
}

enum UserGroup {
    USER = 0; // Name conflict with UserType.USER
    ADMIN = 1; // Name conflict with UserType.ADMIN
}

Разобравшись с моделью, давайте перейдем к определию интерфейса сервиса:

user_service.proto
syntax = "proto3";
package com.example.user.service.v1;
option go_package = "com.example/usersvcapi/v1";

import "user_model.proto";
import "google/api/annotations.proto";

service UserService {

    rpc CreateUser(CreateUserRequest) returns (CreateUserResponse) {
        option (google.api.http) = {
            post: "/v1/users"
            body: "user"
        };
    }

    rpc GetUser(GetUserRequest) returns (GetUserResponse) {
        option (google.api.http) = {
            get: "/v1/users"
        };
    }
}

message CreateUserRequest {
    com.example.user.model.v1.UserWrite user = 1;    
}

message CreateUserResponse {
    string id = 1;    
}

message GetUserRequest {
    string id = 1;
}

message GetUserResponse {
    com.example.user.model.v1.UserRead user = 1;
}

Мы импортировали "google/api/annotations.proto", которые содержат исходные определения интерфейсов Google API, для описания RESTful API в protobuf.

Теперь, когда мы описали интерфейс приложения, мы можем скомпилировать protobuf файлы под Golang. Для компиляции нам нужно установить следующие библиотеки:

$ go get -u google.golang.org/grpc
$ go get -u github.com/golang/protobuf/protoc-gen-go
$ sudo apt install protobuf-compiler
$ mkdir consignment && cd consignment
$ protoc -I=. --go_out=plugins=grpc:. consignment.proto

Другим вариантом, чтобы скомпилировать protobuf файлы под Golang, мы можем воспользоваться докер образом namely/protoc-all и тогда не нужно устанавливать дополнительные библиотеки. Опишем файл docker-compose:

version: "3.3"
services:
  protoc-all:
    image: namely/protoc-all:latest
    command: 
      -d proto
      -o gen/pb-go
      -i third_party/googleapis
      -l go
      --with-gateway
    volumes:
      - ./:/defs

Где:

  • -o - директория, куда будут скомпилированы proto stubs.

  • -i - путь к сторонним зависимостям, в нашем случае googleapis

  • -l - ЯП, в нашем случае Golang (go)

  • флаг --with-gateway, для генерации RESTful API

Когда protobuf файлы скомпилированы, мы можем приступить к написанию main файла, где собственно будет описан gRPC сервер.

main.go
func main() {

	// Flags.
	//
	fs := flag.NewFlagSet("", flag.ExitOnError)
	grpcAddr := fs.String("grpc-addr", ":6565", "grpc address")
	httpAddr := fs.String("http-addr", ":8080", "http address")
	if err := fs.Parse(os.Args[1:]); err != nil {
		log.Fatal(err)
	}

	// Setup gRPC servers.
	//
	baseGrpcServer := grpc.NewServer()
	userGrpcServer := NewUserGRPCServer()
	apiv1.RegisterUserServiceServer(baseGrpcServer, userGrpcServer)

	// Setup gRPC gateway.
	//
	ctx := context.Background()
	rmux := runtime.NewServeMux()
	mux := http.NewServeMux()
	mux.Handle("/", rmux)
	{
		err := apiv1.RegisterUserServiceHandlerServer(ctx, rmux, userGrpcServer)
		if err != nil {
			log.Fatal(err)
		}
	}

	// Serve.
	//
	var g run.Group
	{
		grpcListener, err := net.Listen("tcp", *grpcAddr)
		if err != nil {
			log.Fatal(err)
		}
		g.Add(func() error {
			log.Printf("Serving grpc address %s", *grpcAddr)
			return baseGrpcServer.Serve(grpcListener)
		}, func(error) {
			grpcListener.Close()
		})
	}
	{
		httpListener, err := net.Listen("tcp", *httpAddr)
		if err != nil {
			log.Fatal(err)
		}
		g.Add(func() error {
			log.Printf("Serving http address %s", *httpAddr)
			return http.Serve(httpListener, mux)
		}, func(err error) {
			httpListener.Close()
		})
	}
	{
		cancelInterrupt := make(chan struct{})
		g.Add(func() error {
			c := make(chan os.Signal, 1)
			signal.Notify(c, syscall.SIGINT, syscall.SIGTERM)
			select {
			case sig := <-c:
				return fmt.Errorf("received signal %s", sig)
			case <-cancelInterrupt:
				return nil
			}
		}, func(error) {
			close(cancelInterrupt)
		})
	}
	if err := g.Run(); err != nil {
		log.Fatal(err)
	}
}

type userServer struct {
	m map[string]*apiv1.UserWrite
}

func NewUserGRPCServer() apiv1.UserServiceServer {
	return &userServer{
		m: map[string]*apiv1.UserWrite{},
	}
}

func (s *userServer) CreateUser(ctx context.Context, req *apiv1.CreateUserRequest) (*apiv1.CreateUserResponse, error) {
	id, err := uuid.NewRandom()
	if err != nil {
		return nil,
			status.Error(codes.Internal, err.Error())
	}
	s.m[id.String()] = req.User
	return &apiv1.CreateUserResponse{
		Id: id.String(),
	}, nil
}

func (s *userServer) GetUser(ctx context.Context, req *apiv1.GetUserRequest) (*apiv1.GetUserResponse, error) {
	foundUser, ok := s.m[req.Id]
	if !ok {
		return nil,
			status.Error(codes.NotFound, fmt.Errorf("User not found by id %v", req.Id).Error())
	}
	return &apiv1.GetUserResponse{
		User: &apiv1.UserRead{
			Id:   req.Id,
			Name: foundUser.Name,
			Type: foundUser.Type,
		},
	}, nil
}

Запустим приложение и проверим как работают наши ендпоинты. Для тестирования RESTful API, вызовем следующие команды:

Создание пользователя
$ curl -d '{"name":"John", "type":1}' -H "Content-Type: application/json" -X POST http://localhost:8080/v1/users
Получение пользователя по ИД
$ curl -H "Content-Type: application/json" -X GET http://localhost:8080/v1/users?id=${USER_ID}

Для тестирования gRPC ендпоинтов, нужно будет воспользоваться BloomRPC.

Весь исходный код, доступен на github.

Комментарии (3)


  1. sergeymolchanovsky
    07.03.2022 23:03

    Не очень понятно, в чем смысл статьи. Этакий гайд для новичков по Grpc. Как написать proto-файлы, скомпилить, и накидать простейшее API.

    А где про микросервисы? Про архитектуру? Почему не подсвечены плюшки Grpc (bidirectional streaming)? А еще напрашивается профайлинг Rest vs Grpc, чтобы продемонстрировать выигрыш в скорости.


    1. monomoto Автор
      08.03.2022 08:14

      В этой статья я не хотел рассказывать, что такое gRPCprotobuf и для чего они нужны, вместо этого я хотел сосредоточится на технической части.


  1. GoodGod
    08.03.2022 00:03
    +2

    Я слышал что в Go одновременная запись в map запрещена. По-этому наверное map следует обернуть в блоки синхронизации.