Пишем gRPC сервис на Go — сервис авторизации

Моя цель - предложение широкого ассортимента товаров и услуг на постоянно высоком качестве обслуживания по самым выгодным ценам.

Пишем gRPC сервис на Go — сервис авторизации


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


Итого, наш план:


  • Напишем простой, но полноценный gRPC-сервис
  • Разберемся с базовыми принципами работы авторизации — чтобы не было скучно
  • Настроим автоматический деплой в прод — потому что руками деплоить лень
  • Подружим его с уже готовым сервисом URL Shortener — чтобы был практический смысл
  • Напишем полноценные функциональные тесты

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


Кратко обо мне: меня зовут Николай Тузов, я много лет занимаюсь разработкой на Go, очень люблю этот язык. Также веду свой YouTube-канал, на котором есть видеоверсия текущего гайда, с более подробными объяснениями.


Используйте навигацию, если нет времени читать текст целиком:


→ Архитектура
→ Контракт Protobuf
→ Точка входа и конфигурация
→ gRPC сервер и обработка запросов
→ Сервисный слой — Auth
→ Слой работы с данными
→ Собираем компоненты приложения воедино
→ Функциональные тесты
→ Интеграция с внешним сервисом
→ Настраиваем автоматический деплой — GitHub Actions
→ Заключение


Важные вступительные оговорки


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


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


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


Комментарии в коде будут двух типов:


  • на русском: для читателей статьи
  • на английском: для читателей кода

То есть, английские комментарии — это часть моей программы (godoc и прочие), они будут также и в репозитории. Русские же комментарии — пояснения для читателей статьи, их не будет в репозитории.


SSO или Auth?


Обычно термином Auth называют сервисы, которые отвечают только за авторизацию и аутентификацию, а SSO нечто более общее — работа с правами (permissions), предоставление информации о пользователе и др.


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


Чтобы внести ясность, термином SSO я называю сервис, объединяющий в себе три важных функции:


  • Авторизация (Auth)
  • Работа с пермишеннами (Permissions)
  • Предоставление информации о пользователе (User Info)

В этой статье будет только авторизация, но я планирую развивать этот сервис дальше в будущих статьях / роликах, поэтому буду планировать именно как SSO. Если не хотите пропустить продолжение, то советую подписаться на мой Telegram-канал, т.к. контент я публикую на разных площадках, а общую информацию обо всех активностях пишу только в нём.


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



Архитектура


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


Основные моменты, которыми будем жертвовать:


  • Архитектура сервиса. Сама архитектура в целом будет максимально приближена к боевому сервису. Жертвовать же будем в основном обвязками: мониторинг, lifecycle, тестами и т.п. (оно будет, но по минимуму)
  • Схема авторизации. А вот она будет максимально упрощена, т.к. основной фокус мы делаем на написание gRPC-сервиса. Но тем не менее, его функционала хватит для обслуживания ваших пет-проектов, а для дальнейшего развития я покажу направления и дам советы

Общая схема проекта — как будет работать авторизация


Действующие лица:


  • Пользователь (User) — человек, который вынужден авторизовываться, т.к. он хочет воспользоваться нашим URL Shortener'ом
  • URL Shortener — сервис, который будет клиентом SSO
  • Сервер авторизации (SSO) — сервис, который умеет авторизовывать, предоставлять информацию о правах пользователей и т.п.

Как это будет работать:


  • User (или используемое им приложение) отправляет запрос в SSO, чтобы получить JWT токен авторизации
  • С этим токеном он идёт в URL Shortener, чтобы выполнять разные полезные запросы — создавать короткие ссылки, удалять их и т.п.
  • URL Shortener получает запрос от клиента, достаёт из него токен, по которому понимает кто пришел, и что ему разрешено делать

Схема, взаимодействия пользователь (Client), SSO и другого сервиса
Схема, взаимодействия пользователь (Client), SSO и другого сервиса


Важные вещи, которых у нас не будет:


  • Проверка актуальности JWT — мы будем верить информации, которая в нём содержится. Мы можем так делать, т.к. JWT подписывается секретным ключом и подделать его не получится (об этом ниже)
  • Отзыв авторизации — поскольку мы верим информации в JWT, мы не сможем разлогинить пользователя до "протухания" его токена. Это сильно усложнило бы статью, ведь нам бы понадобилось хранить сессии в SSO, делать проверочные запросы от URL Shortener после получения токена и др.
  • Гибкая система ролей и пермишенов — это тема для отдельной большой статьи / видео

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


Краткий ликбез по JWT


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

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


eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhcHBfaWQiOjEsImVtYWlsIjoiYXNkYWRzQGFzZC5jb20iLCJleHAiOjE2OTY5NTExMTgsInVpZCI6NjR9.b58TXBm1NKnVw0FvWyb5KFkG35WdB7JXeCiMWu8rNw0

А в декодированном так:


JWT токен в декодированном виде


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


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


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



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


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


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


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


Итак, создаём проект с названием protos (либо contract / api-contracts или как вам больше нравится).


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


  • proto — тут будем хранить сами proto-файлы
  • gen — а здесь будет сгенерированный по ним код

В папке proto/sso создаём файл sso.proto. Его формат очень прост, в нём будут описаны:


  • Общая информация: версия протокола, пакет и опции для генерации go-файлов
  • Сервисы: по сути, это аналогии интерфейсам в 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-код. Для этого используется официальная утилита — protoc (компилятор Protocol Buffers). Для начала, вам её нужно установить, подробности по установке тут. Внимательно читайте инструкцию — нужно установить не только утилиту, но и плагин для Go.


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


mkdir -p gen/go

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


protoc -I proto proto/sso/sso.proto --go_out=./gen/go/ --go_opt=paths=source_relative --go-grpc_out=./gen/go/ --go-grpc_opt=paths=source_relative

Давайте её разберём:


  • -I proto: Опция -I или --proto_path указывает путь к корневой директории с .proto файлами. Это нужно для того, чтобы компилятор смог найти импорты, если они есть. В нашем случае это директория proto.
  • proto/sso/sso.proto: путь к конкретному .proto файлу, который мы компилируем.
  • --go_out=./gen/go/: Опция --go_out указывает, куда записывать сгенерированный Go-код. В нашем случае — ./gen/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 код содержит клиентскую и серверную часть:
    • Серверная часть: это определения интерфейсов gRPC сервисов, включая методы RPC, которые должны быть реализованы. Также тут будут базовые реализации интерфейсов сервера, которые мы должны дополнить своей бизнес-логикой. Они предоставляют "скелет" сервера.
    • Клиентская часть: готовые клиенты для обращения к gRPC серверу.

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


Для удобства рекомендую добавить вызов этой команды в Makefile, либо в Taskfile (аналог Make, но формат файлов более удобный). На текущей работе мы используем Task, он мне нравится и я к нему привык, поэтому покажу на его примере.


Для начала необходимо установить утилиту 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=./gen/go/ --go-grpc_opt=paths=source_relative

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


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

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


Вкратце по пунктам:


  • Env — текущее окружение: local, dev, prod и т.п.
  • StoragePath — мы будем использовать SQLite, поэтому нужно указать путь до файла, где хранится наша БД
  • GRPCConfig — порт gRPC-сервиса и таймаут обработки запросов
  • MigrationsPath — путь до директории с миграциями БД. Он будет использоваться утилитой migrator
  • TokenTTL — время жизни выдаваемых токенов авторизации. На самом деле, время жизни — довольно сложная штука, и, по-хорошему, оно должно зависеть от различных факторов. Но мы для простоты сделаем его фиксированным, и будем хранить в конфиге. Далее вы сможете переделать его под себя, при необходимости.

Теперь можем написать функцию 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
}

По соглашению, функции с префиксом Must вместо возвращения ошибок создают панику. Используйте их с осторожностью.

Зачем нужна функция fetchConfigPath()? Нашему приложению нужно понимать, где искать конфиг-файл, для этого нужно указать configPath. Сделать это можно разными способами, например: флаг --config или переменная окружения CONFIG_PATH. Наша функция реализует оба варианта. Если вдруг указаны оба значения, то будет использован флаг.


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


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

Либо так в случае флага:


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

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


# 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 — нам не нужны debug-логи в проде. Т.е. мы будем получать сообщения только с уровнем 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
}

// Тот самый интерфейс, котрый мы передавали в grpcApp
type Auth interface {
    Login(
        ctx context.Context,
        email string,
        password string,
        appID int,
    ) (token string, err error)
    RegisterNewUser(
        ctx context.Context,
        email string,
        password string,
    ) (userID int64, err error)
}

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, которая будет реализовывать функционал API
  • каркасы для двух RPC-методов, которые мы будем использовать: Login и Register (методы этой структуры serverAPI)
  • интерфейс будущего Auth из сервисного слоя — его реализацию мы напишем чуть позже, а пока достаточно интерфейса в качестве контракта
  • также здесь у нас есть функция Register, которая регистрирует эту serverAPI в gRPC-сервере

В структуре serverAPI у нас также присутствует вложенная структура UnimplementedAuthServerprotoc генерирует её на основе proto-файла. Она представляет собой некую пустую имплементацию всех методов gRPC сервиса. Использование этой структуры помогает обеспечить обратную совместимость при изменении .proto файла. Если мы добавим новый метод в наш .proto файл и заново сгенерируем код, но не реализуем этот метод в serverAPI, то благодаря встраиванию UnimplementedAuthServer наш код все равно будет компилироваться, а новый метод просто вернет ошибку "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 — это тоже необходимо для совместимости с клиентами. Рекомендую всегда выбирать подходящие коды ошибок для каждой ситуации. К примеру, если не подошел пароль или мы не нашли пользователя в БД, это — codes.InvalidArgument, если же БД вернула неожиданную ошибку, это уже codes.Internal.


Всё это сделает работу с нашим сервером гораздо более удобной на стороне клиента.


Теперь метод 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(ctx context.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)  
}

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


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


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


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


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


// internal/domain/models/app.go

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. Начнём с первого. Для него нам также понадобится внешний пакет 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
}

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


Ликбез по хранению паролей


В чистом виде пароль хранить нельзя — это чревато утечками: если злоумышленник украдёт нашу БД, он узнает пароли всех пользователей, получит доступ к их аккаунтам и т.п. Очень плохо, очень неприятно.


Чтобы этого избежать, мы можем брать хэш от пароля и хранить уже его (если не знаете что такое хэш, у меня есть об этом видео), а когда пользователь попытается залогиниться, мы снова возьмём хэш от полученного пароля и сравним хэши вместо прямого сравнения двух паролей.


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


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


Это очень популярный подход, вы легко найдёте более подробную информацию о нём в интернете.


Как работает функция bcrypt.GenerateFromPassword


Функция bcrypt.GenerateFromPassword() выполняет процедуру генерации хэша и соли за нас. Она принимает на вход пароль, генерирует соль, хэширует и возвращает результат единой строкой. В этой строке содержится одновременно и хэш, и соль, что очень удобно — ведь нам достаточно сохранить единственное значение.


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


Таким образом, в случае попытки авторизации мы используем функцию bcrypt.CompareHashAndPassword(savedHash, loginPassword), которая выполнит за нас всю работу по сравнению и сообщит — подходит пароль или нет.


Перед тем как перейти непосредственно к методу 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.


Обратите внимание! Текущая реализация метода имеет одну критичную дыру в безопасности — он не защищен от брутфорса (перебора паролей). Я решил не усложнять логику разбором этой темы. Советую вам быть аккуратней — желательно, придумать какую-то логику для защиты. Либо напишите в комментариях, что вам интересна эта тема, я постараюсь написать об этом отдельную заметку в ТГ-канале, либо в виде отдельного поста.

Он заметно длиннее предыдущего, но при этом довольно простой, поэтому также покажу его целиком и прокомментирую:


// 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 mod:


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

Он нам пригодится чуть позже.


Код пакета хранилища я размещу в internal/storage, а реализацию для sqlite положу сюда: internal/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, Postgres, MongoDB и т.п.), поэтому мы их разместили в общем пакете.


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


Теперь нам нужно подготовить схему данных. По просьбам читателей / зрителей прошлой статьи / ролика по REST API, в этот раз я буду использовать полноценные миграции для работы со схемой БД.


Миграции БД — это пошаговые изменения структуры базы данных, позволяющие последовательно применять и откатывать модификации схемы. Они, обычно, представляют из себя набор файлов с 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. Обычно это не обязательно, но я буду хранить отдельный набор миграций для тестов, и информация о них будет храниться в отдельной таблице. Но об этом позже — в разделе про тестирование.


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


  • number — используется для определения порядка применения миграций, они выполняются по возрастанию номеров. Тут должно быть любое целое число — это может быть порядковый номер, timestamp и т.п. Я буду именовать по порядку: 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, советую посмотреть мой ролик на эту тему.


Выше мы уже установили драйвер для работы с 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,
    ),
}

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


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


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

Теперь можем добавить всё это к конструктор, и в итоге получаем такой код:


// 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(), в котором будем падать с паникой, если случилась какая-то ошибка.


Теперь можем создать 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,
    }
}

Понимаю, что многих может смущать эта строчка:


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

А именно, трижды передаваемый storage. Увы, таковы издержки минималистичных интерфейсов. Но подумайте о том, что не во всех случаях реализациями этих интерфейсов может быть 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 уже есть подходящий метод. Что он делает:


  1. Перестаёт принимать новые запросы
  2. Ждёт завершения обработки текущих запросов
  3. Возвращает управление

Теперь в 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 — это хороший подход.



Функциональные тесты


Как и в статье / ролике про REST API, мы напишем функциональные тесты, которые будут тестировать наше приложение как чёрную коробку. Это значит, что мы будем честно поднимать приложение, которое не будет подозревать о том, что его тестируют, и будем честно отправлять в него сетевые запросы.


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


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


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


Ещё одно преимущество подобных тестов в нашем случае — в процессе их написания мы научимся писать gRPC-клиент для нашего сервиса. А после прочтения статьи вы можете использовать их как полигон для своих экспериментов, играясь с параметрами клиента, пытаясь отправлять различные хитрые запросы, воспроизводить интересные кейсы и т.п.


Приступим. Функциональные тесты мы положим в отдельную папку — tests/. Забегая вперед, структура будет выглядеть так:


./tests
├── auth_register_login_test.go ... Тест-кейсы для проверки Login 
├── some_other_case_test.go ....... Какие-то другие кейсы
├── migrations .................... Миграции для тестов
│   └── 1_init_apps.up.sql
└── suite
    └── suite.go .................. Подготовка всего необходимого для тестов

Подробнее:


  • В корне директории tests мы храним сами файлы тестов с конкретными кейсами (у них обязательно должен быть суффикс _test.go)
  • tests/migrations — дополнительные миграции, которые нужны только для тестов. Обычно я их использую для инициализации БД самыми необходимыми данными. Например, здесь мы напишем миграцию для добавления тестового приложения в таблицу apps.
  • tests/suite — здесь мы будем готовить всё, что необходимо каждому тесту. Например, соединение с БД, создание gRPC-клиента и др.

Миграции для тестов


Начнём с самого простого — с миграций, нам понадобится всего одна:


-- tests/migrations/1_init_apps.up.sql

INSERT INTO apps (id, name, secret)  
VALUES (1, 'test', 'test-secret') 
ON CONFLICT DO NOTHING;

Обратите внимание, на строчку ON CONFLICT DO NOTHING — если такая запись уже есть, миграция просто ничего не сделает. Также держим в голове, что указанные id и secret нам скоро понадобятся в тестах.


Обратную миграцию я здесь писать не планирую, т.к. в тестовой базе мне это не нужно.


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


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

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


Suite — подготовка к тесту


Теперь подготовим Suite. Он представляет из себя структуру, в которой содержится всё необходимое для теста:


// tests/suite/suite.go

import (
    "testing"
    "grpc-service-ref/internal/config"
    ssov1 "github.com/JustSkiv/protos/gen/go/sso"
)

type Suite struct {
    *testing.T                  // Потребуется для вызова методов *testing.T
    Cfg        *config.Config   // Конфигурация приложения
    AuthClient ssov1.AuthClient // Клиент для взаимодействия с gRPC-сервером Auth
}

  • *testing.T — объект для управлением тестом, подробнее можно почитать тут
  • Cfg — обычный объект конфига, тот же что используется при запуске приложения из cmd
  • AuthClient — gRPC-клиент нашего Auth-сервера, основной компонент Suit'а, с его помощью будем отправлять запросы в тестируемое приложение

Теперь напишем для него конструктор:


// tests/suite/suite.go

import (
    "testing"
    "context"
)

const configPath

// New creates new test suite.
func New(t *testing.T) (context.Context, *Suite) {
    t.Helper()   // Функция будет восприниматься как вспомогательная для тестов
    t.Parallel() // Разрешаем параллельный запуск тестов

    // Читаем конфиг из файла
    cfg := config.MustLoadPath(configPath)

    // Основной родительский контекст   
    ctx, cancelCtx := context.WithTimeout(context.Background(), cfg.GRPC.Timeout)

    // Когда тесты пройдут, закрываем контекст
    t.Cleanup(func() {
        t.Helper()
        cancelCtx()
    })

    // Адрес нашего gRPC-сервера
    grpcAddress := net.JoinHostPort(grpcHost, strconv.Itoa(cfg.GRPC.Port))

    // Создаем клиент
    cc, err := grpc.DialContext(context.Background(),
        grpcAddress,
        // Используем insecure-коннект для тестов
        grpc.WithTransportCredentials(insecure.NewCredentials())) 
    if err != nil {
        t.Fatalf("grpc server connection failed: %v", err)
    }

    // gRPC-клиент сервера Auth
    authClient := ssov1.NewAuthClient(cc)

    return ctx, &Suite{
        T:          t,
        Cfg:        cfg,
        AuthClient: authClient,
    }
}

t.Helper() — помечает функцию как вспомогательную, это нужно для формирования правильного вывода тестов, особенно при их падении. А именно, что-то сфэйлится внутри такого хелпера, в выводе будет указана родительская функция (но в трейсе текущая функция будет, конечно), что очень удобно при отладке.


И вот тут мы можем снова прочувствовать крутость кодогенерации из Protobuf: всего одной строчкой мы создаём готовый клиент для нашего gRPC-сервера: authClient := ssov1.NewAuthClient(cc). Если раньше вы работали с веб-сервисами без кодогенерации, вам точно должно это понравится

Источник: https://habr.com/ru/articles/774796/


Интересные статьи

Интересные статьи

Меня зовут Андрей Цыган - и я исследую практическое применение AI и сервисов со стороны предпринимателя. Дизайнерских навыков у меня нет, а потребность в презентациях всегда была достаточно большая.В ...
Здравствуйте! Меня зовут Максим Кульгин, моя компания clickfraud.ru занимается защитой от скликивания контекстной рекламы на постсоветском пространстве. Меня часто спрашивают в Телеграм-кана...
Сквозная аналитика – волшебная палочка, которая делает вжух и превращает данные по рекламе, заказам, звонкам и клиентам из разрозненных таблиц в единую связанную систему. Даже если раньше вы работали ...
Мы в Smart Engines занимаемся разработкой систем распознавания документов уже более 7 лет, предоставляя нашим клиентам уникальные алгоритмы, «завернутые» в локальные (on-premises) безопасные программн...
Всем привет! В процессе работы над гексаподом AIWM я все чаще задумывался о каком-нибудь удобном интерфейсе для общения с ним. В результате тесной работы с Linux через терминал я подумал, а почему бы ...