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

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

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

Урок 5 из 25 ~18 мин +50 XP

JSON-ответы и сериализация

Каждый хендлер в этом курсе заканчивался вызовом c.JSON — пора вскрыть gin json-сериализацию построчно. Разберём, что делает render.JSON и откуда берётся charset=utf-8, как теги json и omitempty формируют форму ответа, зачем нужен свой MarshalJSON для дедлайнов Todo и чем IndentedJSON, PureJSON и SecureJSON отличаются от базового рендера.

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

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(). Клиент получает выбранный вами статус с пустым телом — и это самое мягкое из возможных последствий.

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.

type Envelope struct { Data any `json:",omitempty"` Error *APIError `json:"error,omitempty"` } func respondData(c *gin.Context, code int, data any) { c.(code, Envelope{Data: data}) } func unauthorized(c *gin.Context) { c.(http.StatusUnauthorized, Envelope{ Error: &APIError{Code: "unauthorized", Message: "нет токена"}, }) }

Соберите WriteJSON — ядро JSON-ответа

Соберите по шагам то, что выполняет Gin на каждый c.JSON: заголовок, маршалинг, запись тела.

Строки

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

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

Карточки: JSON-ответы в Gin

Термин

c.JSON(code, obj)

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

render.JSON: Content-Type application/json; charset=utf-8, json.Marshal, запись тела. Ошибка маршалинга — c.Error и Abort при уже выбранном статусе.

1 / 6

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

Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +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, ...) на четвёртый запрос за секунду.

Личные заметкиПусто
Хранятся только в вашем браузере (Gin · JSON-ответы и сериализация)

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

Почему кириллица в c.JSON не превращается в \uXXXX?

Экранирование в encoding/json касается только <, > и &; остальные юникод-символы пишутся как есть в UTF-8. Хотите чистый ASCII на проводе — есть c.AsciiJSON, но нуждаются в нём немногие: клиенты без UTF-8 почти вымерли.

Чем c.AbortWithStatusJSON отличается от c.JSON?

Только прерыванием: AbortWithStatusJSON сперва зовёт c.Abort() и лишь затем c.JSON. Для хендлера разницы нет, для middleware — вся: либо цепочка встаёт сразу, либо запрос дойдёт до следующего хендлера.

Когда свой MarshalJSON — плохая идея?

Когда формат нужен только фронтенду: проще хранить поле строкой или завести отдельный тип ответа. Кастомный marshaller размазывает формат по коду, и каждая такая структура требует тестов. Для дат и дедлайнов он оправдан — формат времени хочется зафиксировать один раз.

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

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

Сравните с альтернативами

Практический вопрос после первых уроков: а почему именно эта библиотека? Мы разобрали конкурентов по одним критериям, с кодом одной задачи рядом и персональным подбором под ваши приоритеты.

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

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

Echo: минималистичный быстрый фреймворк

Курс Echo для Go: маршруты, middleware, валидация, JWT, SSE, тестирование и деплой. 25 уроков с примерами и итоговым API-проектом.

  • Строить REST API на Echo
  • Использовать типизированные запросы/ответы (Request/Response)
  • Управлять ошибками через echo.HTTPError
  • Писать middleware, включая JWT-аутентификацию