В этой статье мы напишем полноценный REST API сервис — URL Shortener — и задеплоим его на виртуальный сервер с помощью GitHub Actions.

Говоря «полноценный», я имею в виду, что это будет не игрушечный проект, а готовый к использованию:

  • мы выберем для него актуальный http-роутер,
  • позаботимся о логах,
  • напишем тесты: unit-тесты, тесты хэндлеров и функциональные,
  • настроим автоматический деплой через GitHub Actions и др.

Но важно понимать, что «готовый к продакшену» != «энтерпрайз».

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


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

Выбор библиотек
Конфигурация приложения
Настраиваем logger
Пишем Storage
Handlers — обработчики запросов
Авторизация
Функциональные тесты
Деплой проекта
Заключение

Выбор библиотек


Для проекта нам понадобятся несколько основных библиотек:

  • go-chi/chi — для обработки HTTP-запросов,
  • slog — для логирования,
  • stretchr/testify — для покрытия проекта тестами,
  • ilyakaznacheev/cleanenv — для конфигурирования,
  • SQLite — для хранения данных, СУБД.

Далее расскажу подробнее, почему я выбрал именно их.

HTTP-роутер


Работа с HTTP-запросами — основной компонент нашего сервиса, поэтому это очень важный выбор.

Можно было просто взять пакет net/http из стандартной библиотеки, но я решил использовать более продвинутый вариант, который упростит работу и добавит удобную маршрутизацию, поддержку middleware и другие приятные вещи.

В то же время, я бы не хотел брать что-то слишком сложное. В идеале нужно решение, совместимое с net/http и легко заменяемое.

Я провел опрос в своем Telegram-канале, учел его результаты и комментарии подписчиков и остановился на go-chi/chi. Он как раз полностью совместим с net/http, минималистичный и производительный — на мой взгляд, наиболее Go-idiomatic.

Логирование


Здесь можно вообще не думать, просто взять привычный uber/zap и двигаться дальше. Но мне не нравится привязка проекта к конкретному логгеру. Можно, конечно, написать собственный интерфейс, чтобы потом легко заменять логгеры. Однако это сложнее, чем может показаться: с большой вероятностью получится интерфейс, заточенный под изначально выбранный логгер. Да и в целом, пока не набьешь кучу шишек, сложно заранее понять, какой именно набор методов в интерфейсе нам подойдет.

К счастью, умные люди уже подумали за нас и написали go-logr/logr, в целом, очень мне понравился. Советую почитать описание: авторы провели серьезную работу по переосмыслению логирования в Go.

Другой хороший вариант — slog. Это пакет для логирования, который позволяет отвязаться от конкретного логгера и легко заменять его при необходимости. Более подробно останавливаться на slog не будем — это тема для полноценной статьи. Оставлю только ссылку на пост с хорошей подборкой материалов о нем. В данном проекте — используем именно slog.

Другое


Для тестирования запросов можно взять привычный testify и httpexpect, тут без сюрпризов.

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

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



Конфигурация приложения


Приступим к коду и подготовим все необходимое для конфигурации сервиса. Создадим в корне папку config — здесь будем хранить файлы с конфигурацией. Я буду использовать yaml, но вы можете выбрать любой другой удобный вам формат. Главное, чтобы его поддерживал cleanenv.

Итак, в папке config создаем файл local.yaml:

# config/local.yaml

env: "local" # Окружение - local, dev или prod
storage_path: "./storage/storage.db" # файл, в котором будет храниться наша БД
http_server: # конфигурация нашего http-сервера
  address: "localhost:8082"
  timeout: 4s
  idle_timeout: 30s

Не забудьте освободить выбранный порт и создать папку, в которой будет размещен db-файл. Сам файл создавать не нужно, он появится автоматически.

Теперь создадим файл internal/config/config.go. Здесь и далее я подразумеваю пути от корня проекта: если такого пути еще нет, его надо создать. Например, так:

mkdir -p internal/config && touch internal/config/config.go

В config.go заведем структуры, в которые будем анмаршалить конфигурационный файл:

// internal/config/config.go

type Config struct {
    Env         string `yaml:"env" env-default:"development"`
    StoragePath string `yaml:"storage_path" env-required:"true"`
    HTTPServer `yaml:"http_server"`
}

type HTTPServer struct {
    Address     string        `yaml:"address" env-default:"0.0.0.0:8080"`
    Timeout     time.Duration `yaml:"timeout" env-default:"5s"`
    IdleTimeout time.Duration `yaml:"idle_timeout" env-default:"60s"`
}

Здесь я использую cледующие struct-теги:

  • yaml — имя соответствующего параметра в Yaml-файле,
  • env-default — дефолтное значение,
  • env-required — делает параметры обязательными. Если такой параметр не указан, мы будем получать ошибку.

Теперь установим cleanenv и напишем функцию, которая будем возвращать заполненную структуру.

go get -u github.com/ilyakaznacheev/cleanenv


// internal/config/config.go

func MustLoad() *Config {
    // Получаем путь до конфиг-файла из env-переменной CONFIG_PATH
    configPath := os.Getenv("CONFIG_PATH")
    if configPath == "" {
        log.Fatal("CONFIG_PATH environment variable is not set")
    }

    // Проверяем существование конфиг-файла
    if _, err := os.Stat(configPath); err != nil {
        log.Fatalf("error opening config file: %s", err)
    }

    var cfg Config

    // Читаем конфиг-файл и заполняем нашу структуру
    err := cleanenv.ReadConfig(configPath, &cfg)
    if err != nil {
        log.Fatalf("error reading config file: %s", err)
    }

    return &cfg
}

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

Также обращаю внимание, что путь до конфиг-файла я получаю из переменной окружения CONFIG_PATH, дефолтный путь не предусмотрен. Чтобы передать значение такой переменной, можно запустить приложение следующей командой:

CONFIG_PATH=./config/local.yaml ./your-app

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

Теперь создадим в корне проекта папку cmd — здесь будем хранить все команды для нашего проекта (например, запуск самого сервиса). В будущем здесь могут быть вспомогательные утилиты, моки и другое.

Далее создаем в cmd папку url-shortener, а внутри нее — файл main.go. Здесь будем конфигурировать и запускать наш сервис — в том числе и MustLoad():

// cmd/url-shortener/main.go

package main

import (
    "url-shortener/internal/config"
)

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

Настраиваем logger


Объект конфига у нас есть, теперь соберем логгер. Как я писал выше, использовать будем slog. Это очень гибкий пакет, и конкретная реализация может быть разной. Вы можете написать собственный хендлер (обработчик логов, который определяет, что происходит с записями), обернуть в него привычный логгер (например, zap или logrus) либо использовать дефолтные варианты, которые предоставляются вместе с пакетом. Я выберу последний вариант.

Устанавливаем slog:

go get golang.org/x/exp/slog

Если вы читаете статью уже после выхода Go 1.21, можете просто импортировать slog из std lib:

import "log/slog"

Из коробки в slog есть два вида хендлеров. Для локальной разработки нам подойдет TextHandler, а для деплоя лучше использовать JSONHandler, чтобы агрегатор логов (Kibana, Grafana, Loki и другие) мог его распарсить.

Кроме того, важно учесть уровень логирования — это минимальный уровень сообщений, которые будут выводиться. К примеру, если мы установим уровень Info, то Debug-сообщения не увидим. Поэтому для локальной разработки и Dev-окружения лучше использовать уровень Debug, а для продакшена — Info.

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

// cmd/url-shortener/main.go
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
}

В зависимости от окружения эта функция создает логгер с разными параметрами — TextHandler / JSONHandler и уровень LevelDebug / LevelInfo.

Теперь создадим логгер в main, добавим параметр env с помощью метода log.With и выведем информацию о запуске приложения:

// cmd/url-shortener/main.go

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

    log := setupLogger(cfg.Env)
    log = log.With(slog.String("env", cfg.Env)) // к каждому сообщению будет добавляться поле с информацией о текущем окружении

    log.Info("initializing server", slog.String("address", cfg.Address)) // Помимо сообщения выведем параметр с адресом
    log.Debug("logger debug mode enabled")
}

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

time=2023-06-18T19:27:41.720+06:00 level=INFO msg="initializing server" env=local address=localhost:8082
time=2023-06-18T19:27:41.720+06:00 level=DEBUG msg="logger debug mode enabled" env=local

Благодаря функции With к каждому сообщению будет добавлено поле env с информацией о текущем окружении. Это очень удобно, советую получше изучить эту механику и обогащать свой логгер необходимой информацией.

Помимо стандартных реализаций, нам все же придется написать одну свою — DiscardHandler. В таком виде логгер будет игнорировать все сообщения, которые мы в него отправляем, — это понадобится в тестах. Создадим пакет slogdiscard и имплементируем в нем интерфейс slog.Handler:

// internal/lib/logger/handlers/slogdiscard/slogdiscard.go
package slogdiscard

import (
    "context"

    "golang.org/x/exp/slog"
)

func NewDiscardLogger() *slog.Logger {
    return slog.New(NewDiscardHandler())
}

type DiscardHandler struct{}

func NewDiscardHandler() *DiscardHandler {
    return &DiscardHandler{}
}

func (h *DiscardHandler) Handle(_ context.Context, _ slog.Record) error {
    // Просто игнорируем запись журнала
    return nil
}

func (h *DiscardHandler) WithAttrs(_ []slog.Attr) slog.Handler {
    // Возвращает тот же обработчик, так как нет атрибутов для сохранения
    return h
}

func (h *DiscardHandler) WithGroup(_ string) slog.Handler {
    // Возвращает тот же обработчик, так как нет группы для сохранения
    return h
}

func (h *DiscardHandler) Enabled(_ context.Context, _ slog.Level) bool {
    // Всегда возвращает false, так как запись журнала игнорируется
    return false
}

Также предлагаю создать пакет sl (сокращенно от slog), в который добавим некоторые функции для работы с логгером. Они пригодятся в будущем.

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

import (
    "golang.org/x/exp/slog"

    "url-shortener/internal/lib/logger/handlers/slogdiscard"
)

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

Пишем Storage


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

  • url — длинный адрес, который мы сохраняем,
  • alias — короткий идентификатор, по которому будем искать оригинальный адрес.

Предлагаю использовать следующий формат таблицы:

CREATE TABLE IF NOT EXISTS url(
        id INTEGER PRIMARY KEY,
        alias TEXT NOT NULL UNIQUE,
        url TEXT NOT NULL);
CREATE INDEX IF NOT EXISTS idx_alias ON url(alias);

Обратите внимание:

  • поле alias уникальное (параметр UNIQUE), чтобы не было коллизий,
  • все поля обязательные,
  • для быстрого поиска записей создаем индекс idx_alias.

Здесь мы могли бы обойтись даже без поле id и оставить в качестве уникального идентификатора только alias, но мне такой вариант не нравится. Например, потому что записи будут удаляться и создаваться с повторяющимися алиасами, но id всегда будет уникальным. Это может когда-нибудь помочь в дебаге и т.п.

Код Storage будет находиться в папке internal/storage — создадим в ней файл storage.go. В нем будет лишь базовый для всех имплементаций код. Сейчас такого кода мало — только информация о возможных ошибках:

// internal/storage/storage.go

package storage

import "errors"

var (
    ErrURLNotFound = errors.New("url not found")
    ErrURLExists   = errors.New("url exists")
)

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

Теперь установим библиотеку для работы с sqlite и создадим структуру для объекта Storage.

go get github.com/mattn/go-sqlite3



// internal/storage/sqlite/sqlite.go

type Storage struct {
    db *sql.DB
}

И его конструктор:

// internal/storage/sqlite/sqlite.go

func New(storagePath string) (*Storage, error) {
    const op = "storage.sqlite.NewStorage" // Имя текущей функции для логов и ошибок

    db, err := sql.Open("sqlite3", storagePath) // Подключаемся к БД
    if err != nil {
        return nil, fmt.Errorf("%s: %w", op, err)
    }

    // Создаем таблицу, если ее еще нет
    stmt, err := db.Prepare(`
    CREATE TABLE IF NOT EXISTS url(
        id INTEGER PRIMARY KEY,
        alias TEXT NOT NULL UNIQUE,
        url TEXT NOT NULL);
    CREATE INDEX IF NOT EXISTS idx_alias ON url(alias);
    `)
    if err != nil {
        return nil, fmt.Errorf("%s: %w", op, err)
    }

    _, err = stmt.Exec()
    if err != nil {
        return nil, fmt.Errorf("%s: %w", op, err)
    }

    return &Storage{db: db}, nil
}

Зачем здесь константа op? Я стараюсь всегда добавлять имя текущей функции в возвращаемые ошибки и в логгер, чтобы потом было проще «искать хвосты» в логах. Ведь разные функции часто возвращают одинаковые ошибки и пишут одинаковые логи, а нам обычно нужно понимать, где именно произошло событие.

К примеру, я оборачиваю возвращаемую функцией sql.Open ошибку таким образом: fmt.Errorf("%s: %w", op, err).

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

Методы хранилища


У нашего хранилища будет всего два метода — SaveURL() и GetURL(). Начнем с первого:

// internal/storage/sqlite/sqlite.go

func (s *Storage) SaveURL(urlToSave string, alias string) (int64, error) {
    const op = "storage.sqlite.SaveURL"

    // Подготавливаем запрос
    stmt, err := s.db.Prepare("INSERT INTO url(url,alias) values(?,?)")
    if err != nil {
        return 0, fmt.Errorf("%s: prepare statement: %w", op, err)
    }

    // Выполняем запрос
    res, err := stmt.Exec(urlToSave, alias)
    if err != nil {
        if sqliteErr, ok := err.(sqlite3.Error); ok && sqliteErr.ExtendedCode == sqlite3.ErrConstraintUnique {
            return 0, fmt.Errorf("%s: %w", op, storage.ErrURLExists)
        }

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

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

    // Возвращаем ID
    return id, nil
}

Тут все просто и понятно, поясню только эту строчку:

if sqliteErr, ok := err.(sqlite3.Error); ok && sqliteErr.ExtendedCode == sqlite3.ErrConstraintUnique {
// …

Здесь мы приводим полученную ошибку ко внутреннему типу библиотеки sqlite3, чтобы посмотреть, не является ли эта ошибка sqlite3.ErrConstraintUnique. Если это так, значит, мы попытались добавить дубликат имеющейся записи. Об этом мы сообщим в вызывающую функцию, вернув уже свою ошибку для данной ситуации: storage.ErrURLExists. Получив ее, сервер сможет сообщить клиенту о том, что такой alias у нас уже есть.

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

Аналогичным образом пишем метод GetURL():

// internal/storage/sqlite/sqlite.go

func (s *Storage) GetURL(alias string) (string, error) {
    const op = "storage.sqlite.GetURL"

    stmt, err := s.db.Prepare("SELECT url FROM url WHERE alias = ?")
    if err != nil {
        return "", fmt.Errorf("%s: prepare statement: %w", op, err)
    }

    var resURL string
    
    err = stmt.QueryRow(alias).Scan(&resURL)
    if errors.Is(err, sql.ErrNoRows) {
        return "", storage.ErrURLNotFound
    }
    if err != nil {
        return "", fmt.Errorf("%s: execute statement: %w", op, err)
    }

    return resURL, nil
}

Надеюсь, здесь пояснения не нужны. Метод DeleteURL можете написать самостоятельно, в качестве упражнения.

Наконец добавим создание объекта Storage в функцию main:

// cmd/url-shortener/main.go

func main() {
    // ...
    storage, err := sqlite.New(cfg.StoragePath)
    if err != nil {
        log.Error("failed to initialize storage", sl.Err(err))
    }

Забегая вперед, интерфейс для Storage мы тут объявлять не будем — он будет находиться в месте использования. Мотивацией такого решения я делился в отдельном ролике.

Подготовка HTTP Server



Переходим к самому интересному — работе с HTTP-сервером. Первым делом установим наш chi:

go get -u github.com/go-chi/chi/v5

И еще нам понадобится пакет go-chi/render, который идет отдельно от роутера:

go get github.com/go-chi/render


Middleware


В main создадим объект роутера и подключим к нему необходимый middleware:

// cmd/url-shortener/main.go

router := chi.NewRouter()  
  
router.Use(middleware.RequestID) // Добавляет request_id в каждый запрос, для трейсинга
router.Use(middleware.Logger) // Логирование всех запросов
router.Use(middleware.Recoverer)  // Если где-то внутри сервера (обработчика запроса) произойдет паника, приложение не должно упасть
router.Use(middleware.URLFormat) // Парсер URLов поступающих запросов

Все эти middleware доступны из коробки в пакете chi. Обсудим тут пару моментов.

По умолчанию middleware.Logger использует свой собственный внутренний логгер, который желательно переопределить, чтобы использовался наш. Иначе могут возникнуть проблемы — например, со сбором логов. Либо можно написать собственный middleware для логирования запросов:

// internal/http-server/middleware/logger/logger.go

package logger

import (
    "net/http"
    "time"

    "github.com/go-chi/chi/v5/middleware"
    "golang.org/x/exp/slog"
)

func New(log *slog.Logger) func(next http.Handler) http.Handler {
    return func(next http.Handler) http.Handler {
        log = log.With(
            slog.String("component", "middleware/logger"),
        )

        log.Info("logger middleware enabled")

        // код самого обработчика
        fn := func(w http.ResponseWriter, r *http.Request) {
            // собираем исходную информацию о запросе
            entry := log.With(
                slog.String("method", r.Method),
                slog.String("path", r.URL.Path),
                slog.String("remote_addr", r.RemoteAddr),
                slog.String("user_agent", r.UserAgent()),
                slog.String("request_id", middleware.GetReqID(r.Context())),
            )
            
            // создаем обертку вокруг `http.ResponseWriter`
            // для получения сведений об ответе
            ww := middleware.NewWrapResponseWriter(w, r.ProtoMajor)

            // Момент получения запроса, чтобы вычислить время обработки
            t1 := time.Now()
            
            // Запись отправится в лог в defer
            // в этот момент запрос уже будет обработан
            defer func() {
                entry.Info("request completed",
                    slog.Int("status", ww.Status()),
                    slog.Int("bytes", ww.BytesWritten()),
                    slog.String("duration", time.Since(t1).String()),
                )
            }()

            // Передаем управление следующему обработчику в цепочке middleware
            next.ServeHTTP(ww, r)
        }

        // Возвращаем созданный выше обработчик, приведя его к типу http.HandlerFunc
        return http.HandlerFunc(fn)
    }
}

Подключается middleware следующим образом:

router.Use(mwLogger.New(log))

Если вы решили завести себе такой middleware, разместить его рекомендую в internal/http-server/middleware.

Handlers — обработчики запросов


Save — сохранение нового URL


Вот и добрались до главного — обработчиков запросов. Начнем с запроса на сохранение новой записи. Создаем папку internal/http-server/handlers/save и одноименный файл save.go.

Заведем сразу две структуры — Request и Response. В первый будем анмаршалить запрос, а из второго формировать ответ.

// internal/http-server/handlers/url/save/save.go

type Request struct {
    URL   string `json:"url" validate:"required,url"`
    Alias string `json:"alias,omitempty"`
}

type Response struct {
    Status string `json:"status"`
    Error  string `json:"error,omitempty"`
    Alias string `json:"alias"`
}

validate:«required,url» — эта строчка для валидации, об этом будет ниже.

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

Опытный глаз сразу заметит два привычных поля — Status и Error. Как и во многих других API-сервисах, эти поля могут присутствовать в ответе любого хэндлера. А раз так, то имеет смысл их вынести в общий пакет. В моем случае он будет тут: internal/lib/api/response.

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

// internal/lib/api/response/response.go

type Response struct {
    Status string `json:"status"`
    Error  string `json:"error,omitempty"`
}

const (
    StatusOK    = "OK"
    StatusError = "Error"
)

Теперь Response будет выглядеть следующим образом:


// internal/http-server/handlers/url/save/save.go

import (
    // ...

    // для краткости даем короткий алиас пакету
    resp "url-shortener/internal/lib/api/response"
)

type Response struct {
    resp.Response
    Alias string `json:"alias,omitempty"`
}

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

type URLSaver interface {
    SaveURL(URL, alias string) (int64, error)
}

Теперь переходим к самому хендлеру. Для его получения будем использовать конструктор — функцию New.

// internal/http-server/handlers/url/save/save.go

import (
    // ...
    
    // Напоминаю, что тут мы используем алиас для краткости
    resp "url-shortener/internal/lib/api/response"
)

func New(log *slog.Logger, urlSaver URLSaver) http.HandlerFunc {
    return func(w http.ResponseWriter, r *http.Request) {
        const op = "handlers.url.save.New"


        // Добавляем к текущму объекту логгера поля op и request_id
        // Они могут очень упростить нам жизнь в будущем
        log = log.With(
            slog.String("op", op),
            slog.String("request_id", middleware.GetReqID(r.Context())),
        )

        // Создаем объект запроса и анмаршаллим в него запрос
        var req Request

        err := render.DecodeJSON(r.Body, &req)
        if errors.Is(err, io.EOF) {
            // Такую ошибку встретим, если получили запрос с пустым телом
            // Обработаем её отдельно
            log.Error("request body is empty")

            render.JSON(w, r, render.JSON(w, r, resp.Response{
                Status: resp.StatusError,
                Error:  "empty request",
            }))

            return
        }
        if err != nil {
            log.Error("failed to decode request body", sl.Err(err))

            render.JSON(w, r, render.JSON(w, r, resp.Response{
                Status: resp.StatusError,
                Error:  "failed to decode request",
            }))

            return
        }

        // Лучше больше логов, чем меньше - лишнее мы легко сможем почистить,
        // при необходимости. А вот недостающую информацию мы уже не получим.
        log.Info("request body decoded", slog.Any("req", req))

        // ...
    }
}

Объект urlSaver передадим при создании хендлера из main.

Этот код можно сделать немного красивее, если вынести повторяющийся код формирования объекта ответа в общую функцию. Напишем ее в том же пакете response:

// internal/lib/api/response/response.go

func Error(msg string) Response {
    return Response{
        Status: StatusError,
        Error:  msg,
    }
}

func OK() Response {
    return Response{
        Status: StatusOK,
    }
}

Теперь код в save.go будет выглядеть следующим образом:

// internal/http-server/handlers/url/save/save.go

err := render.DecodeJSON(r.Body, &req)
if errors.Is(err, io.EOF) {
    log.Error("request body is empty")

    render.JSON(w, r, resp.Error("empty request")) // <----

    return
}
if err != nil {
    log.Error("failed to decode request body", sl.Err(err))

    render.JSON(w, r, resp.Error("failed to decode request")) // <----

    return
}

Далее нам нужно провалидировать запрос. Один из вариантов — сделать это вручную, проверив, что URL — это действительно URL, что он не пустой. Наш сервис очень маленький, поэтому такого метода вполне достаточно. Но в общем случае лучше использовать специализированный пакет, который сильно упрощает жизнь — например, go-playground/validator. Я покажу, как им пользоваться, а вы сами решайте, что вам больше нравится.

Вспоминаем про validate:«required,url» в объекте Request — он как раз будет использован валидатором. Для валидации нужно проделать следующее:

// internal/http-server/handlers/url/save/save.go

func New(log *slog.Logger, urlSaver URLSaver) http.HandlerFunc {
    return func(w http.ResponseWriter, r *http.Request) {

    // ...

    // Создаем объект валидатора
    // и передаем в него структуру, которую нужно провалидировать
    if err := validator.New().Struct(req); err != nil {
        // Приводим ошибку к типу ошибки валидации
        validateErr := err.(validator.ValidationErrors)
    
        log.Error("invalid request", sl.Err(err))
    
        render.JSON(w, r, resp.Error(validateErr.Error()))
    
        return
    }

В случае некорректного ввода данных, клиент получит такой ответ:

{
    "status": "Error",
    "error": "Key: 'Request.URL' Error:Field validation for 'URL' failed on the 'url' tag"
}

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

// internal/lib/api/response/response.go

func ValidationError(errs validator.ValidationErrors) Response {
    var errMsgs []string

    for _, err := range errs {
        switch err.ActualTag() {
        case "required":
            errMsgs = append(errMsgs, fmt.Sprintf("field %s is a required field", err.Field()))
        case "url":
            errMsgs = append(errMsgs, fmt.Sprintf("field %s is not a valid URL", err.Field()))
        default:
            errMsgs = append(errMsgs, fmt.Sprintf("field %s is not valid", err.Field()))
        }
    }

    return Response{
        Status: StatusError,
        Error:  strings.Join(errMsgs, ", "),
    }
}

Теперь обработчик вернет внятный ответ:

render.JSON(w, r, resp.ValidationError(validateErr))
{
    "status": "Error",
    "error": "field URL is not a valid URL"
}

Alias проверяем вручную. Если он пустой — генерируем случайный:

// internal/http-server/handlers/url/save/save.go

// TODO: move to config when needed
const aliasLength = 6

func New(log *slog.Logger, urlSaver URLSaver) http.HandlerFunc {
    return func(w http.ResponseWriter, r *http.Request) {
        // ...
    
        alias := req.Alias
        if alias == "" {
            alias = random.NewRandomString(aliasLength)
        }
    }
}

Тут можете сгенерировать строку своими методами либо использовать для этого готовую библиотеку. Я же использую random, в котором реализовал функцию NewRandomString:

// internal/lib/random/random.go

// NewRandomString generates random string with given size.
func NewRandomString(size int) string {
    rnd := rand.New(rand.NewSource(time.Now().UnixNano()))

    chars := []rune("ABCDEFGHIJKLMNOPQRSTUVWXYZ" +
        "abcdefghijklmnopqrstuvwxyz" +
        "0123456789")

    b := make([]rune, size)
    for i := range b {
        b[i] = chars[rnd.Intn(len(chars))]
    }

    return string(b)
}

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

Осталось только сохранить URL и Alias, а после — вернуть ответ с сообщением об успехе.

// internal/http-server/handlers/url/save/save.go

func New(log *slog.Logger, urlSaver URLSaver) http.HandlerFunc {
    return func(w http.ResponseWriter, r *http.Request) {
        // ...

        id, err := urlSaver.SaveURL(req.URL, alias)
        if errors.Is(err, storage.ErrURLExists) {
            // Отдельно обрабатываем ситуацию,
            // когда запись с таким Alias уже существует
            log.Info("url already exists", slog.String("url", req.URL))

            render.JSON(w, r, resp.Error("url already exists"))

            return
        }
        if err != nil {
            log.Error("failed to add url", sl.Err(err))

            render.JSON(w, r, resp.Error("failed to add url"))

            return
        }

        log.Info("url added", slog.Int64("id", id))

        responseOK(w, r, alias)
    }
}

Функцию responseOK опишем в этом же файле:

// internal/http-server/handlers/url/save/save.go

func responseOK(w http.ResponseWriter, r *http.Request, alias string) {
    render.JSON(w, r, Response{
        Response: resp.OK(),
        Alias:    alias,
    })
}

Супер — хендлер полностью написан. Если хотите посмотреть его код целиком, можете заглянуть в репозиторий проекта.

Чтобы все это протестировать, напишем простой тест с использованием пакета httptest из стандартной библиотеки. И вместо настоящего Storage будем использовать Mock (мок). На эту тему у меня также есть подробный ролик — там я рассказываю про суть моков и их генерацию.

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

//go:generate go run github.com/vektra/mockery/v2@v2.28.2 --name=URLSaver
type URLSaver interface {
    SaveURL(URL, alias string) (int64, error)
}

После — генерируем сам мок с помощью команды:

./internal/http-server/handlers/url/save/save.go

Теперь напишем сам тест. Рядом с файлом save.go создадим — save_test.go. Там у нас будет классический табличный тест:

// internal/http-server/handlers/url/save/save_test.go
func TestSaveHandler(t *testing.T) {
    cases := []struct {
        name      string // Имя теста
        alias     string // Отправляемый alias
        url       string // Отправляемый URL
        respError string // Какую ошибку мы должны получить?
        mockError error  // Ошибку, которую вернёт мок
    }{
        {
            name:  "Success",
            alias: "test_alias",
            url:   "https://google.com",
            // Тут поля respError и mockError оставляем пустыми,
            // т.к. это успешный запрос
        },
        // Другие кейсы ...
    }

    for _, tc := range cases {  
        t.Run(tc.name, func(t *testing.T) {
            // Создаем объект мока стораджа
            urlSaverMock := mocks.NewURLSaver(t)

            // Если ожидается успешный ответ, значит к моку точно будет вызов
            // Либо даже если в ответе ожидаем ошибку,
            // но мок должен ответить с ошибкой, к нему тоже будет запрос:
            if tc.respError == "" || tc.mockError != nil {
                // Сообщаем моку, какой к нему будет запрос, и что надо вернуть
                urlSaverMock.On("SaveURL", tc.url, mock.AnythingOfType("string")).
                    Return(int64(1), tc.mockError).
                    Once() // Запрос будет ровно один
            }

            // Создаем наш хэндлер
            handler := save.New(sl.NewDiscardLogger(), urlSaverMock)

            // Формируем тело запроса
            input := fmt.Sprintf(`{"url": "%s", "alias": "%s"}`, tc.url, tc.alias)

            // Создаем объект запроса
            req, err := http.NewRequest(http.MethodPost, "/save", bytes.NewReader([]byte(input)))
            require.NoError(t, err)

            // Создаем ResponseRecorder для записи ответа хэндлера
            rr := httptest.NewRecorder()
            // Обрабатываем запрос, записывая ответ в рекордер
            handler.ServeHTTP(rr, req)

            // Проверяем, что статус ответа корректный
            require.Equal(t, rr.Code, http.StatusOK)

            body := rr.Body.String()

            var resp save.Response

            // Анмаршаллим тело, и проверяем что при этом не возникло ошибок
            require.NoError(t, json.Unmarshal([]byte(body), &resp))

            // Проверяем наличие требуемой ошибки в ответе
            require.Equal(t, tc.respError, resp.Error)

            // Другие проверки
        })
    }
}

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

Возвращаемся в main и добавляем наш первый хендлер в роутер:

router.Post("/", save.New(log, storage))

На этом этапе советую остановиться и поиграться с получившимся сервисом. Убедитесь, что он запускается и попробуйте отправить в него «честные» запросы — например, через Postman.

Redirect — перенаправление на сохраненный URL


Переходим к следующему хендлеру — redirect. Это будет GET-запрос, поэтому объект Request здесь не потребуется, как и Response. Ведь возвращать мы тоже ничего не будем, а просто сделаем редирект. Код хендера будет таким:

// cmd/url-shortener/main.go

//go:generate go run github.com/vektra/mockery/v2@v2.28.2 --name=URLGetter
//
// URLGetter is an interface for getting url by alias.
type URLGetter interface {
    GetURL(alias string) (string, error)
}

func New(log *slog.Logger, urlGetter URLGetter) http.HandlerFunc {
    return func(w http.ResponseWriter, r *http.Request) {
        const op = "handlers.url.redirect.New"

        log = log.With(
            slog.String("op", op),
            slog.String("request_id", middleware.GetReqID(r.Context())),
        )

        // Роутер chi позволяет делать вот такие финты -
        // получать GET-параметры по их именам.
        // Имена определяются при добавлении хэндлера в роутер, это будет ниже.
        alias := chi.URLParam(r, "alias")
        if alias == "" {
            log.Info("alias is empty")

            render.JSON(w, r, resp.Error("not found"))

            return
        }

        // Находим URL по алиасу в БД
        resURL, err := urlGetter.GetURL(alias)
        if errors.Is(err, storage.ErrURLNotFound) {
            // Не нашли URL, сообщаем об этом клиенту
            log.Info("url not found", "alias", alias)

            render.JSON(w, r, resp.Error("not found"))

            return
        }
        if err != nil {
            // Не удалось осуществить поиск
            log.Error("failed to get url", sl.Err(err))

            render.JSON(w, r, resp.Error("internal error"))

            return
        }

        log.Info("got url", slog.String("url", resURL))

        // Делаем редирект на найденный URL
        http.Redirect(w, r, resURL, http.StatusFound)
    }
}

В последней строчке делаем редирект со статусом http.StatusFound — код HTTP 302. Он обычно используется для временных перенаправлений, а не постоянных, за которые отвечает 301.

Наш сервис может перенаправлять на разные URL в зависимости от ситуации (мы ведь можем удалить или изменить сохраненный URL), поэтому есть смысл использовать именно http.StatusFound. Это важно для систем кэширования и поисковых машин — они обычно кэшируют редиректы с кодом 301, то есть считают их постоянными. Нам такое поведение не нужно.

Подключаем новый хендлер в main:

router.Get("/{alias}", redirect.New(log, storage))

Здесь формируем путь для обращения и именуем его параметр — {alias}. В хендлере можно получить этот параметр по указанному имени, что мы и сделали выше.

Это очень удобная и гибкая штука. Вы можете формировать и более сложные пути, например:

router.Get("/v1/{user_id}/uid", redirect.New(log, storage))

При запросе вида /v1/1234/uid вы можете извлечь параметр 1234 по имени user_id. Если в будущем формат пути изменится, на код хендлера это никак не повлияет. Главное — сохранить имя параметра.

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

Также можете самостоятельно написать запрос на удаление URL. Подключать его рекомендую так:

r.Delete("/{alias}", remove.New(log, storage))

Путь будет как у редиректа, но тип запроса — DELETE. Это более правильно для REST API. Либо /url/{alias}, если планируете удалять какие-то сущности, кроме URL.

Авторизация


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

Здесь мы реализуем самую простейшую авторизацию HTTP Basic Auth — стандартную проверку по логину и паролю. Если захотите выдать доступы своим друзьям, достаточно просто завести несколько пар «логин-пароль» — это не проблема. Но если вы решите открыть сервис для всех желающих, лучше написать более серьезную систему — возможно, с распределением прав доступа и другими фичами. Либо можете взять готовое решение.

Пару логин-пароль (креды, credentials) будем брать из конфига приложения. Не переживайте, мы НЕ будем хранить пароль в общем конфиге. При деплое будем его пробрасывать через секреты GitHub Actions.

Для начала добавим в объект конфига сервера поля User и Password:

// internal/config/config.go

type HTTPServer struct {
    Address     string        `yaml:"address" env-default:"0.0.0.0:8080"`
    Timeout     time.Duration `yaml:"timeout" env-default:"5s"`
    IdleTimeout time.Duration `yaml:"idle_timeout" env-default:"60s"`
    // Добавляем:
    User        string        `yaml:"user" env-required:"true"`
    Password    string        `yaml:"password" env-required:"true" env:"HTTP_SERVER_PASSWORD"`
}

В свой локальный конфиг проекта можете добавить креды в явном виде:

# config/local.yaml

env: "local"
storage_path: "./storage/storage.db"
http_server:
  address: "localhost:8082"
  timeout: 4s
  idle_timeout: 30s
  user: "my_user"
  password: "my_pass"

Обратите внимание: в продакшен-конфиг добавляем только логин. Пароль важно хранить более безопасным образом — подробнее в разделе про деплой.

В функции main немного изменим регистрацию хендлеров в роутере. Для защищенных хендлеров создадим отдельный вложенный роутер, к которому подключим middleware с авторизацией (он идет вместе с пакетом chi).

// cmd/url-shortener/main.go

// Все пути этого роутера будут начинаться с префикса `/url`
router.Route("/url", func(r chi.Router) {
    // Подключаем авторизацию
    r.Use(middleware.BasicAuth("url-shortener", map[string]string{
        // Передаем в middleware креды
        cfg.HTTPServer.User: cfg.HTTPServer.Password,
        // Если у вас более одного пользователя,
        // то можете добавить остальные пары по аналогии.
    }))

    r.Post("/", save.New(log, storage))
})

// Хэндлер redirect остается снаружи, в основном роутере
router.Get("/{alias}", redirect.New(log, storage))

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


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

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

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

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

  • httpexpect — для тестирования REST API,
  • gofakeit — для генерации случайных данных разного формата (имена, имейлы, номера телефонов, URL и другое).

Установим их:

> go get github.com/brianvoe/gofakeit/v6
> go get github.com/gavv/httpexpect/v2

Функциональные тесты будем размещать в папке tests, которая расположена в корне проекта. Создадим в ней файл url_shortener_test.go и напишем самый простенький тест Happy Path:

// tests/url_shortener_test.go

const (
    host = "localhost:8082"
)

func TestURLShortener_HappyPath(t *testing.T) {
    // Универсальный способ создания URL
    u := url.URL{
        Scheme: "http",
        Host:   host,
    }

    // Создаем клиент httpexpect
    e := httpexpect.Default(t, u.String())

    e.POST("/url"). // Отправляем POST-запрос, путь - '/url'
        WithJSON(save.Request{ // Формируем тело запроса
            URL:   gofakeit.URL(), // Генерируем случайный URL
            Alias: random.NewRandomString(10), // Генерируем случайную строку
        }).
        WithBasicAuth("myuser", "mypass"). // Добавляем к запросу креды авторизации
        Expect(). // Далее перечисляем наши ожидания от ответа
        Status(200). // Код должен быть 200
        JSON().Object(). // Получаем JSON-объект тела ответа
        ContainsKey("alias") // Проверяем, что в нём есть ключ 'alias'
}

Тест делает лишь самые простые проверки, и я советую добавить свои. Например, убедиться, что нам вернули корректный alias.

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

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

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

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


Сервис готов — осталось перенести его со своего локального компьютера на удаленный сервер, чтобы он был доступен 24/7.

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

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


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

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



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

В поле Name указываем имя сервера. Советую указать что-то осмысленное, иначе будете постоянно путаться, если у вас больше одного сервера.

Здесь же сразу добавляем SSH Key — он нам понадобится для деплоя. Пароль не потребуется, но можете его сохранить на всякий случай.

Коротенький ликбез по SSH-ключам
Если вы в этом не разбираетесь, то очень рекомендую изучить вопрос подробнее. Но пока достаточно знать следующее.

Первым делом нам надо сгенерировать ключ — локально, у себя на компьютере. Если у вас Mac или Linux, то это можно сделать следующей командой (с Windows я не дружу, тут вам придется погуглить или обратиться к сообществу):

ssh-keygen -t rsa -b 4096 -C "some comment"

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

В итоге, вы получите два файла:

  • [path]/[key_name] — приватный ключ,
  • [path]/[key_name].pub — публичный ключ.


Содержимое публичного ключа мы передадим серверу. Это можно сделать при его создании (см. скриншоты выше). Содержимое приватного ключа будет использоваться GitHub Actions, туда мы его добавим чуть позже.


Настройка GitHub Workflow


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

Для добавления 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 его перезапустит.

Создадим этот файл внутри проекта — deployment/url-shortener.service. Содержимое файла:

[Unit]
Description=Url Shortener
After=network.target

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

[Install]
WantedBy=multi-user.target

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

# 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. О пароле поговорим ниже

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



GitHub, Settings/Secrets and variables/Actions.

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: http://[your_ip]:[http-port]/url.

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

Заключение


Разработка REST API-сервисов — обширная тема, и в одной статье сложно подробно объяснить все аспекты. Если вам нравятся такие темы, и в целом разработка на Go, подписывайтесь на мой канал. Напомню, что видеоверсия этого материала доступна по ссылке.

У меня много различных проектов и активностей, связанных с разработкой на Go и не только. Проще всего за моей активностью следить через мой Telegram-канал. Анонсы всех статьей, роликов, подкастов и прочего будут там. Кроме того, я пишу там короткие гайды в более свободном формате.

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


  1. lolopat2
    13.07.2023 13:52

    Посмотрел ваше видео, жалко, что нет prometheus метрик и jaegertracing'а. Это довольно несложно делается, зато приносит много пользы.


    1. JustSkiv Автор
      13.07.2023 13:52
      -1

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


      Там также будет разобрана настройка Grafana и Loki, кстати. То есть и метрики, и логи, и алерты будут уходить туда.


      Будет ли статья на эту тему, не уверен. Возможно, только видео.


    1. Konvergent
      13.07.2023 13:52

      Сейчас рекомендуют больше opentelemetry-go использовать для трэсинга.


      1. JustSkiv Автор
        13.07.2023 13:52

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


  1. Plesser
    13.07.2023 13:52
    +2

    Спасибо большое! Видео конечно хорошо, но для такого проекта текст лучше!


    1. JustSkiv Автор
      13.07.2023 13:52
      -1

      Все по разному усваивают гайды — кому-то лучше заходит текст, кому-то видео.


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


      1. Plesser
        13.07.2023 13:52

        Мне конкретно это видео не зашло, потому что ты увеличивал картинку и при переходах вниз вверх я терялся не понимал куда ты "убежал", то ли в этом файле ты переместился то ли вообще ушел в другой файл

        Ну и плюс в России сейчас стало очень печально с продуктами JetBrains, особенно под Linux


        1. JustSkiv Автор
          13.07.2023 13:52
          -1

          В каком смысле увеличивал? В какие-то конкретные моменты, или напротяжении всего видео было слишком крупно?


          1. Plesser
            13.07.2023 13:52

            на протяжении всего видео


            1. JustSkiv Автор
              13.07.2023 13:52
              -1

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


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


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


        1. tempick
          13.07.2023 13:52

          Ну и плюс в России сейчас стало очень печально с продуктами JetBrains, особенно под Linux

          Хз, как на линукс, но на винде просто регается новый аккаунт раз в месяц на временную почту и берется триал на месяц (по крайней мере, c PhpStorm и GoLand работает)


          1. Plesser
            13.07.2023 13:52

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


  1. Gariks
    13.07.2023 13:52

    Честно, внимательно прочитал не всё, остановился на константах env, local, prod... Это не вписывается в методологию разработки облачных приложений и я бы не рекомендовал так делать. Вместо if env prod, лучше поставить конфигурацию из env переменных или config файла, это позволит запускать сервис на любых окружениях с любой конфигурацией. Это может быть полезно, например при разворачивании окружения для нагрузочного тестирования или интеграционного. https://12factor.net/config

    Опять же с моей скромной точки зрения, разработку сервиса стоит начинать с паттерна graceful shutdown


    1. JustSkiv Автор
      13.07.2023 13:52
      +1

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


      А место, в котором я проверяю текущее окружение (if env prod) — это просто сборка логгера, который немного отличается в разных окружениях. Тип текущего окружения берётся всё из того же конфига.


      graceful shutdown — тут да, стоило его упомянуть. Но не беда, расскажу в следующем посте / ролике.


      1. Gariks
        13.07.2023 13:52
        +3

        Я как раз и говорю о том, что группировать настройки по типам окружения в коде плохая затея. Потому что количество окружений может расти независимо от разработчика, например latest, release, qa, stress-test, dima-local, slava-docker-qa... Передавайте настойки логгера так же через env, а файл config.yaml, можно использовать как default values, если настроек очень много. В оркестраторах типа k8s, обычно default values, опрсаывется в helm chart.


        1. JustSkiv Автор
          13.07.2023 13:52

          Окей, теперь понял, что вы имели ввиду. Да, это имеет смысл, и в рабочих проектах я именно так и делаю.


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


  1. savostin
    13.07.2023 13:52

    <зануда>Если сгенерированный alias совпадет с существующим, пользователь получит неправильный и негативный пользовательский опыт - он ничего плохого не делал, а ошибочка вышла. Но тут дилемма - проверять наличие до бесконечности или как-то реализовывать гарантию уникальности нового alias'а, при этом, конечно, не потеряв его случайность. Да и транзакции явно не хватает (даже без SELECT) - random-то у нас pseudo хоть и nano, два одновременных запроса могут и... Да и alias не плохо было бы sanitise...</зануда>


    1. JustSkiv Автор
      13.07.2023 13:52

      Да, есть такой момент, я в ролике об этом тоже говорил. Статью старался сделать не слишком огромной, поэтому некоторые моменты пришлось сократить.


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


  1. Plesser
    13.07.2023 13:52

    Тут ошибочка закралась


    1. Plesser
      13.07.2023 13:52

      И туда же еще одна (Я не придираюсь а хочу помочь вылизать очень полезный гайд)


      1. JustSkiv Автор
        13.07.2023 13:52

        А тут не не понял, в чем ошибка? Вроде, всё ок.


        1. Plesser
          13.07.2023 13:52

          В моем скрине все правильно, в статье отсутствует тег у HTTPServer


          1. JustSkiv Автор
            13.07.2023 13:52

            Понял, спасибо. Поправил тоже.


            1. Plesser
              13.07.2023 13:52

              Еще такое замечание, в приведенном коде иногда отсутствуют импорты, и приходится лазить в гит что бы посмотреть какую именно библиотеку мы подключаем (например в файле sqlite.go и где то выше по тексту мне попадалось). Замечание не критичное, так шероховатость :)


    1. JustSkiv Автор
      13.07.2023 13:52

      Спасибо, поправил


  1. Plesser
    13.07.2023 13:52

    Если кто то будет писать сей код под Windows то необходимо перед запуском программы во первых установить gcc под Windows (я сделал под tdm-gcc-64) и установить флаг gco_enabled в единичку следующей командой

    go env -w CGO_ENABLED=

    Иначе вы не сможете работать с sqlite