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

Разработка REST API с пакетом net/http

Введение

Пакет net/http — это сердце веб-разработки в Go. Он встроен в язык, невероятно быстрый, надёжный и даёт полный контроль над всем процессом. Многие популярные фреймворки (Gin, Echo, Fiber) построены поверх него, но в 2026 году всё больше разработчиков выбирают чистый net/http для микросервисов, API и даже полноценных приложений — потому что это меньше зависимостей, лучше производительность и проще отладка.

Мы пройдём путь от "Hello World" до реального REST API с middleware, graceful shutdown, обработкой JSON, файлов, ошибок и лучшими практиками. После него вы сможете самостоятельно писать свои веб-сервисы.

Инструменты для разработки REST API

Для тестирования ваших REST API мы рекомендуем использовать следующие инструменты:

  • Postman — Стандартный выбор многих разработчиков для тестирования REST API
  • ApiDog — Платформа для тестирования и документирования REST API. Имеет огромный функционал и интеграции с другими инструментами разработки. Предпочтительнее научиться работать с ним из-за его удобства и мощных возможностей.
  • cURL — Простой и мощный инструмент для тестирования REST API


В данном уроке мы будем использовать стандартный cURL из-за его простоты, но рекомендуем научиться работать с ApiDog, чтобы получить лучший опыт разработки REST API.

Самый простой сервер — Hello World

package main

import (
"fmt"
"net/http"
)

func main() {
// Обработчик для корневого пути "/"
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, "Привет, мир! 🌍\n")
fmt.Fprintf(w, "Твой путь: %s\n", r.URL.Path)
fmt.Fprintf(w, "Метод запроса: %s\n", r.Method)
})

fmt.Println("Сервер запущен на http://localhost:8080")
fmt.Println("Нажми Ctrl+C для остановки")

// Запуск сервера на порту 8080
log.Fatal(http.ListenAndServe(":8080", nil))
}

Что тут происходит?

  • http.HandleFunc("/", handler) — когда приходит запрос на "/", вызывается эта функция.
  • handler имеет сигнатуру func(http.ResponseWriter, *http.Request).
  • ResponseWriter — то, куда ты пишешь ответ (текст, JSON, заголовки).
  • *http.Request — вся информация о запросе: путь, метод, заголовки, тело, параметры.
  • ListenAndServe запускает сервер и блокирует main() (поэтому используем log.Fatal для обработки ошибок).

Для тестирования используйте curl:

curl -X GET http://localhost:8080

# Привет, мир! 🌍
# Твой путь: /
# Метод запроса: GET

HTTP-методы

Когда ты пишешь веб-сервис, браузер или клиент (Postman, curl) отправляет запросы с определённым методом — это слово в начале строки запроса (GET, POST и т.д.). Метод говорит серверу, что именно хочет сделать клиент.

В Go (net/http) ты всегда можешь узнать метод через r.Method и реагировать по-разному.

GET — "Получение данных"

Что делает:
Запрашивает ресурс (данные) с сервера. Не меняет ничего на сервере.

Аналогия:
Зашёл в библиотеку и попросил книгу почитать — книгу тебе дали, но она осталась в библиотеке.

Когда использовать:

  • Получить список пользователей /users
  • Получить одного пользователя /users/42
  • Поиск /search?q=go
  • Главная страница сайта

Важные особенности:

  • Безопасный (не меняет состояние)
  • Идемпотентный (сколько ни вызывай — результат один и тот же)
  • Можно кэшировать
  • Параметры в URL (query string): ?name=Аня&age=25
  • Не должен иметь тело запроса


Идемпоте́нтность («равносильность») — свойство объекта или операции при повторном применении операции к объекту давать тот же результат, что и при первом.

func getUserHandler(w http.ResponseWriter, r *http.Request) {
// Проверяем, что запрос GET
if r.Method != http.MethodGet {
http.Error(w, "Только GET", http.StatusMethodNotAllowed)
return
}
// ... отдаём данные
}

// Или с Go 1.22+

mux := http.NewServeMux()
mux.HandleFunc("GET /users", getUser)

POST — "Создание ресурса"

Что делает:
Отправляет данные на сервер для создания нового ресурса.

Аналогия:
Принёс в библиотеку новую книгу и отдал библиотекарю — теперь она есть в каталоге.

Когда использовать:

  • Регистрация пользователя
  • Создание заказа
  • Отправка формы
  • Загрузка файла

Особенности:

  • Меняет состояние сервера
  • Не идемпотентный (два одинаковых POST — два разных ресурса)
  • Данные в теле запроса (JSON, форма)
  • Обычно возвращает статус 201 Created и новый объект
func createUserHandler(w http.ResponseWriter, r *http.Request) {
// Проверяем, что запрос POST
if r.Method != http.MethodPost {
http.Error(w, "Только POST", http.StatusMethodNotAllowed)
return
}
// Читаем тело, создаём пользователя
w.WriteHeader(http.StatusCreated)
}

// Или с Go 1.22+

mux := http.NewServeMux()
mux.HandleFunc("POST /users", createUser)

PUT — "Замени ресурс полностью"

Что делает:
Полностью заменяет существующий ресурс новыми данными.

Аналогия:
Принёс в библиотеку новую версию книги и сказал: "Замените старую полностью этой".

Когда использовать:

  • Обновление профиля пользователя (все поля)
  • Замена всего объекта

Особенности:

  • Идемпотентный (несколько одинаковых PUT — один и тот же результат)
  • Если ресурса нет — может создать (но лучше использовать POST для создания)
  • Все данные должны быть в теле
func updateUserHandler(w http.ResponseWriter, r *http.Request) {
// Проверяем, что запрос PUT
if r.Method != http.MethodPut {
http.Error(w, "Только PUT", http.StatusMethodNotAllowed)
return
}
// Полная замена пользователя
}

// Или с Go 1.22+

mux := http.NewServeMux()
mux.HandleFunc("PUT /users", updateUserHandler)

PATCH — "Измени часть ресурса"

Что делает:
Обновляет только указанные поля ресурса.

Аналогия:
Пришёл в библиотеку и сказал: "В книге на странице 100 исправьте опечатку".

Когда использовать:

  • Частичное обновление профиля (только email)
  • Изменение статуса заказа

Особенности:

  • Не обязательно идемпотентный (зависит от реализации)
  • В теле — только изменяемые поля (часто JSON Patch или просто объект)
func patchUserHandler(w http.ResponseWriter, r *http.Request) {
// Проверяем, что запрос PATCH
if r.Method != http.MethodPatch {
http.Error(w, "Только PATCH", http.StatusMethodNotAllowed)
return
}
// Обновляем только те поля, что пришли
}

// Или с Go 1.22+

mux := http.NewServeMux()
mux.HandleFunc("PATCH /users", patchUserHandler)

DELETE — "Удали ресурс"

Что делает:
Удаляет ресурс с сервера.

Аналогия:
Попросил библиотекаря убрать книгу с полки навсегда.

Когда использовать:

  • Удаление пользователя
  • Удаление заказа
  • Очистка корзины

Особенности:

  • Идемпотентный (удаляешь существующий — 200/204, удаляешь несуществующий — тоже 204)
  • Обычно без тела
  • Возвращает 204 No Content (если успешно)
func deleteUserHandler(w http.ResponseWriter, r *http.Request) {
// Проверяем, что запрос DELETE
if r.Method != http.MethodDelete {
http.Error(w, "Только DELETE", http.StatusMethodNotAllowed)
return
}
// Удаляем
w.WriteHeader(http.StatusNoContent) // 204
}

// Или с Go 1.22+

mux := http.NewServeMux()
mux.HandleFunc("DELETE /users", deleteUserHandler)

Другие методы (коротко)

  • HEAD — как GET, но без тела ответа (только заголовки). Используют для проверки существования.
  • OPTIONS — спрашивает, какие методы разрешены для пути. Браузеры используют для CORS.
  • TRACE — диагностика (редко).
  • CONNECT — для туннелей (proxies).

Сравнительная таблица

МетодМеняет сервер?Идемпотентный?Тело запросаТипичный статус ответаПример использования
GETНетДаНет200 OKПолучить список пользователей
POSTДаНетДа201 CreatedСоздать нового пользователя
PUTДаДаДа200 OK или 201 CreatedПолная замена профиля
PATCHДаИногдаДа200 OKИзменить только email
DELETEДаДаОбычно нет204 No ContentУдалить пользователя

Лучшие практики в Go

с Go 1.22+ роутингом:

mux.HandleFunc("GET /users/{id}", getUser)
mux.HandleFunc("POST /users", createUser)
mux.HandleFunc("PUT /users/{id}", updateUser)
mux.HandleFunc("PATCH /users/{id}", patchUser)
mux.HandleFunc("DELETE /users/{id}", deleteUser)

Ранняя обработка методов запросов:

func userHandler(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
// получить пользователя
case http.MethodPut:
// полностью заменить
case http.MethodPatch:
// частично обновить
case http.MethodDelete:
// удалить
default:
http.Error(w, "Метод не разрешён", http.StatusMethodNotAllowed)
}
}

Динамические пути

В чистом net/http не было встроенного роутинга с переменными до Go 1.22. С 1.22 появился паттерн-роутинг и актуальный вариант выглядит так:

mux := http.NewServeMux()

mux.HandleFunc("GET /users/{id}", func(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id") // "42"
fmt.Fprintf(w, "Пользователь с ID: %s", id)
})

mux.HandleFunc("GET /files/{path...}", func(w http.ResponseWriter, r *http.Request) {
path := r.PathValue("path") // остаток пути
fmt.Fprintf(w, "Файл: %s", path)
})

http.ListenAndServe(":8080", mux)

Для старых версий — ручная проверка

func userHandler(w http.ResponseWriter, r *http.Request) {
path := r.URL.Path
if len(path) <= 8 || path[:8] != "/users/" {
http.NotFound(w, r)
return
}

id := path[8:]
fmt.Fprintf(w, "Пользователь ID: %s", id)
}

http.HandleFunc("/users/", userHandler)

Читаем данные из запроса

Query-параметры (?name=Аня&age=25)

Query-параметры — это дополнительные данные, которые добавляются в конец URL после знака ?, чтобы передать серверу настройки или фильтры для запроса. Они выглядят так: https://example.com/search?q=golang&page=2&limit=20, где q=golang — поисковый запрос, page=2 — номер страницы, limit=20 — сколько результатов показать, а параметры разделяются символом &.

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


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

mux.HandleFunc("GET /users", func(w http.ResponseWriter, r *http.Request) {
values := r.URL.Query() // map[string][]string

name := values.Get("name") // первое значение или ""
age := values.Get("age")
category := values["category"] // []string — все значения

fmt.Fprintf(w, "Имя: %s, Возраст: %s, Категории: %v", name, age, category)
})

Тестирование с помощью curl:

curl -X GET http://localhost:8080/users?name=Аня&age=25&category=1,2

# Имя: Аня, Возраст: 25, Категории: [1 2]

Заголовки (Headers)

HTTP-заголовки (Headers) — это дополнительные строки в запросе или ответе, которые передают важную информацию о самом запросе/ответе, но не являются основным содержимым (телом).

Представь письмо: тело письма — это текст сообщения, а заголовки — это конверт с данными: от кого, кому, дата, тип содержимого, язык и т.д. Например, заголовок Content-Type: application/json говорит «в теле JSON», Authorization: Bearer token передаёт токен авторизации, User-Agent рассказывает, какой браузер или программа сделала запрос, а Cache-Control управляет кэшированием.

Заголовки нужны, чтобы клиент и сервер могли договориться о формате данных, авторизации, кэшировании, языке, сжатии и многом другом — без них HTTP работал бы очень ограниченно, и многие современные возможности (авторизация, CORS, кэш) были бы невозможны.

Заголовки — это «метка» на посылке, которая помогает правильно её обработать.

mux.HandleFunc("GET /users/{id}", func(w http.ResponseWriter, r *http.Request) {
auth := r.Header.Get("Authorization")
userAgent := r.Header.Get("User-Agent")
contentType := r.Header.Get("Content-Type")

fmt.Fprintf(w, "Auth: %s\nUser-Agent: %s\nContent-Type: %s", auth, userAgent, contentType)
})

Тестирование с помощью cURL:

curl -X GET http://localhost:8080/users/123 -H "Authorization: Bearer token123" -H "User-Agent: MyClient/1.0" -H "Content-Type: application/json"

# Auth: Bearer token123
# User-Agent: MyClient/1.0
# Content-Type: application/json%

Формы (application/x-www-form-urlencoded)

Формы — это способ отправить данные с веб-страницы на сервер, обычно через POST-запрос.

Представь обычную форму на сайте: поля для логина, пароля, комментария или поиска — ты заполняешь их и нажимаешь "Отправить". Браузер собирает все введённые данные и отправляет их серверу либо в теле запроса (как ключ=значение), либо иногда в URL. Они нужны, чтобы пользователь мог взаимодействовать с сайтом: регистрироваться, входить в аккаунт, отправлять сообщения, делать заказы, загружать файлы или искать информацию.


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

func formHandler(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodPost {
r.ParseForm() // парсим форму
login := r.FormValue("login")
password := r.FormValue("password")

fmt.Fprintf(w, "Логин: %s, Пароль: %s", login, password)
} else {
// Показываем форму
fmt.Fprint(w, `
<form method="post">
Логин: <input name="username"><br>
Пароль: <input name="password" type="password"><br>
<button>Войти</button>
</form>
`)
}
}

mux.HandleFunc("GET /users", formHandler)
mux.HandleFunc("POST /users", formHandler)

Тестирование POST-метода с помощью cURL (Метод GET работает в браузере):

curl -X POST http://localhost:8080/users -d "login=anya&password=secret"

# Логин: anya, Пароль: secret

JSON в теле (POST/PUT)

JSON — это простой и лёгкий формат для хранения и передачи данных, который выглядит как текст и легко читается как человеком, так и компьютером. Он состоит из пар «ключ: значение» (как словарь), массивов (списков) и вложенных объектов — например: {"name": "Аня", "age": 25, "hobbies": ["книги", "спорт"]}.

JSON нужен, чтобы разные программы, сервисы и языки программирования могли обмениваться данными: браузер отправляет JSON на сервер, сервер отвечает JSON'ом, мобильное приложение получает данные в JSON, API (как погода или карты) возвращают JSON. Без него было бы сложно "договориться" между фронтендом, бэкендом и внешними сервисами — JSON стал универсальным "языком" интернета для передачи структурированной информации.


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

type CreateUserRequest struct {
Name string `json:"name"`
Email string `json:"email"`
Age int `json:"age"`
}


func main(){
mux.HandleFunc("GET /users", func(w http.ResponseWriter, r *http.Request) {
var req CreateUserRequest
err := json.NewDecoder(r.Body).Decode(&req)
if err != nil {
http.Error(w, "Неверный JSON: "+err.Error(), http.StatusBadRequest)
return
}

// Здесь сохраняем в БД...
fmt.Fprintf(w, "Создан пользователь: %s (%d лет)", req.Name, req.Age)
})
}

Тестирование POST с помощью cURL:

curl -X POST http://localhost:8080/users \
-H "Content-Type: application/json" \
-d '{"name": "anya", "email": "anya@example.com", "age": 25}'


# Создан пользователь: anya (25 лет)

Возаращаем разные ответы

Текст и HTML

Кроме простых строк и JSON формата, можно использовать HTML (Чаще используется пакет html/template для работы с html)

mux.HandleFunc("GET /users", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprint(w, "<h1>Заголовок</h1><p>Текст</p>")
})

JSON

JSON является самым распространённым форматом ответов от сервера

type APIResponse struct {
Success bool `json:"success"`
Data any `json:"data,omitempty"`
Error string `json:"error,omitempty"`
}

// Удобный кастомный метод для отправки JSON ответа от сервера
func sendJSON(w http.ResponseWriter, data any, status int) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
json.NewEncoder(w).Encode(data)
}

// Использование
sendJSON(w, APIResponse{Success: true, Data: user}, http.StatusOK)
sendJSON(w, APIResponse{Success: false, Error: "Не найдено"}, http.StatusNotFound)

Заголовки и статусы

w.Header().Set("Content-Type", "text/plain")
w.Header().Set("X-Custom-Header", "my-value")
w.WriteHeader(http.StatusCreated) // 201
fmt.Fprint(w, "Создано")

Редиректы

Редирект — это перенаправление пользователя на другой URL.

http.Redirect(w, r, "/login", http.StatusFound) // 302
http.Redirect(w, r, "/dashboard", http.StatusPermanentRedirect) // 308

Отправка файлов и статики

// Один файл
http.ServeFile(w, r, "static/resume.pdf")

// Папка со статичными файлами
fileServer := http.FileServer(http.Dir("static"))
http.Handle("/static/", http.StripPrefix("/static/", fileServer))


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

Middleware — сквозная логика для всех хендлеров

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

Представь, что у тебя есть дверь в комнату (хендлер), а middleware — это охранники или помощники перед дверью: один проверяет пропуск (авторизация), другой записывает, кто пришёл (логирование), третий надевает на гостя тапочки (добавляет заголовки CORS), четвёртый измеряет время посещения (метрики). Каждый middleware получает запрос, может что-то с ним сделать, передать дальше следующему middleware или хендлеру, а потом обработать ответ. Они нужны, чтобы не писать одну и ту же повторяющуюся логику (логи, авторизация, сжатие, восстановление после паники) в каждом хендлере отдельно — вместо этого пишешь её один раз в middleware и "надеваешь" на нужные маршруты или весь сервер.


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

// Логирование
func loggingMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
next.ServeHTTP(w, r)
duration := time.Since(start)
log.Printf("%s %s %v", r.Method, r.URL.Path, duration)
})
}

// Авторизация по токену
func authMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
token := r.Header.Get("Authorization")
if token != "Bearer secret-token" {
http.Error(w, "Доступ запрещён", http.StatusUnauthorized)
return
}
next.ServeHTTP(w, r)
})
}

Цепочка middleware

mux := http.NewServeMux()
// ... регистрируем хендлеры

handler := loggingMiddleware(authMiddleware(mux))

http.ListenAndServe(":8080", handler)

CORS

CORS (Cross-Origin Resource Sharing) — это механизм безопасности в браузерах, который по умолчанию запрещает веб-странице (например, сайту на домене example.com) делать запросы к другому домену (api.other.com), чтобы защитить пользователей от злонамеренных сайтов, которые могли бы красть данные.

Без CORS браузер просто блокирует такой запрос и выдаёт ошибку. Сервер может разрешить доступ, отправляя специальные заголовки в ответе (например, Access-Control-Allow-Origin: https://example.com или * для всех), указывая, какие домены, методы (GET, POST) и заголовки разрешены. CORS нужен, чтобы современный веб работал: фронтенд на одном домене мог безопасно общаться с API на другом, но только если сервер явно это разрешит.


CORS — это "пропуск" от сервера браузеру: "Да, этому сайту можно ко мне обращаться".

// CORS
func corsMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization")

if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusOK)
return
}

next.ServeHTTP(w, r)
})
}

Использование CORS в middleware

mux := http.NewServeMux()
// ... регистрируем хендлеры

handler := corsMiddleware(mux)

// Либо же цепочка middleware
handler := loggingMiddleware(authMiddleware(handler))

http.ListenAndServe(":8080", handler)

Полноценный REST API — собираем всё вместе

package main

import (
"encoding/json"
"log"
"net/http"
"strconv"
"sync"
"time"
)

// Структура пользователя
type User struct {
ID int `json:"id"`
Name string `json:"name"`
Email string `json:"email"`
Age int `json:"age"`
}

// Объявляем глобальные переменные
var (
users = make(map[int]User)
nextID = 1
mu sync.RWMutex
)

func main() {
mux := http.NewServeMux()

// API маршруты
mux.HandleFunc("GET /api/users", listUsers)
mux.HandleFunc("POST /api/users", createUser)
mux.HandleFunc("GET /api/users/{id}", getUser)
mux.HandleFunc("PUT /api/users/{id}", updateUser)
mux.HandleFunc("DELETE /api/users/{id}", deleteUser)

// Статическая папка
mux.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.Dir("static"))))

// Middleware цепочка
handler := corsMiddleware(loggingMiddleware(mux))

log.Println("API сервер запущен на :8080")
log.Fatal(http.ListenAndServe(":8080", handler))
}

// loggingMiddleware логирует запросы
func loggingMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
log.Printf("→ %s %s", r.Method, r.URL.Path)
next.ServeHTTP(w, r)
log.Printf("← %s %s %v", r.Method, r.URL.Path, time.Since(start))
})
}

// corsMiddleware обеспечивает CORS
func corsMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization")

if r.Method == "OPTIONS" {
w.WriteHeader(http.StatusOK)
return
}
next.ServeHTTP(w, r)
})
}

// === Хендлеры ===

// listUsers получает список пользователей
func listUsers(w http.ResponseWriter, r *http.Request) {
// Используем мьютекс для безопасного доступа к данным и чтобы избежать гонок данных
mu.RLock()
defer mu.RUnlock()

list := make([]User, 0, len(users))
for _, u := range users {
list = append(list, u)
}

sendJSON(w, map[string]any{"users": list}, http.StatusOK)
}

// createUser создает нового пользователя
func createUser(w http.ResponseWriter, r *http.Request) {
var u User
if err := json.NewDecoder(r.Body).Decode(&u); err != nil {
sendJSON(w, map[string]string{"error": "Неверный JSON"}, http.StatusBadRequest)
return
}

mu.Lock()
u.ID = nextID
nextID++
users[u.ID] = u
mu.Unlock()

sendJSON(w, u, http.StatusCreated)
}

// getUser получает пользователя по ID
func getUser(w http.ResponseWriter, r *http.Request) {
idStr := r.PathValue("id")
id, err := strconv.Atoi(idStr)
if err != nil {
sendJSON(w, map[string]string{"error": "Неверный ID"}, http.StatusBadRequest)
return
}

mu.RLock()
user, exists := users[id]
mu.RUnlock()

if !exists {
sendJSON(w, map[string]string{"error": "Не найдено"}, http.StatusNotFound)
return
}

sendJSON(w, user, http.StatusOK)
}

// updateUser обновляет пользователя по ID
func updateUser(w http.ResponseWriter, r *http.Request) {
idStr := r.PathValue("id")
id, err := strconv.Atoi(idStr)
if err != nil {
sendJSON(w, map[string]string{"error": "Неверный ID"}, http.StatusBadRequest)
return
}

var u User
if err := json.NewDecoder(r.Body).Decode(&u); err != nil {
sendJSON(w, map[string]string{"error": "Неверный JSON"}, http.StatusBadRequest)
return
}

mu.Lock()
if _, exists := users[id]; !exists {
// Явно указываем на Unlock мьютекса, т.к. при defer Unlock в данном может не быть вызван [Пояснение ниже]
mu.Unlock()
sendJSON(w, map[string]string{"error": "Не найдено"}, http.StatusNotFound)
return
}
u.ID = id
users[id] = u
mu.Unlock()

sendJSON(w, u, http.StatusOK)
}

// deleteUser удаляет пользователя по ID
func deleteUser(w http.ResponseWriter, r *http.Request) {
idStr := r.PathValue("id")
id, err := strconv.Atoi(idStr)
if err != nil {
sendJSON(w, map[string]string{"error": "Неверный ID"}, http.StatusBadRequest)
return
}

mu.Lock()
if _, exists := users[id]; !exists {
mu.Unlock()
sendJSON(w, map[string]string{"error": "Не найдено"}, http.StatusNotFound)
return
}
delete(users, id)
mu.Unlock()

sendJSON(w, map[string]string{"message": "Удалено"}, http.StatusOK)
}

// sendJSON утилита для отправки JSON ответа
func sendJSON(w http.ResponseWriter, data any, status int) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
json.NewEncoder(w).Encode(data)
}

Почему мы явно вызываем mu.Unlock(), а не defer?

При return в ветке if функция завершается сразу, и управление выходит из неё. Но вот в чём нюанс с defer:

defer не выполняется сразу при return — он выполняется только в момент реального выхода из функции, то есть после того, как все инструкции до return выполнятся, но перед тем, как стек фрейм функции окончательно очистится.

В этом случае:

mu.Lock()
defer mu.Unlock() // ← Это поставит Unlock в очередь на выполнение при выходе

if _, exists := users[id]; !exists {
sendJSON(...) // ← Выполнится
return // ← Функция "хочет" завершиться
}
// ← Но defer mu.Unlock() сработает ТОЛЬКО ЗДЕСЬ, после return!

То есть:

  1. Выполняется sendJSON(...)
  2. Выполняется return — функция начинает завершаться
  3. Только потом срабатывает defer mu.Unlock()
  4. Мьютекс разблокируется

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

Вариант с явным mu.Unlock() перед return — правильный, потому что:

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


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

Graceful shutdown — красивое завершение сервера

Graceful shutdown — это красивое завершение сервера, которое позволяет серверу завершить все текущие запросы и отключиться без потери данных.

package main

import (
"context"
"log"
"net/http"
"os"
"os/signal"
"syscall"
"time"
)

func runServer() {
srv := &http.Server{
Addr: ":8080",
Handler: handler, // твой mux с middleware
}

// Запуск в горутине
go func() {
log.Println("Сервер запущен на :8080")
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatalf("Ошибка сервера: %v", err)
}
}()

// Ловим сигналы завершения
stop := make(chan os.Signal, 1)
signal.Notify(stop, os.Interrupt, syscall.SIGTERM)

<-stop
log.Println("Получен сигнал завершения...")

// Таймаут на завершение
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()

if err := srv.Shutdown(ctx); err != nil {
log.Printf("Ошибка при shutdown: %v", err)
} else {
log.Println("Сервер gracefully остановлен")
}
}

Лучшие практики 2026 года

  1. Используй http.NewServeMux с паттернами (Go 1.22+).
  2. Всегда проверяй метод и возвращай правильные статусы.
  3. Обрабатывай ошибки парсинга (JSON, формы).
  4. Устанавливай Content-Type.
  5. Middleware — для логов, CORS, авторизации, паник-рекавери.
  6. Graceful shutdown — обязательно в продакшене.
  7. Context — для отмены долгих операций (БД, внешние API).
  8. Лимиты на тело запросаr.Body = http.MaxBytesReader(w, r.Body, 10<<20) (10 MB).
  9. HTTPS — в проде используй TLS: srv.ListenAndServeTLS(cert, key).