open atlas
↑ К треку
Паттерны и качество кода CP · 14 · 02

Капстоун II: инкрементальный рефакторинг

Выполни рефакторинг из части 1 целиком. Накрой модуль характеризационными тестами, затем делай по одному маленькому обратимому ходу на коммит под зелёным — выделение, объекты-значения, полиморфизм, порт хранилища — не пряча правку поведения в рефакторинг.

CP Senior ◷ 21 min
Уровень
ОсновыJuniorMiddleSenior

В части 1 ты прочитал запутанный модуль OrderProcessor, назвал его запахи и записал план: выделить переплетённые методы, дать Money и id заказа настоящие типы, убить switch по paymentType полиморфизмом и спрятать базу за порт. План верен. План — это ещё и то место, где большинство инженеров губит модуль: они пытаются сделать всё сразу, в одной гигантской ветке, и через неделю у них наполовину переписанный файл, который больше не сходится с тестами и который нельзя ни выкатить, ни откатить.

Этот урок — про исполнение: та же безопасная последовательность — накрой тестами, затем делай по одному маленькому обратимому ходу на коммит, никогда не меняя поведение посреди шага — применённая от первого характеризационного теста до инвертированной зависимости хранилища. Навык здесь не в каком-то одном ходе; это ритм, который держит модуль выкатываемым на каждом коммите, пока он преображается у тебя под руками.

Цель

После этого урока ты можешь выполнить настоящий рефакторинг от начала до конца без переписывания «большим взрывом»: писать характеризационные тесты, чтобы зафиксировать текущее поведение, прежде чем трогать структуру; выстраивать ходы так, чтобы каждый был маленьким, обратимым и зелёным; конкретно выполнять ключевые ходы на TypeScript — выделение метода/класса, введение объектов-значений, замену switch по типу полиморфизмом, инверсию зависимости хранилища за портом — по одному коммиту на ход; и объяснить, почему вшивание правки поведения в коммит рефакторинга уничтожает твою способность изолировать регрессию.

1

Сначала сеть: характеризационные тесты фиксируют, что модуль делает сегодня, ещё до того как ты поймёшь почему. Нетестированный легаси не рефакторят, читая его пристальнее. Его фиксируют. Характеризационный тест утверждает текущее наблюдаемое поведение модуля — не то поведение, которое ты бы хотел, — так что любой последующий структурный ход, меняющий вывод, мгновенно делает тест красным.

// characterize.test.ts — написан ДО любого структурного изменения.
// Мы пока не доверяем округлению или арифметике комиссии; мы фиксируем то, что код ДЕЛАЕТ.
test("card order total with tax and fee", () => {
  const out = processOrder({ items: [{ price: 1000, qty: 2 }], paymentType: "card" });
  expect(out.total).toBe(2070);   // 2000 + 5% налога + 2% комиссии карты, как есть
  expect(out.currency).toBe("USD");
});

test("invoice order skips the card fee", () => {
  const out = processOrder({ items: [{ price: 999, qty: 3 }], paymentType: "invoice" });
  expect(out.total).toBe(3147);   // что бы он сейчас ни считал — зафиксируй это
});

Число 2070 может кодировать баг. И это нормально — ты сейчас его не чинишь, ты его фиксируешь. Характеризационный тест, закрепляющий неверное значение, всё равно делает свою работу: он гарантирует, что твой рефакторинг сохраняет поведение. Баг почини позже, отдельным коммитом, когда структура станет достаточно чистой, чтобы чинить безопасно. Ни одной структурной правки не происходит, пока сеть не зелёная.

2

Выделяй под зелёным, по одному ходу на коммит — и пиши сообщение, которое говорит «refactor». Когда сеть на месте, первые ходы — дешёвые механические из каталога. Вытащи переплетённые блоки processOrder в именованные методы, затем подними связный кластер в класс. Каждый — свой коммит; набор зелёный до и после каждого.

// commit "refactor: extract calcTax / calcFee"  — поведение идентично
function calcTax(subtotal: number): number { return Math.round(subtotal * 0.05); }
function calcFee(subtotal: number, paymentType: string): number {
  return paymentType === "card" ? Math.round(subtotal * 0.02) : 0;
}

// commit "refactor: extract OrderProcessor class"  — то же тело, новый дом
class OrderProcessor {
  process(order: OrderInput): OrderResult {
    const subtotal = order.items.reduce((s, i) => s + i.price * i.qty, 0);
    const tax = calcTax(subtotal);
    const fee = calcFee(subtotal, order.paymentType);
    return { total: subtotal + tax + fee, currency: "USD" };
  }
}

Ничего хитрого здесь нет, и в этом весь смысл. Это сохраняющие поведение ходы; зелёная сеть доказывает это после каждого. Если тест краснеет, ты откатил один маленький коммит, а не неделю работы — радиус поражения любой ошибки ровно один ход.

3

Введи объекты-значения, чтобы убрать одержимость примитивами — поведение всё ещё не меняется. Модуль передаёт сырые number для денег и сырые string для id; это одержимость примитивами, и именно поэтому логика округления и валюты протекла повсюду. Заверни их. Ход всё ещё рефакторинг: Money обязан вычислять те же числа, что уже зафиксировала сеть.

// commit "refactor: introduce Money value object"
class Money {
  private constructor(readonly cents: number, readonly currency: "USD") {}
  static usd(cents: number): Money { return new Money(Math.round(cents), "USD"); }
  add(o: Money): Money { return Money.usd(this.cents + o.cents); }
  percent(p: number): Money { return Money.usd(Math.round(this.cents * p)); }
}

// commit "refactor: introduce OrderId" — типизированный id, больше никаких голых string
class OrderId { private constructor(readonly value: string) {}
  static of(v: string): OrderId { return new OrderId(v); } }

Поскольку Money.usd округляет ровно там же, где это делал старый код, зафиксированное 2070 по-прежнему держится — тест остаётся зелёным. Выигрыш в том, что округление теперь живёт в одном месте, а не выводится заново на каждом месте вызова, — именно это удешевляет следующее изменение. Принципиально, что ты не «починил» округление, раз уж был там; если округление неверно, это отдельный, более поздний, меняющий поведение коммит.

4

Замени switch по типу платежа полиморфизмом, затем инвертируй зависимость хранилища за портом. Остаются два структурных хода, и они — высокоценные. Сначала switch по paymentType, расползающийся по модулю, становится маленькой иерархией стратегий — Open/Closed: новый тип платежа — это новый класс, а не правка switch в пяти файлах.

// commit "refactor: replace paymentType switch with PaymentMethod polymorphism"
interface PaymentMethod { fee(subtotal: Money): Money; }
class Card    implements PaymentMethod { fee(s: Money) { return s.percent(0.02); } }
class Invoice implements PaymentMethod { fee(s: Money) { return Money.usd(0); } }
const methods: Record<string, PaymentMethod> = { card: new Card(), invoice: new Invoice() };

Затем инвертируй зависимость хранилища. OrderProcessor сейчас import-ит конкретный db; это стрелка в неправильную сторону. Определи порт OrderRepository, которым владеет процессор, и сделай базу адаптером, его реализующим, — высокоуровневая политика перестаёт зависеть от низкоуровневой детали.

// commit "refactor: introduce OrderRepository port"
interface OrderRepository { save(id: OrderId, result: OrderResult): Promise<void>; }
// адаптер живёт на краю; процессор зависит от интерфейса, а не от `db`
class PostgresOrderRepository implements OrderRepository { /* старый SQL, перенесён */ }

Оба хода сохраняют поведение: те же комиссии, те же сохранённые строки, та же зелёная сеть. Они меняют лишь кто от кого зависит.

Разбор примера

Один ход в изоляции: switch по платежу становится полиморфизмом — без изменения ни одной комиссии. Раньше тип протекал по модулю как stringly-typed switch, о котором процессору приходилось знать:

// до — Open/Closed нарушен: новый тип правит этот switch в каждом месте, где он встречается
function feeFor(subtotal: number, paymentType: string): number {
  switch (paymentType) {
    case "card":    return Math.round(subtotal * 0.02);
    case "invoice": return 0;
    case "wire":    return 1500;          // фикс $15
    default: throw new Error("unknown payment type");
  }
}

После — каждое правило владеет своим классом, а процессор ищет метод по ключу:

// после — commit "refactor: replace paymentType switch with polymorphism"
interface PaymentMethod { fee(subtotal: Money): Money; }
class Card    implements PaymentMethod { fee(s: Money) { return s.percent(0.02); } }
class Invoice implements PaymentMethod { fee(s: Money) { return Money.usd(0); } }
class Wire    implements PaymentMethod { fee(_: Money) { return Money.usd(1500); } }

const methods: Record<string, PaymentMethod> = {
  card: new Card(), invoice: new Invoice(), wire: new Wire(),
};
function methodFor(type: string): PaymentMethod {
  const m = methods[type];
  if (!m) throw new Error("unknown payment type");   // ТА ЖЕ ошибка, сохранена намеренно
  return m;
}

Комиссии побайтово идентичны: карта по-прежнему 2%, invoice по-прежнему 0, wire по-прежнему фикс $15, сообщение об ошибке неизвестного типа без изменений — поэтому характеризационная сеть остаётся зелёной, и это выкатывается как чистый рефакторинг. Отдача — в следующем изменении: добавить crypto — это новый класс Crypto плюс одна запись в map, с нулём правок процессора. Сопротивляться надо соблазнительному «два в одном» — «раз уж я здесь, комиссия wire должна быть 1%». Это правка поведения; она идёт в свой коммит, сверху, после того как этот станет зелёным и закоммиченным, чтобы, если регрессия комиссии всплывёт позже, git bisect мог указать ровно на одну причину.

Почему это работает

Зачем накрывать модуль сетью до ходов, которые ты и так знаешь как верные? Потому что «я знаю, что это сохраняет поведение» — ровно та уверенность, что выкатывает регрессии. Выделение выглядит тривиально — пока у старого processOrder не оказывается тонкого off-by-one в округлении комиссии, который твоё «более чистое» выделение молча исправляет, меняя итог по invoice в проде. С характеризационным тестом, фиксирующим 3147, твоё чистое выделение краснеет в тот миг, когда расходится, и ты видишь непреднамеренную правку поведения прежде, чем она уйдёт с твоей машины. Ценность сети наивысшая именно на тех ходах, в которых ты увереннее всего, потому что их ты сделаешь быстрее всего и проверишь меньше всего. Нет сети — нет рефакторинга, только переписывание на авось.

Частая ошибка

Два режима провала, которые губят такой капстоун. Переписывание «большим взрывом»: ты открываешь ветку, меняешь всё сразу, и через три дня у тебя прекрасный новый модуль, который не проходит старые тесты и который никто не может отревьюить или откатить — ты заменил известную-несовершенную систему неизвестной, и единственный путь вперёд — ещё больше «большого взрыва». Контрабандная правка поведения: посреди рефакторинга ты «чинишь» округление или подкручиваешь комиссию wire внутри коммита выделения. Работает, набор зелёный, ты пушишь. Через недели всплывает финансовая регрессия, git bisect приземляется на refactor: extract OrderProcessor, и теперь этот коммит содержит и структурный ход, и правку поведения — bisect не может сказать какой именно, а гарантия сохранения поведения, позволявшая ревьюерам бегло его пробежать, исчезла. Правило, предотвращающее оба: коммит — это либо рефакторинг (структура меняется, поведение зафиксировано), либо правка поведения (поведение меняется, структура зафиксирована) — никогда оба, и никогда всё сразу.

Проверь себя
Викторина

Посреди выделения класса OrderProcessor ты замечаешь, что округление налога ошибается на цент, и чинишь это в том же коммите 'refactor: extract OrderProcessor'. Набор зелёный. Почему senior-ревьюер заблокирует это?

Итог

Инкрементальный рефакторинг — это последовательность, а не событие. Ты сначала накрываешь модуль характеризационными тестами — фиксируя текущее поведение, со всеми багами, — чтобы любой структурный ход, меняющий вывод, краснел на твоей машине, а не в проде. Затем делаешь по одному маленькому обратимому ходу на коммит под зелёным: выделяешь методы и класс, вводишь Money и типизированный OrderId, чтобы убрать одержимость примитивами, заменяешь switch по paymentType полиморфизмом, чтобы новые типы были новыми классами, а не правками switch, и инвертируешь зависимость хранилища за портом OrderRepository, чтобы высокоуровневая политика перестала зависеть от низкоуровневой базы. Каждый из этих ходов сохраняет поведение, доказан зелёным до и после, закоммичен с сообщением refactor:, которое ревьюер может бегло пробежать. Два способа всё испортить — это переписывание «большим взрывом», которое меняет известную систему на неоткатываемую неизвестную, и контрабандная правка поведения, которая сплавляет исправление с коммитом рефакторинга и уничтожает git bisect. Держи рефакторинг и поведение строго раздельно, двигайся маленькими шагами, коммить часто — и запутанный модуль из части 1 становится чистым кодом, который ни разу не был сломан по пути туда.

Практика

Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.

вспомнитьприменитьуглубить0 из 4 завершено

Что-то непонятно?

Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.

хоткеи развернуть
поиск
K
пред. пьеса
k
след. пьеса
j
тиры
t
это меню
?
sources3
expand
  1. 01
  2. 02
  3. 03

Trademarks belong to their respective owners. Editorial reference only.