Как разработать gRCP-сервис на Go - Академия Selectel
В панель

Как разработать gRCP-сервис на Go

Показываем, как написать полноценный gRPC-сервис с архитектурой на Go. На примере сервера авторизации.

Введение

Привет! Меня зовут Николай, я много лет занимаюсь разработкой, дружу с Go и веду собственный YouTube-канал.

Одно из моих последних видео — о создании gRPC-приложений. Тема интересная и по началу сложная, поэтому я написал конспект, в котором перечислил самые важные шаги. 

В этом тексте мы научимся писать полноценные gRPC-сервисы на примере сервера авторизации с архитектурой на Go, готовой к продакшену. Попутно познакомимся с базовыми подходами к работе с авторизацией, а в конце — настроим автоматический деплой  на сервер с помощью GitHub Actions.

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

Как правильнее: SSO или Auth?

В инструкции понятия SSO и Auth будут периодически заменять друг друга — они довольно близки по смыслу. Обычно термином Auth называют сервисы, которые отвечают только за авторизацию, а SSO — это нечто более общее. По сути, термином SSO можно называть сервис, который объединяет в авторизацию (Auth), работу с правами (Permissions) и предоставляет информацию о пользователе (User Info).

Пока рассмотрим только авторизацию, но в будущем я планирую развивать свой сервис и дальше, поэтому он станет настоящим SSO.

Архитектура

Построение gRPC-сервиса и сервера авторизации (SSO) — это довольно сложные и объемные темы. Поэтому буду вынужден срезать углы, чтобы не растягивать текст до формата полноценной книги. Но попутно буду давать советы, по которым вы научитесь самостоятельно модифицировать собственные сервисы. Если возьметесь за это — получится отличное упражнение.

Как будет работать авторизация

Действующее лицо — пользователь, который будет пользоваться сервисом URL Shortener (клиентом SSO). Последний расположен на сервере авторизации (SSO).

1. Пользователь отправляет запрос в SSO, чтобы получить JWT-токен авторизации.

2. С этим токеном он идет в URL Shortener и пользуется сервисом.

3. URL Shortener получает запрос от клиента, достает из него токена, по которому определяет отправителя и его права.

Что такое JWT: краткий ликбез

JWT — это формат токена, состоящий из заголовка, payload и подписи.

В закодированном виде он выглядит вот так: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhcHBfaWQiOjEsImVtYWlsIjoiYXNkYWRzQGFzZC5jb20iLCJleHAiOjE2OTY5NTExMT

В декодированном виде информация не зашифрована — любой желающий может ее считать. Однако поддержать payload — например, чтобы выдать себя за другого пользователя — не получится, потому что надпись формируется из содержимого заголовка и payload. Соответственно, если мы меняем payload, то и подпись должна измениться, иначе токен не пройдет валидацию.

Единственный способ подделать токен — украсть приватный ключ. Он будет храниться на сервере авторизации и на клиентском сервисе (URL Shortener). Ключ используется как для формирования токена, так и для его валидации.

Если хотите поглубже погрузиться в эту тему, советую сайт jwt.io — там есть очень удобный encoder / decoder, а также полезные материалы по теме.

Чем придется пожертвовать

Архитектура сервиса. Сама архитектура будет максимально приближена к боевому сервису. Однако в тексте мы не будем подробно говорить про обвязки вроде мониторинга, lifecycles, тестов и другого. 

Схема авторизации. Она будет максимально упрощена, так как основной фокус мы делаем на разработке gRPC-сервиса. Однако его функционала хватит для обслуживания ваших пет-проектов, а для дальнейшего развития я покажу направления и дам советы.

Условности

Будем верить информации, которая содержится в JWT-токене. Мы можем так делать, потому что JWT подписывается секретным ключом и подделать его не получится.

Не сможем разлогинить пользователя до «затухания» его токена. Это сильно бы усложнило статью, ведь пришлось бы хранить сессии в SSO, делать проверочные запросы от URL Shortener после получения токена и так далее.

Приступаем к разработке: описание контракта и генерация кода

Разработка gRPC-сервиса начинается с написания proto-файла. Если вы не знакомы с этим форматом — не пугайтесь, это просто описание API (контракта) нашего сервиса. Ближайшая аналогия из мира REST API — Swagger / OpenAPI.

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

Хранить proto-файлы и сгенерированный код будем в отдельном репозитории, поскольку контракт нужен и серверу, и всем клиентам (сервисам, которые используют SSO). Сервисы будут подключать этот репозиторий через go.mod.

1. Создаем проект protos

В корне проекта у меня будет две основных директории:

  • proto — для хранения самих proto-файлов,
  • gen — для хранения сгенерированного по ним кода.

2. В папке proto/sso создаем файл sso.proto

Его формат очень прост, в нем будут описаны:

  • общая информация — версия протокола, пакет и опции для генерации Go-файлов,
  • сервисы — описание сигнатур методов, которые сервис должен реализовать,
  • формат сообщений — объекты,  которые будут принимать и возвращать методы сервисов.
Внутренний сервис у нас пока будет один — Auth. В будущем я планирую рядом добавить Permissions и UserInfo.

    // proto/sso/sso.proto

// Версия ProtoBuf
syntax = "proto3";

// Текущий пакет - указывает пространство имен для сервиса и сообщений. Помогает избегать конфликтов имен.
package auth;

// Настройки для генерации Go кода.
option go_package = "tuzov.sso.v1;ssov1";

// Auth is service for managing permissions and roles.
service Auth {
   // Register registers a new user.
   rpc Register (RegisterRequest) returns (RegisterResponse);
   // Login logs in a user and returns an auth token.
   rpc Login (LoginRequest) returns (LoginResponse);
}

// TODO: На будущее, следующий сервис можно описать прямо здесь,
// либо вынести в отдельный файл
// service Permissions {
//	GetUserPermissions(GetUserPermissionsRequest) 
       return UserPermissions
// }

// Объект, который отправляется при вызове RPC-метода (ручки) Register.
message RegisterRequest {
   string email = 1; // Email of the user to register.
   string password = 2; // Password of the user to register.
}

// Объект, котрый метод (ручка) вернет.
message RegisterResponse {
   int64 user_id = 1; // User ID of the registered user.
}

// То же самое для метода Login()
message LoginRequest {
   string email = 1; // Email of the user to login. 
   string password = 2; // Password of the user to login. 
   int32 app_id = 3; // ID of the app to login to.
}

message LoginResponse {
    string token = 1; // Auth token of the logged in user.
}

Генерация Go-кода

Теперь по готовому контракту нам нужно сгенерировать Go-код. Для этого используем официальную утилиту — protoc (компилятор Protocol Buffers). Установите ее, если не сделали этого ранее.

Первым делом создаем папку, в которой будем хранить сгенерированные файлы:


    mkdir -p gen/go

Команда для генерации будет следующей:


    protoc -I proto proto/sso/sso.proto --go_out=./gen/go/ --go_opt=paths=source_relative --go-grpc_out=./gen/g
  • -I proto — опция -I или —proto_path указывает путь к корневой директории с файлами .proto. Это нужно, чтобы компилятор смог найти импорты, если они есть. В нашем случае это директория proto.
  • proto/sso/sso.proto — путь к конкретному .proto файлу, который мы компилируем.
  • —go_out=./gen/go/ — опция —go_out указывает, куда записывать сгенерированный код Go. 
  • —go_opt=paths=source_relative — дополнительная опция, которая указывает, как создавать имена пакетов. paths=source_relative означает, что выходные файлы будут иметь тот же пакет, что и исходные файлы .proto.
  • —go-grpc_out=./gen/go/ — указывает, куда записывать сгенерированный Go gRPC-код. Как и в предыдущем случае, выходные файлы будут помещены в директорию ./gen/go/.
  • —go-grpc_opt=paths=source_relative — это аналогичная опция для генерации Go gRPC-кода, которая указывает, как создавать имена пакетов для gRPC.

Вместо пути до конкретного proto-файла можете использовать вот такую запись:


    protoc -I proto proto/sso/*.proto <прочие параметры>

Тогда будет сгенерирован код по всем proto-файлам из указанной директории (не сработает для родной командной строки Windows). Рекуррентно заходить в поддиректории оно в таком виде не будет. При необходимости можете доработать скрипт самостоятельно, либо просто выполнять команду для каждой директории отдельно.

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

  • Go-код — это набор типов данных и методов для работы с нашими protobuf-сообщениями из программы на Go. Этот код позволяет создавать, манипулировать и сериализовать/десериализовать экземпляры сообщений.
  • Go gRPC-код, который содержит готовые клиенты для обращения к серверу и серверную часть: определения и реализацию интерфейсов сервисов и сервера, которые будем дополнять своей бизнес-логикой.

Для удобства рекомендую добавить вызов этой команды в Makefile или Taskfile. Остановимся на втором — это аналог Make, но более удобный. Поэтому устанавливаем утилиту Task.

По аналогии с Makefile, Task использует Taskfile, но в формате YAML. Давайте напишем его:


    # ./Taskfile.yaml
# See: https://taskfile.dev/api/

version: "3"

tasks:
default: # Если не указать конкретную команду, будут выполнены дефолтные
cmds:
task: generate
generate: ## Команда для генерации
aliases: ## Алиасы команды, для простоты использования
gen
desc: "Generate code from proto files"
cmds: ## Тут описываем необходимые bash-команды
protoc -I proto proto/sso/*.proto --go_out=./gen/go/ --go_opt=paths=source_relative --go-grpc_out=.

Для генерации файлов достаточно вызвать команду в корне проекта:


    task generate

Теперь отправляем текущий проект на GitHub — он нам понадобится в основном проекте в виде стороннего пакета.

Точка входа и конфигурация

Теперь создаем проект для самого сервиса авторизации. Напомню, в будущем это будет SSO, поэтому планировать будем в более общем виде. Структура будет выглядеть следующим образом:


    sso
├── cmd.............. Команды для запуска приложения и утилит
│	├── migrator.... Утилита для миграций базы данных
│	└── sso......... Основная точка входа в сервис SSO
├── config........... Конфигурационные yaml-файлы
├── internal......... Внутренности проекта
│	├── app.......... Код для запуска различных компонентов приложения
│	│	└── grpc.... Запуск gRPC-сервера
│	├── config....... Загрузка конфигурации
│	├── domain
│	│	└── models.. Структуры данных и модели домена
│	├── grpc
│	│	└── auth.... gRPC-хэндлеры сервиса Auth
│	├── lib.......... Общие вспомогательные утилиты и функции
│	├── services..... Сервисный слой (бизнес-логика)
│	│	├── auth
│	│	└── permissions
│	└── storage...... Слой хранения данных
│	└── sqlite.. Реализация на SQLite
├── migrations....... Миграции для базы данных
├── storage.......... Файлы хранилища, например SQLite базы данных
└── tests............ Функциональные тесты

Начнем с самого простого — точки входа. На самом деле, у нас для нее ничего не готово, мы просто запишем план работ, который будем пошагово реализовывать его:


    // `cmd/sso/main.go`
package main

func main() {
    // TODO: инициализировать объект конфига

    // TODO: инициализировать логгер

    // TODO: инициализировать приложение (app)

    // TODO: запустить gRPC-сервер приложения
}

Начнем по порядку, с конфигурации: 


    // internal/config/config.go
package config

type Config struct {
    Env  string	 `yaml:"env"
    env-default:"local"` StoragePath string
    `yaml:"storage_path" env-required:"true"` GRPC	
         GRPCConfig `yaml:"grpc"`
    MigrationsPath string
    TokenTTL	time.Duration `yaml:"token_ttl" env-default:"1h"`
}

type GRPCConfig struct {
    Port	int	
   `yaml:"port"` 
    Timeout time.Duration 
   `yaml:"timeout"`
}

Это структуры, в которые будет парситься конфиг-файл. Также мы здесь видим struct-теги — о них я рассказывал в отдельном посте. 

Для парсинга конфигурации я буду использовать библиотеку cleanenv. Соответственно, и struct- теги мы здесь пишем для нее. 

Давайте установим cleanenv:


    go get github.com/ilyakaznacheev/cleanenv@v1.5.0

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

Теперь можем написать функцию MustLoad(), которая будет парсить файл, а также вспомогательную функцию fetchConfigPath() для определения пути до конфиг-файла:


    // internal/config/config.go

func MustLoad() *Config { 
    configPath := fetchConfigPath() 
    if configPath == "" {
        panic("config path is empty")
    }

    // check if file exists
    if _, err := os.Stat(configPath); os.IsNotExist(err) { 
        panic("config file does not exist: " + configPath)
    }

    var cfg Config

    if err := cleanenv.ReadConfig(configPath, &cfg); err != nil {
        panic("config path is empty: " + err.Error())
    }

    return &cfg
}
// fetchConfigPath fetches config path from command line flag or environment variable.
// Priority: flag > env > default.
// Default value is empty string.
func fetchConfigPath() string {
    var res string

    flag.StringVar(&res, "config", "", "path to config file") flag.Parse()

    if res == "" {
        res = os.Getenv("CONFIG_PATH")
    }

    return res
}
  • Env — текущее виртуальное окружение.
  • StoragePath — путь до файла, где хранится база данных.
  • GRPCConfig — порт gRPC-сервиса и таймаут обработки запросов.
  • MigrationsPath — путь до директории с миграциями базы данных, который будет использовать утилита migrator.
  • TokenTTL — время жизни выдаваемых токенов авторизации. Для простоты сделаем фиксированным и будем хранить в конфигурации.

Приложению нужно знать, где искать конфиг-файл. Для этого нужно указать configPath. Это можно сделать разными способами — например, через фраг —config или переменную окружения CONFIG_PATH. Функция fetchConfigPath реализует оба варианта. Если вдруг указаны оба значения, будет использован флаг. 


    CONFIG_PATH=./path/to/config/file.yaml myApp

Запуск приложения с переменной окружения CONFIG_PATH.


    myApp --config=./path/to/config/file.yaml

Запуск приложения через флаг –-config.

Теперь давайте напишем сам конфиг-файл:


    # config/config_local.yaml

env: "local"
storage_path: "./storage/sso.db" grpc:
port: 44044 timeout: 10h

Возвращаемся к функции main() и создаем там объект конфига:


    // cmd/sso/main.go

func main() {
    cfg := config.MustLoad()

    // ...
}

То есть запуск приложения будет выглядеть так в случае переменной окружения.
Далее напишем в этом же файле функцию создания объекта логгера. Для логирования мы будем использовать недавно вышедший log/slog:


    // cmd/sso/main.go

import (
    "log/slog"
    // ...
)

const (
    envLocal = "local" 
    envDev	= "dev" 
    envProd = "prod"
)

func main() {
    // ...
}

func setupLogger(env string) *slog.Logger {
    var log *slog.Logger

    switch env {
        case envLocal:
            log = slog.New(
                slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug}),
            )
        case envDev:
            log = slog.New(
                slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug}),
            )
        case envProd:
            log = slog.New(
                slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo}),
        )
    }

    return log
}

Создаем логгер с указанием текущего окружения. 

  • envLocal — локальный запуск. Используем удобный для консоли TextHandler и уровень логирования Debug (будем выводить все сообщения).
  • envDev — удаленный dev-сервер. Уровень логирования тот же, но формат вывода — JSON, удобный для систем сбора логов вроде Kibana или Grafana Loki. 
  • envProd — продакшен. Повышаем уровень логирования до Info, чтобы не выводить дебаг-логи в проде. То есть мы будем получать сообщения только с уровнем Info или Error.

Дописываем создание логгера в main():


    // cmd/sso/main.go

// ...

func main() {
    cfg := config.MustLoad()

   log := setupLogger(cfg.Env)
}

// ...

Напомню, что тип текущего окружения мы храним в конфигурации cfg.Env.

gRPC-сервер: обработка запросов

Ненадолго оставим main.go и спустимся на уровень ниже: напишем обработчики запросов. Здесь нам понадобятся сгенерированные утилитой protoc Go-файлы из пакета protos, который мы написали ранее. 

Подключаем пакет:


    go get github.com/JustSkiv/protos

Создаем файл, в котором будем описывать обработчики запросов:


    // internal/grpc/auth/server.go

import (
    "context" "google.golang.org/grpc"
    // Сгенерированный код
    ssov1 "github.com/JustSkiv/protos/gen/go/sso"
)

type serverAPI struct {
    ssov1.UnimplementedAuthServer // Хитрая штука, о ней ниже
    auth Auth
}
type Auth interface {     
    Login(
        ctx  context.Context,     
        email string,     
        password string,     
        appID int,
    ) (token string, err error) 
        RegisterNewUser(
            ctx context.Context, 
            email string,

func Register(gRPCServer *grpc.Server, auth Auth) {
    ssov1.RegisterAuthServer(gRPCServer, &serverAPI{auth: auth})
}

func (s *serverAPI) Login( 
    ctx context.Context,
    in *ssov1.LoginRequest,
) (*ssov1.LoginResponse, error) {
    // TODO
}

func (s *serverAPI) Register( 
    ctx context.Context,
    in *ssov1.RegisterRequest,
) (*ssov1.RegisterResponse, error) {
    // TODO
}

В файле мы базово описали структуру serverAPI, которая будет отвечать за функциональность интерфейса программы, каркасы для двух RPC-методов (Login и Register), интерфейс будущего Auth и функцию Register, которая регистрирует serverAPI на gRPC-сервере.

В структуре serverAPI у нас также есть вложенная структура UnimplementedAuth- Server — protoc генерирует ее на основе proto-файла. По сути, это некая пустая имплементация всех методов gRPC-сервиса. 

Структура помогает настроить обратную совместимость при изменении proto-файла. Если мы добавим в него новый метод и заново сгенерируем код, но не реализуем этот метод в serverAPI, наш код все равно будет компилироваться, а новый метод просто вернет исключение: Not implemented.

Обратите внимание: функция регистрации ssov1.RegisterAuthServer и объекты запросов / ответов за нас также уже сгенерированы. Это удобно и экономит много времени.

Теперь давайте напишем обработчики запросов:


    // internal/grpc/auth/server.go
import (
    "context"
    ssov1 "github.com/JustSkiv/protos/gen/go/sso"
    "google.golang.org/grpc/codes" 
    "google.golang.org/grpc/status"
)
type Auth interface {
    // .. см. выше
}
func (s *serverAPI) Login( 
    ctx context.Context,
    in *ssov1.LoginRequest,
) (*ssov1.LoginResponse, error) {
     if in.Email == "" {
         return nil, status.Error(codes.InvalidArgument, "email is required")
     }
    if in.Password == "" {
         return nil, status.Error(codes.InvalidArgument, "password is required")
    }
   if in.GetAppId() == 0 {
        return nil, status.Error(codes.InvalidArgument, "app_id is required")
    }
    token, err := s.auth.Login(ctx, in.GetEmail(), in.GetPassword(), int(in.GetAppId()))
    if err != nil {
        // Ошибку auth.ErrInvalidCredentials мы создадим ниже
        if errors.Is(err, auth.ErrInvalidCredentials) {
            return nil, status.Error(codes.InvalidArgument, "invalid email or password")
        }
    return nil, status.Error(codes.Internal, "failed to login")
    }
    return &ssov1.LoginResponse{Token: token}, nil
}

Валидацию входных данных можно вынести в отдельную функцию. Либо использовать внешний пакет для валидации.

Обратите внимание: возвращаемую ошибку мы создаем с помощью специальной функции status.Error из библиотеки grpc/status. Это нужно, чтобы формат был понятен любому gRPC-клиенту.

Кроме того, мы присваиваем этой ошибке код из пакета grpc/codes — это также необходимо для совместимости с клиентами. Рекомендую всегда с трепетом подходить к определению ошибок, чтобы предусмотреть как можно больше ситуаций. Это сделает работу с нашим сервером более прозрачной на стороне клиента.

Подготовим метод Register():


    // internal/grpc/auth/server.go

func (s *serverAPI) Register( 
    ctx context.Context,
    in *ssov1.RegisterRequest,
) (*ssov1.RegisterResponse, error) {
    if in.Email == "" {
        return nil, status.Error(codes.InvalidArgument, "email is required")
    }

    if in.Password == "" {
        return nil, status.Error(codes.InvalidArgument, "password is required")
    }

    uid, err := s.auth.RegisterNewUser(ctx, in.GetEmail(), in.GetPassword())
    if err != nil {
        // Ошибку storage.ErrUserExists мы создадим ниже
        if errors.Is(err, storage.ErrUserExists) {
            return nil, status.Error(codes.AlreadyExists, "user already exists")
        }

        return nil, status.Error(codes.Internal, "failed to register user")
    }

   return &ssov1.RegisterResponse{UserId: uid}, nil
}

Как видим, наши обработчики достаточно простые. Они полностью готовы, ведь вся бизнес-логика будет в сервисном слое — перейдем к нему.

Сервисный слой: Auth

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

Для удобного обмена данными между слоями опишем модель пользователя:


    // internal/domain/models/user.go
package models

type User struct {
    ID 	      int64
    Email	      string 
    PassHash  []byte
}

Обратите внимание: папка models находится в internal/domain — в ней будут храниться общие модели для всего домена, а не только для конкретного слоя. То есть мы можем свободно ими пользоваться, в том числе для передачи между слоями.

Теперь можем описать интерфейс хранилища:


    // internal/services/auth/auth.go

type UserStorage interface {
    SaveUser(ctx context.Context, email string, passHash []byte) (uid int64, err error)
    User(ctxcontext.Context, email string) (models.User, error)
}

Это хороший подход — его я чаще всего вижу на работе. Но лично мне нравится минималистичные интерфейсы, поэтому я опишу интерфейс хранилища так:


    // internal/services/auth/auth.go

type UserSaver interface { 
    SaveUser(
       ctx context.Context, 
       email string, 
       passHash []byte,
    ) (uid int64, err error)
}

type UserProvider interface {
    User(ctx context.Context, email string) (models.User, error)
}

У нас тут два разных интерфейса, у каждого своя узкая область применения. Это делает код более гибким. Ведь никто не говорил, что за сохранение и получение пользователей должна отвечать одна система. Возможно, в будущем мы захотим сохранять данные асинхронно через Kafka, а получать — через gRPC- или HTTP-запросы. Конечно, это редкие кейсы — не нужно затачивать код под все сценарии.

Кроме того, минималистичные интерфейсы проще реализовывать и тестировать. В нашем примере UserSaver и UserProvider удобнее, чем некий общий UserStorage, который состоит из 50 методов.

Пользователь у нас будет логиниться не во все приложения сразу, а только в одном. Его JWT-токен подписывается ключом конкретного приложения. В нашем случае он логинится в URL Shortener. Это значит, что кроме работы с пользователями, нам необходимо также получать информацию о приложениях (App).

Для начала нам достаточно ID и секретного ключа. Заведем для этого модель App:


    package models

type App struct { 
     ID	   int
     Name	   string 
     Secret   string
}

И интерфейс для получения App из хранилища:


    // internal/services/auth/auth.go
// ...
type AppProvider interface {
     App(ctx context.Context, appID int) (models.App, error)
}

Теперь мы можем написать конструктор для сервиса Auth:


    // internal/services/auth/auth.go

// ...

type Auth struct {
   log	          *slog.Logger 
   usrSaver	  UserSaver 
   usrProvider    UserProvider 
   appProvider   AppProvider 
   tokenTTL	time.Duration
}

func New(
   log *slog.Logger, 
   userSaver UserSaver, 
   userProvider UserProvider, 
   appProvider AppProvider, 
   tokenTTL
   time.Duration,
   ) *Auth {
   return &Auth{
       usrSaver:	     userSaver, 
       usrProvider:  userProvider, 
       log:	             log,
       appProvider: appProvider,
       tokenTTL:	     tokenTTL, // Время жизни возвращаемых токенов
   }
}
Обратите внимание: мы передаем userSaver, userProvider и appProvider отдельными параметрами, хотя у них будет общая реализация. То есть мы будем передавать один объект в три аргумента.

Кому-то это покажется избыточным — доля истины в этом есть. Однако вы можете объединить эти три интерфейса в один, это не помешает вам пройти гайд до конца. Либо можете просто сделать по аналогии.

Также в пакете log/slog сейчас не хватает удобного функции, которая будет добавлять ошибки в сообщения логов. Я обычно пишу простую вспомогательную функцию:


    // internal/lib/logger/sl/sl.go

package sl

import (
    "log/slog"
)

func Err(err error) slog.Attr {
    return slog.Attr{ 
        Key:	    "error",
        Value:  slog.StringValue(err.Error()),
    }
}

Теперь ошибки можно логировать таким образом:


    if err != nil {
    a.log.Error("failed to get user", sl.Err(err))
}

Метод RegisterNewUser

Наконец, переходим к бизнес-логике — начинаем работу над методами RegisterNewUser и Login. Для реализации RegisterNewUser нам понадобится внешний пакет crypto/bgcrypt:


    go get golang.org/x/crypto@v0.13.0

    // internal/services/auth/auth.go

import (
    // ...
    "golang.org/x/crypto/bcrypt"
    "grpc-service-ref/internal/lib/logger/sl"
)

// RegisterNewUser registers new user in the system and returns user ID.
// If user with given username already exists, returns error.
func (a *Auth) RegisterNewUser(ctx context.Context, email string, pass string) (int64, error) {
    // op (operation) - имя текущей функции и пакета. Такую метку удобно
    // добавлять в логи и в текст ошибок, чтобы легче было искать хвосты.
    // в случае поломок.
    const op = "Auth.RegisterNewUser"

    // Создаем локальный объект логгера с доп. полями, содержащими полезную инфу
    // о текущем вызове функции
    log := a.log.With( 
          slog.String("op", op), 
          slog.String("email", email),
    )

    log.Info("registering user")

    // Генерируем хэш и соль для пароля.
    passHash, err := bcrypt.GenerateFromPassword([]byte(pass), bcrypt.DefaultCost)
    if err != nil {
         log.Error("failed to generate password hash", sl.Err(err))

        return 0, fmt.Errorf("%s: %w", op, err)
    }

    // Сохраняем пользователя в БД
    id, err := a.usrSaver.SaveUser(ctx, email, passHash)
    if err != nil {
        log.Error("failed to save user", sl.Err(err))

        return 0, fmt.Errorf("%s: %w", op, err)
    }

    return id, nil
}

В чистом виде пароль хранить нельзя — это может привести к утечкам. Если злоумышленник украдет нашу базу данных, он узнает пароли всех пользователей, получит доступ к их аккаунтам. 

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

Поэтому мы будем использовать salt — случайные строки, которые добавляются к паролям (<password>+<salt>), а уже после — сохранять хэш в базе данных. Когда пользователь будет логиниться, мы будем добавлять строку к его паролю и сравнивать хэши. Такой вариант намного безопаснее.

Именно такая логика реализована в функции bcrypt.GenerateFromPassword(), которая сама генерирует хэш и salt-строку. У этой функции есть аргумент — bcrypt.DefaultCost. Чем выше значение этого параметра, тем лучше защищен пароль, но тем сложнее алгоритм для сохранения и сравнения. Для пет-проекта будет достаточно выбрать DefaultCost.

Перед тем как перейти к методу Login, нам нужно написать код для генерации JWT-токенов, потому что результат работы этой функции — получение токена авторизации.

Генерация JWT-токена для авторизации

Для работы с JWT мы будем использовать следующую библиотеку:


    go get "github.com/golang-jwt/jwt/v5"@v5.0.0

Токен будет содержать в себе информацию о пользователе и текущем приложении. Можно все эти параметры передавать длинным списком аргументов, либо можно передавать сразу модели User и App. Остановимся на последнем варианте.

Функцию генерации токена разместим в пакете internal/lib/jwt:


    // internal/lib/jwt/jwt.go

// NewToken creates new JWT token for given user and app.
func NewToken(user models.User, app models.App, duration time.Duration) (string, error) { 
    token := jwt.New(jwt.SigningMethodHS256)

    // Добавляем в токен всю необходимую информацию 
    claims := token.Claims.(jwt.MapClaims) claims["uid"] = user.ID
    claims["email"] = user.Email
    claims["exp"] = time.Now().Add(duration).Unix() claims["app_id"] = app.ID

    // Подписываем токен, используя секретный ключ приложения 
    tokenString, err := token.SignedString([]byte(app.Secret))
     if err != nil {
         return "", err
    }

    return tokenString, nil
}

Обратите внимание на строку: claims[«exp»] = time.Now().Add(duration).Unix(). В ней мы задаем срок действия (TTL) токена в виде временной метки, до которой он будет считаться валидным. После токен будет считаться неактуальным, на стороне клиента мы его принимать не будем.

NewToken — простая функция, но советую вам написать для нее тесты самостоятельно. Это будет полезная практика.

Метод Login

Теперь напишем метод Login. И предупреждаю сразу: в текущей реализации есть дыра в безопасности, ведь метод не защищен от брутфорса. Рекомендую придумать логику для защиты. 

Метод Login заметно длиннее предыдущего, но при этом довольно простой:


    // internal/services/auth/auth.go

var (
    ErrInvalidCredentials = errors.New("invalid credentials")
)

// Login checks if user with given credentials exists in the system and returns access token.
//
// If user exists, but password is incorrect, returns error.
// If user doesn't exist, returns error.
func (a *Auth) Login( ctx 
    context.Context, 
    email string,
    password string, // пароль в чистом виде, аккуратней с логами!
    appID int, // ID приложения, в котором логинится пользователь
) (string, error) {
    const op = "Auth.Login"

    log := a.log.With( slog.String("op", op), slog.String("username", email),
    // password либо не логируем, либо логируем в замаскированном виде
    )

    log.Info("attempting to login user")

    // Достаем пользователя из БД
    user, err := a.usrProvider.User(ctx, email)
    if err != nil {
        if errors.Is(err, storage.ErrUserNotFound) { a.log.Warn("user not found", sl.Err(err))

        return "", fmt.Errorf("%s: %w", op, ErrInvalidCredentials)
    }

    a.log.Error("failed to get user", sl.Err(err))

    return "", fmt.Errorf("%s: %w", op, err)
    }

    // Проверяем корректность полученного пароля
    if err := bcrypt.CompareHashAndPassword(user.PassHash, []byte(password)); err != nil { a.log.Info("invalid credentials", sl.Err(err))

         return "", fmt.Errorf("%s: %w", op, ErrInvalidCredentials)
   }

   // Получаем информацию о приложении
   app, err := a.appProvider.App(ctx, appID)
   if err != nil {
       return "", fmt.Errorf("%s: %w", op, err)
   }

   log.Info("user logged in successfully")

   // Создаем токен авторизации
   token, err := jwt.NewToken(user, app, a.tokenTTL)
   if err != nil {
   a.log.Error("failed to generate token", sl.Err(err))

   return "", fmt.Errorf("%s: %w", op, err)
}

return token, nil
}

На этом сервис Auth полностью готов, можем переходить к реализации хранилища.

Слой хранения данных: Storage

Для хранения данных я буду использовать SQLite —  вся база данных будет храниться в одном файле, а для работы с ней достаточно импортировать драйвер sqlite:


    go get github.com/mattn/go-sqlite3@v1.14.17

Код пакета хранилища размещаем в internal/storage, а реализацию для sqlite кладем в inter- nal/storage/sqlite.

В основном пакете storage будем хранить лишь общие описания типов, ошибок и прочего:


    // internal/storage/storage.go
package storage

import "errors"

var (
    ErrUserExists = errors.New("user already exists") ErrUserNotFound = errors.New("user not found")
    ErrAppNotFound = errors.New("app not found")
)

По описанным ошибкам сервисный слой сможет понять, что конкретно пошло не так, и реагировать на них. Они не должны зависеть от конкретной реализации хранилища (SQLite, PostgreSQL, MongoDB и других баз данных), поэтому мы их разместили в общем пакете.

Миграции и схема данных

Теперь нужно подготовить схему данных. Я буду использовать полноценные миграции для работы со схемой БД.

Миграции БД — это пошаговые изменения структуры базы данных, позволяющие последовательно применять и откатывать модификации схемы. Обычно они представляют собой набор файлов с SQL-запросами, применяя которые по очереди, вы приведете схему БД к актуальному состоянию. Это нужно для того, чтобы после обновления репозитория с кодом можно было так же легко актуализировать БД.

Если вам сейчас не понятна эта концепция — не страшно, на конкретном примере станет яснее. Давайте перейдем к реализации.

Возьмем готовое решение:


    go get github.com/golang-migrate/migrate/v4@v4.16.2

Миграторов на Go много, и все они неплохие — можно выбрать любой.

Мигратор можно запустить в Docker-контейнере, установить в виде исполняемого файла или написать в текущем проекте собственную утилиту-враппер. Мне больше нравится последний вариант, его удобнее контролировать. Но вы можете выбрать любой.

Создаем файл cmd/migrator/main.go и пишем в нем простую обертку:


    // cmd/migrator/main.go

package main

import ( 
    "flag"
    "fmt"
    // Библиотека для миграций
    "github.com/golang-migrate/migrate/v4"
    // Драйвер для выполнения миграций SQLite 3
    _ "github.com/golang-migrate/migrate/v4/database/sqlite3"
    // Драйвер для получения миграций из файлов
    _ "github.com/golang-migrate/migrate/v4/source/file"
)

func main() {
    var storagePath, migrationsPath, migrationsTable string

    // Получаем необходимые значения из флагов запуска

    // Путь до файла БД
    // Его достаточно, т.к. мы используем SQLite, другие креды не нужны
    flag.StringVar(&storagePath, "storage-path", "", "path to storage")
    // Путь до папки с миграциями
    flag.StringVar(&migrationsPath, "migrations-path", "", "path to migrations")
    // Таблица, в которой будет храниться информация о миграциях. Она нужна
    // для того, чтобы понимать, какие миграции уже применены, а какие нет
    // Дефолтное значение — 'migrations'
    flag.StringVar(&migrationsTable, "migrations-table", "migrations", "name of migrations table") flag.Parse()
    // Выполняем парсинг флагов

    // Валидация параметров
    if storagePath == "" {
        // Простейший способ обработки ошибки 
        // При необходимости можете выбрать более подходящий вариант
        // Меня устраивает паника, поскольку это вспомогательная утилита
        panic("storage-path is required")
    }
    if migrationsPath == "" { 
        panic("migrations-path is required")
    }

    // Создаем объект мигратора, передав креды нашей БД
        m, err := migrate.New( "file://"+migrationsPath,
        fmt.Sprintf("sqlite3://%s?x-migrations-table=%s", storagePath, migrationsTable),
    )
    if err != nil { 
        panic(err)
    }

    // Выполняем миграции до последней версии
    if err := m.Up(); err != nil {
        if errors.Is(err, migrate.ErrNoChange) { 
            fmt.Println("no migrations to apply")
           return
        }
       panic(err)
   }
}

Обратите внимание: мы вынесли название таблицы для миграций в отдельный флаг с помощью параметра ?x-migrations-table=%s. Это необязательно, но я буду хранить отдельный набор миграций для тестов, и информация о них станет храниться в отдельной таблице. 

У выбранной нами библиотеки для миграций следующий формат нейминга миграций: <num- ber>_<title>.<direction>.sql, где:

  • number — используется для определения порядка применения миграций, они выполняются по возрастанию номеров. Тут должно быть любое целое число — я буду именовать по порядку: 1, 2, 3 и т. д.
  • title — игнорируется библиотекой и нужен только для людей, чтобы было проще ориентироваться в списке миграций.
  • direction — значение up или down. Файлы с параметром up в имени обновляют схему до новой версии, down — откатывают изменения.

Создаем первую миграцию в папке ./migrations:


    -- migrations/1_init.up.sql

CREATE TABLE IF NOT EXISTS users (
    id	               INTEGER PRIMARY KEY,
    email	       TEXT	NOT NULL UNIQUE,
    pass_hash	BLOB	NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_email ON users (email);

CREATE TABLE IF NOT EXISTS apps (
    id	        INTEGER PRIMARY KEY, 
    name	TEXT NOT NULL UNIQUE, 
    secret  TEXT NOT NULL UNIQUE
);

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

Обратите внимание, что параметры email, name и secret должны быть уникальными, и мы добавили для них соответствующий constraint.

Обратная миграция:


    -- ./migrations/1_init.down.sql

DROP TABLE IF EXISTS users;
DROP TABLE IF EXISTS apps;

Заведем папку для хранения файла базы данных:


    mkdir storage

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


    go run ./cmd/migrator --storage-path=./storage/sso.db --migrations-path=./migrations

Если все прошло хорошо, у вас должен появиться файл БД (./storage/sso.db) с актуальной схемой.

Реализация Storage для SQLite 3

Этот шаг описан кратко, поскольку мы делаем фокус на gRPC, а не на взаимодействии с БД. Если вы знакомы с пакетом database/sql, то вам все будет понятно. Если нет, обязательно изучите — рано или поздно он может пригодиться вам на работе. А пока можете делать по аналогии, концентрируясь на основной теме.

Выше мы уже установили драйвер для работы с SQLite:


    go get github.com/golang-migrate/migrate/v4@v4.16.2

Теперь создаем файл, в котором опишем тип Storage и конструктор для него:


    // internal/storage/sqlite/sqlite.go

import (
    "fmt"

    "github.com/mattn/go-sqlite3"
)

type Storage struct { 
    db *sql.DB
}

// Конструктор Storage
func New(storagePath string) (*Storage, error) {
    const op = "storage.sqlite.New"

    // Указываем путь до файла БД
    db, err := sql.Open("sqlite3", storagePath)
    if err != nil {
        return nil, fmt.Errorf("%s: %w", op, err)
    }

    return &Storage{db: db}, nil
}

Для хранилища нужно реализовать три метода: SaveUser(), User(), App(). Начнем с первого:


    // internal/storage/sqlite/sqlite.go

import (
    "context" "database/sql" 
    "errors"
    "fmt"

     "grpc-service-ref/internal/storage"

     "github.com/mattn/go-sqlite3"
)

// SaveUser saves user to db
func (s *Storage) SaveUser(ctx context.Context, email string, passHash []byte) (int64, error) {
    const op = "storage.sqlite.SaveUser"

    // Простой запрос на добавление пользователя
    stmt, err := s.db.Prepare("INSERT INTO users(email, pass_hash) VALUES(?, ?)")
    if err != nil {
        return 0, fmt.Errorf("%s: %w", op, err)
    }

    // Выполняем запрос, передав параметры
    res, err := stmt.ExecContext(ctx, email, passHash)
    if err != nil {
        var sqliteErr sqlite3.Error

        // Небольшое кунг-фу для выявления ошибки ErrConstraintUnique
        // (см. подробности ниже)
        if errors.As(err, &sqliteErr) && sqliteErr.ExtendedCode == sqlite3.ErrConstraintUnique {
            return 0, fmt.Errorf("%s: %w", op, storage.ErrUserExists)
    }

       return 0, fmt.Errorf("%s: %w", op, err)
    }

    // Получаем ID созданной записи id, err := res.LastInsertId() 
    if err != nil {
        return 0, fmt.Errorf("%s: %w", op, err)
    }

   return id, nil
}

Разберем подробно только эту обработку ошибки:


    if errors.As(err, &sqliteErr) && sqliteErr.ExtendedCode == sqlite3.ErrConstraintUnique {
    return 0, fmt.Errorf("%s: %w", op, storage.ErrUserExists)
}

Суть конструкции — выявить ошибку нарушения «констреинта» уникальности по email. Другими словами, среагировать на сценарий, когда мы пытаемся добавить в таблицу запись с параметром email, который уже есть в таблице. Если мы ее находим, наружу нужно вернуть ошибку storage.ErrUserExists.

Это нужно, чтобы независимо от БД можно было находить дубликаты. Нам это понадобится и в других слоях сервиса.

Напишем метод User(ctx context.Context, email string) для получения пользователя по email:


    // internal/storage/sqlite/sqlite.go

import (
    "context" "database/sql" 
    "errors"
    "fmt"

    "grpc-service-ref/internal/domain/models" "grpc-service-ref/internal/storage"
)

// User returns user by email
func (s *Storage) User(ctx context.Context, email string) (models.User, error) {
    const op = "storage.sqlite.User"

    stmt, err := s.db.Prepare("SELECT id, email, pass_hash FROM users WHERE email = ?")
    If err != nil {
        return models.User{}, fmt.Errorf("%s: %w", op, err)
    }

    row := stmt.QueryRowContext(ctx, email)

    var user models.User
    err = row.Scan(&user.ID, &user.Email, &user.PassHash)
    if err != nil {
        if errors.Is(err, sql.ErrNoRows) {
            return models.User{}, fmt.Errorf("%s: %w", op, storage.ErrUserNotFound)
        }

        return models.User{}, fmt.Errorf("%s: %w", op, err)
    }

    return user, nil
}

Здесь мы аналогично определяем ошибку, но на этот раз — sql.ErrNoRows. Она сигнализирует, о том что не удалось найти соответствующую запись. В этом случае мы вернем storage.ErrUserNotFound.

Остался последний метод — App(ctx context.Context, id int):


    // App returns app by id
func (s *Storage) App(ctx context.Context, id int) (models.App, error) {
    const op = "storage.sqlite.App"

    stmt, err := s.db.Prepare("SELECT id, name, secret FROM apps WHERE id = ?")
    if err != nil {
        return models.App{}, fmt.Errorf("%s: %w", op, err)
    }

    row := stmt.QueryRowContext(ctx, id)

    var app models.App
    err = row.Scan(&app.ID, &app.Name, &app.Secret)
    if err != nil {
        if errors.Is(err, sql.ErrNoRows) {
            return models.App{}, fmt.Errorf("%s: %w", op, storage.ErrAppNotFound)
        }

        return models.App{}, fmt.Errorf("%s: %w", op, err)
    }

    return app, nil
}

Как и в предыдущих случаях, при отсутствии записи (sql.ErrNoRows) возвращаем storage.ErrAppNotFound. На этом Storage готов.

Собираем компоненты приложения

Мы написали Auth (сервисный слой) и Storage (слой работы с данными). Теперь их нужно подключить к приложению.

Само приложение будет представлять пакет internal/app. Нет, не cmd/sso/main.go — это была лишь точка входа. Такой подход делает код main проще и позволяет создавать экземпляр приложения в других местах, например, в тестах, что облегчает тестирование.

gRPC-сервер мы завернем в еще одно отдельное приложение (internal/app/grpc) вместе со всеми зависимостями. Давайте с этого и начнем:


    // internal/app/grpc/app.go

package grpcapp

import (
    "context" "log/slog"
    "google.golang.org/grpc"
)

type App struct {
   log	*slog.Logger gRPCServer *grpc.Server
   port	int // Порт, на котором будет работать grpc-сервер
}

Здесь мы описали тип, который будет представлять приложение gRPC-сервера и интерфейс для сервисного слоя. В нашем случае это только Auth, но сервисов может быть больше.

Далее напишем конструктор. В нем используем библиотеку grpc-ecosystem/go-grpc- middleware, которая содержит готовые реализации некоторых полезных интерсепторов (подробнее о них ниже). 

Установим библиотеку:


    go get github.com/grpc-ecosystem/go-grpc-middleware/v2@v2.0.0

Теперь можем написать сам конструктор. В нем мало кода, но есть непростые моменты, поэтому будем разбираться поэтапно:


    // internal/app/grpc/app.go
// ...

// New creates new gRPC server app.
func New(log *slog.Logger, authService authgrpc.Auth, port int) *App {
   // TODO: создать gRPCServer и подключить к нему интерсепторы

   // TODO: зарегистрировать у сервера наш gRPC-сервис Auth

   // TODO: вернуть объект App со всеми необходимыми полями
}

Один из параметров — authgrpc.Auth, это интерфейс сервисного слоя. Не путайте с gRPC-сервисом Auth.

Сервер создается так:


    gRPCServer := grpc.NewServer(opts)

Он принимает на вход различные опции. В нашем случае это будут только интерсепторы (Interceptors).

Интерсептор gRPC — это, в некотором смысле, аналог Middleware из мира HTTP/REST-серверов. То есть функция, которая вызывается перед или после обработки RPC-вызова на стороне сервера или клиента. С помощью интерсепторов мы можем выполнять различные полезные действия (логирование запросов, аутентификацию, авторизацию и другое), не изменяя основной логики обработки RPC.

Допишем в конструкторе создание и регистрацию gRPC-сервера:


    // internal/app/grpc/app.go
package grpcapp

import (
    // ...
    "github.com/grpc-ecosystem/go-grpc-middleware/v2/interceptors/recovery"
)

// ...

// New creates new gRPC server app.
func New(log *slog.Logger, authService AuthService, port int) *App {
    // Создаем новый сервер с единственным интерсептором
    gRPCServer := grpc.NewServer(grpc.ChainUnaryInterceptor( recovery.UnaryServerInterceptor(),
    ))

    // Регистрируем gRPC-сервис Auth, об этом будет ниже
    authgrpc.Register(gRPCServer, authService)

    return &App{
        log:	log,
        gRPCServer: gRPCServer, port:	port,
    }
}

У нас пока один интерсептор. Я обернул его в grpc.ChainUnaryInterceptor, который принимает в качестве аргументов набор интерсепторов, а когда приходит одиночный запрос (Unary), запускает их поочередно (об этом говорит слово Chain в названии).

Помимо одиночных запросов, gRPC умеет работать с потоковыми (Stream). Для них мы бы использовали grpc.ChainStreamInterceptor.

Интерсептор recovery.UnaryServerInterceptor восстановит и обработает «панику», если она случится внутри обработчика. Это полезно, ведь мы не хотим, чтобы она в одном запросе уронила весь сервис, остановив обработку даже корректных запросов.

В текущем виде «паника» просто восстанавливается. Если мы хотим добавить какие-то действия, можно написать свой RecoveryHandler. К примеру, давайте добавим обработчик, который будет логировать содержимое «паники»:


    recoveryOpts := []recovery.Option {

 recovery.WithRecoveryHandler(func(p interface{}) (err error) {
     // Логируем информацию о панике с уровнем Error
     log.Error("Recovered from panic", slog.Any("panic", p))
     }),
     }
     // Можете либо честно вернуть клиенту содержимое паники
     // Либо ответить "internal error", если не хотите делиться внутренностями
     return status.Errorf(codes.Internal, "internal error")
     gRPCServer := grpc.NewServer(grpc.ChainUnaryInterceptor(recovery.UnaryServerInterceptor(recoveryOpts...),
 ))

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

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

Возьмем готовое решение из пакета — logging.UnaryServerInterceptor(log, opts). На вход он принимает логгер и опции. К сожалению, мы не можем просто передать текущий логгер, потому что у его метода Log() сигнатура отличается от той, которую требует обработчик:


    // have
Log(context.Context, slog.Level, string, ...any)
// need
Log(context.Context, logging.Level, string, ...any)

Это значит, нам снова нужна простая обертка:


    // InterceptorLogger adapts slog logger to interceptor logger.
// This code is simple enough to be copied and not imported.
func InterceptorLogger(l *slog.Logger) logging.Logger {
    return logging.LoggerFunc(func(ctx context.Context, lvl logging.Level, msg string, fields ...any) {
        l.Log(ctx, slog.Level(lvl), msg, fields...)
    })
}

Здесь мы просто конвертируем имеющуюся функцию Log() в аналогичную из пакета интерсептора. Помимо логгера этот интерсептор принимает опции. К примеру, можем передать ему параметры:


    loggingOpts := []logging.Option{ logging.WithLogOnEvents(
    logging.PayloadReceived, logging.PayloadSent,
    ),
}

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

В качестве упражнения допишите код, который будет маскировать пароль в логах. Это можно сделать, например, модифицировав написанный выше InterceptorLog- ger(). Маскировать можно разными способами: менять пароль символами звездочки, заменять звездочками лишь часть пароля и т. п.

Обратите внимание: если вы не маскируете пароль, это потенциальная дыра в безопасности. В таком случае лучше вообще отказаться от логирования тела запроса.

Теперь можем добавить все это в конструктор:


    // internal/app/grpc/app.go


import (
    "log/slog"
    "authgrpc"
    "grpc-service-ref/internal/grpc/auth"
    "github.com/grpc-ecosystem/go-grpc-middleware/v2/interceptors/logging" 
    "github.com/grpc-ecosystem/go-grpc-middleware/v2/interceptors/recovery" 
    "google.golang.org/grpc"
    "google.golang.org/grpc/codes" 
    "google.golang.org/grpc/status"
 )
 // ...
// New creates new gRPC server app
 func New( log *slog.Logger, authService authgrpc.Auth, port int, ) *App {
     loggingOpts := []logging.Option{ logging.WithLogOnEvents(
     logging.PayloadReceived, logging.PayloadSent,
     ),
  }
 recoveryOpts := []recovery.Option{ recovery.WithRecoveryHandler(func(p interface{}) (err error) {
 log.Error("Recovered from panic", slog.Any("panic", p))
    return status.Errorf(codes.Internal, "internal error")
 }),
 }
gRPCServer := grpc.NewServer(grpc.ChainUnaryInterceptor( recovery.UnaryServerInterceptor(recoveryOpts...), logging.UnaryServerInterceptor(InterceptorLogger(log), loggingOpts...),
))
  authgrpc.Register(gRPCServer, authService)
     return &App{
     log:  log,
     gRPCServer: gRPCServer, port: port,
     }
}

Вам также могут понадобиться интерсепторы для трейсинга, метрик, алертов, авторизации и прочего. Но в текущей статье этого достаточно.

Также вы можете заметить функцию authgrpc.Register, которая регистрирует реализацию сервиса аутентификации (authService) на нашем gRPC-сервере (gRPCServer). В контексте gRPC это обычно означает, что сервер будет знать, как обрабатывать входящие RPC-запросы, связанные с этим сервисом аутентификации, потому что реализация сервиса (методы, которые она предоставляет) теперь связаны с сервером gRPC.

Осталось лишь научить приложение запускаться:


    
// internal/app/grpc/app.go

// MustRun runs gRPC server and panics if any error occurs
func (a *App) MustRun() {
    if err := a.Run(); err != nil { panic(err)
    }
}

// Run runs gRPC server
func (a *App) Run() error {
    const op = "grpcapp.Run"

    // Создаем listener, который будет слушать TCP-сообщения, адресованные
    // Нашему gRPC-серверу
    l, err := net.Listen("tcp", fmt.Sprintf(":%d", a.port))
    if err != nil {
        return fmt.Errorf("%s: %w", op, err)
    }

    a.log.Info("grpc server started", slog.String("addr", l.Addr().String()))

    // Запускаем обработчик gRPC-сообщений
    if err := a.gRPCServer.Serve(l); err != nil {
        return fmt.Errorf("%s: %w", op, err)
    }

    return nil
}

Если не удалось запустить gRPC-сервис, нет смысла идти по коду дальше. Так что можем смело использовать MustRun(), в котором будем падать с «паникой», если случилась ошибка.

Cоздаем grpcApp внутри основного приложения:


    // internal/app/app.go

package app

import (
    "log/slog" "time"

     grpcapp "grpc-service-ref/internal/app/grpc" 
     "grpc-service-ref/internal/services/auth" 
     "grpc-service-ref/internal/storage/sqlite"
)

type App struct { GRPCServer 
    *grpcapp.App
}

func New(
    log *slog.Logger, 
    grpcPort int, 
    storagePath string, 
    tokenTTL time.Duration,
) *App {
    storage, err := sqlite.New(storagePath)
    if err != nil { 
        panic(err)
    }

    authService := auth.New(log, storage, storage, storage, tokenTTL) grpcApp := grpcapp.New(log, authService, grpcPort)
    return &App{
        GRPCServer: grpcApp,
    }
}

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


    authService := auth.New(log, storage, storage, storage, tokenTTL)

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

Теперь можем создать и запустить приложение в cmd/sso. Наш main.go будет выглядеть так:


    // cmd/sso/main.go

package main

import (
    "log/slog" "os"
    "grpc-service-ref/internal/app" 
    "grpc-service-ref/internal/config"
)

const (
    envLocal = "local" 
    envDev = "dev" 
    envProd = "prod"
)

func main() {
    cfg := config.MustLoad() 
    log := setupLogger(cfg.Env)
    application := app.New(log, cfg.GRPC.Port, cfg.StoragePath, cfg.TokenTTL)

    application.GRPCServer.MustRun()
}

func setupLogger(env string) *slog.Logger {
    // ...
}

Вместо строчки application.GRPCServer.MustRun() можете научить основное приложение автоматически запускать все внутренние, а не дергать запуск внутренних в main(). Это выглядит так:


    application.MustRun()

Graceful shutdown — правильная остановка приложения

Сервис хорошо работает. Теперь давайте научим его завершать работу при необходимости. Речь, конечно же, про graceful shutdown.

Graceful shutdown — это процесс корректного завершения работы приложения, при котором оно завершает текущие операции и освобождает ресурсы перед окончательным выключением. Простыми словами, если приложение понимает, что его попросили завершить работу, оно перед этим должно сделать некоторые важные действия: например, не обрывать запросы посреди работы.

Чтобы научить приложение grpcApp правильно завершать работу, добавим ему метод Stop():


    // internal/app/grpc/app.go

// Stop stops gRPC server.
func (a *App) Stop() {
    const op = "grpcapp.Stop"

    a.log.With(slog.String("op", op)).
        Info("stopping gRPC server", slog.Int("port", a.port))

   // Используем встроенный в gRPCServer механизм graceful shutdown
   a.gRPCServer.GracefulStop()
}

Нам почти ничего не пришлось для этого делать самостоятельно. В gRPCServer *grpc.Server уже есть подходящий метод. Он умеет прерывать прием новых запросов и возвращает управление.

Теперь в main() нужно в правильный момент вызвать этот метод. Чтобы это сделать, необходимо научить программу перехватывать сигналы SIGINT и SIGTERM от ОС (т. е. понимать, когда от ОС пришла команда остановки работы). Для этого обычно используется такой подход:


    // Создаем канал для передачи информации о сигналах
stop := make(chan os.Signal, 1)
// Слушаем перечисленные сигналы
signal.Notify(stop, syscall.SIGTERM, syscall.SIGINT)

// Ждем данных из канала
<-stop

// TODO: здесь будет код graceful shutdown

Пока сигналов нет, команда <-stop блокирует дальнейшее выполнение кода. То есть приложение будет работать, и мы не выйдем из main() раньше времени. Как только поступит соответствующий сигнал, в канал stop придет значение и мы двинемся дальше — выполним код завершения и выйдем из программы.

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


    go application.GRPCServer.MustRun()

Собираем все вместе и получаем:


    // cmd/sso/main.go

import (
    "os" 
    "os/signal"
    "syscall"
)

func main() {
    // ...

    go func() {
        application.GRPCServer.MustRun()
    }()

    // Graceful shutdown

    stop := make(chan os.Signal, 1)
    signal.Notify(stop, syscall.SIGTERM, syscall.SIGINT)

    // Waiting for SIGINT (pkill -2) or SIGTERM
    <-stop

    // initiate graceful shutdown
    application.GRPCServer.Stop() // Assuming GRPCServer has Stop() method for graceful shutdown
    log.Info("Gracefully stopped")
}

Далее предлагаю вам самостоятельно написать аналогичный метод Stop() для sqlite-реализации Storage. Это делается одной строкой: s.db.Close(). Также придется добавить Storage в структуру App основного приложения (internal/app/app.go). При желании можете обернуть хранилище в отдельное приложение StorageApp — это хороший подход.

Тестируем сервис

Формат статьи не позволяет написать полноценные функциональные тесты либо интегрировать наш SSO с другим сервисом. Но это обязательно будет в видео-гайде на моем канале.

Нам нужно проверить, что код работает, поэтому я покажу, как отправлять gRPC-запросы через Postman. Это бесплатная программа для взаимодействия с любыми популярными API.

Открываем Postman и создаем новый запрос:

Указываем адрес и порт нашего сервиса:

Теперь просто покажем Postman наш контракт, загрузив в него proto-файл, благодаря этому программа будет знать все методы сервиса и структуру запросов и ответов:

Теперь мы можем просто выбрать нужный метод из списка и выполнить его:

Postman, зная структуру запросов, может сгенерировать для нас пример сообщения, чтобы мы не писали все вручную:

Деплой проекта

Сервис готов, теперь нужно задеплоить его на удаленный сервер. Я настрою автоматический деплой через GitHub Actions — он бесплатный и удобный. При изменении кода приложение на сервере будет автоматически обновляться и перезапускаться.

Аренда облачного сервера

Я буду использовать облачный сервер линейки Shared Line от Selectel. Он позволяет использовать все преимущества облака и не переплачивать за неиспользуемые ресурсы, так как можно арендовать только часть ядра — например, 10, 20 или 50%.

Для начала зарегистрируемся в панели управления и создадим новый сервер в разделе Облачная платформа. Затем настроим его.

Сервису подойдет ОС Ubuntu 22.04 LTS, 2 виртуальных ядра с минимальной границей в 20% процессорного времени, 2 ГБ оперативной памяти, а также 10 ГБ на сетевом диске (базовый HDD).

В поле Имя указываем название сервера. Советую указать что-то осмысленное, иначе будете постоянно путаться, если у вас больше одного сервера. В этом же окне добавляем SSH Key — он нам понадобится для деплоя. Пароль не нужен, но можете его сохранить на всякий случай.

Настройка GitHub Actions

У GitHub есть сервис Actions, который позволяет выполнять различные workflow-процессы — например, деплой на разные серверы, прогон тестов и многое другое. Разберемся, как его настроить. 

Самое основное — необходимо добавить YAML-файл с его конфигурацией в папку .github/workflows, которая находится в корне проекта. Для примера назовем наш файл deploy.yaml. Он будет состоять из трех общих секций:

  • name — название процесса workflow, которое будет отображаться в разделе Actions,
  • on — условия, при которых будет запускаться workflow,
  • jobs — действия, которые необходимо проделать.

Начнем с первых двух, они самые простые:


    # .github/workflows/deploy.yaml

name: Deploy App # Даем осмысленное имя

on:
  workflow_dispatch: # Ручкой запуск
    inputs: # Что нужно ввести вручную при запуске
      tag: # Мы будем указывать тег для деплоя
        description: 'Tag to deploy'
        required: true

В такой конфигурации workflow будет запускаться только вручную. Притом нужно будет указать тег git, по которому будем деплоить сервис.Можно было бы сделать намного проще — деплоить при каждом пуше / мерже в основную ветку, но мне такой вариант сложнее контролировать по мере развития проекта.

Далее идет секция jobs. Она состоит из двух вложенных секций: deploy и steps. Начнем с самого простого — deploy:


    # .github/workflows/deploy.yaml

# name: ..., on: ...

jobs:
  deploy:
    runs-on: ubuntu-latest # ОС для runner
    env: # Вводим переменные, которые будем использовать далее
      HOST: root@<your_ip> # логин / хост сервера, на которые деплоим
      DEPLOY_DIRECTORY: /root/apps/url-shortener # папка проекта на сервере
      CONFIG_PATH: /root/apps/url-shortener/config/prod.yaml # конфиг сервиса на сервере
      ENV_FILE_PATH: /root/apps/url-shortener/config.env # env-файл на сервере

Далее идет секция steps — она самая большая:


    # .github/workflows/deploy.yaml

# name: ..., on: ...

jobs:
    # deploy: ...
    
    steps:
      - name: Checkout repository
        uses: actions/checkout@v2
        with:
          ref: ${{ github.event.inputs.tag }}
      - name: Check if tag exists
        run: |
          git fetch --all --tags
          if ! git tag | grep -q "^${{ github.event.inputs.tag }}$"; then
            echo "error: Tag '${{ github.event.inputs.tag }}' not found"
            exit 1
          fi
      - name: Set up Go
        uses: actions/setup-go@v2
        with:
          go-version: 1.20.2
      - name: Build app
        run: |
          go mod download
          go build -o url-shortener ./cmd/url-shortener
      - name: Deploy to VM
        run: |
          sudo apt-get install -y ssh rsync
          echo "$DEPLOY_SSH_KEY" > deploy_key.pem
          chmod 600 deploy_key.pem
          ssh -i deploy_key.pem -o StrictHostKeyChecking=no ${{ env.HOST }} "mkdir -p ${{ env.DEPLOY_DIRECTORY }}"
          rsync -avz -e 'ssh -i deploy_key.pem -o StrictHostKeyChecking=no' --exclude='.git' ./ ${{ env.HOST }}:${{ env.DEPLOY_DIRECTORY }}
        env:
          DEPLOY_SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY }}
      - name: Remove old systemd service file
        run: |
          ssh -i deploy_key.pem -o StrictHostKeyChecking=no ${{ env.HOST }} "rm -f /etc/systemd/system/url-shortener.service"
      - name: List workspace contents
        run: |
          echo "Listing deployment folder contents:"
          ls -la ${{ github.workspace }}/deployment
      - name: Create environment file on server
        run: |
          ssh -i deploy_key.pem -o StrictHostKeyChecking=no ${{ env.HOST }} "touch ${{ env.ENV_FILE_PATH }}"
          ssh -i deploy_key.pem -o StrictHostKeyChecking=no ${{ env.HOST }} "chmod 600 ${{ env.ENV_FILE_PATH }}"
          ssh -i deploy_key.pem -o StrictHostKeyChecking=no ${{ env.HOST }} "echo 'CONFIG_PATH=${{ env.CONFIG_PATH }}' > ${{ env.ENV_FILE_PATH }}"
          ssh -i deploy_key.pem -o StrictHostKeyChecking=no ${{ env.HOST }} "echo 'HTTP_SERVER_PASSWORD=${{ secrets.AUTH_PASS }}' >> ${{ env.ENV_FILE_PATH }}"
      - name: Copy systemd service file
        run: |
          scp -i deploy_key.pem -o StrictHostKeyChecking=no ${{ github.workspace }}/deployment/url-shortener.service ${{ env.HOST }}:/tmp/url-shortener.service
          ssh -i deploy_key.pem -o StrictHostKeyChecking=no ${{ env.HOST }} "mv /tmp/url-shortener.service /etc/systemd/system/url-shortener.service"
      - name: Start application
        run: |
          ssh -i deploy_key.pem -o StrictHostKeyChecking=no ${{ env.HOST }} "systemctl daemon-reload && systemctl restart url-shortener.service"

В steps перечислены действия, которые будут выполняться по порядку. Каждый шаг состоит из следующих параметров (некоторые опциональны):

  • name — имя шага, будет выводиться в процессе выполнения workflow, пишем что-то осмысленное;
  • uses — использование внешней команды. Например, uses: actions/setup-go@v2указывает, что шаг будет использовать действие setup-go, доступное в репозитории actions на GitHub;
  • with — параметры, которые передаются в действие;
  • env — определяет переменные окружения для этого шага;
  • run — выполняемая команда.

Теперь разберем каждый шаг:

  • Checkout repository — клонируем репозиторий в runner,
  • Check if tag exists — проверяем, существует ли указанный тег,
  • Set up Go — устанавливаем определенную версию Go,
  • Build app — скачиваем зависимости и собираем приложение,
  • Deploy to VM — загружаем файлы из репозитория на виртуальную машину,
  • Remove old systemd service file — удаляем старый файл сервиса systemd на сервере,
  • List workspace contents — выводим содержимое рабочего каталога на runner,
  • Create environment file on server — создаем файл окружения на сервере,
  • Copy systemd service file — копируем файл сервиса systemd на сервер,
  • Start application — перезапускаем приложение на сервере.

Как видите, здесь мы проделываем различные манипуляции с файлом конфига systemd, потому что сервис будет запускаться через эту службу. Это надежней, чем запускать его напрямую. К примеру, если сервис упадет, systemd его перезапустит.

Теперь создаем файл конфига systemd, который будет отправляться на сервер:


    [Unit] 
Description=gRPC 
Auth After=network.target

[Service] 
User=root
WorkingDirectory=/root/apps/grpc-auth 
ExecStart=/root/apps/grpc-auth/grpc-auth 
Restart=always
RestartSec=4 
StandardOutput=inherit
EnvironmentFile=/root/apps/grpc-auth/config.env

[Install]
WantedBy=multi-user.target

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


    # config/prod.yaml

env: "prod"
storage_path: "./storage.db"
http_server:
  address: "0.0.0.0:8082" # 0.0.0.0 вместо localhost, чтобы работали внешние запросы
  timeout: 4s
  idle_timeout: 30s
  user: "some_username" # указываем только user, но не password. О пароле поговорим ниже

Поскольку у нас пока нет RPC-методов для добавления сущностей в таблицу apps, заводить там приложения придется вручную. Сейчас самый простой вариант — взять ваш локальный файл БД, скопировать на удаленный сервер, а затем указать путь до него. Именно так я и поступлю. Но это костыль, а хорошим решением было бы написание ручки grpcAuth.CreateApp() и подключение новых клиентских сервисов через нее.

Наконец, можно отправить проект на GitHub и добавить в секреты SSH-ключ (DEPLOY_SSH_KEY):

GitHub, Settings Secrets and variables Actions. Имена обязательно должны совпадать, потому что они используются в нашем файле workflow.

Деплой в продакшен

Если вы все сделали правильно, то осталось лишь установить текущий тег и нажать кнопку деплоя. Тег я устанавливаю локально:


    git tag v0.0.1 && git push origin v0.0.1

Теперь в проекте на GitHub открываем секцию Actions и в списке workflow выбираем свой Deploy App:

Нажимаем Run workflow и ждем. Если все сработало, вы можете обратиться к сервису по публичному IP. Обратите внимание: порт должен быть не стандартный 80, а тот, что указан в конфиге сервиса.

Заключение

Разработка gRCP-сервисов — обширная тема, в которой много условностей и простора для воображения. Если вам нравятся такие топики и инструкции, следите за обновлениями в Академии Selectel.

Автор: Николай Тузов, YouTube-блогер

Читайте также:

Инструкция
Инструкция
Инструкция