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

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

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

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

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

Рабочий комплект лежит в examples/spec/migration/: legacy-discount.mjs, discount.flang и 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» — разница видна только на ровно пятидесяти тысячах.

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

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

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

модуль «Скидка заказа»

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

тотальная функция «Скидка за сумму»
  принимает заказ: «Заказ»
  возвращает число
  пример «порог пятидесяти тысяч включён»
    дано заказ равно запись «Заказ» с «сумма» равным 50000 и «месяцев с клиентом» равным 0 и «партнёр» равным нет и «промокод» равным ""
    ожидается 5000
  если заказ.«сумма» не меньше 50000
    то (заказ.«сумма» умножить на 10) делить на 100
    иначе если заказ.«сумма» не меньше 10000
      то (заказ.«сумма» умножить на 5) делить на 100
      иначе 0

тотальная функция «Надбавка за стаж»
  принимает заказ: «Заказ»
  возвращает число
  если заказ.«месяцев с клиентом» не меньше 12
    то (заказ.«сумма» умножить на 3) делить на 100
    иначе 0

тотальная функция «Партнёрская надбавка»
  принимает заказ: «Заказ»
  возвращает число
  если заказ.«партнёр» и притом заказ.«сумма» не меньше 20000
    то (заказ.«сумма» умножить на 2) делить на 100
    иначе 0

тотальная функция «Бонус по промокоду»
  принимает заказ: «Заказ»
  возвращает число
  если заказ.«промокод» равен "ВЕСНА25"
    то 500
    иначе 0

тотальная функция «Рассчитать скидку заказа»
  принимает заказ: «Заказ»
  возвращает число
  обеспечивает «скидка не больше четверти заказа» результат не больше ((заказ.«сумма» умножить на 25) делить на 100)
  пример «мелкий заказ без скидки»
    дано заказ равно запись «Заказ» с «сумма» равным 5000 и «месяцев с клиентом» равным 0 и «партнёр» равным нет и «промокод» равным ""
    ожидается 0
  пример «порог десяти тысяч»
    дано заказ равно запись «Заказ» с «сумма» равным 10000 и «месяцев с клиентом» равным 0 и «партнёр» равным нет и «промокод» равным ""
    ожидается 500
  пример «ровно пятьдесят тысяч»
    дано заказ равно запись «Заказ» с «сумма» равным 50000 и «месяцев с клиентом» равным 0 и «партнёр» равным нет и «промокод» равным ""
    ожидается 5000
  пример «партнёр со стажем»
    дано заказ равно запись «Заказ» с «сумма» равным 60000 и «месяцев с клиентом» равным 24 и «партнёр» равным да и «промокод» равным ""
    ожидается 9000
  пример «промокод на среднем заказе»
    дано заказ равно запись «Заказ» с «сумма» равным 20000 и «месяцев с клиентом» равным 0 и «партнёр» равным нет и «промокод» равным "ВЕСНА25"
    ожидается 1500
  («Скидка за сумму» от заказ) плюс («Надбавка за стаж» от заказ) плюс («Партнёрская надбавка» от заказ) плюс («Бонус по промокоду» от заказ)

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

Шаг за шагом

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

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

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

4. Перенести условия. Вложенные if из легаси переносятся цепочкой если … иначе если: ветки проверяются сверху вниз, выполняется ровно одна.

модуль «Ступени скидки»

тотальная функция «Скидка за сумму»
  принимает сумма: неотрицательное
  возвращает число
  пример «ниже первой ступени»
    дано сумма равно 9999
    ожидается 0
  пример «первая ступень»
    дано сумма равно 10000
    ожидается 500
  пример «ровно пятьдесят тысяч — уже вторая ступень»
    дано сумма равно 50000
    ожидается 5000
  если сумма не меньше 50000
    то (сумма умножить на 10) делить на 100
    иначе если сумма не меньше 10000
      то (сумма умножить на 5) делить на 100
      иначе 0

Ловушка ждёт с другой стороны: ступени нельзя разносить по отдельным функциям и складывать. Сложение функций — это сложение, и две ступени «не меньше 10000» и «не меньше 50000» на сумме 60 000 дадут 15 процентов вместо 10. Складываются только вклады, которые и по смыслу складываются: стаж, партнёрство, промокод. Порог > 50000 здесь записан так, как называется тариф, — «от 50 000»; что это меняет, покажет тест эквивалентности.

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

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

Отчёт о доказательствах честно говорит, на чём это обещание держится:

постусловие «скидка не больше четверти заказа» функции «Рассчитать скидку заказа» — сетка 5 значений (примеры функции): нарушений НЕ ИСКАЛИ — прогона примеров не было, посчитано только их число. Это не доказательство — теоремы при утверждении нет

«Сетка 5 значений» — потолок проверен на пяти примерах, а для всех входов не доказан, и flang check --proof выходит кодом 3. Проценты от суммы ядро доказательств не сравнивает. Поэтому напечатанная модель проверяет обещание при каждом вызове — и ниже это обещание окажется нарушенным на входе, которого нет в примерах. Форма предела повторяет легаси дословно: cap = order.amount * 0.25. Это осознанный долг — если ужесточить предел уже на этом шаге, расхождения теста эквивалентности перестанут быть находками в старом коде и станут следствием переписывания. Как выглядит предел после ужесточения, разобрано в главе об антипаттернах.

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

$ flang test examples/spec/migration/discount.flang
examples/spec/migration/discount.flang: примеров 6, прошло 6, не прошло 0

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

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

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

Сравнивать нужно не легаси и модель, а легаси и адаптер — то, что реально займёт место старой функции. Модель печатается в JavaScript (flang emit --target js), и адаптер зовёт напечатанную функцию — ту самую, что поедет в приложение:

const calculate = (await importModel(MODEL_FILE)).functions.get('Рассчитать скидку заказа');

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

importModel из examples/spec/lib/flang.mjs печатает модель один раз и подключает модуль. Компилятора в рантайме нет: правило — обычная функция без зависимостей.

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

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/spec/migration/equivalence.test.mjs
входов: 360; совпало: 300; расхождений: 60 (Порог 50 000 включительно — 30; Промокод на заказе меньше 5 000 — 30)
✔ модель проходит проверку, и её собственные примеры сходятся (259.934045ms)
✔ легаси и модель совпадают на всей сетке, кроме объявленных расхождений (7.02131ms)
✔ каждое объявленное расхождение подтверждается сеткой и объяснено (1.129933ms)
✔ на сетке нет входов, отвергнутых моделью по типам (1.7331ms)
ℹ tests 4
ℹ pass 4
ℹ fail 0

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

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

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

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

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

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

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

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

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

Не тащите в модель то, что делает её недетерминированной или неполной. Часть этого flang не даст записать вовсе — у функции нет доступа к сети, файлам и часам:

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

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

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

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

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

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

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

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

Скопируйте examples/spec/migration/discount.flang в песочницу — или откройте модель скидки курса по ссылке ниже, у неё то же устройство.

Упражнение 1. Замените в функции «Скидка за сумму» условие не меньше 50000 на больше 50000, как в легаси, и проверьте. Ответ:

FLANG_EXAMPLE: пример «порог пятидесяти тысяч включён» функции «Скидка за сумму»: значение не совпало с ожидаемым: ожидалось 5000, получено 2500
FLANG_EXAMPLE: пример «ровно пятьдесят тысяч» функции «Рассчитать скидку заказа»: значение не совпало с ожидаемым: ожидалось 5000, получено 2500

Это ровно та ошибка легаси, которую нашёл тест эквивалентности, — теперь её держат два примера.

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

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

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

Чек-лист

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

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

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

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

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

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