32. API versioning

Интерфейсы прикладного программирования (API) подобны дипломатическим договорам между независимыми государствами: однажды подписав и опубликовав контракт, вы больше не можете в одностороннем порядке изменить даже одну запятую. Если ваш сервис используется мобильными приложениями, внешними партнерами или фронтендом, вы не можете просто переименовать поле user_id в id в понедельник утром: тысячи пользователей на старых версиях клиентов мгновенно столкнутся с падением приложения при запуске.

Здесь вступает в силу закон Хайрама (Hyrum's Law): «При достаточном количестве пользователей API не имеет значения, что вы обещали в контракте: все наблюдаемые особенности поведения вашей системы станут частью соглашения, на которое кто-то обязательно заложится».

Единственный цивилизованный способ развивать функциональность и не ломать существующих клиентов — грамотное версионирование. В языке Go версионирование реализуется прозрачно, детерминированно и без скрытой магии фреймворков.

Философия версионирования API

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

1. Стратегии версионирования

Существует три основных подхода, каждый со своими накладными расходами и ограничениями:

  • URL Path (/api/v1/users): Стандарт де-факто в современной индустрии. Кристально явная семантика, безупречное кэширование на уровне CDN и Nginx, удобство чтения системных логов доступа и простота отладки через curl.
  • HTTP Headers (Accept: application/vnd.app.v1+json): Сохраняет чистые URL без префиксов, но требует сложного парсинга заголовков на каждом запросе, создает проблемы с прозрачными прокси-кэшами и усложняет CORS preflight проверки.
  • Query Parameters (?version=1): Опасный антипаттерн для REST. Ломает идемпотентность кэшей, путает семантику ресурсов, запрещен в строгой архитектуре.
flowchart TD
    A["Клиентский HTTP-запрос"] --> B["Извлечение версии"]
    B --> C{"Метод маршрутизации"}
    C -->|"URL путь"| D["/api/v1/resource"]
    C -->|"HTTP заголовок"| E["Accept vendor media"]
    D --> F["Статический роутер (ServeMux)"]
    E --> G["Middleware парсер"]
    F --> H["Выбор обработчика"]
    G --> H
    H --> I["Валидация DTO версии"]
    I --> J["Вызов единого домена"]
    J --> K["Форматирование ответа"]

    classDef normal fill:#1e293b,stroke:#38bdf8,color:#f8fafc;
    classDef decision fill:#334155,stroke:#f59e0b,color:#f8fafc;
    classDef path fill:#0f172a,stroke:#10b981,color:#f8fafc;
    class A,D,F,H,I,J,K normal;
    class B,C decision;
    class D,F path;
  

2. Идиоматичная реализация в Go

В Go 1.22+ стандартный http.ServeMux поддерживает матчинг по методу и пути, что упрощает версионирование без внешних зависимостей. Идиома: разделять слои доставки по версиям, а бизнес-логику оставлять общей.

Go
package main

import (
    "context"
    "encoding/json"
    "net/http"
)

type User struct {
    ID        int
    FirstName string
    LastName  string
    Email     string
}

// DTO для разных версий контракта
type UserV1 struct {
    ID   int    `json:"id"`
    Name string `json:"name"`
}

type UserV2 struct {
    ID       int    `json:"id"`
    FullName string `json:"full_name"`
    Email    string `json:"email"`
}

// Роутер версии 1
func v1Routes(mux *http.ServeMux) {
    mux.HandleFunc("GET /api/v1/users/{id}", handleGetUserV1)
}

// Роутер версии 2
func v2Routes(mux *http.ServeMux) {
    mux.HandleFunc("GET /api/v2/users/{id}", handleGetUserV2)
}

func handleGetUserV1(w http.ResponseWriter, r *http.Request) {
    // Получаем данные из общего сервиса
    user := fetchUserFromDB(r.Context())
    // Маппим в DTO версии 1
    resp := UserV1{ID: user.ID, Name: user.FirstName + " " + user.LastName}
    writeJSON(w, http.StatusOK, resp)
}

func handleGetUserV2(w http.ResponseWriter, r *http.Request) {
    user := fetchUserFromDB(r.Context())
    // Маппим в DTO версии 2
    resp := UserV2{ID: user.ID, FullName: user.FirstName + " " + user.LastName, Email: user.Email}
    writeJSON(w, http.StatusOK, resp)
}

func fetchUserFromDB(ctx context.Context) *User {
    return &User{ID: 42, FirstName: "Brian", LastName: "Kernighan", Email: "bwk@bell-labs.com"}
}

func writeJSON(w http.ResponseWriter, status int, v any) {
    w.Header().Set("Content-Type", "application/json")
    w.WriteHeader(status)
    if err := json.NewEncoder(w).Encode(v); err != nil {
        // Логирование ошибки сериализации
    }
}

3. Под капотом. Маршрутизация и кэш-локальность

Стандартный http.ServeMux в Go 1.22+ использует компиляцию паттернов в детерминированный автомат (DFA) на этапе инициализации сервера. При запуске http.ListenAndServe все пути преобразуются в дерево префиксов. Поиск версии /api/v2/ занимает O(L), где L — длина пути, без динамического парсинга.

При использовании заголовочного версионирования с внешними роутерами (chi, gin) добавляется уровень индирекции:

  1. Middleware читает r.Header.Get("Accept")
  2. Парсит медиа-тип, извлекает v2
  3. Записывает версию в context или r.URL
  4. Передает управление роутеру

Это добавляет 1-2 аллокации на запрос и нарушает кэш-локальность процессора: данные заголовков разбросаны по http.Request, парсер создает временные строки, context разрастается. Для высоконагруженных API path-версионирование выигрывает за счет предсказуемого доступа к памяти и отсутствия рантайм-парсинга.

4. Управление обратной совместимостью и депрекацией

Эволюция API должна подчиняться строгим правилам:

  • Non-breaking (Неломающие изменения): Добавление новых полей в ответ, регистрация новых эндпоинтов, необязательные поля в теле запроса. Не требуют изменения версии API!
  • Breaking (Ломающие изменения): Удаление или переименование полей, изменение типов данных, ужесточение валидации, удаление эндпоинтов. Обязательно требуют выпуска новой мажорной версии vN+1.

Стратегия депрекации устаревших версий (RFC 8594):

  1. Добавить заголовок Deprecation: true и Sunset: <RFC-1123 date> в ответы старой версии.
  2. Логировать использование старой версии для метрик api_deprecated_requests_total.
  3. Уведомить клиентов за 3-6 месяцев до отключения.
  4. Вернуть 410 Gone после наступления даты Sunset.
Go
func deprecationMiddleware(version string, next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        if version == "v1" {
            w.Header().Set("Deprecation", "true")
            w.Header().Set("Sunset", "Sat, 01 Jan 2025 00:00:00 GMT")
            w.Header().Set("Link", `</api/v2/users>; rel="successor-version"`)
        }
        next.ServeHTTP(w, r)
    })
}

5. Ловушки и антипаттерны

  • Поддержка >3 версий одновременно: Резко увеличивает сложность тестирования, поддержку кода и размер бинарного файла. Устанавливайте SLA на поддержку (например, текущая + 1 предыдущая).
  • Семантическое версионирование (SemVer) в путях: /api/v1.2.3 — антипаттерн. API версионируется по мажорным изменениям контракта. Минорные патчи применяются инкрементально.
  • Отсутствие Vary при смешанных стратегиях: Если сервис поддерживает и path, и headers для версии, ответ должен явно указывать Vary: Accept, X-API-Version, иначе кеши будут отдавать мусор.
  • Дублирование валидации: Если v1 и v2 используют одинаковые правила бизнес-валидации, вынесите их в общую функцию. Версионировать нужно только контракт (DTO), а не логику проверки.

6. Итог

  1. Предпочитайте URL-path версионирование (/v1/) для предсказуемости, кешируемости и минимального CPU overhead.
  2. Версионировать должен слой доставки (handlers, DTOs, маршруты). Бизнес-логика и доменные модели остаются стабильными.
  3. Используйте заголовки Deprecation и Sunset для плавного вывода старых версий из эксплуатации.
  4. Избегайте query-параметров для версии: они ломают семантику REST и кеширование.
  5. При заголовочном версионировании всегда возвращайте Vary: Accept, иначе прокси-кеши будут некорректно агрегировать ответы.
  6. Ограничивайте количество поддерживаемых версий (обычно 2) для снижения когнитивной нагрузки и ускорения деплоя.
  7. Версионирование решает проблему контрактов, но не заменяет грамотную схему БД: миграции должны быть обратно совместимыми на уровне хранения.

Следующая статья: 33. Документация API