Содержание урока · 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 в ответ
GOtype 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, а фронтенд падает с Unexpected token — виной отсутствие заголовка. Без явного Content-Type сервер снифит первые 512 байт тела и для JSON честно угадывает text/plain; charset=utf-8. Дальше расхождение: браузер при навигации не понимает, что это данные, и предлагает скачать файл; fetch() в приложении вызывает res.json(), тот сверяет MIME и бросает ошибку до парсинга. Баг всплывает не на бэкенде, а «где-то во фронте», и стоит полдня перекрёстных разборов. Привычка одна: Content-Type ставится первой строкой ответа, до WriteHeader — и закрепляется тестом.
Проверка: порядок записи ответа
Хендлер сначала вызвал 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
GOfunc 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: лимит, декод, различение ошибок
GOfunc 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"} |
| Тело больше лимита MaxBytesReader | 413 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.Encoder.Encode после объекта дописывает \n — задокументированное поведение энкодера. В тесте сравнение тела со строкой {"id":7} упадёт: в ответе {"id":7}\n. Лечится strings.TrimSpace(rec.Body.String()) перед сравнением или повторным Unmarshal в структуру. И помните про predict выше: второй Encode дописывает второй объект — для единого ответа вызывайте его один раз.
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-ответа: заголовок, энкодер и лимит тела.
Соберите хендлер по строкам
Расставьте строки хендлера создания в правильном порядке: от чтения тела до записи JSON.
Строки
Ваша программа
Нажимайте на строки слева, чтобы собрать программу
Карточки: JSON в хендлерах chi
Хендлеры каркаса заговорили на 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.
Личные заметкиПусто
Вопросы и ответы по уроку
Обязательно ли закрывать 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.
Термины урока
Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.
Изучайте дальше
- Собственный middleware: таймер, лог, авторизация
- Timeout и Throttle: защита от перегрузки
- CORS: чиним браузерные запросы
- Централизованные ошибки: errorHandler и NotFound
- context.Context: прокидываем данные в хендлеры
- Wildcards: маршруты со звёздочкой
- Mount и статика: FileServer
- Route и Group: группируем маршруты
- Все курсы по Go
Следующая ступень учебного пути
Redis в Go: кэш, сессии и очереди на go-redis
Курс Redis для Go-разработчиков: кэширование запросов, сессии, счётчики, Pub/Sub, Streams и rate limiter. 25 уроков с примерами кода go-redis v9.
- Подключаться к Redis из Go и настраивать пул соединений
- Кэшировать результаты запросов и придумывать TTL
- Работать с хэшами, списками, множествами и ZSET
- Строить сессии, счётчики, лидерборды и rate limiter