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

API Versioning: эволюция API без боли

Зачем знать на Middle 3: API без версионирования — это API без будущего. Tech lead обязан понимать: как ввести /v2/, чтобы не сломать /v1/; как эволюционировать protobuf без breaking changes; зачем нужен Schema Registry для Kafka; как Buf принуждает к compatibility. Без этих знаний один деплой ломает 50 клиентов и месяц refund’ов. С ними — годами поддерживаешь старых клиентов и плавно мигрируешь.

  1. Концепция: зачем версионировать API
  2. Глубже: стратегии, protobuf/JSON evolution, Schema Registry, Buf
  3. Gotchas: типовые ошибки эволюции
  4. Real cases: Stripe, GitHub, gRPC миграции
  5. Вопросы (20+)
  6. Practice: эволюция API без breaking
  7. Источники

Today: запускаем /api/users → {id, name, email}
3 месяца: 50 клиентов интегрировались
6 месяцев: нужно поле age → как добавить, не сломав?
12 месяцев: нужно убрать email (GDPR) → как?
18 месяцев: пишем v2 → как мигрировать 50 клиентов?

Реальность:

  • У клиентов разные релизные циклы (mobile app — годы).
  • Деплой нельзя «отменить» — клиент уже скачал.
  • Backward compatibility — не «фича», а обязательство.

Backward compatibility — новая версия сервера понимает старых клиентов. Forward compatibility — старая версия клиента понимает новый ответ сервера.

Server v2 ←→ Client v1 (backward)
Server v1 ←→ Client v2 (forward)

Хочется обе, тогда можно деплоить server и client независимо.

✅ Не breaking:

  • Добавить optional поле в response.
  • Добавить новый endpoint.
  • Добавить enum value (если клиенты толерантны).
  • Расширить диапазон допустимых значений.

❌ Breaking:

  • Убрать поле из response.
  • Изменить тип поля (string → int).
  • Сделать optional поле required.
  • Изменить семантику (тот же ключ, другой смысл).
  • Сузить диапазон.

1. URL path (самый популярный)

GET /api/v1/users/123
GET /api/v2/users/123

Pros:

  • Видно в URL, легко логировать.
  • Просто роутить (gateway).
  • Кеши прозрачны.

Cons:

  • Семантически не RESTful (URI должен быть «ресурс», не версия).
  • Дублирование кода / endpoints.

2. Header (Accept-Version / custom)

GET /api/users/123
Accept-Version: 2

или:

GET /api/users/123
X-API-Version: 2024-08-15

Pros:

  • URL остаётся «чистым».
  • Гибко: можно versionать по датам (Stripe).

Cons:

  • Не видно «глазами» в URL.
  • Сложнее с кешами.
  • Часто забывают.

3. Content-Type (vendor MIME types)

GET /api/users/123
Accept: application/vnd.myapi.v2+json

Pros:

  • HTTP-стандарт (content negotiation).
  • Самый RESTful.

Cons:

  • Уродливо в коде клиента.
  • Многословно.

4. Subdomain

api.v2.example.com/users/123

Pros:

  • Можно по-разному масштабировать.

Cons:

  • Дополнительный DNS / cert.
  • Сложно для клиентов.

5. Query parameter (worst)

GET /api/users/123?version=2

Cons:

  • Хрупкий: легко забыть.
  • Кеши.
СтратегияПростотаRESTfulCache-friendlyПопулярность
URL path⭐⭐⭐⭐⭐⭐Очень
Header⭐⭐⭐⭐⭐⭐Средне
Content-Type⭐⭐⭐⭐⭐Редко
Subdomain⭐⭐⭐⭐⭐Редко
Query⭐⭐⭐Анти-паттерн

Stripe использует header с date-based версией (Stripe-Version: 2024-08-15). Это «дата API» — позволяет fine-grained эволюцию.

1. Tolerant reader pattern

Клиент игнорирует неизвестные поля:

type User struct {
ID string `json:"id"`
Name string `json:"name"`
Email string `json:"email"`
// не описываем новые поля — JSON decoder их пропускает
}

Это forward compatibility клиента.

2. Defaults для отсутствующих полей

type User struct {
ID string `json:"id"`
Role string `json:"role,omitempty"` // default = ""
}
func (u *User) GetRole() string {
if u.Role == "" {
return "user" // default
}
return u.Role
}

3. Field deprecation

Не удаляй сразу — пометь:

type User struct {
ID string `json:"id"`
Email string `json:"email"` // current
Mail string `json:"mail,omitempty"` // DEPRECATED: use Email
}

OpenAPI:

properties:
mail:
type: string
deprecated: true
description: "DEPRECATED. Use email."

4. New endpoint instead of breaking change

Вместо:

GET /users/123 → раньше возвращал {name}, теперь {firstName, lastName}

Лучше:

GET /v1/users/123 → {name} (старый)
GET /v2/users/123 → {firstName, lastName} (новый)

Protobuf изначально проектировался для эволюции схем.

Правила:

Безопасно:

  1. Добавить новое поле с новым номером — старые читатели игнорируют.
  2. Удалить поле — но reserved для номера:
    message User {
    string id = 1;
    string name = 2;
    reserved 3; // нельзя переиспользовать
    reserved "old_field"; // и имя
    }
  3. Изменить optional → required в proto3 (там всё optional с дефолтами).
  4. Совместимые типы:
    • int32 ↔ uint32 ↔ int64 ↔ uint64 ↔ bool (с потерей precision если range).
    • sint32 ↔ sint64.
    • bytes ↔ string (если valid UTF-8).
  5. Добавить enum value — старые читатели увидят UNKNOWN.
  6. Расширить oneof добавлением полей в него.

Опасно:

  1. Изменить номер поля — старые клиенты будут читать другое поле.
  2. Изменить тип несовместимо (string ↔ int32 — нет).
  3. Переименовать поле — wire format ок (по номерам), но JSON/code generators ломаются.
  4. Сменить cardinality singular → repeated → breaking в wire format.

Пример эволюции:

// v1
message Order {
string id = 1;
double total = 2;
}
// v2 — backward compatible
message Order {
string id = 1;
double total = 2;
string currency = 3; // new field
reserved 4; // зарезервировали на будущее
repeated Item items = 5; // new (старые клиенты не увидят items)
}
message Item {
string sku = 1;
int32 quantity = 2;
}

JSON не имеет встроенной schema → правила менее формальные, но похожие.

✅ Безопасно:

  • Добавить optional поле (с default или omitempty).
  • Принимать дополнительные поля.

❌ Breaking:

  • Удалить поле, которое клиенты читают.
  • Изменить тип ({"age": 25}{"age": "25"}).
  • Сделать optional → required.
  • Изменить семантику.

Go tolerant reader:

type User struct {
ID string `json:"id"`
Name string `json:"name"`
// Не объявленные поля — игнорируются json.Unmarshal по умолчанию.
}
// СТРОГИЙ режим:
dec := json.NewDecoder(r)
dec.DisallowUnknownFields() // упадёт на новых полях — НЕ ИСПОЛЬЗОВАТЬ

Best practice: никогда не использовать DisallowUnknownFields в production-клиентах.

Подход 1: версия в protobuf package

v1/users.proto
syntax = "proto3";
package api.users.v1;
service UserService {
rpc GetUser(GetUserRequest) returns (User);
}
v2/users.proto
package api.users.v2;
service UserService {
rpc GetUser(GetUserRequest) returns (User);
rpc GetUserProfile(GetUserRequest) returns (UserProfile); // new
}

Регистрируются как разные сервисы: api.users.v1.UserService, api.users.v2.UserService.

Подход 2: deprecation внутри одного service

service UserService {
rpc GetUser(GetUserRequest) returns (User) {
option deprecated = true; // mark old method
}
rpc GetUserV2(GetUserRequest) returns (User);
}

Подход 3: эволюция request/response

Добавлять поля по правилам proto, не менять методы.

GraphQL философия: no versions in URL. Эволюция через deprecation.

type User {
id: ID!
name: String!
email: String! @deprecated(reason: "Use `contactInfo.email`")
contactInfo: ContactInfo!
}
type ContactInfo {
email: String!
phone: String
}

Deprecation lifecycle:

  1. Add new field contactInfo.
  2. Mark old email as @deprecated.
  3. Monitor usage (через analytics, например, GraphQL Hive).
  4. После N месяцев — drop с announcements.

Преимущества:

  • Один endpoint.
  • Клиент сам выбирает поля (overlong response не проблема).

Сложности:

  • Schema breaking changes возможны (изменение типа аргумента).
  • Нужны tools для контроля (GraphQL Inspector, Apollo Studio).

Зачем: при сериализации событий (Avro/Protobuf/JSON Schema) обе стороны должны знать схему. Schema Registry — центральное хранилище.

Confluent Schema Registry (de facto):

  • REST API.
  • Хранит схемы по subjects (обычно <topic>-value и <topic>-key).
  • Каждая схема — версия.
  • Compatibility check при регистрации.

Альтернативы:

  • Karapace (Aiven) — open-source Confluent SR replacement.
  • Apicurio Registry (Red Hat) — поддерживает Avro/Proto/JSON Schema/OpenAPI/AsyncAPI.
  • Buf Schema Registry (BSR) — для protobuf.
ModeОписаниеСтарая схема может читать новые данные?Новая схема может читать старые данные?
BACKWARDDefaultДа (новые consumers ↔ старые данные)
BACKWARD_TRANSITIVEСо всеми предыдущими версиямиДа
FORWARDДа
FORWARD_TRANSITIVEДа
FULLBothДаДа
FULL_TRANSITIVEBoth со всеми версиямиДаДа
NONENo checks

Чаще всего: BACKWARD (новые consumers могут читать любые старые сообщения).

Avro schema:

{
"type": "record",
"name": "Order",
"fields": [
{"name": "id", "type": "string"},
{"name": "total", "type": "double"},
{"name": "currency", "type": "string", "default": "USD"}
]
}

Backward compatibility правила (Avro):

  • Можно добавить поле с default.
  • Можно удалить поле, если оно имеет default в новой схеме (sic).
  • Тип можно promotion: int → long → float → double.
  • Union с null: ["null", "string"] для optional.
import (
"github.com/riferrei/srclient"
)
client := srclient.CreateSchemaRegistryClient("http://localhost:8081")
// Register schema (or get existing)
schema, err := client.CreateSchema("orders-value", schemaStr, srclient.Avro)
if err != nil { panic(err) }
// При продюсе:
// magic byte (0x00) + schema_id (4 bytes) + Avro payload
header := make([]byte, 5)
header[0] = 0x00
binary.BigEndian.PutUint32(header[1:5], uint32(schema.ID()))
avroBytes, _ := avroCodec.BinaryFromNative(nil, record)
msg := append(header, avroBytes...)
producer.Produce(msg)
StrategyNamingКогда
TopicName (default)<topic>-value / <topic>-keyОдна schema per topic
RecordName<full.record.Name>Одна schema повторяется в разных topics
TopicRecordName<topic>-<full.Record>Несколько schemas в одном topic

Buf (buf.build) — инструмент Lyft/Buf inc. для protobuf:

  • buf lint — линтер (best practices).
  • buf breaking — детектит breaking changes.
  • buf generate — генерация stubs.
  • buf build — сборка в *.bin image.
  • BSR (Buf Schema Registry) — managed registry.

buf.yaml:

version: v2
modules:
- path: proto
deps:
- buf.build/googleapis/googleapis
breaking:
use:
- FILE
lint:
use:
- DEFAULT

buf.gen.yaml:

version: v2
plugins:
- remote: buf.build/protocolbuffers/go
out: gen/go
opt: paths=source_relative
- remote: buf.build/grpc/go
out: gen/go
opt: paths=source_relative

Breaking detection в CI:

Окно терминала
buf breaking --against 'https://github.com/myorg/proto.git#branch=main'

Если меняешь схему так, что нарушаешь правила — buf breaking фейлит PR. Сэйф.

import "google.golang.org/protobuf/proto"
// Получили message со «лишними» полями (от нового сервера)
var u userpb.User
if err := proto.Unmarshal(data, &u); err != nil { /* */ }
// Unknown fields сохранены в u.ProtoReflect().GetUnknown() и при serialize отправлены обратно.

Unknown fields preservation — proto3 рантайм сохраняет неизвестные поля. При forwarding они не теряются.

Stripe, AWS, GCP делают:

  • API не имеет version в URL.
  • Клиентский SDK имеет версию.
  • При вызове SDK отправляет header Stripe-Version.
  • Сервер по этому header выбирает behavior.
// Stripe Go SDK
stripe.APIVersion = "2024-08-15"
charge, _ := charge.Get("ch_123", nil)
// Внутри SDK добавляется Stripe-Version: 2024-08-15

Stripe approach: версия = дата (2024-08-15).

Преимущества:

  • Fine-grained.
  • Хронология понятна.
  • Можно постепенно эволюционировать.

Сложности:

  • Нужно поддерживать много версий поведения на сервере.
  • Версии — feature-flag в коде.

1. Big bang — все клиенты переходят разом (мало где работает).

2. Parallel running — v1 и v2 живут вместе, постепенная миграция.

3. Strangler — новые фичи только в v2, старые потом мигрируют.

4. Adapter layer — внутренний сервер на v2, gateway трансформирует v1 запросы.

1. Анонс — за N месяцев.
2. Mark deprecated в docs/headers (Deprecation: <date>).
3. Sunset header (RFC 8594): Sunset: Tue, 31 Dec 2026 23:59:59 GMT.
4. Логирование вызовов старых endpoints (metrics).
5. Outreach: email клиентов, остающихся на v1.
6. Soft deprecation: returns 200 + warning header.
7. Hard removal — после deadline.
HTTP/1.1 200 OK
Sunset: Tue, 31 Dec 2026 23:59:59 GMT
Deprecation: Wed, 01 Jan 2026 00:00:00 GMT
Link: <https://api.example.com/v2/users/123>; rel="successor-version"

OpenAPI:

openapi: 3.0.0
info:
version: 1.0.0
servers:
- url: https://api.example.com/v1
paths:
/users/{id}:
get:
deprecated: true
...

Buf BSR — генерирует docs из proto автоматически.


3.1 ⚠️ Удалили поле «никто не использовал»

Заголовок раздела «3.1 ⚠️ Удалили поле «никто не использовал»»

Решили дропнуть поле, посмотрели логи — нули. Через неделю — поломались клиенты, у которых cache holdover. Метрики != правда.

Менять required на optional — backward compatible. Менять optional на requiredbreaking! Старые клиенты не отправят поле.

enum Status {
ACTIVE = 0;
INACTIVE = 1;
BANNED = 2; // new value
}

Старые клиенты увидят UNKNOWN или fallback. Если они делают switch без default → panic. Всегда default-case в switch’е enum’ов.

Удалили int32 age = 3 → потом переиспользовали string email = 3. Wire format ломается, старые данные читаются неверно. Всегда reserved.

{"name": null} // явно null
{} // отсутствует

Семантически разное, но в Go *string == nil для обоих. Может ломать update endpoints (PATCH).

Кто-то выставил NONE — теперь любые изменения. Через месяц — chaos. Default — BACKWARD.

10% пользователей на v1 даже через 2 года. Не получится «дропнуть v1 после 6 месяцев».

3.8 ⚠️ Внутренние клиенты «всегда обновим»

Заголовок раздела «3.8 ⚠️ Внутренние клиенты «всегда обновим»»

Иллюзия. Если у тебя 30 микросервисов, обновить все за квартал — нереально. Even внутри компании нужно version-stability.

Тип тот же (string), но раньше было “ISO 8601 datetime”, теперь “RFC 3339”. Технически не breaking, фактически — поломка. Семантика == часть API.

3.10 ⚠️ Buf breaking ругается на «реструктуризацию»

Заголовок раздела «3.10 ⚠️ Buf breaking ругается на «реструктуризацию»»

Поменял Order { Item items } на Order { Items inner } — formal breaking, хотя wire format может быть совместим. Иногда правила слишком строгие.

3.11 ⚠️ Версии в URL + content negotiation одновременно

Заголовок раздела «3.11 ⚠️ Версии в URL + content negotiation одновременно»

Не путай стратегии. Выбери одну и придерживайся.

CDN кеширует /api/users/123 без учёта Accept-Version. Если кеш не учитывает header → cross-version mix. Используй Vary: Accept-Version.

«Внутренние» API становятся public через интеграции, скрипты, ML pipelines. Версионируй и их.

В GraphQL non-null arguments не добавляются. Сначала добавь nullable, потом мигрируй, потом — non-null.


  • Date-based versions: 2024-08-15.
  • Header: Stripe-Version.
  • Каждая версия — описанные изменения в changelog.
  • Сервер поддерживает все версии (внутри feature-flag).
  • 10+ лет старых версий ещё работают.
  • URL path /v3/.
  • GraphQL для новых интеграций — preview features через header.
  • Deprecation через Sunset header.
  • aws-sdk-go vs aws-sdk-go-v2 (полностью новое API).
  • Внутри version — backward compatible.
/api/v1 # core
/apis/apps/v1 # extensions
/apis/networking.k8s.io/v1beta1 # beta
/apis/networking.k8s.io/v1 # stable
  • v1alpha1v1beta1v1 lifecycle.
  • Конверсия между версиями через webhook.
  • Internal: огромная база .proto файлов.
  • Глобальная политика: no breaking changes.
  • Tooling: внутренний аналог Buf.
  • Часто: REST → gRPC внутри.
  • Strangler pattern: API gateway транслирует HTTP/JSON → gRPC.
  • Постепенно: внутренние сервисы переходят на gRPC native.
  • Каждый topic привязан к subject.
  • Backward compatibility check before publish.
  • При nondeterministic schema → отказ.
  • Метрики: schema usage per consumer.

  1. Какие 5 стратегий версионирования API ты знаешь? Pros/cons каждой.
  2. Что такое backward vs forward compatibility?
  3. Что такое breaking change? Перечисли 5 примеров.
  4. Что такое tolerant reader pattern?
  5. Какие правила эволюции protobuf?
  6. Зачем reserved в protobuf?
  7. Какие типы protobuf совместимы (int32 ↔ … )?
  8. Какие правила эволюции Avro?
  9. Что такое Schema Registry? Зачем?
  10. Чем BACKWARD отличается от FULL в Schema Registry?
  11. Какие subject naming strategies в Schema Registry?
  12. Как версионировать gRPC API?
  13. Как делается deprecation в GraphQL?
  14. Что такое Sunset header?
  15. Как использует версии Stripe?
  16. Зачем нужен Buf, что он делает?
  17. Что делает buf breaking?
  18. Как обрабатывать новые enum values в клиенте?
  19. Почему DisallowUnknownFields в JSON — обычно плохо?
  20. Какие проблемы при reuse field number в protobuf?
  21. Что делать, если 10% mobile клиентов на v1 через 2 года?
  22. Как версионировать events в Kafka?
  23. Чем BACKWARD отличается от BACKWARD_TRANSITIVE?
  24. Как deprecation работает в OpenAPI?
  25. Как организовать миграцию клиентов с v1 на v2?

  1. Создай proto Order { string id; double total; }.
  2. Добавь поле currency — должно работать со старыми клиентами.
  3. Удали поле через reserved — добавь test «старый клиент читает новые данные».
  4. Прогони buf breaking против main — должно пройти.
  1. Подними Confluent Platform (Schema Registry + Kafka) в Docker.
  2. Зарегистрируй Avro schema для topic orders.
  3. На Go: продюс события с этой схемой.
  4. Изменить схему backward-compatible (default value).
  5. Попытаться сделать breaking change → должен fail.
  1. Реализуй HTTP API /v1/users/{id} с полем name.
  2. Добавь /v2/users/{id} с полями firstName, lastName.
  3. На уровне service layer — общая логика.
  4. На handler — разная сериализация.
  5. Добавь deprecation header в /v1/.
  1. Создай buf.yaml, buf.gen.yaml.
  2. Опиши proto файл.
  3. buf generate → получи Go stubs.
  4. Сделай breaking change → buf breaking фейлит.
  5. Настрой в GitHub Actions: buf breaking против main.
  1. Сделай HTTP middleware, который читает X-API-Version: 2026-01-15.
  2. На основе версии — выбирает behavior (feature flags).
  3. Реализуй 2 версии endpoint (поле было email, стало contact).
  4. Документ в README: список версий и changes.

  1. Roy Fielding — Architectural Styles and the Design of Network-based Software Architectures (REST dissertation)
  2. RESTful Web APIs — Leonard Richardson, Sam Ruby, Mike Amundsen
  3. Stripe Engineering — APIs as infrastructure: future-proofing Stripe with versioning: https://stripe.com/blog/api-versioning
  4. Designing Web APIs — Brenda Jin et al. (O’Reilly)
  5. Google AIP (API Improvement Proposals): https://google.aip.dev/
  6. Microsoft REST API Guidelines: https://github.com/microsoft/api-guidelines
  7. Protocol Buffers — Language Guide: https://protobuf.dev/programming-guides/
  8. Buf documentation: https://buf.build/docs/
  9. Confluent Schema Registry: https://docs.confluent.io/platform/current/schema-registry/
  10. Karapace: https://github.com/Aiven-Open/karapace
  11. Apicurio Registry: https://www.apicur.io/registry/
  12. GraphQL deprecation: https://graphql.org/learn/best-practices/#versioning
  13. RFC 8594 — Sunset HTTP Header: https://datatracker.ietf.org/doc/html/rfc8594
  14. Documenting REST APIs with OpenAPI — Swagger/OpenAPI specs
  15. Apollo GraphQL versioning: https://www.apollographql.com/docs/technotes/TN0021-graph-versioning/
  16. Buf Schema Registry: https://buf.build/docs/bsr/
  17. AsyncAPI for event-driven APIs: https://www.asyncapi.com/

Резюме. API versioning — это не «выбрать /v1 или header», это методика эволюции схем без поломки клиентов. Tech lead понимает: protobuf rules (reserved, type compatibility), Schema Registry compatibility modes, Buf для CI-проверок, GraphQL deprecation lifecycle. URL path — самая популярная стратегия для REST. Date-based versioning (Stripe) — для тонкой эволюции. Главное правило: никогда не ломай прод-клиентов, всегда добавляй новое и deprecate старое с явным sunset.