Сериализация и сжатие: JSON, gob, gzip
Любой backend-сервис на Go рано или поздно превращается в “конвертер форматов”: JSON туда, JSON обратно, иногда XML, иногда бинарник, иногда gzip. В 02-idioms-and-stdlib мы прошлись по encoding/json поверхностно. Этот урок, глубже: кастомные маршалеры, стримы, выбор между JSON, gob и protobuf, и как корректно сжимать ответы HTTP-сервиса.
1. Где на самом деле живёт сериализация
В encoding/json прячется три отдельных слоя, которые решают разные задачи:
- Reflection-маршалер:
json.Marshalрекурсивно ходит по структуре, читает теги, пишет байты. Это удобно, но дорого: на каждое поле, рефлексия. На горячем пути это часто бутылочное горлышко. - Стрим-маршалер:
json.Encoderпишет вio.Writer(например, прямо вhttp.ResponseWriter), не выделяя промежуточный[]byte. Для больших ответов экономит десятки мегабайт. - Custom маршалер: тип сам реализует
json.Marshaler/json.Unmarshaler. Полностью контролируешь представление, обходишь рефлексию.
Идиома: для public DTO бери reflection, для горячего пути с большим объёмом, кастом или сторонний JSON. Для streaming, Encoder/Decoder.
2. Структурные теги
JSON-теги, это контракт между схемой Go и схемой JSON:
type User struct {
ID int64 `json:"id"`
Email string `json:"email"`
Name string `json:"name,omitempty"`
Password string `json:"-"` // никогда не выводим
CreatedAt time.Time `json:"created_at"`
Internal string `json:"internal,omitempty"`
}
Что сидит в теге:
- Имя поля в JSON. Если без тега, Go использует имя поля как есть (PascalCase), что почти никогда не то, что хочешь.
,omitempty. Поле опускается, если значение, zero. Опасно для bool:false, тоже zero, поэтому булевые опциональные поля чаще делают*bool.,string. Принудительно выводит число как строку ("42"), полезно когда другая сторона на JS теряет точность на больших int64.-. Поле не сериализуется и не десериализуется. Удобно для секретов и derived-полей.
Любой тег (json, xml, yaml, db) Go хранит в reflect.StructTag, и парсятся они независимыми пакетами:
type User struct {
Email string `json:"email" xml:"email,attr" db:"email" validate:"email"`
}
Один тип, одна структура, разные представления.
3. Marshal и Unmarshal: гочи
// 1. Числовые типы из JSON падают в interface{} как float64
var v any
_ = json.Unmarshal([]byte(`{"x":42}`), &v)
fmt.Printf("%T", v.(map[string]any)["x"]) // float64
// 2. Большие int64 теряют точность через any
// 18446744073709551615 на JS округлится. Используй json.Number.
dec := json.NewDecoder(strings.NewReader(`{"id":18446744073709551615}`))
dec.UseNumber()
// 3. Неизвестные поля по дефолту молча игнорируются.
// Хочешь строгий контракт, ставь:
dec.DisallowUnknownFields()
// 4. nil-карта при Marshal даст null, не {}.
var m map[string]int
_, _ = json.Marshal(m) // null
_, _ = json.Marshal(map[string]int{}) // {}
Это четыре места, где новички теряют половину дня. Записываем в чек-лист.
4. Custom MarshalJSON/UnmarshalJSON
Самое полезное в encoding/json, возможность подменить логику для одного типа. Канонический пример, деньги в копейках вместо float64:
type Money int64 // хранится в копейках
func (m Money) MarshalJSON() ([]byte, error) {
rub := m / 100
kop := m % 100
if kop < 0 { kop = -kop }
return []byte(fmt.Sprintf(`"%d.%02d"`, rub, kop)), nil
}
func (m *Money) UnmarshalJSON(b []byte) error {
s := strings.Trim(string(b), `"`)
parts := strings.SplitN(s, ".", 2)
rub, err := strconv.ParseInt(parts[0], 10, 64)
if err != nil {
return fmt.Errorf("money: %w", err)
}
var kop int64
if len(parts) == 2 {
kop, err = strconv.ParseInt(parts[1], 10, 64)
if err != nil {
return fmt.Errorf("money kop: %w", err)
}
}
*m = Money(rub*100 + kop)
return nil
}
Теперь любая структура с полем Money сериализуется в строку "42.50", а не в число. И ни одна функция, которая пишет JSON, не должна об этом думать.
Аналогично для time.Time с фиксированным форматом, для UUID, для enum-ов с человекочитаемыми именами.
Тонкий момент.
MarshalJSONлучше определять на значении,UnmarshalJSON, на указателе (он мутирует). Если перепутаешь, на map-ах и слайсах будет сюрприз: метод не вызовется через интерфейс.
Закрепи теги и свой MarshalJSON: длительность в конфиге пишется строкой "1m30s", а не числом наносекунд, и конфиг разбирается строго, без молчаливых опечаток.
Не могу найти задачу 22-golang/22-encoding-compression/json-duration. Проверь путь в src/content/tasks/.
5. json.Decoder и json.Encoder: streams
Когда payload большой или приходит куском, Marshal/Unmarshal, плохой выбор: они держат всё в памяти. Decoder/Encoder работают со стримами:
// Сервер пишет ответ напрямую в ResponseWriter, без промежуточного []byte:
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(status)
if err := json.NewEncoder(w).Encode(v); err != nil {
slog.Error("encode response", "err", err)
}
}
// Клиент декодит NDJSON (по одному JSON-объекту на строку) без загрузки в память:
func processFeed(r io.Reader) error {
dec := json.NewDecoder(r)
for {
var ev Event
if err := dec.Decode(&ev); err != nil {
if errors.Is(err, io.EOF) {
return nil
}
return err
}
if err := handle(ev); err != nil {
return err
}
}
}
Decoder.Decode за один вызов читает один JSON-документ. Это удобно для NDJSON (newline-delimited JSON), для длинного списка событий, для бесконечного стрима.
Decoder.Token() (низкоуровневый стрим), редкая, но полезная штука для парсинга гигабайтных JSON-массивов: проходишь токен за токеном, не строя дерево.
6. Альтернативы encoding/json
encoding/json, безопасный и универсальный, но не самый быстрый. На 2026 шорт-лист:
json-iterator/go, drop-in replacement, в 2-3 раза быстрее на типовых нагрузках. Импортируешь какjsoniterи продолжаешь жить.goccy/go-json, ещё быстрее на новых workloads, более активно развивается.bytedance/sonic, JIT-парсер, рекордные скорости, но требует amd64/arm64 и накладные расходы на инициализацию. Имеет смысл для сервисов с очень высокой нагрузкой на JSON.
Когда брать: если профилировка показывает, что десятки процентов CPU уходят в json.*. Если не упёрся, не оптимизируй преждевременно.
премиум-урок
Продолжение в премиуме: 1 200 ₽ в месяц или 10 000 ₽ в год. Вместе с ним серверные проверки задач и школьные токены AI-коуча.
Оплата пока не подключена: премиум и курсы открываются промокодом.