Как написать свой REST API на Go?

Как написать свой REST API на Go? Разрабатываем сокращатель ссылок

В статье делимся пошаговой инструкцией, как написать REST API сервис URL Shortener и развернуть готовое приложение на сервере.

Введение

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

Одно из моих последних видео — о разработке сервисов REST API на примере URL Shortener, так называемого «сокращателя ссылок». В статье делюсь подробными инструкциями и советами по этой теме. Рассказываю, как реализовать реальный проект, по какому принципу выбирать HTTP-роутер, как позаботиться о логах и настроить автоматический деплой через GitHub Actions на виртуальный сервер

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

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

  • 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
}

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()
}

Настраиваем логгер

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


    go get golang.org/x/exp/slog

Установка 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, envProd:
        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), чтобы не было коллизий. Все поля обязательные, а для быстрого поиска записей по alias создан индекс idx_alias. 

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

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

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

У нашего хранилища будет всего два метода — 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. Если это так — значит, мы попытались добавить дубликат имеющейся записи.

Тут можно поступить иначе: сначала проверять наличие записи с помощью 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

Middleware

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


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

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


    go get github.com/go-chi/render

В 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 — обработчики запросов

Вот и добрались до главного — обработчиков запросов. Начнем с запроса на сохранение новой записи. Создаем папку 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 на корректность, что он не пустой. Наш сервис очень маленький, поэтому такого метода вполне достаточно. Но на практике лучше использовать специализированный пакет — например, go-playground/validator. Я покажу, как им пользоваться, а вы сами решайте, что вам больше нравится.

Вспоминаем про struct-tag 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 — кодом 302. Он обычно используется для временных перенаправлений, а не постоянных, за которые отвечает 301. 

Наш сервис может перенаправлять на разные 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))

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

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

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


    // 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'
}

Здесь используем две новые библиотеки, которые очень упрощают написание тестов: httpexpect — для тестирования REST API и gofakeit — для генерации случайных данных разного формата.

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

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

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

Если нет желания деплоить проект руками и ходить на сервер при каждом изменении в коде, можно использовать для этого GitHub Actions.

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

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

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

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

Настройка GitHub Actions

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

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

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

    # .github/workflows/deploy.yaml

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

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

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

Секция jobs состоит из двух параметров — deploy и steps. Начнем с самого простого — deploy:


    # .github/workflows/deploy.yaml

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

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

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


    # .github/workflows/deploy.yaml

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

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

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

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

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

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

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

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


    # 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.

Выход в продакшен

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


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

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

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

Заключение

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

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

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

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