Во многих фреймворках тело HTTP-запроса превращается в объект автоматически: написал аннотацию — и данные уже в переменной. В Go этого нет. Тело запроса — это поток байт, и ты сам его читаешь, декодируешь и проверяешь на ошибки. Звучит как лишняя работа, но на практике это значит: в коде видно ровно то, что происходит. Никаких неожиданностей.
Что такое обработчик
В Go обработчик HTTP-запроса — это просто функция с определённой сигнатурой:
func(w http.ResponseWriter, r *http.Request)
Два аргумента, и этого достаточно для любого HTTP:
w http.ResponseWriter— куда писать ответ: статус, заголовки, тело.r *http.Request— что пришло: метод, URL, заголовки, тело запроса.
Обычно обработчики группируют в структуру, чтобы передавать зависимости (например, сервис или репозиторий):
type ProductHandler struct {
service *ProductService
}
func (h *ProductHandler) create(w http.ResponseWriter, r *http.Request) {
// здесь читаем запрос и пишем ответ
}
Структура типа http.HandlerFunc — это просто псевдоним для такой функции. Любую функцию с правильной сигнатурой можно передать роутеру напрямую.
Как читать JSON из запроса
Тело запроса (r.Body) — это io.Reader, поток байт. Чтобы превратить его в структуру Go, используют json.Decoder:
func decodeJSON(r *http.Request, dst any) error {
dec := json.NewDecoder(r.Body)
dec.DisallowUnknownFields()
if err := dec.Decode(dst); err != nil {
return fmt.Errorf("decode json: %w", err)
}
return nil
}
Что здесь важно:
json.NewDecoder(r.Body)— создаём декодер поверх тела запроса.dec.DisallowUnknownFields()— если в JSON придёт поле, которого нет в структуре, декодер вернёт ошибку вместо того чтобы молча проигнорировать. Это помогает поймать опечатки в именах полей.dec.Decode(dst)— заполняет переданную структуру данными из JSON.
Структура для входящего запроса описывается с json-тегами:
type CreateProductRequest struct {
Name string `json:"name"`
Price int `json:"price"`
}
Тег json:"name" связывает поле Go с именем в JSON. Экспортируемые поля (с заглавной буквы) участвуют в сериализации, неэкспортируемые — нет.
Как отправить JSON в ответ
Отправить JSON — тоже явная операция: выставить заголовок, записать статус, закодировать тело:
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
if err := json.NewEncoder(w).Encode(v); err != nil {
slog.Error("encode response", "err", err)
}
}
Порядок здесь принципиален:
- Сначала выставляем заголовки (
w.Header().Set(...)). - Потом записываем статус (
w.WriteHeader(status)). - Потом пишем тело.
После вызова WriteHeader изменить заголовки уже нельзя — они отправлены. Нарушить этот порядок легко, поэтому лучше сразу вынести writeJSON в отдельный helper и использовать его везде.
Раздельные структуры запроса и ответа
Частая ошибка у начинающих — использовать одну структуру и для входящих данных, и для ответа, и как внутреннее представление. Это приводит к тому, что в ответ попадают поля, которые клиент не должен видеть (например, хэш пароля или внутренний идентификатор).
Правило простое: три разные структуры, три разные задачи.
// Что клиент присылает
type CreateProductRequest struct {
Name string `json:"name"`
Price int `json:"price"`
}
// Что живёт внутри сервиса
type Product struct {
ID int
Name string
Price int
}
// Что сервис возвращает клиенту
type ProductResponse struct {
ID int `json:"id"`
Name string `json:"name"`
Price int `json:"price"`
}
func toProductResponse(p Product) ProductResponse {
return ProductResponse{ID: p.ID, Name: p.Name, Price: p.Price}
}
Явная функция toProductResponse — точка, где контролируется, что попадёт в ответ. Добавишь поле во внутреннюю структуру — в ответ оно автоматически не просочится.
Полный пример обработчика
Собираем всё вместе — обработчик, который принимает запрос на создание продукта:
func (h *ProductHandler) create(w http.ResponseWriter, r *http.Request) {
var req CreateProductRequest
if err := decodeJSON(r, &req); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
product, err := h.service.Create(r.Context(), req.Name, req.Price)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal error")
return
}
writeJSON(w, http.StatusCreated, toProductResponse(product))
}
Структура всегда одна: прочитали запрос → вызвали сервис → записали ответ. Бизнес-логики в обработчике нет — он только переводит HTTP в вызов и обратно.
Коротко
- Обработчик в Go — функция
func(http.ResponseWriter, *http.Request). Никакой магии, только два аргумента. - JSON читают через
json.Decoder.DisallowUnknownFields()защищает от молчаливого игнорирования лишних полей. - Ответ пишут явно: сначала заголовки, потом
WriteHeader, потом тело — только в этом порядке. - Для запроса, внутренней модели и ответа — отдельные структуры. Явная функция маппинга контролирует, что попадёт к клиенту.
- Вынеси
decodeJSONиwriteJSONв helpers — не повторяй один и тот же код в каждом обработчике.
Что почитать дальше
- Роутинг с chi — как регистрировать обработчики и группировать маршруты.
- Ошибки и HTTP-ответы — как единообразно возвращать ошибки клиенту.
- Валидация — что и как проверять в данных запроса.