Перейти к содержимому
ГоМастер

Поиск по урокам

Найдите урок, курс, термин или раздел

Урок 11 из 25 ~18 мин +20 XP

JSON API: парсим и отдаём JSON

JSON API на go с chi собирается из двух функций стандартной библиотеки: json.NewEncoder отдаёт объекты клиенту, json.NewDecoder разбирает тела запросов. Разберём порядок заголовков и WriteHeader, лимит MaxBytesReader, единый формат 400-ошибок и спор «стриминг против Marshal» — на живом каркасе /notes.

Содержание урока · 9 разделов

Ситуация знакомая: REST-каркас из урока про маршруты и методы отвечает на POST /notes строкой «заметка создана», а фронтенд-разработчик рядом делает JSON.parse этой строки и получает SyntaxError. До сих пор клиентами сервиса были curl и Postman, которые едят любой текст; настоящий клиент ожидает JSON и в ответе, и в запросе. Хорошая новость в том, что json api в go не требует ни одного пакета поверх стандартной библиотеки: json.NewEncoder пишет объекты прямо в ResponseWriter, json.NewDecoder читает тела запросов, а chi не вмешивается ни во что из этого — хендлеры остаются чистым net/http. Но между «вызвать функцию» и «работающий JSON API» лежат четыре ловушки: порядок заголовков и WriteHeader, обработка битых тел, лимит размера и выбор между Encoder и Marshal. Каждую разберём на одном и том же хендлере заметок.

Отдаём JSON в chi: json.NewEncoder и Content-Type#

Ответ JSON-ом — три строки, и порядок в них жёсткий. Сначала w.Header().Set("Content-Type", "application/json") — заголовки можно менять только до первой записи в w. Потом w.WriteHeader(http.StatusCreated) — отправка статус-строки и заголовков клиенту; с этого момента заголовки заморожены, а повторный WriteHeader приводит к строке «superfluous WriteHeader» в логе сервера. Наконец json.NewEncoder(w).Encode(note) — энкодер получает ResponseWriter как io.Writer и пишет сериализованный объект прямо в сеть, без полной копии в памяти. Статус 201 Created — не украшение: он означает «ресурс создан», и клиентские библиотеки, генераторы типов и мониторинг читают его как сигнал. Пропустите WriteHeader — Go отправит 200 по умолчанию, и REST-контракт «создано» потеряется. Пропустите Set Content-Type — самая дорогая из ошибок урока, о ней отдельная врезка ниже. Сам Encode, кроме объекта, дописывает в конец один символ переноса строки — деталь, которая всплывёт в тестах.

Хендлер создания: 201 и JSON в ответ

GO
type noteRequest struct {
        Title string `json:"title"`
        Body  string `json:"body"`
}

type noteResponse struct {
        ID    int    `json:"id"`
        Title string `json:"title"`
}

func createNote(w http.ResponseWriter, r *http.Request) {
        var req noteRequest
        err := json.NewDecoder(r.Body).Decode(&req)
        if err != nil {
                writeErr(w, http.StatusBadRequest, "битый JSON")
                return
        }

        n := saveNote(req.Title, req.Body)

        w.Header().Set("Content-Type", "application/json")
        w.WriteHeader(http.StatusCreated)
        json.NewEncoder(w).Encode(n)
}

Хендлер читает тело через Decoder, создаёт заметку в хранилище и отвечает тройкой «заголовок, статус, тело». Структура noteResponse с тегами json:"id" — поля наружу идут в snake_case, а не в Go-вом Title. saveNote здесь заглушка хранилища: в настоящем сервисе это вызов слоя репозитория. Ответ пишется одним вызовом Encode: объект, перенос строки — готово.

Проверка: порядок записи ответа

Хендлер сначала вызвал json.NewEncoder(w).Encode(note), а уже потом w.Header().Set("Content-Type", "application/json") и w.WriteHeader(201). Что получит клиент?

Парсим тело запроса: json.NewDecoder#

Входящий JSON читает json.NewDecoder(r.Body): декодер наворачивается на поток тела и раскладывает значения в структуру через Decode(&req). Три факта, которые экономят часы. Первый: Decode читает ровно одно JSON-значение — если клиент прислал два объекта подряд, второй останется в теле непрочитанным; для API это нормально, но знать стоит. Второй: пустое тело возвращает io.EOF — ту же ошибку, что и битый JSON, и обе ветки ведут в один ответ 400. Третий: Decode не закрывает тело запроса, и закрывать его в хендлере не нужно — документация net/http прямо снимает эту заботу с хендлера: Server закроет Body сам. Привычка из клиентского кода, где resp.Body закрывает вызывающий, на серверной стороне не работает. Ошибка Decode — почти всегда вина клиента, и код ответа соответствующий: 400 Bad Request, ровно как на битом id из пути в уроке про параметры.

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

GO
func writeErr(w http.ResponseWriter, status int, msg string) {
        w.Header().Set("Content-Type", "application/json")
        w.WriteHeader(status)
        json.NewEncoder(w).Encode(map[string]string{"error": msg})
}

Единый формат ошибок — {"error": "..."} — договор с клиентом: фронтенд пишет один обработчик на все ответы со статусом не 2xx и читает поле error. writeErr сам ставит Content-Type и статус, поэтому вызов в хендлере — одна строка и return. Дальше по курсу этот помощник вырастет в централизованный обработчик ошибок на весь роутер — здесь важен сам контракт.

MaxBytesReader: лимит тела запроса#

Content-Length в заголовке запроса обещает размер, но обещание не гарантия: при chunked-кодировке заголовка нет вообще, а прокси и шлюзы умеют менять тело. Хендлер, который доверяет Decode без лимита, предлагает злоумышленнику отправить гигабайт JSON и съесть столько же памяти. Стандартное лекарство — http.MaxBytesReader(w, r.Body, 1<<20): обёртка над телом с потолком в 1 048 576 байт; запись 1<<20 — идиоматичное «один мегабайт», а число выбирается по самому большому легитимному запросу вашего API. При превышении Read возвращает ошибку «http: request body too large», а с Go 1.19 она имеет тип *http.MaxBytesError и ловится через errors.As. Тонкость из исходников: сервер при превышении помечает соединение на закрытие (Connection: close) и рвёт его после вашего ответа — статус при этом выбираете вы, автоматического 413 у сервера нет.

readJSON: лимит, декод, различение ошибок

GO
func readJSON(w http.ResponseWriter, r *http.Request, dst any) error {
        r.Body = http.MaxBytesReader(w, r.Body, 1<<20)

        err := json.NewDecoder(r.Body).Decode(dst)
        if err != nil {
                var maxErr *http.MaxBytesError
                if errors.As(err, &maxErr) {
                        writeErr(w, http.StatusRequestEntityTooLarge, "тело больше лимита")
                } else {
                        writeErr(w, http.StatusBadRequest, "битый JSON")
                }
                return err
        }

        return nil
}

// в хендлере:
var req noteRequest
if err := readJSON(w, r, &req); err != nil {
        return // ответ уже отправлен
}

Обёртка readJSON собирает всё вместе: лимит, декод и различение причин ошибки. errors.As проверяет, что перед нами именно *http.MaxBytesError, — тогда 413 Request Entity Too Large; иначе битый JSON и 400. После любой ошибки ответ уже написан, поэтому хендлер просто делает return. Проверьте обе ветки на живом сервисе: curl -i с телом в два мегабайта и с обрезанным JSON — запускать нужно локальным Go, песочница сайта сеть не видит.

Ситуация, код ответа и тело#

СитуацияКод ответаТело ответа
Заметка создана201 Created{"id":7,"title":"..."}
Битый JSON или пустое тело400 Bad Request{"error":"битый JSON"}
Тело больше лимита MaxBytesReader413 Request Entity Too Large{"error":"тело больше лимита"}
JSON валиден, но это массив вместо объекта400 Bad Request{"error":"битый JSON"}
Обязательное поле title пустое400 Bad Request{"error":"title обязателен"}

Проверка: превышение лимита

В readJSON лимит http.MaxBytesReader(w, r.Body, 1<<20). Клиент отправил POST с телом 2 МБ, Decode вернул ошибку. Что вернёт клиенту readJSON?

Что увидит клиент?

Прочитайте код и выберите, что он выведет:

func weird(w http.ResponseWriter, r *http.Request) {
        w.Header().Set("Content-Type", "application/json")
        json.NewEncoder(w).Encode(map[string]string{"a": "1"})
        json.NewEncoder(w).Encode(map[string]string{"b": "2"})
}

json.Marshal против json.NewEncoder: когда важна память#

Спор, который делит команды на два лагеря. Стриминговая пара NewEncoder/NewDecoder не держит ответ в памяти целиком: байты уходят в сеть по мере сериализации, и для больших выгрузок это единственный вариант без лишних копий. Против — json.Marshal и Unmarshal: []byte перед глазами, его можно обогатить перед записью, залогировать, сравнить в тесте без HTTP-слоя, положить в кэш. Моё правило: в хендлере — Encoder, потому что ответ пишется один раз и целиком; когда []byte нужен второй раз — Marshal. «Второй раз» — это ответ-конверт {"data": ..., "meta": ...} вокруг marshal-нутых данных, кэширование сериализованного результата, дублирование в лог для отладки. Граница нарушения понятна: маршихэл ради трёх строк ответа — лишняя аллокация и шум. Если теги, omitempty и вложенные структуры у вас со времён урока про JSON продвинутого курса не всплывали — загляните: encoding/json внутри хендлера ведёт себя точно так же.

Гигиена JSON-хендлера#

  • Content-Type: application/json — первой строкой ответа, до WriteHeader и до любой записи в w.
  • Статус несёт смысл: 201 на создание, 400 на мусор от клиента, 413 на тело сверх лимита.
  • Все ошибки — в одном формате {"error": "..."} через writeErr: клиент пишет один обработчик на всё.
  • Каждый вход с телом — через readJSON с http.MaxBytesReader; лимит равен максимуму легитимного запроса.
  • Структуры ответов — отдельные *Response-типы с json-тегами; структуры хранилища наружу не отдаём.
  • Тесты сравнивают JSON после strings.TrimSpace или повторным Unmarshal: Encode дописывает \n.

Собранный каркас проверяется без сети: httptest.NewRequest делает *http.Request с телом из strings.NewReader, httptest.NewRecorder подставляет себя вместо ResponseWriter и запоминает всё, что в него писали. Проверки простые: rec.Code равен 400 на битом теле, Content-Type — application/json, а после Unmarshal rec.Body в структуру поле error содержит ожидаемую строку. Три кейса — битый JSON, превышение лимита, счастливый путь — закрывают обе ветки readJSON. Инструментарий assert-проверок разобран в курсе testify, а системному тестированию хендлеров chi посвящён отдельный урок ближе к финалу курса. Прогон тестов, как и сервер, запускается локальным Go — этого достаточно, чтобы поймать все четыре ловушки урока до деплоя.

Соберите writeErr и readJSON

Восстановите три опоры JSON-ответа: заголовок, энкодер и лимит тела.

func writeErr(w http.ResponseWriter, status int, msg string) { w.Header().Set("", "application/json") w.WriteHeader(status) json.(w).Encode(map[string]string{"error": msg}) } func readJSON(w http.ResponseWriter, r *http.Request, dst any) error { r.Body = http.(w, r.Body, 1<<20) return json.NewDecoder(r.Body).Decode(dst) }

Соберите хендлер по строкам

Расставьте строки хендлера создания в правильном порядке: от чтения тела до записи JSON.

Строки

Ваша программа

Нажимайте на строки слева, чтобы собрать программу

Карточки: JSON в хендлерах chi

Термин

Content-Type: application/json

нажмите, чтобы перевернуть
Определение

Ставится до WriteHeader: заголовки замораживаются первой записью в w. Без него сервер сниффит text/plain, fetch() падает на res.json(), браузер предлагает скачать файл.

1 / 6

Хендлеры каркаса заговорили на JSON: отдача через Encoder с правильным Content-Type и 201, приём через Decoder с 400 в едином формате и лимитом MaxBytesReader на входе. Из практики напоследок: включите лимит даже в пет-проекте — первый же кривой клиент с бесконечным телом окупит десять минут настройки. Следующий шаг — собственный middleware: таймер-лог с RequestID и авторизация по API-ключу уходят из хендлеров в обёртку func(http.Handler) http.Handler, и writeErr остаётся у хендлеров единственной общей заботой.

Песочница урока

Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.

Вывод

Запустите код, чтобы увидеть вывод.

Коротко: что запомнить

  • JSON API в chi — стандартная библиотека: json.NewEncoder пишет ответ в w, json.NewDecoder читает тело; фреймворк в это не вмешивается.
  • Content-Type: application/json ставится до WriteHeader — заголовки замораживаются первой записью в w.
  • Ошибку Decode не прячем: битый JSON и пустое тело — 400 в едином формате {"error": "..."}.
  • http.MaxBytesReader ограничивает тело (1<<20 — мегабайт); превышение ловится errors.As по *http.MaxBytesError и отвечает 413.
  • Decode не закрывает тело: за серверные запросы отвечает net/http, клиентские resp.Body закрывает вызывающий.
  • Encode пишет тело сразу и дописывает \n — двойной вызов даёт два объекта подряд, тесты сравнивайте после TrimSpace.
  • Marshal берите, когда []byte нужен до записи: конверт, кэш, лог, тест; в остальном — стриминг Encoder.

Домашнее задание

Добавьте в каркас POST /notes с парсингом через readJSON: битый JSON отвечает 400 с {"error":"битый JSON"}, тело больше 1 МБ — 413, пустой title — 400. Проверьте curl-ом все три ветки и убедитесь, что Content-Type каждого ответа — application/json.

Личные заметкиПусто
Хранятся только в вашем браузере (Chi · JSON API: парсим и отдаём JSON)

Вопросы и ответы по уроку

Обязательно ли закрывать r.Body после Decode?

Нет: серверные тела закрывает net/http сам — документация снимает это с хендлера, у json.Decoder метода Close нет. Закрывать нужно клиентские ответы в http-клиенте: там resp.Body и его Close — другое место кода. Лимит размера всё равно ставьте: MaxBytesReader про объём, а не про закрытие.

Почему без Content-Type браузер предлагает скачать файл?

Не получив заголовок, сервер сниффит первые 512 байт тела, и JSON угадывается как text/plain; charset=utf-8. Браузер при навигации не знает, что это данные для скрипта, и предлагает загрузку. fetch() в приложении падает раньше: res.json() сверяет MIME и бросает ошибку, не доходя до парсинга.

json.Marshal или json.NewEncoder — что выбрать в хендлере?

Дефолт — Encoder: пишет прямо в ResponseWriter без полной копии в памяти и добавляет завершающий перенос строки. Marshal берите, когда []byte нужен до записи: обогатить ответ конвертом, залогировать, протестировать без HTTP-слоя, положить в кэш. Граница простая: байты нужны второй раз — Marshal.

Термины урока

Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.

Следующая ступень учебного пути

Redis25 уроков~12 чпрактика25 уроков уже доступны

Redis в Go: кэш, сессии и очереди на go-redis

Курс Redis для Go-разработчиков: кэширование запросов, сессии, счётчики, Pub/Sub, Streams и rate limiter. 25 уроков с примерами кода go-redis v9.

  • Подключаться к Redis из Go и настраивать пул соединений
  • Кэшировать результаты запросов и придумывать TTL
  • Работать с хэшами, списками, множествами и ZSET
  • Строить сессии, счётчики, лидерборды и rate limiter