Files

8 lines
23 KiB
JSON
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{
"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\" &gt; \"$TMP_DIR/old.sorted.json\"\njq -S . \"$TMP_DIR/new.json\" &gt; \"$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>"
}