Совместимость: как менять то, чем уже пользуются
Пока у кода нет потребителей, менять его бесплатно: переименовали, переставили аргументы, удалили поле — компилятор поправит вызовы, тесты позеленеют. Как только появился хотя бы один потребитель, которого вы не контролируете — другая команда, внешний клиент, вчерашняя версия вашего же сервиса, работающая рядом во время выкатки, — правила меняются. Теперь у изменения есть радиус поражения, и большая часть инженерной работы уходит не на «как сделать лучше», а на «как перейти от текущего к лучшему, никого не сломав».
Это отдельная дисциплина, и её отсутствие видно сразу: команда либо боится трогать публичные интерфейсы вообще (и они гниют годами), либо ломает их регулярно (и на неё перестают полагаться). Между этими крайностями есть набор техник, которые здесь и разберём.
В предыдущей статье мы говорили о том, кто на
кого имеет право ссылаться. Теперь — о том, что происходит с этими ссылками во времени. Механику
безопасных преобразований (Parallel Change, Branch by Abstraction) мы уже разбирали в
статье про рефакторинг; здесь фокус другой —
не «как поменять код», а «что именно вы пообещали и как это обещание эволюционирует».
1. Закон Хайрама: контракт — не то, что вы написали в документации
With a sufficient number of users of an API, it does not matter what you promise in the contract: all observable behaviors of your system will be depended on by somebody.
— Hyrum Wright, hyrumslaw.com
Формулировка выглядит шуткой, но это самый практичный закон в этой статье. Он утверждает: ваш фактический контракт — это множество всех наблюдаемых свойств вашей системы, а не подмножество, которое вы задокументировали. Примеры, которые каждый видел:
- Порядок ключей в JSON-ответе (не гарантирован, но клиент парсит регулярным выражением).
- Порядок строк без
ORDER BY(не гарантирован, но отчёт «сломался» после смены плана запроса). - Текст сообщения об ошибке (парсится клиентом, потому что кода ошибки в API нет).
- Скорость ответа (клиент выставил таймаут 200 мс, потому что «оно всегда отвечало за 50»).
- Порядок обхода map в Go — до тех пор, пока Go его специально не рандомизировал, чтобы сломать зависимость от него рано и у всех сразу.
Последний пример — главный практический вывод из закона: если что-то не должно стать контрактом, сделайте это наблюдаемо нестабильным. Так поступают все зрелые платформы:
- Go рандомизирует порядок итерации по map.
- TLS использует GREASE (RFC 8701) — периодически посылает фиктивные значения расширений, чтобы реализации не «зашивали» текущий набор.
- Многие API добавляют в ответы случайные пробелы или переставляют поля, чтобы клиенты не сравнивали ответы побайтово.
Обратная сторона закона — история операционных систем. Microsoft десятилетиями возит в Windows слой совместимости, потому что в SimCity был баг работы с памятью, а «SimCity перестал запускаться» — это дефект Windows с точки зрения пользователя (см. The Old New Thing). Линус Торвальдс формулирует то же правило одной строкой: «we do not break userspace».
Практический смысл для обычной команды: чем богаче наблюдаемое поведение, тем дороже эволюция. Отсюда — узкие интерфейсы, отсутствие лишних гарантий в ответах, явное «этим полагаться нельзя» в документации и, главное, механизмы, которые не дают привыкнуть к незадокументированному.
2. Что является контрактом: полная инвентаризация
Разработчики обычно вспоминают HTTP API и забывают всё остальное. Полный список того, что вы кому-то пообещали:
| Контракт | Кто потребитель | Чем ломается | Чем проверяется |
|---|---|---|---|
| Публичный API библиотеки | код, который её импортирует | сигнатуры, семантика | apidiff, japicmp, cargo-semver-checks |
| HTTP/gRPC API | внешние и внутренние клиенты | поля, коды, статусы | OpenAPI-diff, buf breaking, контрактные тесты |
| Схема событий | подписчики, возможно из прошлого года | типы полей, обязательность | schema registry, правила совместимости |
| Схема БД | все версии приложения во время выкатки | колонки, ограничения, типы | миграции + тесты N-1 |
| Формат файлов/состояния | старые и новые версии приложения | сериализация | golden-файлы, тесты чтения старых версий |
| CLI и коды возврата | скрипты, CI других команд | флаги, формат вывода | snapshot-тесты CLI |
| Конфигурация | эксплуатация, IaC | имена и семантика ключей | валидация схемы конфига |
| Метрики и логи | дашборды, алерты, биллинг | имена метрик и полей | тесты на имена, ревью алертов |
Последние две строки — самые недооценённые. Переименование метрики http_requests_total ломает
дашборды и алерты так же надёжно, как переименование поля в API ломает клиента; изменение формата
лога ломает парсер, на котором висит алерт. Это ровно закон Хайрама на инфраструктурном уровне.
3. Три вида совместимости и почему их путают
потребитель или поставщик?"} Q1 -- "поставщик
(типично для API и БД)" --> B["Нужна ОБРАТНАЯ совместимость:
новый код читает старые данные
и обслуживает старых клиентов"] Q1 -- "потребитель
(типично для событий, файлов, форматов)" --> F["Нужна ПРЯМАЯ совместимость:
старый код не падает
на данных нового формата"] Q1 -- "порядок неизвестен
(rolling deploy, мобильные клиенты)" --> FU["Нужна ПОЛНАЯ совместимость:
обе стороны в любом порядке"] B --> R["Разрешено: добавлять необязательное,
расширять множество принимаемых значений"] F --> R2["Требуется: игнорировать неизвестное,
не валидировать строго лишнее"] FU --> R3["Пересечение обоих наборов правил:
только аддитивные изменения"]
Три термина, которые нужно различать, потому что от них зависит план изменения:
- Обратная совместимость (backward) — новая версия поставщика работает со старыми потребителями и старыми данными. Это то, что обычно имеют в виду, говоря «не сломали».
- Прямая совместимость (forward) — старая версия потребителя не падает, встретив данные новой версии. Обеспечивается только заранее: «игнорируй неизвестные поля» должно было быть написано до того, как поля появились.
- Полная (full) — оба свойства сразу. Требуется всегда, когда порядок обновления сторон не контролируется: rolling deploy, мобильные приложения, очереди с накопленными сообщениями.
Ортогонально — уровень, на котором совместимость нарушается:
| Уровень | Что ломается | Пример |
|---|---|---|
| Исходный (source) | компиляция потребителя | добавили обязательный параметр в функцию |
| Двоичный/провода (binary/wire) | линковка или разбор сообщения | сменили тип поля с int32 на string |
| Поведенческий (behavioral) | ничего не ломается формально, поведение другое | метод стал возвращать пустой список вместо ошибки |
| Семантический | смысл значения изменился | поле amount стало в копейках вместо рублей |
Самые дорогие — два нижних: они проходят все автоматические проверки и обнаруживаются инцидентом.
Именно поэтому семантические изменения делают только через новое имя: не «поменяли смысл
amount», а «добавили amount_minor, старое поле объявлено устаревшим».
4. SemVer: что номер версии обещает и чего не обещает
Семантическое версионирование — соглашение, а не механизм:
MAJOR.MINOR.PATCH, где мажор означает несовместимые изменения, минор — совместимые добавления,
патч — совместимые исправления. Пользу оно приносит ровно в той мере, в которой автор его
соблюдает, а автоматика проверяет.
Что важно понимать про его границы:
- SemVer описывает намерение, а не факт. По закону Хайрама почти любое изменение кому-то ломает жизнь; «патч» означает «мы считаем это исправлением», а не «сломать не может».
- Мажорная версия — это не разрешение ломать, а обязательство мигрировать. Каждый мажор стоит вашим потребителям денег. Три мажора в год — способ потерять потребителей.
0.x— отдельная зона. По спецификации там можно всё, поэтому долгое сидение на0.xу популярной библиотеки — плохая практика: пользователи всё равно на неё опираются.
Экосистемы решают проблему мажоров по-разному, и это поучительно:
// Go: правило совместимости импорта — новый мажор живёт по НОВОМУ пути.
// Обе версии могут сосуществовать в одной сборке, миграция идёт по файлам.
import (
kafka "github.com/segmentio/kafka-go" // v0/v1
kafkav2 "github.com/example/kafka-go/v2" // v2: /v2 прямо в пути импорта
)
// Плюс: нет "diamond dependency hell" — разные мажоры это разные пакеты.
// Минус: код, отдающий типы наружу, при мажоре обязан продублировать типы.
// Rust: тот же приём вручную ("semver trick") — старый крейт становится
// тонкой обёрткой над новым, чтобы типы v1 и v2 были ОДНИМИ И ТЕМИ ЖЕ типами.
// old_crate 1.0.1 = pub use new_crate::Type; → диамант разрешается.
// Java: japicmp/revapi в сборке сравнивают публичный API с предыдущим релизом
// и падают, если изменение не соответствует заявленному приросту версии.
// Мажорные версии часто получают новое имя пакета (com.example.lib3),
// иначе classpath не позволит двум версиям сосуществовать.
Практическое правило, снимающее большую часть споров: версию должен вычислять инструмент, а не
человек. cargo-semver-checks, go-apidiff, japicmp, buf breaking, api-extractor сравнивают
публичную поверхность с предыдущим релизом и говорят, какой прирост допустим. Человек ошибается
здесь систематически: почти никто не помнит, что расширение возвращаемого перечисления —
несовместимое изменение для потребителя, делающего исчерпывающий match.
Для сервисов, у которых потребители внешние, чаще применяют не SemVer, а датированные версии с
закреплением: клиент фиксируется на версии 2024-11-20, сервер держит слой трансформации между
версиями (так устроено версионирование Stripe). Плюс —
клиенту не нужно мигрировать никогда; минус — вы навсегда сопровождаете N слоёв преобразований.
Сравнение подходов по стилям API — в
статье про стили API, а про версии
дистрибутивов и каналы обновлений — в
обновлениях и версиях.
5. Эволюция схем данных
Данные живут дольше кода: сообщение в очереди, файл в S3 и строка в БД переживут три поколения сервисов. Поэтому правила эволюции схем строже, чем правила эволюции функций.
Protocol Buffers — самый продуманный набор правил, полезный как эталон даже если вы на JSON:
syntax = "proto3";
message Order {
string id = 1;
int64 amount_minor = 2; // сумма в минорных единицах: смысл зафиксирован в имени
string currency = 3;
reserved 4, 7 to 9; // номера удалённых полей НИКОГДА не переиспользуются
reserved "discount_percent"; // и имена тоже: старые читатели не должны обмануться
// Новое поле — только новый номер и только необязательное по смыслу.
optional string promo_code = 10;
// Перечисления: значение по умолчанию (0) обязано означать "неизвестно",
// иначе старый читатель примет новое значение за осмысленное.
enum Status {
STATUS_UNSPECIFIED = 0;
STATUS_NEW = 1;
STATUS_PAID = 2;
}
Status status = 11;
}
Правила, которые из этого следуют и переносятся на любой формат:
- Номер/имя поля — вечный идентификатор. Удалили поле — зарезервируйте, не переиспользуйте.
- Тип поля не меняется. Нужен другой тип — новое поле и период двойной записи.
- Обязательность добавлять нельзя. Требование «поле должно присутствовать» ломает всех старых писателей.
- Неизвестные поля сохраняются, а не отбрасываются. proto3 хранит их (с версии 3.5), Avro делает разрешение схем читателя и писателя. Для JSON это ваша ответственность: сервис-посредник, который «нормализует» сообщение, теряя незнакомые поля, — источник самых загадочных багов.
- Перечисления открыты. Всегда предусматривайте значение «неизвестно» и ветку обработки для него.
Для JSON-схем и Avro ту же дисциплину даёт реестр схем: Confluent Schema Registry
проверяет каждое изменение против режима совместимости (BACKWARD, FORWARD, FULL и их
транзитивные варианты) и отклоняет несовместимую схему на этапе публикации, а не в проде.
Толерантный читатель. Со стороны потребителя правило одно: читайте только то, что вам нужно, и не падайте от лишнего. Это тот же приём, что разбирается в паттернах границ; в статье про обработку ошибок мы обсуждали обратную сторону — строгую валидацию собственного ввода.
Важная поправка к классическому «принципу устойчивости» Постела («будь консервативен в том, что отправляешь, и либерален в том, что принимаешь»): IETF в RFC 9413 «Maintaining Robust Protocols» прямо показывает, что безграничная либеральность вредна — она консервирует чужие ошибки и делает спецификацию нереализуемой заново. Современная формулировка: принимайте расширяемое, но отвергайте невалидное, и активно используйте точки расширения, чтобы они не «залипли».
6. Совместимость во время выкатки: правило N−1
Даже если у вашего сервиса нет внешних клиентов, у него есть потребитель, о котором забывают: предыдущая версия его самого. Во время rolling deploy (и любого канареечного релиза) в одном кластере работают обе версии, читают одну БД и обрабатывают одну очередь.
Три правила, нарушение которых ломает выкатку:
- Одна миграция — один шаг. Добавление колонки и удаление старой не могут быть в одном релизе: между ними должен пройти полный цикл выкатки и период отката.
- Сначала читатели, потом писатели. Способность читать новый формат выкатывается раньше, чем его начинают писать. Иначе старые экземпляры получат данные, которых не понимают.
- Откат должен быть возможен после каждого шага. Если после шага 4 вы откатились на v1, база
уже содержит записи только с
amount_minor— значит, v1 обязан был уметь их читать, либо шаг 4 не должен был удалять запись старого поля. Практически это означает, что двойная запись живёт дольше, чем кажется нужным.
Механику самих миграций без блокировок (gh-ost, pt-online-schema-change, CREATE INDEX CONCURRENTLY) и стоимость длинных ALTER разбирает трек производительности —
производительность БД; стратегии
выкатки (canary, blue-green) — релизные стратегии.
7. Депрекация как процесс, а не как комментарий
@deprecated в коде без плана — это не депрекация, а пожелание. Работающий процесс выглядит как
явный жизненный цикл с датами и метриками.
Что должно быть у каждой депрекации:
- Замена, которая лучше. Депрекация без готовой альтернативы игнорируется законно.
- Дата удаления, а не «когда-нибудь». Без даты миграция не попадёт ни в чей план.
- Метрика использования:
deprecated_usage_total{endpoint, client}— единственный объективный способ понять, можно ли удалять. Именно эта метрика превращает спор в факт. - Машиночитаемое уведомление: для HTTP это заголовки
DeprecationиSunset(RFC 8594), для библиотек — предупреждения компилятора и линтера, которые видит потребитель. - Персональные уведомления главным потребителям. Пять клиентов из метрики дают 95% трафика — напишите им лично, это дешевле рассылки.
Отдельный приём для внутренних API, где вы контролируете и потребителей: мигрируйте за них.
Инструментальная миграция (codemod: jscodeshift, gofmt -r, comby, ast-grep) и PR в чужие
репозитории от команды-владельца работают в разы быстрее, чем рассылка. Это стандартная практика
в монорепозиториях: тот, кто меняет, тот и чинит потребителей — см.
монорепозитории.
8. Как это проверяет машина
Совместимость — идеальный кандидат на автоматическую проверку: правила формальны, а человек их систематически забывает.
| Что проверяем | Инструмент | Где запускается |
|---|---|---|
| Публичный API библиотеки | cargo-semver-checks, go-apidiff, japicmp, api-extractor |
PR, сравнение с базовой веткой |
| Protobuf/gRPC | buf breaking |
PR, сравнение с main |
| OpenAPI | oasdiff, openapi-diff |
PR |
| Схемы событий | Schema Registry compatibility mode | публикация схемы |
| Схема БД + код | тесты «старое приложение против новой схемы» | ночная сборка |
| Взаимодействие сервисов | контрактные тесты (Pact и аналоги) | CI обеих сторон |
Самый недооценённый пункт — тест N−1: интеграционный прогон, где старая версия приложения работает против новой схемы БД и наоборот. Он ловит именно тот класс ошибок, который невозможно увидеть в unit-тестах, и стоит одного docker-compose файла.
# .github/workflows/compat.yml — минимальный набор проверок совместимости
name: compatibility
on: [pull_request]
jobs:
api-breaking:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with: { fetch-depth: 0 } # нужна история: сравниваем с базовой веткой
- name: Protobuf breaking changes
run: buf breaking --against ".git#branch=${{ github.base_ref }}"
- name: OpenAPI breaking changes
run: oasdiff breaking origin/${{ github.base_ref }}:openapi.yaml openapi.yaml
n-minus-1:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Поднять предыдущий релиз приложения против новой схемы
run: |
docker compose up -d db
./scripts/migrate.sh # миграции из текущего PR
APP_TAG=$(git describe --tags --abbrev=0) # предыдущий релиз
docker run --network host "app:${APP_TAG}" ./run-smoke-tests.sh
О том, как встраивать такие проверки в конвейер и не превратить их в раздражитель, — в статье про автоматические проверки; про контрактные тесты как класс — в принципах тестирования.
9. Типичные ошибки
- «Мы же не обещали». Обещали: закон Хайрама не спрашивает разрешения. Если поведение наблюдаемо и стабильно — на него уже полагаются.
- Изменение смысла поля вместо добавления нового.
amountиз рублей в копейки — самый дорогой класс поломок: ничего не падает, всё считается неверно. - Мажорная версия как способ не думать. «Это ломающее изменение, выпустим 3.0» — а потребители останутся на 2.x навсегда, и вы будете сопровождать обе ветки.
- Депрекация без даты и метрики. Живёт вечно; удалять страшно, потому что неизвестно, кто ещё пользуется.
- Удаление и добавление в одном релизе. Ломает rolling deploy и делает откат невозможным.
- Строгая валидация неизвестных полей у потребителя. Гарантирует поломку при любом расширении формата поставщиком.
- Переиспользование номеров/имён удалённых полей. Старый читатель прочитает новое поле как старое — молча и неверно.
- Игнорирование неявных контрактов. Имена метрик, формат логов, коды возврата CLI ломают чужие системы так же, как API.
- Версионирование «на всякий случай».
/v1/в URL, который никогда не станет/v2/, — косметика; реальную совместимость даёт дисциплина изменений, а не префикс. - Отсутствие «нестабильных» зон. Если всё стабильно по умолчанию, вы не сможете экспериментировать; помечайте новое как экспериментальное явно и с самого начала.
10. Как это выглядит в проде
- Политика совместимости записана и лежит рядом с кодом: что считается ломающим изменением, какой срок депрекации, кто утверждает исключения. Это ADR — см. архитектурные решения.
- Проверки совместимости — обязательные (required) в CI, с явной процедурой осознанного обхода: метка на PR плюс ссылка на план миграции.
- Дашборд использования устаревших API по клиентам; удаление разрешает не календарь, а нули на графике.
- Двойная запись и теневое чтение при миграциях данных: новая логика считает результат параллельно и логирует расхождения, прежде чем стать источником истины.
- Каталог схем (реестр событий, OpenAPI-спеки в одном месте) — потому что нельзя эволюционировать то, чего нет в инвентаре.
- Флаг для каждого рискованного переключения: изменение читающей стороны включается флагом и откатывается без деплоя.
- Правило «мигрирует тот, кто ломает» внутри организации: владелец API присылает PR потребителям или пишет codemod.
11. Мини-итог
- Фактический контракт — это все наблюдаемые свойства системы (закон Хайрама), а не только задокументированные. Что не должно стать контрактом, делайте наблюдаемо нестабильным.
- Контрактов больше, чем кажется: API, схемы событий и БД, файлы, CLI, конфиги, метрики и логи.
- Различайте обратную, прямую и полную совместимость и уровень поломки: исходный, двоичный, поведенческий, семантический. Два последних опаснее всего.
- SemVer — обещание, а не гарантия; прирост версии должен вычислять инструмент. Для внешних API часто удобнее датированные версии с закреплением клиента.
- Схемы данных эволюционируют аддитивно: номера полей вечны, типы неизменны, обязательность не добавляется, неизвестное сохраняется, перечисления открыты.
- Во время выкатки ваш главный потребитель — предыдущая версия вас самих: expand → двойная запись → backfill → переключение чтения → contract, по одному шагу на релиз.
- Депрекация — процесс с заменой, датой, метрикой, машиночитаемым уведомлением и brownout.
- Всё перечисленное проверяемо машиной:
buf breaking,oasdiff, japicmp, contract-тесты и прогон N−1.
Источники
- Hyrum Wright. Hyrum’s Law; Titus Winters и др. Software Engineering at Google, гл. «Deprecation» — бесплатно онлайн
- Titus Winters. Non-Atomic Refactoring and Software Sustainability, ICSE-SEIP 2018
- Semantic Versioning 2.0.0; Russ Cox. Go Modules: Import Compatibility Rule
- The Go 1 Compatibility Promise
- Google. Protocol Buffers: Updating a Message Type
- Confluent. Schema Evolution and Compatibility
- IETF. RFC 9413: Maintaining Robust Protocols, RFC 8594: The Sunset HTTP Header Field, RFC 8701: GREASE
- Stripe. APIs as infrastructure: future-proofing Stripe with versioning
- Martin Fowler. TolerantReader, ParallelChange
- Buf. Breaking change detection
Что дальше
Всё, о чём шла речь, предполагает, что вы контролируете обе стороны контракта и можете аккуратно пройти по шагам. Реальность чаще другая: есть система, написанная до вас, без тестов, без документации и с потребителями, о которых никто не помнит. Дальше — про то, как вообще подступиться к такому коду: швы, характеризующие тесты, техники разрыва зависимостей и правила безопасных изменений там, где страховочной сети нет.
Легаси-код: швы, характеризующие тесты и безопасные изменения