{ "index": 143, "slug": "editorial-2024-01-mechanism-legacy-modernization", "title": "Модернизация legacy без ложной совместимости: как проверять поведение", "excerpt": "Одинаковый HTTP-ответ не доказывает совместимость старой и новой реализации. Разбираем контракт, побочные эффекты, повтор запроса и минимальную проверку перед переключением маршрута.", "contentHtml": "

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

\n

Совместимость legacy-кода нельзя свести к сравнению сериализованного тела. Клиент зависит от входных ограничений, статуса, обязательных полей, заголовков, времени чтения состояния и побочных эффектов. Если эти свойства не записать до миграции, после переключения команда будет спорить не о факте регрессии, а о самом определении контракта.

\n

Главный вопрос: что именно нужно сохранить

\n

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

\n

Полезная рабочая формулировка: новая реализация должна сохранить не каждую строку старого кода, а согласованный набор инвариантов. Для read-only операции это обычно статус, обязательные поля и отсутствие записи. Для создания ресурса добавляются ключ намерения, результат повтора и границы транзакции. Для платежа понадобятся денежная точность, авторизация, журнал операции и сверка с внешней системой. Один универсальный чек-лист здесь опасен: он смешивает косметическую разницу и повторное списание.

\n

Четыре слоя поведенческого контракта

\n

Разложите шов на четыре слоя. Transport — метод, путь, статус и значимые заголовки. Payload — типы, обязательность полей, разница между отсутствующим и null, формат ошибки. Effect — запись в базе, публикация сообщения, очистка кеша или запрет повторного действия. Time — состояние, в котором прочитаны данные, часовой пояс, срок действия и смысл слова «повтор».

\n

OpenAPI фиксирует интерфейс HTTP и описывает входные и выходные схемы. Это полезная граница: инструменты видят paths, operations, responses и типы данных. Но схема не сообщает, записал ли обработчик событие, из какого snapshot прочитал баланс или будет ли второй POST создавать ресурс. Наличие валидной схемы — доказательство формы, а не поведенческой совместимости.

\n
\"Матрица
У каждого слоя свой способ наблюдения: заголовки и статус, тело ответа, журнал эффекта и фиксированное состояние. Иллюстрация не содержит ответов реального сервиса и не заменяет проверку в тестовом контуре.
\n

Для каждого слоя задайте минимальное доказательство. Статус берите из HTTP-ответа, а не из текста ошибки. Поля сравнивайте после нормализации порядка ключей, но не удаляйте неизвестные поля без правила потребителя. Эффект проверяйте журналом, счётчиком, тестовой записью или API аудита. Время фиксируйте в данных кейса: иначе утренний и вечерний ответы могут расходиться из-за состояния, а не из-за миграции.

\n
Как интерпретировать различие между legacy и новой реализацией
РазличиеПочему оно возниклоЧто проверитьРешение
Порядок ключей в JSONИзменился сериализаторЗависит ли потребитель от порядка объектаНормализовать сравнение; не считать порядок значимым без явного контракта
Добавилось необязательное полеНовая схема расшириласьИгнорирует ли старый клиент неизвестные поля по договоруОставить поле optional и задокументировать правило либо версионировать ответ
Поменялись статус или код ошибкиДругой валидатор или обработчик исключенийКакие ветки клиента зависят от 4xx/5xx и error codeСохранить error contract или объявить несовместимое изменение
Повтор создал вторую записьОтвет сравнили, эффект — нетОдинаковый ли ключ намерения и есть ли дедупликация на сервереДобавить прикладную идемпотентность либо не переводить операцию
Расходится только значение утромРазное состояние или часовой поясФиксированные данные, timestamp и источник времениОпределить time boundary и повторить кейс на одном состоянии
\n

Минимальный набор кейсов

\n

Для первой проверки достаточно трёх кейсов, но это не полное тестовое покрытие. Valid показывает основной результат и обязательные поля. Invalid проверяет отказ, его категорию и отсутствие запрещённого эффекта. Repeat отправляет тот же intent повторно и проверяет состояние после первой попытки. В каждом кейсе должны быть precondition, запрос, ожидаемый ответ, ожидаемый эффект, место наблюдения и владелец.

\n

Не называйте любой одинаковый JSON идемпотентностью. В HTTP идемпотентность относится к намеренному эффекту повторных одинаковых запросов, а не гарантирует одинаковый текст ответа и не делает любой POST безопасным для повтора. Прикладной ключ должен однозначно обозначать намерение в пределах выбранного потребителя и окна хранения. Если тот же ключ приходит с другим набором параметров, это отдельная ошибка конфликта, а не повод молча выполнить новый запрос.

\n

Воспроизводимое сравнение двух путей

\n

Ниже — стендовый shell-пример. Он не предполагает конкретный фреймворк: укажите адреса старого и нового маршрута в первых строках. Для работы нужны только curl и jq. Пример сравнивает статус и тело, а для эффекта оставляет отдельный запрос к журналу аудита. Если у сервиса нет такого наблюдаемого источника, эффект остаётся неизвестным.

\n
set -eu\n\nOLD_URL=\"http://localhost:8080/legacy/orders\"\nNEW_URL=\"http://localhost:8081/orders\"\nAUDIT_URL=\"http://localhost:8081/audit\"\nCASE_ID=\"compat-42\"\nTMP_DIR=\"$(mktemp -d)\"\ntrap 'rm -rf \"$TMP_DIR\"' EXIT\n\nrequest() {\n  url=\"$1\"\n  name=\"$2\"\n  curl -sS -D \"$TMP_DIR/$name.headers\" \\\n    -o \"$TMP_DIR/$name.json\" \\\n    -w '%{http_code}' \\\n    -H 'content-type: application/json' \\\n    -H \"Idempotency-Key: $CASE_ID\" \\\n    --data '{\"orderId\":\"demo-42\",\"amount\":1000,\"currency\":\"RUB\"}' \\\n    \"$url\"\n}\n\nold_status=\"$(request \"$OLD_URL\" old)\"\nnew_status=\"$(request \"$NEW_URL\" new)\"\nprintf 'status: old=%s new=%s\\n' \"$old_status\" \"$new_status\"\njq -S . \"$TMP_DIR/old.json\" > \"$TMP_DIR/old.sorted.json\"\njq -S . \"$TMP_DIR/new.json\" > \"$TMP_DIR/new.sorted.json\"\ndiff -u \"$TMP_DIR/old.sorted.json\" \"$TMP_DIR/new.sorted.json\" || true\n\n# Это отдельная проверка эффекта, если в стенде есть audit endpoint.\ncurl -sS \"$AUDIT_URL?case=$CASE_ID\" | jq '{records,events}'
\n

Здесь нормализация через jq -S убирает только шум порядка ключей. Она не скрывает отсутствующее поле, другое значение или лишнюю запись. diff возвращает ненулевой код при различии, поэтому в учебном примере добавлено || true, чтобы вывести результат и продолжить ручную классификацию. В CI лучше убрать его и завершать job с ошибкой после формирования артефакта сравнения.

\n

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

\n

Что наблюдать кроме response body

\n

Снимок ответа удобен для payload, но слеп к эффекту. Для записи в базу нужен запрос к тестовой базе или endpoint аудита. Для сообщения — счётчик публикаций и идентификатор события. Для кеша — наблюдаемый hit/miss или версия записи. Для внешнего провайдера — его тестовый журнал и correlation id. У каждого доказательства должны быть одинаковые precondition и временное окно для старого и нового пути.

\n

Разделяйте request id и idempotency key. Первый помогает найти конкретную попытку в логах. Второй описывает намерение, общее для повторных попыток. У одного intent может быть несколько request id. Если эти значения смешать, расследование покажет два запроса и не ответит на вопрос, была ли операция одна.

\n

Постепенное переключение и rollback

\n

После локального сравнения не переводите весь трафик сразу. Выберите небольшую, репрезентативную группу и сравнивайте её с control по тем метрикам, которые отражают риск: ошибки по типам, latency, число эффектов и бизнес-результат. Маленькая доля снижает потенциальный ущерб, но слишком короткий или однообразный прогон не доказывает совместимость. Для редких операций нужен срок, за который проявится отложенный эффект.

\n

Canary-сравнение не отменяет абсолютных ограничений. Даже если ошибка canary похожа на control, обе группы могут совместно использовать базу, очередь или внешний провайдер. Поэтому задайте отдельные стоп-условия: неожиданный статус, повторная запись, лишняя публикация, нарушение авторизации или рост задержки выше согласованного порога. Rollback маршрута возвращает запросы на legacy, но не откатывает данные и уже доставленные события. Для них нужен самостоятельный план сверки.

\n
  1. Выберите одну операцию, потребителя и владельца контракта.
  2. Заполните четыре слоя: transport, payload, effect и time.
  3. Зафиксируйте valid, invalid и repeat с одинаковыми начальными данными.
  4. Снимите ответы старого пути и сохраните заголовки, тело, timestamp и request id.
  5. Повторите те же кейсы на новом пути; для repeat используйте тот же ключ намерения.
  6. Проверьте запись, публикацию, кеш и внешние вызовы через реальные наблюдаемые источники.
  7. Классифицируйте каждое отличие как косметическое, расширение, поведенческое или неизвестное.
  8. До расширения canary определите стоп-условие, владельца решения и судьбу данных после rollback.
\n

Когда миграцию нужно остановить

\n

Остановите переключение, если неизвестен владелец эффекта, нельзя восстановить начальное состояние или скрыт потребитель, на которого влияет ответ. Остановите его также при расхождении в сумме, доступе, обязательном поле, категории ошибки или публикации. Отсутствие лога не означает отсутствие эффекта. Это отсутствие доказательства.

\n

Иногда старое поведение выглядит ошибочным, но на него уже опирается клиент. Исправление может быть правильным и всё равно несовместимым. Тогда есть три честных решения: временно сохранить поведение, выпустить новую версию с переходом клиентов или явно изменить контракт и принять стоимость миграции. Нельзя назвать breaking change совместимостью только потому, что новая логика лучше с точки зрения разработчика.

\n

Ограничения применимости

\n

Метод подходит для HTTP-швов и других операций, где можно сопоставить вход, ответ, состояние и эффект. Он не заменяет нагрузочное тестирование, security review, миграцию базы, сверку финансовых данных или формальное доказательство распределённой транзакции. Три кейса задают стартовую границу, но не покрывают все роли, права, размеры данных и внешние сбои.

\n

Shell-пример учебный: он не запускается против конкретной production-системы, не знает её схему аудита и не утверждает, что какой-либо реальный endpoint идемпотентен. URL, формат ключа, окно хранения, правила ошибок и допустимые различия нужно взять из договора именно вашего сервиса. Пока критичный unknown не проверен и не принят владельцем, расширять маршрут нельзя.

\n

Итог: критерий готовности

\n

Шов готов к ограниченному переключению, когда владелец назван, потребители известны, четыре слоя контракта заполнены, три базовых кейса воспроизводимы, эффекты наблюдаемы, а каждое отличие классифицировано. Для критичного unknown должно быть принято отдельное решение: следующая проверка, сохранение legacy или новая версия контракта. Такой результат скромнее обещания «полной совместимости», зато его можно повторить, оспорить и проверить до того, как ошибка попадёт к пользователю.

\n

Проверяемые источники

" }