Перейти к основному содержимому

Первая программа на Gin

Разработчику Архитектору

Первая программа на Gin

Где применяют Gin

HTTP — протокол, по которому браузер и мобильное приложение обращаются к серверу — метод (GET, POST, DELETE), URL (/api/notes), заголовки и тело (часто JSON).

REST API — набор URL + методов для работы с ресурсами (заметки, пользователи) без HTML-страниц. Клиент шлёт JSON, сервер отвечает JSON и кодом статуса (200, 201, 404).

Gin — популярный фреймворк поверх стандартного пакета Go net/http. Он упрощает маршруты, разбор JSON, группы URL и middleware (цепочка функций "до и после" handler).

Обзор Gin, Echo, Fiber: Фреймворки и библиотеки Go. Соседние туториалы: Echo · Fiber. Тесты handlers: Тестирование в Go. Чистый net/http: Пример микросервиса.


Что получится

Мини-сервис "Заметки" на порту 8080 — health-check, список заметок, создание и удаление. Данные в памяти (слайс), защита от гонок через sync.Mutex.


Термины

ТерминПростыми словами
Маршрут (route)Связка "метод + путь" → функция-обработчик
HandlerФункция, которая читает запрос и пишет ответ
gin.ContextВсё про текущий запрос: параметры URL, тело, ответ
MiddlewareПрослойка: лог, recovery от паники, авторизация
JSON-тегjson:"text" — имя поля в JSON
bindingПравила проверки тела запроса (обязательное поле, длина)
:id в путиПараметр, например /notes/5id = "5"

Инициализация модуля

mkdir gin-notes && cd gin-notes
go mod init example.com/gin-notes
go get github.com/gin-gonic/gin

Разбор:

  • mkdir gin-notes && cd gin-notes создаёт отдельную папку проекта и сразу делает её текущей рабочей директорией.
  • go mod init example.com/gin-notes инициализирует модуль Go, после чего зависимости и версия языка управляются через go.mod.
  • go get github.com/gin-gonic/gin скачивает пакет Gin и фиксирует его в графе зависимостей, чтобы сборка воспроизводилась на другой машине.
  • Команды выполняются один раз на старте проекта; дальше работа идёт через go run, go test и обычное редактирование кода.
КомандаСмысл
go mod init example.com/gin-notesСоздаёт go.mod — имя модуля и список зависимостей
go get github.com/gin-gonic/ginСкачивает Gin и записывает версию в go.mod

example.com/... — учебный идентификатор; в реальном проекте обычно домен компании (github.com/org/app).


REST API "Заметки"

Полный main.go:

Код ITЗагрузка примера кода…

Разбор:

  • package main и функция main() определяют точку входа исполняемого приложения.
  • Блок import подтягивает стандартные пакеты (net/http, strconv, sync) и внешний фреймворк gin.
  • Структура Note описывает форму данных и контракт JSON-ответов через теги json:"...".
  • Глобальные notes, nextID, mu формируют учебное in-memory хранилище и защиту от гонок между конкурентными запросами.
  • gin.Default() поднимает роутер с полезными middleware по умолчанию, затем регистрируются маршруты GET/POST/DELETE.
  • r.Run(":8080") запускает HTTP-сервер и блокирует текущую горутину до остановки процесса.

Структура Note и теги

type Note struct {
ID int `json:"id"`
Text string `json:"text" binding:"required,min=1,max=500"`
}

Разбор:

  • type Note struct объявляет пользовательский составной тип для хранения заметки.

  • Поле ID int хранит числовой идентификатор и сериализуется как id в JSON.

  • Поле Text string хранит содержимое заметки и сериализуется как text.

  • Тег binding:"required,min=1,max=500" активирует валидацию при разборе тела запроса: поле обязательно и ограничено по длине.

  • Такая структура одновременно используется и для входных данных, и для ответа API, что упрощает учебный пример.

  • json:"id" — при c.JSON поле уйдёт в JSON как "id", не ID.

  • binding:"required,min=1,max=500" — при ShouldBindJSON Gin проверит: поле есть, длина текста от 1 до 500 символов.


Память и mutex

var (
notes []Note
nextID = 1
mu sync.Mutex
)

Разбор:

  • var (...) группирует связанные глобальные переменные в одном блоке.
  • notes []Note — слайс, в котором хранятся все заметки в памяти процесса.
  • nextID = 1 — простой счётчик для выдачи нового идентификатора при создании заметки.
  • mu sync.Mutex — мьютекс, который сериализует доступ к общим данным между горутинами.
  • Такой подход подходит для демо и локального запуска; в боевом сервисе данные обычно выносят в БД.

Несколько HTTP-запросов обрабатываются параллельно. Без блокировки два POST могут одновременно менять notes и nextIDгонка данных. mu.Lock() / defer mu.Unlock() в handler гарантируют, что в один момент времени список меняет только одна горутина.


main — маршруты и группа

СтрокаСмысл
gin.Default()Роутер + встроенные middleware Logger и Recovery
r.GET("/health", ...)GET → JSON {"status":"ok"}
r.Group("/api")Префикс: реальные пути /api/notes, не /notes
api.DELETE("/notes/:id", ...):id — сегмент URL, читается через c.Param("id")
r.Run(":8080")Слушает http://127.0.0.1:8080

gin.H{"status": "ok"} — сокращение для map[string]interface{}.


Handlers — разбор

Список:

func listNotes(c *gin.Context) {
mu.Lock()
defer mu.Unlock()
c.JSON(http.StatusOK, notes)
}

Разбор:

  • Handler принимает *gin.Context, через который читает запрос и формирует ответ.
  • mu.Lock() захватывает блокировку перед чтением общего слайса, чтобы избежать гонок.
  • defer mu.Unlock() гарантирует освобождение блокировки при любом выходе из функции.
  • c.JSON(http.StatusOK, notes) отправляет клиенту код 200 и массив заметок в формате JSON.
  • Функция короткая и предсказуемая: только чтение данных без изменения состояния.

defer mu.Unlock() — разблокировка при выходе из функции, даже если позже добавите return с ошибкой.

Создание:

func createNote(c *gin.Context) {
var body Note
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
mu.Lock()
body.ID = nextID
nextID++
notes = append(notes, body)
mu.Unlock()
c.JSON(http.StatusCreated, body)
}

Разбор:

  • var body Note создаёт переменную для данных из JSON-тела запроса.

  • c.ShouldBindJSON(&body) десериализует входной JSON и одновременно запускает проверку binding-тегов.

  • Ветка if err != nil возвращает клиенту 400 и текст ошибки в JSON, после чего выполнение останавливается через return.

  • После успешного bind код захватывает mu, присваивает ID, увеличивает nextID и добавляет объект в notes.

  • c.JSON(http.StatusCreated, body) возвращает 201 Created и созданную сущность как подтверждение результата операции.

  • ShouldBindJSON(&body) — читает тело запроса в struct и применяет binding-теги.

  • При ошибке — 400 Bad Request и обязательный return, иначе код пойдёт дальше с пустым телом.

  • 201 Created — стандартный код "ресурс создан".

Удаление:

func deleteNote(c *gin.Context) {
id, err := strconv.Atoi(c.Param("id"))
// ...
notes = append(notes[:i], notes[i+1:]...)
c.Status(http.StatusNoContent)
}

Разбор:

  • c.Param("id") получает параметр пути :id как строку из URL.
  • strconv.Atoi(...) конвертирует строку в число; при ошибке обычно возвращают 400.
  • Внутри цикла ищется индекс заметки с нужным ID.
  • append(notes[:i], notes[i+1:]...) собирает новый слайс без удаляемого элемента.
  • c.Status(http.StatusNoContent) возвращает 204 без тела, что удобно для успешного удаления.

strconv.Atoi — строка "5" → число 5. Удаление из слайса: два append "вырезают" элемент по индексу. 204 No Content — успех без тела ответа.

Запуск:

go run .

Разбор:

  • go run . компилирует текущий модуль и сразу запускает получившийся бинарник без явного go build.
  • Точка . означает "собирать пакет из текущей директории".
  • Команда удобна для локальной разработки, когда важна скорость цикла "изменил код -> проверил".
  • Если порт уже занят, запуск завершится ошибкой bind и это сигнал проверить фоновые процессы.

Проверка через curl

curl http://127.0.0.1:8080/health
curl http://127.0.0.1:8080/api/notes
curl -X POST http://127.0.0.1:8080/api/notes \
-H "Content-Type: application/json" \
-d '{"text":"Изучить Gin"}'
curl -X DELETE http://127.0.0.1:8080/api/notes/1

Разбор:

  • Первый запрос проверяет liveness endpoint и быстрый отклик сервера.
  • Второй запрос читает текущую коллекцию заметок и помогает проверить сериализацию массива.
  • POST передаёт JSON-тело и заголовок Content-Type, чтобы сервер корректно распарсил payload.
  • Последний DELETE проверяет работу маршрута с параметром пути и логику удаления по id.
  • Последовательный запуск этих команд покрывает базовый жизненный цикл ресурса: create/read/delete.
КомандаЧто проверяет
ПерваяСервер жив (200, JSON status)
ВтораяПустой или текущий список заметок
POSTСоздание; заголовок Content-Type нужен для JSON
DELETEУдаление заметки с id=1

В PowerShell кавычки в -d иногда экранируют иначе — удобно использовать одинарные снаружи для JSON.


Свой middleware

Middleware в Gin — функция, возвращающая gin.HandlerFunc. Обычно вызывают c.Next() — передать управление следующему звену цепочки.

func requestLogger() gin.HandlerFunc {
return func(c *gin.Context) {
c.Next()
// после всех handler — статус уже известен:
// c.Writer.Status(), c.Request.Method, c.Request.URL.Path
}
}

// r.Use(requestLogger())

Разбор:

  • Функция возвращает gin.HandlerFunc, то есть совместимый middleware-обработчик для Gin.
  • Внутренняя функция получает *gin.Context и может выполнять логику до и после c.Next().
  • c.Next() передаёт управление следующему middleware или конечному handler в цепочке.
  • После возврата из c.Next() доступны итоговый статус и детали запроса, что удобно для логирования метрик.
  • Строка r.Use(requestLogger()) показывает подключение middleware глобально ко всем маршрутам.

gin.Default() уже подключает лог запросов и Recovery (перехват паники, чтобы сервер не падал целиком).


Graceful shutdown

При остановке процесса (Ctrl+C, деплой) обрывать активные запросы резко плохо. Graceful shutdown — дать handler’ам до N секунд завершиться.

Код ITЗагрузка примера кода…

Разбор:

  • Дополнительные импорты (context, os/signal, syscall, time) нужны для контролируемого завершения процесса.
  • http.Server{Handler: r} оборачивает Gin-роутер в стандартный сервер и даёт больше контроля над жизненным циклом.
  • signal.Notify(...) подписывает канал quit на системные сигналы завершения (SIGINT, SIGTERM).
  • context.WithTimeout(..., 5*time.Second) задаёт верхнюю границу ожидания активных запросов.
  • srv.Shutdown(ctx) прекращает приём новых соединений и мягко завершает текущие до истечения таймаута.
ЧастьСмысл
http.Server{Handler: r}Gin-роутер совместим с http.Handler
signal.NotifyОжидание SIGINT/SIGTERM
Shutdown(ctx)Прекращает приём новых соединений, ждёт текущие

Частые ошибки

СимптомПричина
404 на POSTНеверный метод или путь без префикса /api
bind не заполняет structНет json:"..." или неверный Content-Type
Паника в handlerОшибка после ShouldBindJSON без return
Пустой список после двух POSTГонка без mutex (в учебнике mutex есть — проверьте копии кода)

Что попробовать

  1. Группа v1 := r.Group("/api/v1") — версионирование API.
  2. Сравните тот же API на Echo.
  3. Напишите тест из Тестирование в Go: httptest + router.ServeHTTP.

Что часто упускают в первом API на Gin

Единый формат ошибок

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

{
"error": {
"code": "validation_error",
"message": "text is required"
}
}

Разбор:

  • Формат с вложенным объектом error делает структуру ответа расширяемой без ломающих изменений.
  • Поле code предназначено для машинной обработки на клиенте и маршрутизации сценариев в UI.
  • Поле message даёт человекочитаемое описание, полезное для логов и интерфейса.
  • Единый шаблон стоит применять во всех ошибках (400, 404, 409, 500), чтобы фронтенд не писал ветвления под каждый endpoint.
  • Такая стандартизация упрощает observability и поддержку API на длинной дистанции.

Даже в учебном проекте полезно ввести один формат ответа для 400, 404 и 500. Это упрощает фронтенд и ускоряет отладку.


Пагинация списка

Endpoint GET /api/notes быстро становится тяжёлым. Базовая версия с limit и offset:

limit, _ := strconv.Atoi(c.DefaultQuery("limit", "20"))
offset, _ := strconv.Atoi(c.DefaultQuery("offset", "0"))

Разбор:

  • c.DefaultQuery("limit", "20") читает query-параметр limit, а при отсутствии подставляет значение по умолчанию.
  • c.DefaultQuery("offset", "0") аналогично задаёт сдвиг для выборки.
  • strconv.Atoi преобразует строковые значения query в числа для дальнейшей работы с пагинацией.
  • Игнорирование ошибки через _ допустимо в учебной версии, но в проде лучше валидировать и возвращать 400 при нечисловом вводе.
  • Пара limit/offset помогает контролировать размер ответа и предсказуемо нагружать сервер.

Пагинация сразу закладывает масштабирование API и снижает риск "случайно отдали 100000 записей одним ответом".


Логика идемпотентности

POST обычно не идемпотентен, а DELETE /notes/:id должен быть идемпотентным: повторный запрос на уже удалённый объект возвращает либо 204, либо 404 по политике команды. Главное — закрепить правило и применять его одинаково.


Контекст и таймауты

Если handler вызывает базу данных или внешний API, передавайте c.Request.Context() дальше в сервисы. Тогда отмена запроса клиентом завершает и внутренние операции.


Мини-чеклист качества для Gin-сервиса

  • Есть gin.Recovery() и request logging.
  • Все ветки ошибок завершаются return.
  • Данные защищены от гонок (mutex) или вынесены в потокобезопасный слой.
  • Валидация входных данных отделена от бизнес-логики.
  • Есть хотя бы 3 теста — happy path, validation error, not found.

Дополнительные сниппеты с разбором

Пример — middleware для API key

Код ITЗагрузка примера кода…

Разбор:

  • apiKeyGuard(expected string) создаёт middleware-фабрику, чтобы ключ можно было передать параметром при регистрации.
  • c.GetHeader("X-API-Key") читает значение конкретного HTTP-заголовка из входящего запроса.
  • Ветка с StatusUnauthorized (401) обрабатывает отсутствие ключа как неаутентифицированный запрос.
  • Ветка с StatusForbidden (403) отделяет ситуацию "ключ есть, но неверный".
  • c.Abort() останавливает цепочку middleware и не даёт запросу дойти до handler.
  • c.Next() вызывается только для валидных запросов и передаёт управление маршруту.

Пример — валидация query-параметров пагинации

func parsePagination(c *gin.Context) (limit, offset int, err error) {
limit, err = strconv.Atoi(c.DefaultQuery("limit", "20"))
if err != nil || limit <= 0 || limit > 100 {
return 0, 0, fmt.Errorf("limit must be in range 1..100")
}

offset, err = strconv.Atoi(c.DefaultQuery("offset", "0"))
if err != nil || offset < 0 {
return 0, 0, fmt.Errorf("offset must be >= 0")
}
return limit, offset, nil
}

Разбор:

  • Функция инкапсулирует разбор параметров и убирает дублирование кода по handlers.
  • DefaultQuery задаёт безопасные значения по умолчанию, если клиент не передал параметры.
  • Проверка диапазона limit защищает сервис от слишком тяжёлых ответов.
  • Проверка offset < 0 предотвращает некорректные смещения и ошибки логики выборки.
  • Возврат error позволяет handler явно решить, какой HTTP-код отправить клиенту.
  • Такой helper удобно покрыть unit-тестами отдельно от HTTP-слоя.

Ключевые тезисы

  • Gin ускоряет запуск REST API за счёт удобной маршрутизации и binding.
  • Потокобезопасность важна даже в учебных примерах, если есть общее состояние.
  • Качество API повышают единый формат ошибок, пагинация и тесты handlers.

Мини-практикум

  1. Добавьте маршрут GET /api/notes/:id с ответами 200 и 404.
  2. Реализуйте пагинацию для GET /api/notes через limit и offset.
  3. Напишите тесты httptest на создание и удаление заметки.

Типичные ошибки

  • ShouldBindJSON вернул ошибку, но handler продолжил выполнение.
  • Роуты зарегистрированы без /api, а клиент стучится с префиксом.
  • Ответы 400/404/500 имеют разный формат и усложняют клиентскую логику.

Версии REST API

Несколько версий контракта в одном сервисе:

ПодходПримерПлюсы
Prefix в URL/v1/notes, /v2/notesЯвно, просто в Gin
HeaderAccept: application/vnd.app.v2+jsonЧистый URL
Subrouterv1 := r.Group("/v1")Изоляция middleware
v1 := r.Group("/v1")
{
v1.GET("/contacts", listContactsV1)
}
v2 := r.Group("/v2")
{
v2.GET("/contacts", listContactsV2)
}

Разбор:

  • Группы Gin задают общий prefix и middleware только для версии.
  • Старый клиент продолжает ходить на /v1, новый — на /v2.
  • Breaking changes не ломают существующие интеграции.

Swagger (OpenAPI)

Документация REST из комментариев — swaggo/swag:

// @Summary Список заметок
// @Tags notes
// @Produce json
// @Success 200 {array} Note
// @Router /api/notes [get]
func listNotes(c *gin.Context) { /* ... */ }
go install github.com/swaggo/swag/cmd/swag@latest
swag init -g cmd/api/main.go

Разбор:

  • Аннотации @Summary, @Router парсятся в docs/swagger.json.
  • gin-swagger отдаёт UI на /swagger/index.html.
  • Контракт живёт рядом с handler — обновляйте комментарии при изменении API.

Общая теория REST — REST, GraphQL и gRPC.


Загрузка и скачивание файлов

Бинарные тела в REST:

Код ITЗагрузка примера кода…

Разбор:

  • FormFile читает multipart/form-data; лимит размера задают MaxMultipartMemory у Engine.
  • filepath.Base отсекает ../ в имени файла — базовая защита от path traversal.
  • FileAttachment выставляет Content-Disposition: attachment для скачивания.

Дальше


Основа по протоколу

Базовый разбор HTTP и HTTPS находится в отдельной статье — HTTP как основа веб-интеграций.


В подборках

Статья входит в тематические подборки и блок "С чего начать?" на главной. Соседние шаги того же маршрута:

Первые шаги (маршрут подборки) — Spring Boot на Kotlin — первая программа, Первая программа на Echo, Compose Multiplatform — первая программа, Первая программа на Fiber, Ktor — первая программа, Первая программа на Axum.