FTS — исполняемые спецификации Миграция в FTS: от вложенных if к исполняемой спецификации
0%

Миграция в FTS: от вложенных if к исполняемой спецификации

Миграция в FTS: от вложенных if к исполняемой спецификации

Предыдущие главы показывали FTS на чистом листе. В реальной работе чистого листа нет: есть функция на семьдесят строк, которая считает скидку, ходит за профилем клиента, пишет в лог и последний раз обсуждалась с бизнесом три года назад. Эта глава — про то, как перенести такую функцию в модель, ничего не сломав, и как понять, что перенос действительно состоялся.

Главный тезис: миграция заканчивается не тогда, когда написана .fts-модель, а тогда, когда доказано, что модель и старый код дают одинаковый ответ на всех входах, которые вас интересуют. Всё остальное — подготовка к этому доказательству.

Рабочий комплект лежит в examples/fts/migration/: legacy-discount.mjs, discount.fts и equivalence.test.mjs.

Что мы переносим

Легаси-функция считает скидку на заказ. Внутри — ступенчатый тариф, надбавка за стаж, партнёрская надбавка, промокод и потолок:

export function calculateOrderDiscount(order, deps = {}) {
  const { logger = silentLogger, loadProfile = loadCustomerProfile } = deps;

  const profile = loadProfile(order.customerId);
  let discount = 0;

  if (order.amount >= 10000) {
    if (order.amount > 50000) {
      discount += order.amount * 0.1;
    } else {
      discount += order.amount * 0.05;
    }
  }

  if (profile.monthsWithUs >= 12) {
    discount += order.amount * 0.03;
  }

  if (profile.partner && order.amount >= 20000) {
    discount += order.amount * 0.02;
  }

  if (order.promoCode === 'ВЕСНА25') {
    discount += 500;
  }

  const cap = order.amount * 0.25;
  if (discount > cap) {
    logger.warn(`скидка ${discount} обрезана до ${cap} по заказу ${order.id}`);
    discount = cap;
  }

  const rounded = Math.round(discount);
  logger.info(`заказ ${order.id}: сумма ${order.amount}, скидка ${rounded}`);
  return rounded;
}

Что здесь плохо, помимо магических чисел. Функция берёт order.customerId и сама идёт за профилем — поэтому её нельзя вызвать без подмены зависимости и нельзя показать бизнесу как правило. Потолок реализован через молчаливое обрезание: если ступени насчитали лишнего, никто, кроме logger.warn, об этом не узнает. И один порог записан как > 50000, хотя тариф называется «от 50 000» — разница видна только на ровно пятидесяти тысячах.

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

Минимальный пример

Та же логика моделью. Обратите внимание на две вещи: границы ступеней записаны с обеих сторон, а потолок стал свойством, а не строчкой кода.

категория «Продажи»

  объект Заказ
    сумма является деньгами
    «месяцев с клиентом» является числом
    партнёр является признаком
    промокод является строкой

  утилита «Рассчитать скидку заказа»
    принимает Заказ
    возвращает деньги
    начинает с 0

    правило «Крупный заказ»
      если сумма не меньше 50000
      то добавить 10 процентов от поля сумма

    правило «Средний заказ»
      если сумма не меньше 10000
      и сумма меньше 50000
      то добавить 5 процентов от поля сумма

    правило «Клиент больше года»
      если «месяцев с клиентом» не меньше 12
      то добавить 3 процента от поля сумма

    правило «Партнёрский заказ»
      если партнёр равен да
      и сумма не меньше 20000
      то добавить 2 процента от поля сумма

    правило «Весенний промокод»
      если промокод равен «ВЕСНА25»
      то добавить 500

    свойство «Скидка не больше четверти заказа»
      результат не больше 25 процентов от поля сумма

    пример «Порог десяти тысяч»
      дано сумма равна 10000
      дано «месяцев с клиентом» равно 0
      дано партнёр равен нет
      дано промокод равен ""
      ожидается результат равен 500

    пример «Ровно пятьдесят тысяч»
      дано сумма равна 50000
      дано «месяцев с клиентом» равно 0
      дано партнёр равен нет
      дано промокод равен ""
      ожидается результат равен 5000

    пример «Партнёр со стажем»
      дано сумма равна 60000
      дано «месяцев с клиентом» равно 24
      дано партнёр равен да
      дано промокод равен ""
      ожидается результат равен 9000

    пример «Промокод на среднем заказе»
      дано сумма равна 20000
      дано «месяцев с клиентом» равно 0
      дано партнёр равен нет
      дано промокод равен «ВЕСНА25»
      ожидается результат равен 1500

Модель короче исходной функции не потому, что делает меньше, а потому, что не делает ничего лишнего: ни профиля, ни лога, ни округления.

Шаг за шагом

1. Выделить чистую часть. Пройдите функцию сверху вниз и отметьте каждую строку одной из трёх меток: правило, получение данных, эффект. В примере выше loadProfile — получение данных, оба logger — эффект, Math.round — эффект представления, всё остальное — правило. Если после разметки правило не собирается в связный кусок, сначала сделайте обычный рефакторинг: вынесите чистую функцию с явными аргументами. FTS не заменяет этот шаг.

2. Зафиксировать текущее поведение. До первой строчки .fts напишите характеризационные тесты: они проверяют не «как должно быть», а «как есть сейчас». Их задача — поймать момент, когда вы измените поведение, думая, что переписываете форму. Эти тесты потом станут скелетом теста эквивалентности.

3. Выписать входы как объект. Каждый аргумент правила становится полем объекта FTS со встроенным типом. Здесь решается главный вопрос миграции: что действительно скаляр, а что пришло из базы. profile.monthsWithUs — это не профиль, а число «месяцев с клиентом». profile.partner — не связь с таблицей партнёров, а признак. Объект FTS описывает не строку в БД, а вход правила: чем он уже, тем меньше поводов тащить в модель ORM-сущность целиком.

4. Перенести условия и действия. И здесь ждёт ловушка, из-за которой проваливается большинство первых попыток: в FTS нет else. Выполняются все правила, условия которых истинны. Цепочка if / else if из легаси даёт скидку только по одной ступени, а два правила с условиями «не меньше 10000» и «не меньше 50000» сработают на сумме 60 000 оба — 15 процентов вместо 10. Поэтому каждая ступень получает обе границы:

правило «Средний заказ»
  если сумма не меньше 10000
  и сумма меньше 50000
  то добавить 5 процентов от поля сумма

Это не издержка, а выигрыш: диапазоны, которые в else if были неявными, теперь написаны словами, и дыру между ступенями видно глазами.

5. Добавить свойства. Ищите в легаси Math.min, Math.max, финальные if (x > limit) x = limit и комментарии вида «скидка не может превышать четверть». Всё это — инварианты, которые в коде выглядят как исправление результата. В модели они становятся постусловиями:

свойство «Скидка не больше четверти заказа»
  результат не больше 25 процентов от поля сумма

Разница принципиальная. Math.min чинит симптом и прячет причину; свойство останавливает выполнение с FTS_UTILITY_PROPERTY и заставляет разобраться, какое правило насчитало лишнее.

Форма предела при переносе повторяет легаси дословно — cap = order.amount * 0.25 превращается в 25 процентов от поля сумма. Это осознанный долг: если переписать предел числом уже на этом шаге, расхождения теста эквивалентности перестанут быть находками в старом коде и станут следствием переписывания. Плата известна и её видно инструментом: ftsmap examples/fts/migration/discount.fts --text сообщает, что на отрицательной сумме такой предел уходит в минус и нарушается там, где не сработало ни одно правило. Ужесточение — отдельный шаг после того, как старый код удалён; как оно выглядит, разобрано в главе об антипаттернах.

6. Перенести известные кейсы в примеры. Всё, что команда помнит наизусть — «на десяти тысячах ровно пятьсот», «партнёру со стажем на шестьдесят тысяч девять тысяч скидки», — становится примерами внутри утилиты. Это регрессия, которая живёт в том же файле, что и правило, и выполняется командой fts test, а не отдельным тестовым проектом.

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

8. Что остаётся в приложении. Получение профиля, логирование, запись результата в заказ, округление. Ниже про это отдельно.

Тест эквивалентности

Сравнивать нужно не легаси и утилиту, а легаси и адаптер — то, что реально займёт место старой функции:

function calculateByModel(order, deps = {}) {
  const profile = deps.loadProfile(order.customerId);
  const input = {
    сумма: order.amount,
    'месяцев с клиентом': profile.monthsWithUs,
    партнёр: profile.partner,
    промокод: order.promoCode ?? '',
  };
  return Math.round(executeUtility(document, UTILITY, input));
}

Входы перебираются по сетке, а не генерируются случайно. Мигающему тесту эквивалентности перестают верить на второй неделе, а верить ему нужно долго. Значения подобраны вокруг каждого порога — по одному чуть ниже, ровно на нём и чуть выше:

const AMOUNTS = [0, 999, 1500, 9999, 10000, 19999, 20000, 49999, 50000, 50001, 99999, 250000];
const MONTHS = [0, 6, 11, 12, 36];
const PARTNER = [false, true];
const PROMO_CODES = ['', 'ВЕСНА25', 'ЛЕТО10'];

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

$ node --test examples/fts/migration/equivalence.test.mjs
входов: 360; совпало: 300; расхождений: 60 (Порог 50 000 включительно — 30; Промокод на заказе меньше 5 000 — 30)
✔ модель компилируется, и её собственные примеры сходятся (1.29387ms)
✔ легаси и модель совпадают на всей сетке, кроме объявленных расхождений (7.553157ms)
✔ каждое объявленное расхождение подтверждается сеткой и объяснено (1.06001ms)
✔ на сетке нет входов, отвергнутых утилитой по типам (1.74687ms)
ℹ tests 4
ℹ pass 4
ℹ fail 0

Оба расхождения — находки, ради которых всё и затевалось.

Порог 50 000. Легаси проверяет order.amount > 50000, тариф называется «от 50 000». На ровно пятидесяти тысячах старый код даёт 5 процентов вместо 10. Это не разница реализаций, а баг, который три года никто не видел, потому что попасть в точное значение порога на живом трафике удаётся редко. Решение принимает владелец правила; принятое решение фиксируется примером «Ровно пятьдесят тысяч» — и с этого момента оно перестаёт быть устным.

Промокод на маленьком заказе. Фиксированные 500 рублей не имеют минимальной суммы заказа, поэтому на сумме 1 500 они перебивают потолок в 25 процентов. Легаси обрезает результат до 375 и продолжает работать; модель останавливается на FTS_UTILITY_PROPERTY. Формально «расхождение», по сути — модель обнаружила противоречие между двумя правилами, которое старый код маскировал. Правильная починка не в модели, а в бизнес-правиле: промокод не должен выдаваться на такие заказы.

Проверить, что тест действительно ловит регресс, можно за десять секунд: замените предикат первого расхождения на () => false. Тест покажет список неучтённых входов с фактическими числами — сумма=50000 … легаси: 2500, модель: 5000 — и упадёт.

Что осталось в приложении

После миграции приложение делает ровно то, что модель делать не умеет и не должна:

  • получение профиляloadProfile(order.customerId), любой источник, любые кэши и ретраи;
  • приведение к скалярамorder.promoCode ?? ''. Утилита требует все объявленные поля и проверяет их типы; null для строки не значение, а дефект границы. Поле можно объявить как иногда является строкой, но тогда на него нельзя ссылаться в правиле: условие по отсутствующему полю даёт FTS_UTILITY_INPUT;
  • округление денегMath.round на выходе. Округлять надо один раз и на границе, иначе получите двойное округление в отчётах;
  • логирование, метрики, запись результата в заказ и в аудит.

Это и есть правильная граница: адаптер занимает десяток строк, читается целиком и не содержит ни одного условия про предметную область.

Чего не переносить

Не тащите в модель то, что делает её недетерминированной или неполной:

  • обращения к БД и сервисам — утилита принимает уже полученные данные;
  • логи и метрики — эффект, ноль пользы для правила;
  • ретраи и таймауты — свойство инфраструктуры, а не предметной области;
  • работу с текущим временемDate.now() внутри правила превращает примеры в мигающие тесты. Возраст, срок, просрочка вычисляются снаружи и приходят числом: не «дата регистрации», а «месяцев с клиентом»;
  • форматирование и локализацию — представление, а не решение;
  • ветвления по фича-флагам — они меняются чаще правил и живут в приложении.

Практическое правило: если ответ зависит не только от аргументов, это не утилита FTS.

Сколько это стоит

Честный счёт, без маркетинга.

Что вы выигрываете. Одно место правды: правило перестаёт существовать одновременно в коде, в конфлюенсе и в голове аналитика. Тесты на языке предметной области: примеры внутри модели читает и правит человек, который формулирует тариф, а не только тот, кто пишет node:test. Генерация TypeScript и типов входа — реализация и интерфейс перестают расходиться. И, как показал этот перенос, побочный эффект переписывания правила словами — найденные баги: > вместо >= и противоречие между промокодом и потолком нашлись не потому, что их искали, а потому, что модель заставила проговорить границы.

Что вы платите. Ещё один артефакт в репозитории со своим жизненным циклом, проверкой в CI и review. Обучение команды: отсутствие else и «выполняются все подходящие правила» — контринтуитивно для людей с императивным опытом, первые две недели ошибаются все. Граница данных: адаптер придётся писать и поддерживать, а решение «что здесь скаляр» иногда требует отдельного обсуждения. Плюс ограничения версии: округления, дат и else в языке нет, и часть логики всё равно останется в приложении.

Когда не стоит: правило меняется раз в три года и умещается в два условия; логика по существу процедурная (парсинг, обход дерева); ответ зависит от внешнего состояния. Когда стоит: тарифы, скидки, лимиты, классификаторы, допуски к операции — всё, что бизнес формулирует таблицей и что меняется чаще, чем релизится сервис.

Практика в песочнице

Упражнение 1. Уберите из правила «Средний заказ» строку и сумма меньше 50000 и запустите примеры. Пример «Ровно пятьдесят тысяч» покажет 7500 вместо 5000: сработали обе ступени. Это ровно та ошибка, которую делают при переносе else if.

Упражнение 2. Замените в правиле «Весенний промокод» действие на то добавить 5000 и выполните утилиту на сумме 20 000. Вместо ответа получите FTS_UTILITY_PROPERTY: свойство поймало то, что легаси обрезал бы молча.

Типичные ошибки миграции

  1. Перенести if / else if как несколько правил без верхних границ. Самая частая и самая дорогая: правила не исключают друг друга, скидка удваивается.
  2. Считать, что правила выполняются до первого совпадения. Нет: выполняются все истинные, сверху вниз, накопительно.
  3. Взять в модель объект из ORM целиком. Вход правила — это несколько скаляров, а не строка таблицы с двадцатью полями и связями.
  4. Оставить Math.min в адаптере рядом со свойством. Тогда свойство никогда не сработает, и вы потеряете диагностику, ради которой его писали.
  5. Молча «починить» расхождение в модели, чтобы тест позеленел. Расхождение — это вопрос к владельцу правила, а не к разработчику. Правьте легаси или объявляйте расхождение с объяснением.
  6. Случайные входы в тесте эквивалентности. Первый же красный прогон, который не воспроизводится, обесценит весь тест.
  7. Ссылаться в правиле на поле иногда является. Условие по отсутствующему полю падает на FTS_UTILITY_INPUT; нормализуйте значение в адаптере.
  8. Выключить старый код по расписанию, а не по метрике shadow-прогона.
  9. Оставить обе реализации «на всякий случай». Через полгода они разойдутся, и никто не будет знать, какая из них права.

Чек-лист

  • Правило отделено от получения данных и эффектов обычным рефакторингом.
  • Характеризационные тесты написаны до начала переноса.
  • Вход объявлен скалярами; для каждого поля понятно, откуда оно берётся.
  • Каждая ступень тарифа имеет обе границы; перекрытий и дыр нет.
  • Все Math.min / Math.max / «не может превышать» стали свойствами.
  • Известные кейсы перенесены в примеры и проходят fts test.
  • Тест эквивалентности гоняет детерминированную сетку вокруг всех порогов.
  • Список расхождений пуст или каждая запись объяснена и подтверждена сеткой.
  • Расхождения показаны владельцу правила, решение зафиксировано примером.
  • Shadow-прогон на реальном трафике отработал согласованный срок.
  • Адаптер не содержит ни одного условия про предметную область.
  • Старый код удалён, а не оставлен под флагом.

Кейсы каталога по этой теме

Нашли неточность? Выделите фрагмент текста — рядом появится жучок.

Нужен разбор именно вашей ситуации?

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

Доска запросов
Дальше