Раздел 22 · Go

Сериализация и сжатие: JSON, gob, gzip

middle~35 мин

без входаВойди, чтобы отмечать прогресс, вести заметки и решать задачи в редакторе. войти

Сериализация и сжатие: JSON, gob, gzip

Любой backend-сервис на Go рано или поздно превращается в “конвертер форматов”: JSON туда, JSON обратно, иногда XML, иногда бинарник, иногда gzip. В 02-idioms-and-stdlib мы прошлись по encoding/json поверхностно. Этот урок, глубже: кастомные маршалеры, стримы, выбор между JSON, gob и protobuf, и как корректно сжимать ответы HTTP-сервиса.

1. Где на самом деле живёт сериализация

В encoding/json прячется три отдельных слоя, которые решают разные задачи:

  1. Reflection-маршалер: json.Marshal рекурсивно ходит по структуре, читает теги, пишет байты. Это удобно, но дорого: на каждое поле, рефлексия. На горячем пути это часто бутылочное горлышко.
  2. Стрим-маршалер: json.Encoder пишет в io.Writer (например, прямо в http.ResponseWriter), не выделяя промежуточный []byte. Для больших ответов экономит десятки мегабайт.
  3. 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-коуча.

Оплата пока не подключена: премиум и курсы открываются промокодом.