Принципы разработки Совместимость: как менять то, чем уже пользуются
0%

Совместимость: как менять то, чем уже пользуются

Совместимость: как менять то, чем уже пользуются

Пока у кода нет потребителей, менять его бесплатно: переименовали, переставили аргументы, удалили поле — компилятор поправит вызовы, тесты позеленеют. Как только появился хотя бы один потребитель, которого вы не контролируете — другая команда, внешний клиент, вчерашняя версия вашего же сервиса, работающая рядом во время выкатки, — правила меняются. Теперь у изменения есть радиус поражения, и большая часть инженерной работы уходит не на «как сделать лучше», а на «как перейти от текущего к лучшему, никого не сломав».

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

В предыдущей статье мы говорили о том, кто на кого имеет право ссылаться. Теперь — о том, что происходит с этими ссылками во времени. Механику безопасных преобразований (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. Три вида совместимости и почему их путают

Три термина, которые нужно различать, потому что от них зависит план изменения:

  • Обратная совместимость (backward) — новая версия поставщика работает со старыми потребителями и старыми данными. Это то, что обычно имеют в виду, говоря «не сломали».
  • Прямая совместимость (forward) — старая версия потребителя не падает, встретив данные новой версии. Обеспечивается только заранее: «игнорируй неизвестные поля» должно было быть написано до того, как поля появились.
  • Полная (full) — оба свойства сразу. Требуется всегда, когда порядок обновления сторон не контролируется: rolling deploy, мобильные приложения, очереди с накопленными сообщениями.

Ортогонально — уровень, на котором совместимость нарушается:

Уровень Что ломается Пример
Исходный (source) компиляция потребителя добавили обязательный параметр в функцию
Двоичный/провода (binary/wire) линковка или разбор сообщения сменили тип поля с int32 на string
Поведенческий (behavioral) ничего не ломается формально, поведение другое метод стал возвращать пустой список вместо ошибки
Семантический смысл значения изменился поле amount стало в копейках вместо рублей

Самые дорогие — два нижних: они проходят все автоматические проверки и обнаруживаются инцидентом. Именно поэтому семантические изменения делают только через новое имя: не «поменяли смысл amount», а «добавили amount_minor, старое поле объявлено устаревшим».


4. SemVer: что номер версии обещает и чего не обещает

Семантическое версионирование — соглашение, а не механизм: MAJOR.MINOR.PATCH, где мажор означает несовместимые изменения, минор — совместимые добавления, патч — совместимые исправления. Пользу оно приносит ровно в той мере, в которой автор его соблюдает, а автоматика проверяет.

Что важно понимать про его границы:

  1. SemVer описывает намерение, а не факт. По закону Хайрама почти любое изменение кому-то ломает жизнь; «патч» означает «мы считаем это исправлением», а не «сломать не может».
  2. Мажорная версия — это не разрешение ломать, а обязательство мигрировать. Каждый мажор стоит вашим потребителям денег. Три мажора в год — способ потерять потребителей.
  3. 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 (и любого канареечного релиза) в одном кластере работают обе версии, читают одну БД и обрабатывают одну очередь.

Три правила, нарушение которых ломает выкатку:

  1. Одна миграция — один шаг. Добавление колонки и удаление старой не могут быть в одном релизе: между ними должен пройти полный цикл выкатки и период отката.
  2. Сначала читатели, потом писатели. Способность читать новый формат выкатывается раньше, чем его начинают писать. Иначе старые экземпляры получат данные, которых не понимают.
  3. Откат должен быть возможен после каждого шага. Если после шага 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. Типичные ошибки

  1. «Мы же не обещали». Обещали: закон Хайрама не спрашивает разрешения. Если поведение наблюдаемо и стабильно — на него уже полагаются.
  2. Изменение смысла поля вместо добавления нового. amount из рублей в копейки — самый дорогой класс поломок: ничего не падает, всё считается неверно.
  3. Мажорная версия как способ не думать. «Это ломающее изменение, выпустим 3.0» — а потребители останутся на 2.x навсегда, и вы будете сопровождать обе ветки.
  4. Депрекация без даты и метрики. Живёт вечно; удалять страшно, потому что неизвестно, кто ещё пользуется.
  5. Удаление и добавление в одном релизе. Ломает rolling deploy и делает откат невозможным.
  6. Строгая валидация неизвестных полей у потребителя. Гарантирует поломку при любом расширении формата поставщиком.
  7. Переиспользование номеров/имён удалённых полей. Старый читатель прочитает новое поле как старое — молча и неверно.
  8. Игнорирование неявных контрактов. Имена метрик, формат логов, коды возврата CLI ломают чужие системы так же, как API.
  9. Версионирование «на всякий случай». /v1/ в URL, который никогда не станет /v2/, — косметика; реальную совместимость даёт дисциплина изменений, а не префикс.
  10. Отсутствие «нестабильных» зон. Если всё стабильно по умолчанию, вы не сможете экспериментировать; помечайте новое как экспериментальное явно и с самого начала.

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.

Источники


Что дальше

Всё, о чём шла речь, предполагает, что вы контролируете обе стороны контракта и можете аккуратно пройти по шагам. Реальность чаще другая: есть система, написанная до вас, без тестов, без документации и с потребителями, о которых никто не помнит. Дальше — про то, как вообще подступиться к такому коду: швы, характеризующие тесты, техники разрыва зависимостей и правила безопасных изменений там, где страховочной сети нет.

Легаси-код: швы, характеризующие тесты и безопасные изменения

Нашли неточность? Выделите фрагмент текста — рядом появится жучок.

Нужен разбор именно вашей ситуации?

Статья описывает общий случай. Если у вас частный — можно разобрать его отдельно, платно. А если не хватает целого материала, предложите тему: её оплачивают вскладчину, и она выходит открытой для всех.

Доска запросов