Сегодня я хотел бы поделиться особенностью разработки сервисов на 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
Другим вариантом, чтобы скомпилировать protobuf файлы под Golang, мы можем воспользоваться докер образом namely/protoc-all и тогда не нужно устанавливать дополнительные библиотеки. Именно этот вариант рекомендуется при разработке сервиса более чем одним разработчиком, так можно стандартизировать процесс разработки и компиляции protobuf файлов.
Опишем файл 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 файлов:
docker-compose -f docker-compose.yml up
Когда 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.