8. JSON API best practices

Формат JSON (JavaScript Object Notation) стал де-факто международным языком общения в веб-программировании. Он прост, читаем человеком и поддерживается любым языком программирования. Однако за этой кажущейся простотой скрывается серьезный инженерный вызов: преобразование текстового формата в строго типизированные структуры Go требует интенсивного разбора строк, проверок типов и рефлексии.

В мире динамических языков сериализаторы прячут детали под капотом, часто ценой чудовищных накладных расходов. В Go мы исповедуем другой подход — механическое сочувствие к оборудованию (Mechanical Sympathy). Мы должны точно понимать: сколько байт выделит аллокатор в куче, как отреагирует сборщик мусора на тысячи временных срезов, и как защитить сервис от вредоносных клиентов, отправляющих гигабайтные полезные нагрузки.

flowchart LR
    subgraph ClientSide ["Клиентский запрос"]
        Raw["Сырой JSON (Текст / UTF-8)"]
    end

    subgraph Streaming ["Потоковая обработка (Рекомендуется)"]
        Raw --> MaxGuard["http.MaxBytesReader\n(Ограничение размера тела)"]
        MaxGuard --> Dec["json.NewDecoder(r.Body)\n.DisallowUnknownFields()"]
        Dec --> HeapRecv["DTO Структура\n(Минимум промежуточных срезов)"]
    end

    subgraph Buffering ["Буферизация всего тела (Опасно)"]
        Raw -.-> All["io.ReadAll(r.Body)\n(Выделение цельного []byte)"]
        All -.-> Unm["json.Unmarshal(bytes, &DTO)\n(Пиковая нагрузка на GC)"]
    end

    classDef stream fill:#064e3b,stroke:#10b981,stroke-width:2px,color:#f8fafc;
    classDef buffer fill:#450a0a,stroke:#f43f5e,stroke-width:2px,color:#f8fafc;
    classDef client fill:#1e293b,stroke:#38bdf8,stroke-width:2px,color:#f8fafc;
    class Raw client;
    class MaxGuard,Dec,HeapRecv stream;
    class All,Unm buffer;
  

Философия JSON API в Go

Проектирование JSON API в Go отличается от PHP, Python или C#. Здесь нет магических сериализаторов, которые автоматически конвертируют динамические объекты. Go требует явных контрактов, строгой типизации и понимания стоимости рефлексии. Правильная работа с JSON — это баланс между читаемостью кода, предсказуемым поведением и минимизацией давления на Garbage Collector.

1. Потоковая обработка против Буферизации

Стандартная библиотека предлагает два пути: json.Marshal/Unmarshal и json.Encoder/Decoder. Выбор напрямую влияет на использование памяти и CPU.

Go
// Плохо: буферизация всего ответа в памяти
func badHandler(w http.ResponseWriter, r *http.Request) {
    data, err := getUsersFromDB(r.Context())
    if err != nil {
        http.Error(w, "internal error", http.StatusInternalServerError)
        return
    }

    // Создаёт полный буфер в памяти, затем один syscall write
    payload, err := json.Marshal(data)
    if err != nil {
        http.Error(w, "encoding error", http.StatusInternalServerError)
        return
    }

    w.Header().Set("Content-Type", "application/json")
    w.Write(payload)
}

// Хорошо: потоковая запись напрямую в сокет
func goodHandler(w http.ResponseWriter, r *http.Request) {
    data, err := getUsersFromDB(r.Context())
    if err != nil {
        http.Error(w, "internal error", http.StatusInternalServerError)
        return
    }

    w.Header().Set("Content-Type", "application/json")
    // Encoder пишет чанками в http.ResponseWriter, который сам буферизует
    if err := json.NewEncoder(w).Encode(data); err != nil {
        log.Printf("failed to encode response: %v", err)
        return
    }
}

2. Zero Values, omitempty и Проблема указателей

В Go каждый тип имеет zero value (0, "", false, nil). При сериализации тег omitempty скрывает поля, равные zero value. Это создаёт семантическую проблему для API.

Go
type UpdateUserRequest struct {
    Name    string `json:"name"`
    Age     int    `json:"age"`
    IsActive bool  `json:"is_active"`
}

Если клиент пришлёт {"age": 0}, сервер не отличит это от отсутствующего поля, так как 0 — это zero value для int. Решение — использовать указатели или отдельные типы sql.NullInt64-аналоги.

Go
type UpdateUserRequest struct {
    Name    *string `json:"name,omitempty"`
    Age     *int    `json:"age,omitempty"`
    IsActive *bool  `json:"is_active,omitempty"`
}

Теперь nil означает «не передавалось», а ptr to 0 означает «явно установи ноль».


3. Строгий декодинг и Безопасность

json.Unmarshal по умолчанию игнорирует неизвестные поля. Это удобно при эволюции API, но опасно: клиент может передать мусор или обойти валидацию. Всегда включайте строгий режим в production.

Go
func strictDecode(r *http.Request, target any) error {
    // Защита от переполнения памяти (DOS через гигантский JSON)
    r.Body = http.MaxBytesReader(nil, r.Body, 1<<20) // 1MB

    dec := json.NewDecoder(r.Body)
    dec.DisallowUnknownFields() // Критично для безопасности

    if err := dec.Decode(target); err != nil {
        // Разбираем специфичные ошибки JSON пакета
        var syntaxErr *json.SyntaxError
        var unmarshalTypeErr *json.UnmarshalTypeError
        var maxBytesErr *http.MaxBytesError

        switch {
        case errors.As(err, &syntaxErr):
            return fmt.Errorf("invalid json syntax at offset %d", syntaxErr.Offset)
        case errors.As(err, &unmarshalTypeErr):
            return fmt.Errorf("field %s has wrong type, expected %s", 
                unmarshalTypeErr.Field, unmarshalTypeErr.Type)
        case errors.As(err, &maxBytesErr):
            return errors.New("request payload too large")
        case err == io.EOF:
            return errors.New("empty request body")
        default:
            return fmt.Errorf("decode error: %w", err)
        }
    }
    return nil
}

4. Кастомная сериализация и Type Aliases

Часто требуется изменить формат вывода (даты, маскировка паролей, кастомные null). Реализуйте json.Marshaler / json.Unmarshaler.

Go
type SafeDate struct {
    time.Time
}

func (sd SafeDate) MarshalJSON() ([]byte, error) {
    if sd.IsZero() {
        return []byte("null"), nil
    }
    return []byte(`"` + sd.Format("2006-01-02") + `"`), nil
}

func (sd *SafeDate) UnmarshalJSON(b []byte) error {
    if string(b) == "null" {
        return nil
    }
    // Убираем кавычки
    s := string(b[1 : len(b)-1])
    t, err := time.Parse("2006-01-02", s)
    if err != nil {
        return err
    }
    sd.Time = t
    return nil
}

5. Структура ответов и Пагинация

Идиоматичный Go API придерживается предсказуемого envelope или чистого ответа. Рекомендую чистый ответ для GET /resource, но с заголовками для пагинации.

Go
// Header-базадная пагинация (совместимо с GraphQL, REST, HAL)
w.Header().Set("X-Total-Count", strconv.Itoa(total))
w.Header().Set("X-Page", strconv.Itoa(page))
w.Header().Set("X-Per-Page", strconv.Itoa(perPage))
w.Header().Set("Link", fmt.Sprintf(`</api/v1/users?page=%d>; rel="next"`, page+1))

// Тело ответа - только данные
json.NewEncoder(w).Encode(users)

Для сложных мета-данных используйте wrapper:

Go
type APIResponse struct {
    Data   any     `json:"data"`
    Meta   *Meta   `json:"meta,omitempty"`
    Errors []Error `json:"errors,omitempty"`
}

6. Производительность и Mechanical Sympathy

Пакет encoding/json активно использует рефлексию. Каждый вызов Unmarshal создаёт reflect.Value, обходит поля через vtable и часто заставляет данные мигрировать в кучу из-за Escape Analysis.

Как оптимизировать:

  1. sync.Pool: Переиспользуйте структуры запросов/ответов, если они большие и часто создаются.
  2. Предварительная компиляция: Используйте easyjson или go-json (goccy), которые генерируют код сериализации на этапе компиляции. Они избегают рефлексии, работают напрямую с байтовыми срезами и ускоряют обработку в 3-5 раз.
  3. Кэш-локальность: Размещайте часто используемые поля структуры в начале. Это улучшает предвыборку CPU-кэша при обходе полей рефлексией.

Итог

  1. Всегда используйте потоковый json.Encoder/Decoder для HTTP, чтобы снизить нагрузку на GC.
  2. Применяйте DisallowUnknownFields для строгого контракта и защиты от мусора.
  3. Ограничивайте r.Body через http.MaxBytesReader обязательно.
  4. Различайте omitempty и nil с помощью указателей только для PATCH-эндпоинтов.
  5. Для highload систем рассматривайте codegen-сериализаторы (easyjson, go-json), избегая рефлексии в hot-path.

Следующая статья: 9. Работа с context в HTTP