Содержание урока · 12 разделов
- c.JSON изнутри: render.JSON и charset=utf-8
- omitempty и указатели: состояния поля в ответе
- gin.H против структур: границы применения
- IndentedJSON: красивый ответ для отладки
- PureJSON: ответ без HTML-экранирования
- SecureJSON и AsciiJSON: два редких специализированных
- Сериализация gin: карта методов ответа
- AbortWithStatusJSON: ответы на выходе из middleware
- Конверт ответа: data, error и честное мнение
- Песочница урока
- Коротко: что запомнить
- Вопросы и ответы
c.JSON изнутри: render.JSON и charset=utf-8#
Сериализация gin вращается вокруг вызова, который мы писали с первого сервера: c.JSON(code, obj). Внутри он оборачивает значение в render.JSON и передаёт в c.Render — тот ставит статус c.Status(code), рендерер определяет заголовок Content-Type: application/json; charset=utf-8, затем значение прогоняется через json.Marshal и готовые байты уходят в w.Write. То есть gin json-ответ собирает стандартной библиотекой: в свежих версиях Gin сериализация вынесена в пакет codec/json — по умолчанию это encoding/json, тегами сборки движок меняется на jsoniter, go-json или sonic, но смысл тегов json при этом не меняется. Порядок действий важен: статус и Content-Type определены до маршалинга, поэтому ошибка сериализации уже не меняет статус — gin кладёт её в c.Errors через c.Error и прерывает цепочку c.Abort(). Клиент получает выбранный вами статус с пустым телом — и это самое мягкое из возможных последствий.
Маршалится всё, кроме каналов, функций и комплексных чисел, — и структур, где такие поля сидят глубже. Второй источник ошибок — циклы по указателям: структура, которая ссылается сама на себя, уведёт маршал в ошибку. Ответ оборвётся: клиент увидит выбранный статус без тела, ошибка попадёт в c.Errors, и пропустить её проще простого — проверяйте ответы curl-ом, а непрериализуемые поля держите вне структур ответа.
GET /todos: Todo с тегами json
GO// Todo — модель задачи: теги json задают форму ответа.
type Todo struct {
ID int64 `json:"id"`
Title string `json:"title"`
Done bool `json:"done"`
Priority int `json:"priority,omitempty"`
Deadline *Deadline `json:"deadline,omitempty"`
}
func listTodos(c *gin.Context) {
todos := []Todo{
{ID: 1, Title: "написать урок про JSON", Priority: 2},
{ID: 2, Title: "выпить кофе", Done: true},
}
// Срез маршалится массивом: [{...}, {...}].
c.JSON(http.StatusOK, todos)
}Тело ответа: [{"id":1,"title":"написать урок про JSON","priority":2},{"id":2,"title":"выпить кофе","done":true}]. Приоритет 0 у второй задачи пропал вместе с тегом omitempty, а ключа deadline нет вовсе — в обоих случаях Deadline равна nil. Поля без omitempty — id, title, done — выходят всегда, даже нулевые: это основа предсказуемого контракта. Тег задаёт и имя: без него поле вышло бы как Title, с большой буквы. Отдельная ловушка срезов: nil-срез выйдет null, пустой make([]Todo, 0) — честным []. Для списков инициализируйте срез, даже если он пуст.
omitempty и указатели: состояния поля в ответе#
Тег json:"...,omitempty" скрывает нулевые значения при маршалинге: false, 0, пустую строку, nil-указатель, пустые срез, карту и интерфейс. Структуры это правило не касается: omitempty никогда не скрывает struct — потому Deadline в Todo объявлена указателем Deadline. Указатель даёт два честных состояния: nil — поля в ответе нет, значение — поле есть. Комбинация int плюс omitempty ловит тонкость: легитимный ноль пропадёт из ответа, и клиент не отличит «priority не задан» от «priority равен нулю». Если ноль — валидное значение, берите int без omitempty: явный ноль выйдет как "priority": 0, отсутствие — как null. Со стороны входа мы разобрали это в прошлом уроке: указатели различали «не прислали» и false — у выхода задача симметричная. В курсе ent та же идея зовётся Optional-полями: nil в базе и ответе — честное «значения нет», а не перепутанный ноль.
gin.H против структур: границы применения#
На первом сервере мы сформулировали правило: gin.H — для черновиков, структуры — для живого API. Уточню границы с точки зрения сериализации. gin.H честно хорош для быстрых ответов: health-чеков вида {"ok": true}, ошибок с одним-двумя полями, временных заглушек. Как только ответ живёт дольше недели, карта проигрывает: ключи не типизируются, форма ответа не видна ни компилятору, ни OpenAPI-генератору, ни коллеге. Структура с тегами json — одновременно документация и контракт: по ней видно, что уйдёт клиенту, а переименование поля подсвечивается при сборке. Мой порог: три обращения к ключу или повтор формы в двух местах — переезжайте в структуру. Ответы об ошибках в gin.H оставляю сознательно — respondBindError из прошлого урока так и работает, и это осознанный выбор, а не лень.
Свой MarshalJSON: дедлайны в time.RFC3339
GO// Deadline — дата дедлайна с форматом RFC3339 без наносекунд.
type Deadline time.Time
func (d Deadline) MarshalJSON() ([]byte, error) {
// Строка оборачивается в json.Marshal: байты должны быть валидным JSON.
return json.Marshal(time.Time(d).Format(time.RFC3339))
}
func (d *Deadline) UnmarshalJSON(b []byte) error {
// "null" — по конвенции ничего не делаем: поле останется нулевым.
if string(b) == "null" {
return nil
}
var s string
if err := json.Unmarshal(b, &s); err != nil {
return err
}
t, err := time.Parse(time.RFC3339, s)
if err != nil {
return err
}
*d = Deadline(t)
return nil
}time.Time маршалится сам — но с наносекундами: 2025-06-01T12:00:00.348591+03:00. Для API это шум, поэтому тип Deadline переопределяет формат на time.RFC3339: секунды и зона, Z для UTC. MarshalJSON обязан вернуть готовые байты JSON — строка оборачивается в json.Marshal, иначе получим байты без кавычек, а это не строка, и клиент споткнётся. UnmarshalJSON объявлен на указательном приёмнике: метод записывает значение через *d. Ветка "null" — не украшение: encoding/json вызывает UnmarshalJSON и для null, и правильная реакция — ничего не делать. Приёмник у MarshalJSON здесь значением: так метод работает и для значений внутри карт и срезов, где указательного приёмника не достать.
Проверка: MarshalJSON вернул байты без кавычек
MarshalJSON типа Deadline вернул []byte("2025-06-01T12:00:00Z") — без кавычек. Что получит клиент на c.JSON(200, todo)?
IndentedJSON: красивый ответ для отладки#
c.IndentedJSON(code, obj) повторяет c.JSON с одной разницей: маршал идёт через json.MarshalIndent с отступом в четыре пробела и переводами строк. Content-Type тот же — application/json; charset=utf-8, по заголовкам рендеры не различить. Авторы Gin предупреждают прямо в исходниках: только для разработки, красивая печать стоит процессора и трафика — тело растёт в разы, и gzip сглаживает разницу лишь частично. Автопереключения в release-режиме нет: компактным ответ сделает только ваш код. Мой рабочий расклад: прод живёт на c.JSON, красоту в терминале даёт curl | jq, а IndentedJSON оставлен /debug-эндпоинтам и ответам, которые человек копирует в баг-репорт.
PureJSON: ответ без HTML-экранирования#
json.Marshal экранирует три символа: < в \u003c, > в \u003e и & в \u0026. Идея — безопасность одного сценария: если JSON вклеивается в HTML-страницу сырыми байтами, строка не должна вынести за пределы JSON-литерала тег <script>. Обычному API экранирование не мешает: JSON.parse и любой уважающий себя декодер вернут исходную строку. c.PureJSON(code, obj) пишет ответ через json.Encoder с выключенным SetEscapeHTML(false): <, > и & уходят как есть, а в конец тела дописывается перевод строки — единственная байтовая разница, кроме экранирования. Настоящие поводы для PureJSON: важны точные байты ответа (считаете хеш тела для кэша, сверяете подписи), строк с угловыми скобками много и размер критичен, клиент — самодельный парсер без юникод-эскейпов. Пользовательский контент со <script> внутри — не повод менять c.JSON: экранирование не защищает фронтенд, его защита — аккуратная вставка в DOM на его стороне.
JSON против PureJSON: разница в байтах
GO// GET /json — стандартный рендер: спецсимволы экранированы.
r.GET("/json", func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"bio": "<b>уроки & уроки</b>"})
// {"bio":"\u003cb\u003eуроки \u0026 уроки\u003c/b\u003e"}
})
// GET /pure — тот же объект без HTML-экранирования.
r.GET("/pure", func(c *gin.Context) {
c.PureJSON(http.StatusOK, gin.H{"bio": "<b>уроки & уроки</b>"})
// {"bio":"<b>уроки & уроки</b>"}
})Клиентская разница — ноль: JSON.parse вернёт одинаковую строку bio в обоих случаях. Разница в байтах на проводе: шесть экранированных позиций против трёх сырых символов. Кириллицу ни один из рендеров не трогает — экранирование касается только <, > и &. Переключение рендера — смена одного вызова в хендлере, форма данных не меняется.
Что уйдёт клиенту на /bio
Прочитайте код и выберите, что он выведет:
r.GET("/bio", func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"bio": `<script>alert("!")</script>`})
})SecureJSON и AsciiJSON: два редких специализированных#
c.SecureJSON(code, obj) добавляет префикс while(1); перед ответом — но только когда маршаленный вывод оказался массивом: [1,2] уйдёт как while(1);[1,2], а объект со срезом внутри — без префикса. Это окаменелость эпохи JSON hijacking: старые браузеры исполняли <script src="/api/todos"> с чужого сайта, переопределённый конструктор массива отдавал данные атакующему, а префикс ломал такой скрипт синтаксической ошибкой. Сегодня атака мертва — кросс-доменный запрос не отдаёт тело без CORS-разрешения, — метод остался для legacy-клиентов; свой префикс ставится вызовом r.SecureJsonPrefix(")]}'"), как исторически делал Google. c.AsciiJSON(code, obj) превращает все руны выше ASCII в \uXXXX: слово «сериалы» станет цепочкой \u0441...; заголовок у него application/json — без charset=utf-8, единственный из JSON-семейства. Клиентов без UTF-8 почти не осталось, а руны выше U+FFFF (эмодзи) пишутся эскейпом длиннее четырёх цифр — формально уже невалидный JSON; ещё один повод не увлекаться.
SecureJSON и AsciiJSON на трёх эндпоинтах
GO// Массив: SecureJSON добавит префикс.
r.GET("/arr", func(c *gin.Context) {
c.SecureJSON(http.StatusOK, []string{"a", "b"})
// while(1);["a","b"]
})
// Объект: срез внутри не считается — префикса не будет.
r.GET("/obj", func(c *gin.Context) {
c.SecureJSON(http.StatusOK, gin.H{"items": []string{"a", "b"}})
// {"items":["a","b"]}
})
// AsciiJSON: кириллица и знак номера станут эскейпами.
r.GET("/ascii", func(c *gin.Context) {
c.AsciiJSON(http.StatusOK, gin.H{"title": "Урок №5"})
// {"title":"\u0423\u0440\u043e\u043a \u21165"}
})Проверка на массив идёт по готовым байтам: вывод должен начинаться с [ и заканчиваться ], поэтому gin.H со срезом внутри префикса не получает — проверяется внешний тип, а не содержимое. У AsciiJSON заголовок Content-Type: application/json без charset — исходник рендерера определяет его отдельно. № — руна U+2116, её \u2116 валиден; эмодзи при той же схеме дали бы пять цифр после \u, и JSON-парсер такое не примет.
Где появится while(1);
Прочитайте код и выберите, что он выведет:
r.GET("/a", func(c *gin.Context) {
c.SecureJSON(http.StatusOK, []string{"todo"})
})
r.GET("/b", func(c *gin.Context) {
c.SecureJSON(http.StatusOK, gin.H{"items": []string{"todo"}})
})Сериализация gin: карта методов ответа#
| Метод | Что особенного в выводе | Когда брать |
|---|---|---|
| c.JSON | компактный JSON, Content-Type: application/json; charset=utf-8 | по умолчанию для всех ответов API |
| c.IndentedJSON | тот же JSON с отступами в четыре пробела | отладка и /debug-эндпоинты, дороже CPU и трафика |
| c.PureJSON | без экранирования <, > и &, перевод строки в конце | когда важны точные байты: экспорт, хеши, подписи |
| c.SecureJSON | префикс while(1); перед массивами | legacy-клиенты эпохи JSON hijacking |
| c.AsciiJSON | все не-ASCII руны как \uXXXX, Content-Type без charset | клиенты без UTF-8 — большая редкость |
| c.AbortWithStatusJSON | статус, JSON и прерывание цепочки | ошибки из middleware: 401, 403, 429 |
AbortWithStatusJSON: ответы на выходе из middleware#
До сих пор ответ писался из хендлера — конца цепочки. Middleware стоит раньше, и его «нет» выглядит иначе: надо и прервать цепочку, и сразу написать ответ, не дожидаясь хендлера. Для этого у Context есть c.AbortWithStatusJSON(code, obj). В исходниках это две строки: сперва c.Abort(), затем c.JSON(code, jsonObj) — прерывание идёт первым, чтобы после записи ответа цепочка гарантированно встала. Ответы 401, 403 и 429 из middleware принято писать именно им: клиент получает и статус, и JSON-тело с причиной, а не голый AbortWithStatus без тела. Это заготовка к следующему уроку про middleware — здесь нам важна только ответная часть.
authGate: 401 с JSON-телом из middleware
GO// authGate — пускает дальше или отвечает 401 с JSON-телом.
func authGate(c *gin.Context) {
token := c.GetHeader("Authorization")
if token == "" {
// Прерываем цепочку и сразу пишем ответ.
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{
"error": "требуется заголовок Authorization",
})
return
}
c.Next()
}
func main() {
r := gin.Default()
// authGate стоит в цепочке перед listTodos.
r.GET("/todos", authGate, listTodos)
r.Run(":8080")
}authGate — функция с сигнатурой хендлера, поставленная в цепочку перед listTodos: r.GET("/todos", authGate, listTodos). Запрос без Authorization получает 401 и тело {"error":"требуется заголовок Authorization"} — до listTodos дело не доходит. С заголовком цепочка продолжается через c.Next(). gin.H здесь уместен: ошибка из одной строки, отдельная структура под неё не окупится.
Проверка: c.JSON со статусом 204
Что получит клиент после c.JSON(http.StatusNoContent, gin.H{"data": 1})?
Конверт ответа: data, error и честное мнение#
Популярный паттерн — конверт (envelope): каждый ответ обёрнут в {"data": ..., "error": ...}, ошибки описаны машиночитаемым кодом. Для публичных API со сторонними клиентами он оправдан: единый парсер на фронте, место для метаданных — пагинации limit/offset из урока про параметры, версионирование /v1. Моё честное мнение для внутреннего CRUD: начните без конверта. HTTP-статус уже несёт исход — 2xx успех, 4xx и 5xx ошибка, — а второй слой обёртки плодит if'ы на клиенте и соблазн возвращать ошибки со статусом 200, что ломает мониторинг и кэши. Вводите конверт, когда появляются второй тип клиентов или общая пагинация; пути вида /v1 дешевле решаются группами маршрутов, чем полем version в каждом ответе.
Envelope: типизированный конверт с помощниками
GO// Envelope — единый конверт: данные или ошибка, никогда оба сразу.
type Envelope struct {
Data any `json:"data,omitempty"`
Error *APIError `json:"error,omitempty"`
}
// APIError — машиночитаемый код плюс человекочитаемый текст.
type APIError struct {
Code string `json:"code"`
Message string `json:"message"`
Details []string `json:"details,omitempty"`
}
// respondData — успешный ответ: статус и данные в конверте.
func respondData(c *gin.Context, code int, data any) {
c.JSON(code, Envelope{Data: data})
}
// respondError — ошибочный ответ: статус и ошибка в конверте.
func respondError(c *gin.Context, code int, apiCode, message string) {
c.JSON(code, Envelope{Error: &APIError{Code: apiCode, Message: message}})
}Data any — в конверт помещается и Todo, и срез, и gin.H для однострочных ответов. Успешный ответ несёт data и не несёт error: nil-указатель с omitempty пропадает; ошибочный — наоборот. respondError не вызывает Abort: внутри хендлера после ответа вы всё равно возвращаетесь return'ом, Abort нужен только когда цепочку надо оборвать — как в authGate выше. Цена any — компилятор не проверяет, что вы положили в Data: контракт держат тесты.
Допишите конверт и двух помощников
Восстановите имя поля данных, метод ответа и abort-вариант для middleware.
Соберите WriteJSON — ядро JSON-ответа
Соберите по шагам то, что выполняет Gin на каждый c.JSON: заголовок, маршалинг, запись тела.
Строки
Ваша программа
Нажимайте на строки слева, чтобы собрать программу
Карточки: JSON-ответы в Gin
Формат ответа виден на проводе, а не в коде: curl -i localhost:8080/todos покажет и заголовок Content-Type, и тело целиком. Добавьте | jq — красивая печать без IndentedJSON на сервере. А спор о том, экранирует ли c.JSON угловые скобки, закрывает curl -s ... | xxd | head: сырые байты покажут \u003c или честный <. Один взгляд на байты экономит час переписки о том, «кто что шлёт».
Песочница урока
Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.
Запустите код, чтобы увидеть вывод.
Коротко: что запомнить
- c.JSON ставит статус, Content-Type: application/json; charset=utf-8 и пишет json.Marshal; ошибка маршалинга уходит в c.Errors с Abort при уже выбранном статусе.
- Теги json формируют форму ответа; omitempty скрывает нулевые значения, но не структуры — для «пусто» берите указатели.
- Свой MarshalJSON возвращает готовые байты: строки — через json.Marshal; UnmarshalJSON — на указателе и с веткой null.
- time.Time сам пишет RFC3339 с наносекундами; свой тип Deadline форматирует время через time.RFC3339.
- IndentedJSON — отладка (MarshalIndent, дороже CPU и трафика); PureJSON — без экранирования <, > и &; AsciiJSON — всё в \uXXXX и Content-Type без charset.
- SecureJSON ставит while(1); только перед массивами — наследие защиты от JSON hijacking.
- AbortWithStatusJSON = Abort + JSON: ответ ошибкой из middleware; конверт data/error — для публичных API, внутреннему CRUD чаще хватает статусов.
Домашнее задание
Добавьте в Todo поле CreatedAt *time.Time с тегом json:"created_at,omitempty" и убедитесь curl-ом: без значения ключа в ответе нет, а нулевой time.Time без указателя уехал бы как 0001-01-01T00:00:00Z. Сделайте GET /todos/pretty через c.IndentedJSON и сравните размеры ответов с c.JSON. Напишите middleware, который отвечает c.AbortWithStatusJSON(http.StatusTooManyRequests, ...) на четвёртый запрос за секунду.
Личные заметкиПусто
Вопросы и ответы по уроку
Почему кириллица в c.JSON не превращается в \uXXXX?
Экранирование в encoding/json касается только <, > и &; остальные юникод-символы пишутся как есть в UTF-8. Хотите чистый ASCII на проводе — есть c.AsciiJSON, но нуждаются в нём немногие: клиенты без UTF-8 почти вымерли.
Чем c.AbortWithStatusJSON отличается от c.JSON?
Только прерыванием: AbortWithStatusJSON сперва зовёт c.Abort() и лишь затем c.JSON. Для хендлера разницы нет, для middleware — вся: либо цепочка встаёт сразу, либо запрос дойдёт до следующего хендлера.
Когда свой MarshalJSON — плохая идея?
Когда формат нужен только фронтенду: проще хранить поле строкой или завести отдельный тип ответа. Кастомный marshaller размазывает формат по коду, и каждая такая структура требует тестов. Для дат и дедлайнов он оправдан — формат времени хочется зафиксировать один раз.
Термины урока
Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.
Сравните с альтернативами
Практический вопрос после первых уроков: а почему именно эта библиотека? Мы разобрали конкурентов по одним критериям, с кодом одной задачи рядом и персональным подбором под ваши приоритеты.
Изучайте дальше
Следующая ступень учебного пути
Echo: минималистичный быстрый фреймворк
Курс Echo для Go: маршруты, middleware, валидация, JWT, SSE, тестирование и деплой. 25 уроков с примерами и итоговым API-проектом.
- Строить REST API на Echo
- Использовать типизированные запросы/ответы (Request/Response)
- Управлять ошибками через echo.HTTPError
- Писать middleware, включая JWT-аутентификацию