golang

gRPC на Go: Пишем микросервис аутентификации с нуля

  • понедельник, 24 августа 2026 г. в 00:00:14
https://habr.com/ru/articles/1073376/

Введение

Привет, Хабр! Сегодня рассмотрим тему gRPC и работы с ним в контексте языка программирования Go на примере простейшей реализации аутентификации. Полный пример вы можете найти в архивном репозитории github.

gRPC является современным фреймворком, разработанным Google для связи между программными сервисами. RPC - это тип связи, который позволяет приложениям взаимодействовать друг с другом по сети. Он позволяет вызывать процедуры одного приложения из другого, что обеспечивает большую масштабируемость и позволяет реализовывать сложную микросервисную архитектуру. Также благодаря возможности версионирования и указания конкретной структуры API в файле Protobuf, появляется возможность поддерживать единое состояние у всех сервисов системы. Основным смыслом gRPC является то, что работа происходит в виде набора байт, а не JSON и тому подобных форматов, при этом также нужно учитывать, что gRPC - это не только бинарный формат, но и HTTP/2, так как именно HTTP/2 даёт мультиплексирование и стримминг. Это делает общение между сервисами максимально быстрым. Основным краеугольным камнем здесь является Protobuf.

Protobuf

Protobuf - это протокол сериализации (передачи) структурированных данных. Этот протокол эффективно хранит структурированные данные в двоичной форме, что позволяет быстро передавать их по сети.
Вообще, Protobuf - это универсальный протокол. Он может использоваться в коммуникационных протоколах, хранилищах данных и многом другом.
Основное отличие между Protobuf и JSON форматом в том, что JSON использует человекочитаемый формат обычного текста, а Protobuf кодирует данные в двоичном формате. Получается, что передача того же объёма информации требует меньшей пропускной способности, чем JSON.

На текущий момент последним вариантом Protobuf является proto3, в который добавили: тип map для хэш-карт, типы для работы с датой, временем и динамическими значениями, а также важный тип optional, который решает известную проблему присутствия полей. До этого, если передавать значение поля 0 для числовых или "" для строковых типов, то данные вообще не отправлялись, так как считались пустыми. На текущий момент тип optional позволяет указать присутствие поля.

Для работы Protobuf в контексте gRPC на Go необходимо написать файл .proto, который является схемой API. Важно учитывать, что .proto файл - это не просто какая-то документация, а строгие инструкции, которые являются исполняемым кодом, который, после компиляции protoc будет сгенерирован в код Go. Вот пример такого файла:

syntax = "proto3";

package auth;

option go_package = "./auth";

//Сервис аутентификации:

service Auth {
  rpc Login(LoginRequest) returns (LoginReply);
  rpc CheckAuth(CheckAuthRequest) returns (CheckAuthResponse);
  rpc Logout(LogoutRequest) returns (LogoutResponse);
}

// Запрос и ответ для функционала входа (Login):
message LoginRequest {
  string login = 1;
  string password = 2;
}

message LoginReply {
  string token = 1;
}

// Запрос и ответ для функционала проверки входа/токена (CheckAuth): 
message CheckAuthRequest {
    string token = 1;
}

message CheckAuthResponse {
    bool is_auth = 1; 
    string admin_id = 2; 
    string login = 3;
}


// Запрос и ответ для функционала выхода из сессии (Logout):
message LogoutRequest {
    string token = 1;
}

message LogoutResponse {
    bool is_logout = 1;
}

Здесь можно заметить один сервис Auth и конкретные типы message. В сервисы указываются методы и к методам в дополнение указываются конкретные типы принимаемых и возвращаемых значений.

Детально разбирать установку protoc и генерацию не будем, так как это зависит от вашей ОС, используемых директорий. Главное, что следует указать, что для генерации необходимо:

  • Компилятор protoc.

  • Go-плагины, их можно установить с помощью этих команд:

    go install google.golang.org/protobuf/cmd/protoc-gen-go@latest
    go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@latest
  • Обновить переменные. можно сделать этой командой, если используете оболочку Bash:

export PATH="$PATH:$(go env GOPATH)/bin"

Также вот пример команды, с помощью которой происходит генерация:

protoc --go_out=. --go_opt=paths=source_relative \
    --go-grpc_out=. --go-grpc_opt=paths=source_relative \
    yourdir/yourfile.proto

После компиляции должна появиться ваша директория с двумя файлами:

  • your.pb.go - этот файл содержит описание данных. Здесь находятся Go-структуры, которые соответствуют message из .proto файла. Здесь же лежит логика сериализации и десериализации.

  • your_grpc.pb.go - этот файл содержит описание поведения. Здесь находятся интерфейсы сервера (то, что программисту нужно реализовать), находится клиентский код (функции, которые отправляют запросы по сети) и здесь же описаны сами RPC-методы.

Хотелось бы предостеречь вас. Нужно понимать, что эти файлы являются сгенерированными. Вручную их править ни в коем случае нельзя. Такие действия могут привести к очень печальным последствиям в виде расхождения с основным .proto файлом и ошибок компиляции.

Первый gRPC-сервер

Основой работы с gRPC является клиент-серверная архитектура. При этом клиентом здесь является не браузер или конечное устройство пользователя, а какой-либо другой сервер. К примеру, вполне может существовать подобная архитектура:

Паттерн BFF: Создание отдельного серверного слоя для каждого типа клиентского приложения
Паттерн BFF: Создание отдельного серверного слоя для каждого типа клиентского приложения

На схеме показан паттерн BFF, где браузер общается с веб-сервером по HTTP/JSON, а веб-сервер выступает адаптером к внутреннему gRPC-серверу.

Для работы с gRPC необходимо создать сервер. Нужно создать структуру и метод её создания для сервера. В подобной структуре может находиться все необходимые зависимости и то, что должно быть во всех обработчиках (к примеру пул подключения к базе данных), но главным в любом случае будет UnimplementedYourServer из файла _grpc.pb.go, что позволяет сделать структуру валидной для интерфейса, сгенерированного protoc. Ниже приведён пример подобной структуры:

type AuthServer struct {
	pb.UnimplementedAuthServer
	PostgresPool *pgxpool.Pool
	RedisClient *redis.Client
	JWTSecret string
}


func NewStruct() *AuthServer {
	return &AuthServer{}
}

Ниже приведена функция запуска gRPC-сервера:

func StartServer(ctx context.Context, pool *pgxpool.Pool, redis *redis.Client, JWTSecret string) {

	lis, err := net.Listen("tcp", ":50051")
	if err != nil {
		log.Fatalf("Ошибка создания слушателя порта :50051: %v", err)
	}

	//Создаём экземпляр структуры сервера аутентификации:
	authServer := &internal.AuthServer{
		PostgresPool: pool,
		RedisClient:  redis,
		JWTSecret:    JWTSecret,
	}

	grpcServer := grpc.NewServer()
	pb.RegisterAuthServer(grpcServer, authServer)

	go func() {
		log.Println("Запуск gRPC-сервера на порту :50051...")
		if err := grpcServer.Serve(lis); err != nil {
			log.Printf("Ошибка запуска gRPC-сервера: %v", err)
		}
	}()

	<-ctx.Done()
	log.Println("Получил сигнал завершения, начинаю shutdown...")

	grpcServer.GracefulStop()
	log.Println("gRPC-сервер успешно остановлен!")
}

Сервер создаётся на порту 50051 - это стандартный порт для gRPC-сервера. Для примера реализации одного из методов (Logout) описанных в .proto ниже предоставлен код:

func (s AuthServer) Logout(ctx context.Context, req *pb.LogoutRequest) (*pb.LogoutResponse, error) { 

	//Верифицируем токен и получаем его поля:
	jwtValues, err := auth.VerifyJWT(req.Token, s.JWTSecret)
	if err != nil {
		if errors.Is(err, auth.ErrInvalidToken) {
			return nil, status.Error(codes.Unauthenticated, "invalid token")
		}
		return nil, status.Error(codes.Internal, "token verification failed")
	}

	//Удаляем из Redis: 
	err = repositories.DelRedisSession(ctx, jwtValues.SessionID, s.RedisClient)
	if err != nil {
		if err != redis.Nil {
			return nil, status.Error(codes.Internal, "Internal server error!")
		}
	}
	
	return &pb.LogoutResponse{IsLogout: true}, nil
}

Синтаксис как и у любого другого метода Go, только тип принимаемого значение и возвращаемого в сигнатуре функции особенный. Как правило, сигнатуры берутся из файла _grpc.pb.go, а реализация наполнения остаётся за разработчиком. В случае ошибки в gRPC принято использовать status, о чём описано далее.

status и типы ошибок gRPC

В привычном REST принято возвращать HTTP 404 или другие статусы ошибок, то в gRPC ошибки - это отдельный язык общения между сервисами. Для правильного построения архитектуры нужно использовать пакет google.golang.org/grpc/status и перечисление codes, чтобы клиент не потерял контекст ошибки. Вот основные коды, которые используются в gRPC:

  • OK - аналог 200, означает об успехе.

  • INVALID_ARGUMENT - аналог 400 Bad Request, означает, что клиент прислал не верные данные (пустой email или что-то подобное).

  • NOT_FOUND - аналог 404 Not Found, означает, что запрашиваемый ресурс не найден.

  • ALREADY_EXISTS - аналог 409 Conflict, означает попытку создать дубликат.

  • UNAUTHENTICATED - аналог 401 Unauthorized, нет токена или он протух.

  • PERMISSION_DENIED - аналог 403 Forbidden, токен есть, но права на действие нет.

  • DEADLINE_EXCEEDED - аналог 504, означает, что сервер не дождался ответа или вышло время запроса.

  • UNAVAILABLE - аналог 503, когда сервис временно недоступен.

При этом вот так выглядит стандартный синтаксис, как надо создавать ошибки:

status.Error(codes.Internal, "Internal server error!")

Также есть ситуации, когда клиенту мало знать код ошибки. В таких случаях используют status.WithDetails():

// Создаем статус с дополнительными деталями
st := status.New(codes.InvalidArgument, "validation failed")

// Добавляем структурированные детали
st, _ = st.WithDetails(&errdetails.BadRequest{
    FieldViolations: []*errdetails.BadRequest_FieldViolation{
        {
            Field:       "email",
            Description: "must be a valid email address",
        },
        {
            Field:       "password",
            Description: "must be at least 8 characters",
        },
    },
})

return nil, st.Err()

При разработке важно понимать, что ошибки - это важная часть работы API. Важно всегда стараться передавать конкретную ошибку и отделять ошибки сервера от ошибок бизнес логики. При попытке вернуть вместо корректной ошибки nil, клиент просто потеряет важную информацию и может совершить неверную операцию.

Подключение к сервису. Клиентская часть

На стороне клиента таким же образом генерируется Go-код из .proto. Клиент будет вызывать методы gRPC-сервера, для этого необходимо реализовать подключение к gRPC-серверу. Пример подключения в блоке кода ниже:

	//Создаю подключение к gRPC-серверу:
	authServerAddr := os.Getenv("AUTH_SERVER_ADDR")
	if authServerAddr == "" {
		log.Printf("Ошибка получения адреса сервера аутентификации из переменных!")
		authServerAddr = "localhost:50051"
	}
	// Создаение клиента
	// ВАЖНО ЗАПОМНИТЬ, ЧТО ЭТО ПРИМЕР! В production-коде никогда не используйте insecure! 
	// Всегда нужно настраивать TLS/mTLS!
	conn, err := grpc.NewClient(authServerAddr, grpc.WithTransportCredentials(insecure.NewCredentials()))

	if err != nil {
		log.Fatalf("Ошибка создания gRPC-клиента: %v", err)
	}
	defer conn.Close()
	client := pb.NewAuthClient(conn)

Важно понимать, что это лишь пример кода, реализации могут отличаться от разных версий и способов написания кода. В конкретном примере происходит подключение к порту 50051, который был указан при создании gRPC-сервера. Далее хотелось бы продемонстрировать пример вызова одного из методов, реализованных в gRPC-сервере. Будет рассматриваться вызов метода проверки аутентификации (CheckAuth):

func AuthMiddleware(next http.Handler, client CheckAuthInt) http.Handler{
	return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {

		cookie, err := req.Cookie("session")
		if err != nil {
			if err != http.ErrNoCookie {
				log.Printf("Error parse cookie: %v", err)
			}
			
			response.SendResponseError(w, http.StatusUnauthorized, "Unauthorized!")
			return
		}

		// Делаем запрос на сервер аутентификации для проверки токена:
		resp, err := client.CheckAuth(req.Context(), &pb.CheckAuthRequest{Token: cookie.Value})
		if err != nil{
			if status.Code(err) == codes.Unauthenticated{
				response.SendResponseError(w, http.StatusUnauthorized, "Unauthorized!")
				return
			}
			response.SendResponseError(w, http.StatusInternalServerError, "Internal server error!")
			return
		}
		
		if !resp.IsAuth  {
			response.SendResponseError(w, http.StatusUnauthorized, "Unauthorized!")
			return
		}
		
		next.ServeHTTP(w, req)
	})
}

Основной строкой здесь является resp, err := client.CheckAuth(req.Context(), &pb.CheckAuthRequest{Token: cookie.Value}), так как здесь происходит вызов самого метода. В аргументы передаются те значения, которые были указаны в .proto:

message CheckAuthRequest {
    string token = 1;
}

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

Заключение

В современных FAANG использование gRPC стало уже стандартом. С наибольшей долей вероятности любому разработчику, который стремиться разрабатывать высоконагруженные сервисы необходимо понимать gRPC и всё то, что с ним связанно, уметь работать с .proto-файлами и понимать взаимодействие сервисов по средством gRPC.

Полный код работы с gRPC вы можете посмотреть в моём архивном репозитории github, там же есть документация по запуску проекта в Docker и проверки работоспособности. Оба микросервиса располагаются в одной директории для удобства.

Спасибо за внимание! Если вы обнаружили ошибки или неточности в статье, вы можете указать об этом в комментариях!