Провальное развёртывание

Дело было на моей прошлой работе в финтехе. Новая функциональность была критически важной, по требованию финансового регулятора, мы были обязаны начать проверку третьих лиц, участвующих в транзакциях с нашими клиентами, по спискам санкций и политически значимых лиц (PEP). Если некий Вася обнаруживался в санкционных списках, или, скажем, был выбран когда-то в городскую администрацию, транзакции с его участием должны были отправляться на контроль в отдел комплаенса. На тот момент уже существовали и другие правила, отправляющие транзакции на контроль, например при превышении определённой суммы.

И вот, разработка и тестирование закончены, одним прекрасным утром трафик переключается на новую сборку, мониторинг показывает, что запросы к API проверяющей фирмы идут, ответы приходят, всё нормально. Но уже через час отдел комплаенса бьёт тревогу – задержанные на контроль транзакции стали попадать на счета клиентов! За такие дела можно и лицензию потерять, поэтому начинается аврал: счета этих клиентов замораживаются, версия срочно откатывается, транзакции правятся вручную. Нервная и неприятная для всех ситуация, иными словами, полная ж… жесть.

Окунёмся в истоки проблемы

Как же так получилось? Ведь были и автотесты и ревью кода, тестирование в песочнице. Давайте посмотрим на бизнес-логику. Примечание: рабочий код не является публичным, поэтому я привожу некий псевдокод, примерно передающий картину, но не отражающий все реальные операции. Итак, высокоуровневое описание процесса исходящей транзакции.

seqAnd(
  createTransaction(),
  label(“CollectData”),
  displayPage(“CollectPayoutData”),
  when(
    seqAnd(
      not(processHasAttribute(“Admin”)),
      not(productFlagIsSet(“NoOtpConfirmation”)))
  ).then(
    sendOtpSms(),
    displayPage(“EnterOtp”).onError(reset(“CollectData”))
  ),
  unloadAccount(),
  when(
    volatile(
      pepSanctionsScreening())
  ).then(
    enqueueApprovalRequest(),
    displayPage(“ComplianceCheck”)
  ),
  when(
    transactionHasStatus(“ToBeRejected”)
  ).then(
    loadAccount(),
    failure()
  ),
  initiateTransfer(),
…

Если ваше впечатление было: “Что за чертовщина тут написана?”, то оно полностью совпадает с тем, что я испытывал всё время, пока работал с этим кодом.

Пример показывает, что для программирования бизнес-логики был создан доменный язык (DSL), задающий как порядок операций на бэкенде, так и отображаемые страницы интерфейса. Бэкенд был на С++, фронтенд – веб страницы, генерируемые фреймворком Bottle, написанным на Python. Заданный таким образом бизнес-процесс назывался “алгоритмом”. Каждый шаг “алгоритма” – экземпляр полиморфного класса, интерфейс которого включал методы start(), для вызова заключённой в нём функции, затемgetNextSteps(), для получения следующего шага (ключевой для управляющих конструкций), и getState(), который выдавал одно из пяти возможных для шага состояний: NotInvoked, Running, Success, Failure, Reset.

Вычисление состояния “алгоритма” происходило так - брали дерево шагов данного “алгоритма”, из БД вычитывались уже пройденные шаги и их состояние, и сопоставлялись с деревом. При этом логические операторы и управляющие конструкции хитрым способом отображали пять возможных состояний содержащихся в них шагов на двоичную логику (каждый по-своему), но в итоге всё же выдавали, какой шаг выполнять следующим. Если шаг был фоновым, вызывалась содержащаяся в нем функция, и, с учётом её результата, снова определялся следующий шаг. Если шаг задавал пользовательский интерфейс, вычисление состояния прерывалось, пока через еще одну сущность, “медиатор”, не приходил результат действий пользователя. Чтобы было еще проще анализировать выполнение “алгоритма”, шаги, обёрнутые в volatile, повторялись каждый раз при вычислении состояния, а reset() мог сбросить состояние уже пройденных шагов, возвращая к ранее заданной метке, по сути являясь аналогом печально знаменитого оператора goto.

“Что-то решение выглядит так себе” - скажете вы? Подождите, это еще не всё. Поскольку система была не абы какая, а прогрессивная, с учётом модных веяний, она была построена на сетевом фреймворке Zeroc Ice. Это RPC-фреймворк, реализующий также конфигурации развёртывания, обнаружение служб, SSL и прочее. Сам по себе он достаточно интересен, но в нашем случае при проектировании системы (задолго до моего появления в команде) было решено практически каждую функцию делать доступной через RPC. Такие функции можно было вызвать практически из любого места кода, то есть все они становились, по сути, глобально видимыми. Они могли вызывать другие RPC интерфейсы, а те - третьи. И все они производили побочные эффекты, которые очень часто применялись как условия в управляющих конструкциях “алгоритмов”. Уровень вложенности этих управляющих конструкций тоже редко был меньше 4 - 5.  В результате, даже видя в мониторинге, какие шаги уже пройдены, было почти невозможно сказать, что будет следующим.

Ошибка, описанная в начале статьи, оказалось следующей: шаг, реализующий скрининг, был по технической необходимости обернут в volatile, то есть вычислялся при каждом запуске алгоритма. Без этого “алгоритм”, раз попав в ветку ручного финансового контроля, уже не мог бы пойти по другому пути, когда контроль был бы закончен. Если проверка уже была, он просто ничего не делал (да, да, по побочным эффектам он определял, что транзакция уже “чистая”). А еще был таймер, периодически перезапускающий “алгоритм”. Когда новый бизнес-процесс со скринингом был вызван для уже стоящей на контроле по другим причинам “старой” транзакции, он отправил запрос на санкции (ведь для старой транзакций этого не было сделано), таковых не обнаружилось, транзакция была помечена как “чистая” и радостно пошла на стадию перевода средств. Такого поворота не заподозрили ни автор этих строк, ни ревьюеры, ни тестировщики, ни автотесты, которых было мало, так как, чтобы тестировать бесконечные цепочки RPC, необходимо было писать миллион заглушек, а времени на это практически никогда не было.

Дальше так жить нельзя

Подумал я, и стал проектировать рефакторинг. Надо сказать, кроме этого я сделал выводы и про желательный порядок развёртывания подобных вещей, но это за рамками статьи. Итак, обобщая весь опыт работы со старым фреймворком, я выделил следующие проблемы:

  • Развитые управляющие конструкции провоцируют выносить бизнес-логику на уровень доменного языка.

  • Каждый шаг ничего не знает о других, остаётся единственный способ передавать информацию между ними – побочные эффекты, что приводит к неявной логике и скрытым зависимостям, хрупкости системы.

  • Управление фронтендом из бэкенда загромождает описание бизнес-процесса, а разное поведение фоновых и интерфейсных шагов создаёт дополнительные сложности.

Иными словами, слишком широкий набор возможностей, предоставляемый DSL фреймворком, привёл к переусложнению системы, которая стала слишком запутанной и полной неявных, но жёстких зависимостей. Расширять и изменять получившиеся бизнес-процессы стало крайне трудно и опасно.

Ещё одна большая архитектурная проблема, не относящаяся к фреймворку напрямую, это непосредственная зависимость бизнес-логики от коммуникационного фреймворка Zeroc и способа доступа к БД.

Исходя из описанных проблем, в основу рефакторинга легли такие идеи:

  • Важным требованием является возможность прерывать ход процесса, сохраняя состояние, и возобновлять его, например, для повторной попытки, или после получения ответа или асинхронного обратного вызова от сторонней системы.

  • Новый фреймворк не должен позволять выносить бизнес-логику в описание структуры процесса, все ветвления должны быть в “обычном” коде, а структура бизнес-процесса должна оставаться максимально простой. С учётом реальных потребностей системы, достаточно обеспечить переход к следующему шагу только после успешного завершения предыдущего. Этакий вырожденный конечный автомат.

  • Должна быть возможность передавать информацию между шагами, чтобы свести к минимуму использование побочных эффектов.

  • Фронтенд должен быть отделён от бэкенда, и взаимодействовать через API, а не через странные дополнительные сущности.

  • Бизнес-логика должна быть отделена и независима от деталей реализации, таких как механизм RPC или способ хранения данных.

Как готовые варианты рассматривались boost::future, поддерживающие “продолжения”, а также AWS Step Functions. Первые не позволяли продолжить выполнение с определённой точки. Вторые же, несмотря на удобный интерфейс для разработки и отладки, обладают недостатками нашего старого фреймворка – бизнес-логика начнёт растекаться по системе, и частично реализуется даже не в С++, а в языке описания логики Step Functions. Кроме того, не хотелось жёстко привязываться к определённому поставщику. Полноценные конечные автоматы из boost::statechart также не были использованы из-за избыточной функциональности, и некоторых абстракций (события), плохо подходящих для нашей предметной области.

Особые трудности были с отделением фронтенда, так как свободных веб-разработчиков не было. Пришлось самому изучить React, и сделать новый пользовательский интерфейс собственными руками. Но это отдельная история. С ресурсами вообще было тяжело, а переделка была масштабной, поэтому, чтобы избавиться от боли от работы со старой системой, я занимался рефакторингом в свободное время, и выкраивал какие-то часы из других рабочих задач.

Стоит заметить, что я был простым (старшим) разработчиком, поэтому воплотить свои идеи силой формального авторитета не было возможности. Нужно было убедить коллег и менеджеров, что дело того стоит, а сделать это, рассказывая субъективно красивые идеи, малоперспективно, люди покивают, скажут, “да, было бы круто”, но делать никто ничего не станет. Именно так и провалилась моя первая презентация. Лучше демонстрировать уже работающее решение или хотя бы прототип. Вложив немало времени, я сделал такое решение, и разговор пошёл уже совсем иной. Например, выяснилось, что один из разработчиков бэкенда тоже знает React, и готов взяться за переделку интерфейса для других процессов. Простота нового описания бизнес-процессов произвела крайне положительное впечатление на коллег. Для менеджмента, думаю, было важно, что стало возможно легко добавить некоторую функциональность, экономящую деньги, при этом уже многое сделано, то есть, риски и оценки времени достаточно ясны, да еще и внезапно появились новые фронтендеры-самоучки, ну как тут не выделить ресурсы?

Что получилось в итоге

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

Вот определение и упрощённый пример имплементации одного шага. Его смысл в том, чтобы вернуть деньги на счёт клиента, если исходящая транзакция завершилась ошибкой или была отклонена комплаенсом.

// .h

// Обратите внимание на объявление интерфейса “контекста”.
// Это реализация принципа инверсии зависимостей. То есть, вместо того,
// чтобы напрямую реализовывать нюансы вызова RPC или запросов к БД,
// бизнес-логика задаёт интерфейс, и полагается на него, а как он будет
// реализован - для неё неважно.
class PossibleRevertCtxI {
public:
  virtual bool loadAccount(int consumerId, int amount) = 0;
  virtual int transactionAmount(int transactionId) = 0;
};

// Возвращаемое значение функции автоматически будет передано на вход следующей
std::optional<TransactionData> possibleRevert(
  ComplianceData data, PossibleRevertCtxI& ctx);

// .cpp
std::optional<TransactionData> possibleRevert(
  ComplianceData data, PossibleRevertCtxI& ctx
) {
  if (data.complianceDecision == REQUIRED) {
    // Мы должны дождаться реакции проверяющих, поэтому останавливаем выполнение
    // на этом шаге.
    return std::nullopt;
  }
  if (data.complianceDecision == REJECT) {
    ctx.loadAccount(data.consumerId, ctx.transactionAmount(data.transactionId));
    throw std::runtime_error{ "Transaction rejected by Compliance." };
  }
  // decision is APPROVE or NOT_REQUIRED
  TransactionData result;
  result.consumerId = data.consumerId;
  result.requestId = data.requestId;
  result.transactionId = data.transactionId;
  return result;
}

Для объединения шагов в цепочки, инициализации и смены состояний, а также передачи аргументов и контекста, была написана небольшая библиотека, опубликованная на GitHub. Смена состояний цепочки предельно простая. Если шаг возвращает значение – она переходит к следующему шагу, передавая ему на вход значение, возвращённое предыдущим. Если же возвращается std::nullopt, то переход не происходит и цепь остаётся на том же шаге. Если функция выбрасывает исключение, то, разумеется, цепочка также прерывается. Цепочку можно выполнить сразу целиком, а можно по одному шагу, что было необходимо для финансовой системы, так как каждое состояние сериализовалось и сохранялось в БД. Ограничение, которое библиотека накладывает на код, это сериализуемость структур, описывающих входные данные - они должны иметь метод std::string serialize() const, а также конструироваться из строки. Другое ограничение – определённое количество аргументов для функций-шагов (один или два).

Описание бизнес-процесса стало выглядеть примерно так:

steps_chain::ChainWrapper payoutProcess(
	std::shared_ptr<ApiMock> api,
	std::shared_ptr<DbMock> db,
	std::shared_ptr<TimerMock> timer
) {
  // Шаги спроектированы с учетом принципа разделения интерфейсов, поэтому
  // они принимают разные типы в качестве "контекста". Так имплементация
  // цепочки требует, чтобы тип контекста был одинаковым, требуется промежуточный
  // слой в виде лямбды, который отвечает за приведение общего типа к тому,
  // который требуется данному шагу.
  return steps_chain::ChainWrapper {
    steps_chain::ContextStepsChain{
      [](InitialData d, std::shared_ptr<PayoutContext> c) {
        return unloadAccount(d, *c.get());
      },
      [](TransactionData d, std::shared_ptr<PayoutContext> c) {
        return sanctionsScreening(d, *c.get());
      },
      [](ComplianceData d, std::shared_ptr<PayoutContext> c) {
        return possibleRevert(d, *c.get());
      },
      [](TransactionData d, std::shared_ptr<PayoutContext> c) {
        return startTransfer(d, *c.get());
      }
    },
    std::make_shared<PayoutContext>(api, db, timer)
  };
}

Выполняются эти цепочки примерно следующим образом.

void runProcess(
    steps_chain::ChainWrapper p,
    const std::string& requestId,
    std::shared_ptr<DbMock> db
) {
  const auto data = db->fetchProcessData(requestId);
  // Здесь состояние цепочки инициализируется данными, сохранёнными в БД,
  // но при получении дополнительных данных из API или обратных вызовов,
  // их можно добавить перед инициализацией, подавая таким образом на вход
  // текущего шага. В реальной системе я использовал JSON как формат для
  // сериализации, как раз одним из его полезных свойств является простота 
  // объединения двух объектов.
  p.initialize(data.parameters, data.stepIdx);
  try {
    while (!p.is_finished()) {
        if (p.advance()) {
          const auto [idx, params] = p.get_current_state();
          db->setProcessData(data.requestId, idx, params);
        }
        else {
          // Выполнение остановлено досрочно, пишем лог
          return;
        }
    }
  }
  catch (const std::exception& ex) {
    // Логирование исключения, обновление метаинформации о процессе
    return;
  }
}

Заключение

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

Детальный рабочий пример кода можно найти в моём репозитории на GitHub вместе с публичной версией библиотеки под лицензией MIT.

Чтобы не занимать больше время читателя, приёмы метапрограммирования, а также идиомы С++, примененные при разработке фреймворка, например, внешний полиморфизм (external polymorphism) я опишу в отдельной статье, они довольно интересны.

Благодарю за внимание.

Комментарии (2)


  1. Apoheliy
    03.10.2022 11:06

    По Вашим требованиям (прерывать ход процесса, сохранять состояния, логика в обычном коде, передача состояний) отлично подходят корутины (которые давно есть в boost). Рассматривался ли такой вариант? Почему не подошёл?


    1. ikostruba Автор
      03.10.2022 11:48

      Спасибо за интерес,

      Корутины сохраняют состояние в процессе выполнения, но как сохранить фрейм корутины в БД я не придумал. И так же, как восстановить состояние в случае падения приложения или сбоя машины? Если все операции идемпотентны, то можно просто перезапустить корутину, но в нашем случае это было не вполне так. Поэтому, на мой взгляд, они не подходили.