← назад к разделу

Во многих фреймворках тело 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)
    }
}

Порядок здесь принципиален:

  1. Сначала выставляем заголовки (w.Header().Set(...)).
  2. Потом записываем статус (w.WriteHeader(status)).
  3. Потом пишем тело.

После вызова 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-ответы — как единообразно возвращать ошибки клиенту.
  • Валидация — что и как проверять в данных запроса.