8 lines
23 KiB
JSON
8 lines
23 KiB
JSON
{
|
||
"index": 143,
|
||
"slug": "editorial-2024-01-mechanism-legacy-modernization",
|
||
"title": "Модернизация legacy без ложной совместимости: как проверять поведение",
|
||
"excerpt": "Одинаковый HTTP-ответ не доказывает совместимость старой и новой реализации. Разбираем контракт, побочные эффекты, повтор запроса и минимальную проверку перед переключением маршрута.",
|
||
"contentHtml": "<p>После замены старого обработчика команда видит знакомый статус и почти такой же JSON, но клиент ломается на повторном запросе. В одном случае невалидный ввод превращается в 500. В другом режим предварительного просмотра начинает публиковать событие. В третьем повтор операции создаёт вторую запись. Цена ошибки — не только откат маршрута: данные уже изменились, уведомление уже ушло, а по снимку ответа это незаметно.</p>\n<p>Совместимость legacy-кода нельзя свести к сравнению сериализованного тела. Клиент зависит от входных ограничений, статуса, обязательных полей, заголовков, времени чтения состояния и побочных эффектов. Если эти свойства не записать до миграции, после переключения команда будет спорить не о факте регрессии, а о самом определении контракта.</p>\n<h2>Главный вопрос: что именно нужно сохранить</h2>\n<p>Начинайте с одного наблюдаемого шва — конкретной операции между потребителем и старой реализацией. Не с формулировки «переписать модуль», а с запроса, который можно отправить дважды и сравнить. У шва есть вход, ответ, состояние, эффект и владелец решения. Внутри нового адаптера можно поменять язык, библиотеку и структуру данных. Нельзя молча изменить свойство, на которое опирается потребитель.</p>\n<p>Полезная рабочая формулировка: новая реализация должна сохранить не каждую строку старого кода, а согласованный набор инвариантов. Для read-only операции это обычно статус, обязательные поля и отсутствие записи. Для создания ресурса добавляются ключ намерения, результат повтора и границы транзакции. Для платежа понадобятся денежная точность, авторизация, журнал операции и сверка с внешней системой. Один универсальный чек-лист здесь опасен: он смешивает косметическую разницу и повторное списание.</p>\n<h2>Четыре слоя поведенческого контракта</h2>\n<p>Разложите шов на четыре слоя. <strong>Transport</strong> — метод, путь, статус и значимые заголовки. <strong>Payload</strong> — типы, обязательность полей, разница между отсутствующим и <code>null</code>, формат ошибки. <strong>Effect</strong> — запись в базе, публикация сообщения, очистка кеша или запрет повторного действия. <strong>Time</strong> — состояние, в котором прочитаны данные, часовой пояс, срок действия и смысл слова «повтор».</p>\n<p>OpenAPI фиксирует интерфейс HTTP и описывает входные и выходные схемы. Это полезная граница: инструменты видят paths, operations, responses и типы данных. Но схема не сообщает, записал ли обработчик событие, из какого snapshot прочитал баланс или будет ли второй POST создавать ресурс. Наличие валидной схемы — доказательство формы, а не поведенческой совместимости.</p>\n<figure><img src=\"/assets/editorial/2024/legacy-modernization-2024-compatibility-matrix.svg\" alt=\"Матрица сравнения старого и нового HTTP-пути по слоям transport, payload, effect и time\" loading=\"lazy\" /><figcaption>У каждого слоя свой способ наблюдения: заголовки и статус, тело ответа, журнал эффекта и фиксированное состояние. Иллюстрация не содержит ответов реального сервиса и не заменяет проверку в тестовом контуре.</figcaption></figure>\n<p>Для каждого слоя задайте минимальное доказательство. Статус берите из HTTP-ответа, а не из текста ошибки. Поля сравнивайте после нормализации порядка ключей, но не удаляйте неизвестные поля без правила потребителя. Эффект проверяйте журналом, счётчиком, тестовой записью или API аудита. Время фиксируйте в данных кейса: иначе утренний и вечерний ответы могут расходиться из-за состояния, а не из-за миграции.</p>\n<table><caption>Как интерпретировать различие между legacy и новой реализацией</caption><thead><tr><th scope=\"col\">Различие</th><th scope=\"col\">Почему оно возникло</th><th scope=\"col\">Что проверить</th><th scope=\"col\">Решение</th></tr></thead><tbody><tr><td>Порядок ключей в JSON</td><td>Изменился сериализатор</td><td>Зависит ли потребитель от порядка объекта</td><td>Нормализовать сравнение; не считать порядок значимым без явного контракта</td></tr><tr><td>Добавилось необязательное поле</td><td>Новая схема расширилась</td><td>Игнорирует ли старый клиент неизвестные поля по договору</td><td>Оставить поле optional и задокументировать правило либо версионировать ответ</td></tr><tr><td>Поменялись статус или код ошибки</td><td>Другой валидатор или обработчик исключений</td><td>Какие ветки клиента зависят от 4xx/5xx и error code</td><td>Сохранить error contract или объявить несовместимое изменение</td></tr><tr><td>Повтор создал вторую запись</td><td>Ответ сравнили, эффект — нет</td><td>Одинаковый ли ключ намерения и есть ли дедупликация на сервере</td><td>Добавить прикладную идемпотентность либо не переводить операцию</td></tr><tr><td>Расходится только значение утром</td><td>Разное состояние или часовой пояс</td><td>Фиксированные данные, timestamp и источник времени</td><td>Определить time boundary и повторить кейс на одном состоянии</td></tr></tbody></table>\n<h2>Минимальный набор кейсов</h2>\n<p>Для первой проверки достаточно трёх кейсов, но это не полное тестовое покрытие. <strong>Valid</strong> показывает основной результат и обязательные поля. <strong>Invalid</strong> проверяет отказ, его категорию и отсутствие запрещённого эффекта. <strong>Repeat</strong> отправляет тот же intent повторно и проверяет состояние после первой попытки. В каждом кейсе должны быть precondition, запрос, ожидаемый ответ, ожидаемый эффект, место наблюдения и владелец.</p>\n<p>Не называйте любой одинаковый JSON идемпотентностью. В HTTP идемпотентность относится к намеренному эффекту повторных одинаковых запросов, а не гарантирует одинаковый текст ответа и не делает любой POST безопасным для повтора. Прикладной ключ должен однозначно обозначать намерение в пределах выбранного потребителя и окна хранения. Если тот же ключ приходит с другим набором параметров, это отдельная ошибка конфликта, а не повод молча выполнить новый запрос.</p>\n<h2>Воспроизводимое сравнение двух путей</h2>\n<p>Ниже — стендовый shell-пример. Он не предполагает конкретный фреймворк: укажите адреса старого и нового маршрута в первых строках. Для работы нужны только <code>curl</code> и <code>jq</code>. Пример сравнивает статус и тело, а для эффекта оставляет отдельный запрос к журналу аудита. Если у сервиса нет такого наблюдаемого источника, эффект остаётся неизвестным.</p>\n<pre><code>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}'</code></pre>\n<p>Здесь нормализация через <code>jq -S</code> убирает только шум порядка ключей. Она не скрывает отсутствующее поле, другое значение или лишнюю запись. <code>diff</code> возвращает ненулевой код при различии, поэтому в учебном примере добавлено <code>|| true</code>, чтобы вывести результат и продолжить ручную классификацию. В CI лучше убрать его и завершать job с ошибкой после формирования артефакта сравнения.</p>\n<p>Сделайте ещё один вызов с тем же <code>CASE_ID</code>, затем проверьте число записей и событий. Если сервис не обещает прикладную дедупликацию, не добавляйте её выводом из статуса 200. Заголовок с ключом сам по себе ничего не меняет: сервер должен хранить его в согласованном scope, сопоставлять параметры и атомарно связывать запись ключа с изменением данных.</p>\n<h2>Что наблюдать кроме response body</h2>\n<p>Снимок ответа удобен для payload, но слеп к эффекту. Для записи в базу нужен запрос к тестовой базе или endpoint аудита. Для сообщения — счётчик публикаций и идентификатор события. Для кеша — наблюдаемый hit/miss или версия записи. Для внешнего провайдера — его тестовый журнал и correlation id. У каждого доказательства должны быть одинаковые precondition и временное окно для старого и нового пути.</p>\n<p>Разделяйте request id и idempotency key. Первый помогает найти конкретную попытку в логах. Второй описывает намерение, общее для повторных попыток. У одного intent может быть несколько request id. Если эти значения смешать, расследование покажет два запроса и не ответит на вопрос, была ли операция одна.</p>\n<h2>Постепенное переключение и rollback</h2>\n<p>После локального сравнения не переводите весь трафик сразу. Выберите небольшую, репрезентативную группу и сравнивайте её с control по тем метрикам, которые отражают риск: ошибки по типам, latency, число эффектов и бизнес-результат. Маленькая доля снижает потенциальный ущерб, но слишком короткий или однообразный прогон не доказывает совместимость. Для редких операций нужен срок, за который проявится отложенный эффект.</p>\n<p>Canary-сравнение не отменяет абсолютных ограничений. Даже если ошибка canary похожа на control, обе группы могут совместно использовать базу, очередь или внешний провайдер. Поэтому задайте отдельные стоп-условия: неожиданный статус, повторная запись, лишняя публикация, нарушение авторизации или рост задержки выше согласованного порога. Rollback маршрута возвращает запросы на legacy, но не откатывает данные и уже доставленные события. Для них нужен самостоятельный план сверки.</p>\n<ol><li>Выберите одну операцию, потребителя и владельца контракта.</li><li>Заполните четыре слоя: transport, payload, effect и time.</li><li>Зафиксируйте valid, invalid и repeat с одинаковыми начальными данными.</li><li>Снимите ответы старого пути и сохраните заголовки, тело, timestamp и request id.</li><li>Повторите те же кейсы на новом пути; для repeat используйте тот же ключ намерения.</li><li>Проверьте запись, публикацию, кеш и внешние вызовы через реальные наблюдаемые источники.</li><li>Классифицируйте каждое отличие как косметическое, расширение, поведенческое или неизвестное.</li><li>До расширения canary определите стоп-условие, владельца решения и судьбу данных после rollback.</li></ol>\n<h2>Когда миграцию нужно остановить</h2>\n<p>Остановите переключение, если неизвестен владелец эффекта, нельзя восстановить начальное состояние или скрыт потребитель, на которого влияет ответ. Остановите его также при расхождении в сумме, доступе, обязательном поле, категории ошибки или публикации. Отсутствие лога не означает отсутствие эффекта. Это отсутствие доказательства.</p>\n<p>Иногда старое поведение выглядит ошибочным, но на него уже опирается клиент. Исправление может быть правильным и всё равно несовместимым. Тогда есть три честных решения: временно сохранить поведение, выпустить новую версию с переходом клиентов или явно изменить контракт и принять стоимость миграции. Нельзя назвать breaking change совместимостью только потому, что новая логика лучше с точки зрения разработчика.</p>\n<h2>Ограничения применимости</h2>\n<p>Метод подходит для HTTP-швов и других операций, где можно сопоставить вход, ответ, состояние и эффект. Он не заменяет нагрузочное тестирование, security review, миграцию базы, сверку финансовых данных или формальное доказательство распределённой транзакции. Три кейса задают стартовую границу, но не покрывают все роли, права, размеры данных и внешние сбои.</p>\n<p>Shell-пример учебный: он не запускается против конкретной production-системы, не знает её схему аудита и не утверждает, что какой-либо реальный endpoint идемпотентен. URL, формат ключа, окно хранения, правила ошибок и допустимые различия нужно взять из договора именно вашего сервиса. Пока критичный unknown не проверен и не принят владельцем, расширять маршрут нельзя.</p>\n<h2>Итог: критерий готовности</h2>\n<p>Шов готов к ограниченному переключению, когда владелец назван, потребители известны, четыре слоя контракта заполнены, три базовых кейса воспроизводимы, эффекты наблюдаемы, а каждое отличие классифицировано. Для критичного unknown должно быть принято отдельное решение: следующая проверка, сохранение legacy или новая версия контракта. Такой результат скромнее обещания «полной совместимости», зато его можно повторить, оспорить и проверить до того, как ошибка попадёт к пользователю.</p>\n<h2>Проверяемые источники</h2><ul><li><a href=\"https://www.rfc-editor.org/rfc/rfc9110.html\" target=\"_blank\" rel=\"noopener noreferrer\">IETF RFC 9110: HTTP Semantics</a> — семантика методов, заголовков, статусов и определение идемпотентного намеренного эффекта; RFC не задаёт прикладной ключ для конкретного API.</li><li><a href=\"https://spec.openapis.org/oas/v3.1.0.html\" target=\"_blank\" rel=\"noopener noreferrer\">OpenAPI Specification 3.1.0</a> — официальное описание language-agnostic HTTP-интерфейса, operations, responses и Schema Object; спецификация не доказывает побочные эффекты и бизнес-совместимость.</li><li><a href=\"https://aws.amazon.com/builders-library/making-retries-safe-with-idempotent-APIs/\" target=\"_blank\" rel=\"noopener noreferrer\">Amazon Builders’ Library: Making retries safe with idempotent APIs</a> — caller-provided request identifier, семантически эквивалентный повтор и необходимость атомарно связать фиксацию ключа с изменением данных; это практика AWS, а не универсальный стандарт.</li><li><a href=\"https://sre.google/workbook/canarying-releases/\" target=\"_blank\" rel=\"noopener noreferrer\">Google SRE Workbook: Canarying Releases</a> — сравнение canary и control, представительностью трафика и метрик, ограничением error budget и риском неполной изоляции.</li><li><a href=\"https://github.com/microsoft/api-guidelines/blob/vNext/graph/Guidelines-deprecated.md\" target=\"_blank\" rel=\"noopener noreferrer\">Microsoft REST API Guidelines: versioning and compatibility</a> — официальные рекомендации о breaking changes, версиях, error contract и явном правиле для неизвестных полей; это guidance Microsoft, не обязательный закон для любого API.</li></ul>"
|
||
}
|