Первая программа на 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/5 → id = "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"— приShouldBindJSONGin проверит: поле есть, длина текста от 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 есть — проверьте копии кода) |
Что попробовать
- Группа
v1 := r.Group("/api/v1")— версионирование API. - Сравните тот же API на Echo.
- Напишите тест из Тестирование в 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.
Мини-практикум
- Добавьте маршрут
GET /api/notes/:idс ответами200и404. - Реализуйте пагинацию для
GET /api/notesчерезlimitиoffset. - Напишите тесты
httptestна создание и удаление заметки.
Типичные ошибки
ShouldBindJSONвернул ошибку, но handler продолжил выполнение.- Роуты зарегистрированы без
/api, а клиент стучится с префиксом. - Ответы
400/404/500имеют разный формат и усложняют клиентскую логику.
Версии REST API
Несколько версий контракта в одном сервисе:
| Подход | Пример | Плюсы |
|---|---|---|
| Prefix в URL | /v1/notes, /v2/notes | Явно, просто в Gin |
| Header | Accept: application/vnd.app.v2+json | Чистый URL |
| Subrouter | v1 := 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для скачивания.
Дальше
- Подключить GORM / sqlx вместо слайса в памяти;
- конфиг через Viper;
- Docker — см. Пример микросервиса на Go.
Базовый разбор HTTP и HTTPS находится в отдельной статье — HTTP как основа веб-интеграций.
В подборках
Статья входит в тематические подборки и блок "С чего начать?" на главной. Соседние шаги того же маршрута:
Первые шаги (маршрут подборки) — Spring Boot на Kotlin — первая программа, Первая программа на Echo, Compose Multiplatform — первая программа, Первая программа на Fiber, Ktor — первая программа, Первая программа на Axum.