8 lines
24 KiB
JSON
8 lines
24 KiB
JSON
{
|
|
"index": 144,
|
|
"slug": "editorial-2024-01-practice-legacy-modernization",
|
|
"title": "Модернизация legacy-системы: как выбрать безопасный шов",
|
|
"excerpt": "Полное переписывание редко начинается с доказанной границы. Разбираем, как выделить одну операцию, описать её поведение, подключить новый путь через адаптер и не спутать возврат маршрута с восстановлением данных.",
|
|
"contentHtml": "<p>Симптом обычно появляется ещё до изменения кода: в backlog стоит задача «переписать расчёт заказа», но не названы один вход и один результат. Старый модуль одновременно принимает HTTP-запрос, применяет скидку, пишет статус, отправляет письмо и вызывает соседнюю систему. Команда создаёт новый сервис, а через несколько недель уже не может сказать, какая часть поведения перенесена. Цена ошибки — двойная запись, потерянное уведомление или откат, который меняет маршрут, но не возвращает данные.</p>\n<p>Безопасный первый шаг — не новая технология, а измеримый шов. Шов — одна операция с названным потребителем, входом, результатом, эффектом и владельцем решения. Такая граница не обещает сохранить весь монолит. Она ограничивает изменение так, чтобы расхождение можно было увидеть, остановить и разобрать.</p>\n<h2>Начните с операции, а не с папки</h2>\n<p>Папка, класс или отдельный микросервис не становятся границей автоматически. Граница возникает там, где можно сформулировать проверяемый контракт. Для HTTP это может быть один endpoint, для очереди — один тип сообщения и ключ повторной обработки, для интерфейса — одно действие пользователя.</p>\n<p>Перед проектированием запишите пять полей. <code>Consumer</code> показывает, кто вызывает операцию. <code>Input</code> фиксирует обязательные поля, форматы и правила повтора. <code>Response</code> описывает статус и значимые значения. <code>Effect</code> перечисляет запись, публикацию, отправку письма, очистку кеша или отсутствие изменения. <code>Owner</code> принимает решение при расхождении. Если эффект пока неизвестен, так и напишите: <code>unknown</code> — это открытый риск, а не разрешение считать операцию безопасной.</p>\n<table><caption>Минимальная карточка шва перед первым переключением</caption><thead><tr><th scope='col'>Поле</th><th scope='col'>Пример</th><th scope='col'>Проверка</th></tr></thead><tbody><tr><td>Операция</td><td><code>POST /quote</code></td><td>Понятно, какой запрос входит в волну</td></tr><tr><td>Consumer</td><td>Сервис корзины</td><td>Назван конкретный вызывающий код</td></tr><tr><td>Response</td><td><code>total</code>, <code>expiresAt</code>, код отказа</td><td>Выделены поля, влияющие на потребителя</td></tr><tr><td>Effect</td><td>Только чтение</td><td>Нет записи, публикации или внешнего вызова</td></tr><tr><td>Owner</td><td>Владелец расчёта</td><td>Есть человек или команда для решения stop/continue</td></tr><tr><td>Return</td><td><code>legacy-only</code></td><td>Следующий запрос можно вернуть на известный путь</td></tr></tbody></table>\n<p>Для первой волны чаще подходит чтение или предварительный расчёт без изменения состояния. Операции <code>preview</code> и <code>confirm</code> нельзя объединять только потому, что они используют одну модель данных: у подтверждения появляются идемпотентность, порядок записи и компенсация внешних эффектов.</p>\n<h2>Зафиксируйте контракт до адаптера</h2>\n<p>Совместимость — это не «новый endpoint вернул похожий JSON». Разделите её на четыре слоя. <em>Transport</em> — метод, путь, статус и значимые заголовки. <em>Payload</em> — типы, обязательность, <code>null</code> и отсутствие поля. <em>Effect</em> — запись, публикация, изменение кеша и поведение при повторе. <em>Time</em> — момент чтения состояния и смысл слова «повтор»: тот же набор полей, business key или idempotency key.</p>\n<p>OpenAPI помогает описать HTTP-операции, параметры, схемы и ответы. Это полезная граница формы: другой инженер видит, как вызвать сервис и какие данные ожидать. Но схема сама по себе не говорит, отправил ли обработчик событие, когда он прочитал баланс и создаст ли второй запрос новую запись. Поведенческие свойства нужно фиксировать отдельными кейсами и наблюдаемыми эффектами.</p>\n<p>Ниже — учебный контракт для read-only расчёта. Он не описывает конкретную production-систему и не доказывает parity. В настоящем проекте названия полей, округление, коды ошибок и срок действия должны быть взяты из действующего обработчика, тестов или зафиксированного ответа legacy.</p>\n<pre><code>type QuoteInput = {\n productId: string\n quantity: number\n customerTier?: 'base' | 'plus'\n}\n\ntype QuoteResult =\n | { status: 'ok'; total: number; expiresAt: string }\n | { status: 'rejected'; code: 'INVALID_QUANTITY' | 'NOT_AVAILABLE' }\n\nfunction routeQuote(input: QuoteInput, mode: 'legacy' | 'candidate') {\n return mode === 'candidate'\n ? modernQuoteAdapter(input)\n : legacyQuote(input)\n}</code></pre>\n<p>Адаптер преобразует вход нового кода в старую внешнюю форму или наоборот, но не должен незаметно добавлять письмо, запись заказа или вызов <code>confirm</code>. Если для расчёта нужен скрытый глобальный флаг, общий кеш или внешний провайдер, это часть карты зависимости. Её нельзя спрятать за словом «gateway».</p>\n<h2>Сравнивайте control и candidate</h2>\n<p>Оставьте legacy контрольным путём, а новый код подключайте только для выбранной операции. В каждом тесте должны быть как минимум три класса случаев: корректный ввод, отказ и повтор. Укажите предварительное состояние, запрос, ожидаемые поля, ожидаемый эффект и источник каждого ожидания. Для эффекта одного тела ответа недостаточно: два маршрута могут вернуть одинаковый JSON, но только один отправить событие.</p>\n<p>Команда ниже воспроизводит сравнение на тестовом контуре, если ваш маршрутизатор документированно поддерживает заголовок <code>X-Route</code>. Это условный интерфейс учебного адаптера, его нельзя отправлять в произвольный production endpoint. Значения <code>BASE_URL</code> и <code>ITEM_ID</code> задайте сами, а поля для нормализации согласуйте с владельцем контракта.</p>\n<pre><code>set -eu\n\ntest -n $BASE_URL || { echo 'set BASE_URL' >&2; exit 2; }\ntest -n $ITEM_ID || { echo 'set ITEM_ID' >&2; exit 2; }\n\nfor route in legacy candidate; do\n curl --silent --show-error --fail \\\\\n -H 'X-Route: '$route \\\\\n '$BASE_URL/catalog/items/'$ITEM_ID > $route.json\ndone\n\njq -S 'del(.requestId, .generatedAt)' legacy.json > legacy.normalized.json\njq -S 'del(.requestId, .generatedAt)' candidate.json > candidate.normalized.json\ndiff -u legacy.normalized.json candidate.normalized.json</code></pre>\n<p>Нулевой <code>diff</code> здесь проверяет один идентификатор, один момент и заранее названную нормализацию. Он не доказывает отсутствие побочного эффекта, равную производительность или корректность для других ролей. Добавьте случаи «идентификатор не найден», «нет прав», неверный формат, граничное количество и повтор. Не удаляйте из сравнения поле только ради зелёного результата: сначала объясните его владельцу.</p>\n<table><caption>Симптом, проверка и решение до расширения шва</caption><thead><tr><th scope='col'>Симптом</th><th scope='col'>Вероятная причина</th><th scope='col'>Проверка</th><th scope='col'>Действие</th></tr></thead><tbody><tr><td>Оба пути вернули <code>200</code></td><td>Сравнили транспорт, а не смысл</td><td>Проверить поля, ошибки, время и эффект</td><td>Добавить contract cases</td></tr><tr><td>Отказ стал <code>500</code></td><td>Потеряна категория ошибки</td><td>Повторить invalid case на одном состоянии</td><td>Сохранить внешний контракт или версионировать его</td></tr><tr><td>Повтор создаёт вторую запись</td><td>Не определено правило повтора</td><td>Проверить record id и журнал эффекта</td><td>Добавить idempotency key или оставить запись в legacy</td></tr><tr><td>Candidate медленнее только вечером</td><td>Разные нагрузка или состояние кеша</td><td>Сопоставить окно, population и backend</td><td>Повторить сравнение в сопоставимых условиях</td></tr><tr><td>После возврата появились дубли</td><td>Rollback маршрута не отменил запись</td><td>Сверить состояние и внешние вызовы</td><td>Остановить write-path и назначить reconciliation owner</td></tr></tbody></table>\n<figure><img src='/assets/editorial/2024/legacy-modernization-2024-strangler-map.svg' alt='Схема безопасного шва: клиент отправляет запрос в маршрутизатор, legacy остаётся контрольным путём, а ограниченный candidate подключается через адаптер; запись и внешние эффекты вынесены за границу чтения' loading='lazy'><figcaption>Первый шов оставляет решение о маршруте на границе одной операции. Схема учебная: она не показывает реальный трафик, базу данных или подтверждённую совместимость.</figcaption></figure>\n<h2>Маршрутизатор снижает размер изменения, но не риск сам по себе</h2>\n<p>Маршрутизатор нужен в конкретном месте: он принимает решение, оставить запрос на legacy или передать его candidate. Для остальных операций действует прежнее правило. Если флаг разбросан по внутренним вызовам, один запрос может частично пройти по старому и частично по новому пути, и сравнение потеряет смысл. Логи должны различать как минимум маршрут, версию адаптера, корреляционный идентификатор и результат.</p>\n<p>AWS описывает для strangler fig три фазы: transform — создать новую функциональность параллельно со старой, coexist — временно держать обе реализации и направлять трафик через прокси, eliminate — вывести старую часть после переноса. Это модель постепенного вытеснения, а не доказательство того, что конкретная миграция безопасна. Прокси может стать единой точкой отказа или узким местом, поэтому его latency и ошибки измеряют отдельно.</p>\n<p>Канареечная подача тоже требует границ. Назовите <code>population</code>, контрольную группу, окно наблюдения, сигналы и владельца решения. «Десять процентов» не является универсальным порогом: редкий сценарий может не попасть в малую группу, а общая зависимость может одинаково испортить оба маршрута. Не расширяйте волну, если значение критичного сигнала неизвестно.</p>\n<h2>Не называйте rollback восстановлением</h2>\n<p>Для read-only операции возврат обычно означает смену правила маршрутизатора: следующие запросы идут в legacy. Но общий кеш, мигрированный справочник или sticky session могут связать два пути. До возврата проверьте, понимает ли старая реализация актуальное состояние и не изменял ли candidate его косвенно.</p>\n<p>Для write-операции граница намного строже. Новый обработчик мог создать заказ, отправить сообщение или вызвать платёжного провайдера. Остановка candidate предотвращает часть следующих вызовов, но не удаляет запись во внешней системе. Компенсация может быть невозможной, породить второй эффект или потребовать решения бизнеса. Поэтому в карточке шва отдельно назовите <code>route rollback</code>, <code>data recovery</code> и владельца сверки.</p>\n<p>Минимум для записи — идентификатор операции, журнал переходов, ключ идемпотентности или доказанное отсутствие повторной доставки, владелец сверки и процедура расхождения. Если этих данных нет, первый шов лучше сузить до чтения, добавить preview или оставить запись в legacy. Аварийный delete-скрипт без карты зависимостей не является планом восстановления.</p>\n<h2>Порядок работы</h2>\n<ol><li>Опишите симптом и цену ошибки: что может раздвоиться — ответ, запись, сообщение или внешний вызов.</li><li>Выберите одну операцию с понятными входами и ограниченным числом потребителей.</li><li>Заполните consumer, input, response, effect, owner и известный legacy-маршрут.</li><li>Соберите valid, invalid и repeat cases. Для каждого укажите состояние, ожидаемый результат и источник факта.</li><li>Поставьте адаптер в pass-through и проверьте, что старый путь не изменился.</li><li>Запустите candidate на тех же данных, сравните объявленные инварианты и отдельно проверьте эффект.</li><li>Задайте малую population, контроль, окно, сигналы, пороги и ручное решение stop/continue.</li><li>При расхождении остановите расширение. Классифицируйте причину: контракт, время, зависимость, права или побочный эффект.</li><li>Проверьте возврат следующих запросов на legacy. Судьбу уже изменённых данных разберите отдельной процедурой.</li><li>Расширяйте шов только после закрытия критичных неизвестных. Новую state boundary оформите отдельной операцией.</li></ol>\n<h2>Границы применимости</h2>\n<p>Strangler-подход требует, чтобы внешний вызов можно было перехватить и маршрутизировать. AWS отдельно отмечает, что он не подходит маленьким системам с низкой сложностью, а прокси может стать bottleneck. Если запрос нельзя разделить по операции или нет способа быстро вернуть legacy, сначала нужна другая форма изменения — например, внутренний совместимый слой без переключения трафика.</p>\n<p>Canary и сравнение ответов не дают статистической гарантии. Малая population может не содержать редкую роль, synthetic data не показывает реальные состояния, а общий кеш нарушает независимость control и candidate. Для денег, прав, персональных данных и внешних транзакций read-only схема из примера недостаточна: нужны threat model, аудит доступа, идемпотентность и сверка с владельцем данных.</p>\n<p>Учебный TypeScript и shell выше не запускают ваш legacy, не измеряют его p95 и не доказывают результат в production. Они задают воспроизводимую форму проверки. Факты о конкретной системе берите из кода, тестов, логов и трасс выбранного контура; неизвестное оставляйте неизвестным, пока его не проверит владелец.</p>\n<h2>Критерий готовности</h2>\n<p>Шов готов к ограниченному рассмотрению, когда другой инженер без устного контекста показывает один вход, один ожидаемый результат, список эффектов, owner решения, valid/invalid/repeat cases, источник каждого ожидания и проверенный legacy-маршрут. Для нового пути видны отдельные сигналы. Для возврата есть действие, а для необратимых данных отдельно описано, чего возврат не покрывает.</p>\n<p>Если неизвестен владелец эффекта, нельзя восстановить исходное состояние или consumer скрыт, это не повод увеличивать процент трафика. Сузьте операцию, соберите доказательство или оставьте её на legacy. Безопасность первой волны означает не «новый сервис уже написан», а «расхождение можно обнаружить, остановить и объяснить».</p>\n<h2>Проверяемые источники</h2><ul><li><a href='https://docs.aws.amazon.com/prescriptive-guidance/latest/modernization-decomposing-monoliths/strangler-fig.html' target='_blank' rel='noopener noreferrer'>AWS Prescriptive Guidance: Strangler fig pattern</a> — официальное описание фаз transform, coexist и eliminate, преимуществ и ограничений периметрового прокси.</li><li><a href='https://spec.openapis.org/oas/v3.1.0.html' target='_blank' rel='noopener noreferrer'>OpenAPI Specification 3.1.0</a> — официальный формат описания HTTP API, операций, параметров, схем и ответов; спецификация не заменяет проверку эффектов.</li><li><a href='https://opentelemetry.io/docs/concepts/observability-primer/' target='_blank' rel='noopener noreferrer'>OpenTelemetry: Observability primer</a> — официальная база для разделения сигналов наблюдаемости и выбора того, что следует видеть при сравнении маршрутов.</li><li><a href='https://sre.google/workbook/canarying-releases/' target='_blank' rel='noopener noreferrer'>Google SRE Workbook: Canarying Releases</a> — официальный материал о canary-релизе, контрольной группе, длительности окна и decision gate.</li></ul>"
|
|
}
|