Перейти к основному содержимому

Безопасные изменения в легаси

Разработчику Архитектору
Теория данных (раздел 3)

Главное правило: сначала возможность проверить, потом красота кода. Майкл Фезерс: без тестов вы меняете поведение вслепую. Ниже — как строить безопасность на практике.

Назад: понимание системы. Дальше: стратегии замены целых частей.


Рефакторинг в легаси

Рефакторинг — последовательность малых эквивалентных преобразований внутренней структуры без смены внешнего поведения (те же входы → те же выходы для наблюдателя снаружи). В легаси он нужен, чтобы следующая фича или фикс не стоили неделю отладки.

Реинжиниринг — другой масштаб: перестройка системы "в новой форме" с расширением функциональности или устранением системных дефектов, часто после обратного инжиниринга. Его планируют отдельно от точечного рефакторинга; крупный рефакторинг нередко предшествует реинжинирингу. Стратегии замены целых частей — в статье 4.

Типичные приёмы из каталога Фаулера, полезные в легаси:

ПриёмКогда
Extract MethodДлинная процедура, дублирование фрагмента
Extract ClassГруппа полей и методов "тянет" отдельную ответственность
Encapsulate FieldПрямой доступ к полям мешает тестам и инвариантам
Move MethodМетод чаще использует данные другого объекта
Replace Conditional with PolymorphismРазросшийся switch по типам

Полный обзор — Методы рефакторинга. Схема БД меняется отдельно — рефакторинг баз данных, не путать с переносом только классов.

Play ITЗагрузка интерактивного демо…

Приёмы IDE (извлечь метод, переименовать, вынести класс) рискованны без сетки тестов. Рабочая последовательность Фезерса: тест → рефакторинг → зелёные тесты.


Принципы

  1. Микрошаги — один коммит = одно понятное преобразование.
  2. Один PR — одна цель — рефакторинг структуры отдельно от новой функциональности (YAGNI и техдолг).
  3. Характеризующие тесты — фиксируют "как сейчас", даже если "сейчас" математически спорно.
  4. Изоляция — фасад, обёртка, временный API вокруг куска, который трогаете.
  5. Швы (seams) — места, где в тесте можно подменить зависимость без переписывания половины системы.

Этапы на одну задачу

  1. Найти точку входа — функция/класс под баг или фичу.
  2. Написать защитный слой тестов (unit → integration → e2e по необходимости).
  3. Разорвать зависимости — глобалы, new внутри логики, жёсткие пути к файлам.
  4. Привести к тестируемому виду — интерфейсы, чистые функции, Strategy.
  5. Рефакторить структуру — имена, дубли, размер методов.
  6. Подключить автопрогон тестов в пайплайне (идея — в демо ниже; детали инфраструктуры — в других главах).

Play ITЗагрузка интерактивного демо…


Инструменты рефакторинга

  • IDE — IntelliJ, Visual Studio, VS Code + Roslyn/языковые плагины.
  • Анализ запахов — SonarQube, CodeScene, DeepSource.
  • Массовые правки — ReSharper, Sourcery (Python), jscodeshift (JS/TS).

Характеризующие тесты (characterization tests)

Характеризующий тест отвечает на "изменилось ли по сравнению с вчера?". Сначала фиксируем факт, потом в отдельном изменении исправляем бизнес-логику с согласованием аналитика.

Код ITЗагрузка примера кода…

После согласования "должно быть 10% с банковским округлением" — меняете реализацию и ожидания теста в одном осознанном PR.

Для больших ответов API удобны golden master / approval-тесты: сохраняете JSON-снимок ответа, при рефакторинге сравниваете diff. Подходит, когда формально описать правила трудно, но "вывод не должен поменяться случайно".


Швы (seams)

Шов — место в программе, где можно подменить поведение без редактирования всего дерева вызовов. Фезерс выделяет типы:

ТипИдеяПример
Object seamподмена объекта/интерфейсавнедрить Clock вместо now()
Preprocessing seam#ifdef (редко в app-коде)тестовая сборка
Link seamдругая реализация при линковкеmock .so в C (сложно)

Object seam — время и случайность

Код без шва (трудно тестировать):

class ReportService {
String filename() {
return "report-" + java.time.LocalDate.now() + ".csv";
}
}

Шов через Clock:

Код ITЗагрузка примера кода…


Object seam — HTTP-клиент

async function syncUser(id: string) {
const res = await fetch(`https://legacy.internal/users/${id}`);
return res.json();
}

type HttpGet = (url: string) => Promise<{ json(): Promise<unknown> }>;

async function syncUserWithSeam(id: string, httpGet: HttpGet) {
const res = await httpGet(`https://legacy.internal/users/${id}`);
return res.json();
}

В тесте подставляете функцию, возвращающую заранее заготовленный JSON.


Приёмы Фезерса — Sprout, Wrap, Scratch

Когда рефакторинг "внутри" слишком рискован:

  • Sprout (росток) — новая логика в новом методе/классе; старый код вызывает росток.
  • Wrap (обёртка) — старый код остаётся, снаружи тонкая обёртка с новым API.
  • Scratch (черновик) — эксперимент в ветке/файле, пока не поняли поведение; в main не мержить до тестов.

Пример Sprout:

function calculateLegacyTotal(order) { /* ... */ }

function calculateTotalWithPromo(order, promoRules) {
const base = calculateLegacyTotal(order);
return applyPromo(base, promoRules);
}

Метод Mikado — разрыв зависимостей

Когда модуль "не вытащить" — всё связано со всем. Mikado:

  1. Нарисовать цель (например, "вынести Billing в отдельный пакет").
  2. Попробовать изменение → сборка/тесты падают → записать что мешает как отдельный узел.
  3. Сначала сделать узлы-листья (мелкие разрывы — интерфейс, тест, перенос класса).
  4. Повторять, пока цель не станет зелёной.

Это дисциплина пошагового рефакторинга, а не правки на 3000 строк за раз.


Регресс и уровни тестов

Регресс — новая поломка из-за изменения в другом месте. В связанном легаси он лавинообразный.

УровеньКогда в легаси
Unitпосле появления швов и чистых функций
IntegrationБД, очереди — testcontainers, staging
E2E / APIконтракт с внешним миром, characterization на HTTP
Ручной чек-листпока нет автоматизации — лучше явный список, чем надежда на память

Тест должен быть детерминированным. Flaky-тест хуже отсутствия теста: команда перестаёт верить красному пайплайну.

Если внутренности недоступны — тестируйте через публичный API (REST, CLI, файлы обмена). Это медленнее, но легитимный первый слой.

Подробнее про AAA и структуру unit-тестов — Юнит-тестирование.


Пример — от "простыни" к извлекаемому куску

До (фрагмент):

function processOrder(order) {
let total = order.lines.reduce((s, l) => s + l.price * l.qty, 0);
if (order.customer.isVip) total = Math.floor(total * 0.9);
return total;
}

Шаг 1 — characterization на скидку:

function vipDiscount(total) {
return Math.floor(total * 0.9);
}

function processOrder(order) {
let total = order.lines.reduce((s, l) => s + l.price * l.qty, 0);
if (order.customer.isVip) total = vipDiscount(total);
return total;
}
test('vip discount matches legacy floor', () => {
expect(vipDiscount(100)).toBe(90);
});

Шаг 2 — только после зелёных тестов — имена, налоги, вынос репозитория.


Интеграция в процесс команды

  • Ревью легаси-PR: "какие тесты доказывают, что поведение сохранилось?"
  • Разделять фикс бага и переписывание модуля по разным PR, где возможно.
  • Boy Scout rule: оставить код чуть лучше в границах задачи.

Стратегии, когда менять недостаточно — Strangler или clean room — статья 4.