Рефакторинг платежного процесса Я.Денег — пробуждение силы

в 14:11, , рубрики: javascript, node.js, Блог компании Яндекс.Деньги, Клиентская оптимизация, платежная система, Программирование, Проектирование и рефакторинг, рефакторинг, стандарты

image alt text

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

И тогда приходит он, рефакторинг платежного процесса. Но мы решили сделать процесс еще интереснее, добавив к рефакторингу идеи IDEF-0.

Это все временно, потом поменяем

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

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

Если начинать с нуля, то делать основательно, с использованием признанных методологий — теории конечных автоматов и IDEF-0. Принципы описания бизнес-процессов по этому стандарту знакомы с университетской скамьи как инженерам, так и управленцам — в этом они должны были найти общий язык. Заодно сбудется голубая мечта технаря об автоматическом построении диаграмм процесса, которые так любит руководство. Например, такая схема отображается на одном из дисплеев со статистикой, которые в изобилии развешаны в офисе Яндекс.Денег.

Мало просто причесать код — нужно сделать это с умом

При переводе всего старого кода на новые рельсы появился набор модулей Node.js, в которых описаны все базовые методы-процессы. Причем описаны не просто набором процедур, а в соответствии с идеями IDEF-0: есть функциональные блоки, входные и выходные данные, связи процессов.

Вообще, в IDEF-0 описано много всего, что при разработке можно упростить, поэтому кальку со стандарта мы не делали и просто заимствовали идею и все релевантные принципы.

Функциональные блоки

В IDEF-0 функциональный блок — это просто отдельная функция системы, которую графически изображают в виде прямоугольника. В платежном процессе Яндекс.Денег функциональные блоки содержат частички бизнес-логики того или иного процесса.

image alt text

У каждой из четырех сторон функционального блока своя роль:

  1. Верхняя сторона отвечает за управление;

  2. Левая — входные данные (для кого операция, сколько перевести и прочее);

  3. Правая сторона выводит результат;

  4. Нижняя — это "Механизм", который обозначает используемые в процессе ресурсы.

В платежном фронтенде Яндекс.Денег используются только две стороны функционального блока — вход и выход: на вход передается набор данных для выполнения бизнес-логики, у выхода система ожидает результат выполнения этой логики.

Вот как это выглядит в коде:

/**
 * Функциональный блок для проверки имени пользователя
 * @param {Object} $flow служебный объект, экземпляр текущего процесса, позволяющий управлять переходами от блока к блоку
 * @param {Object} inputData входящие данные
 * @param {Object} inputData.userName имя пользователя
 */
const checkUserName($flow, inputData) {
    if (inputData.userName) {
        // Переходим к следующему функциональному блоку
        const outputData = {
            userName: inputData.userName
            isUserNameValid: true
        };
        $flow.transition('doSomethingElse', outputData);
        return;
    }
    $flow.transition('checkFailed', inputData);
}

Функция принимает в качестве аргументов два параметра:

  1. $flow — служебный объект, экземпляр текущего процесса;

  2. inputData — объект со входными данными для функционального блока. Отличие функционального блока от обычной функции заключается в способе передачи управления внешнему коду. Функциональный блок для этого использует отдельный метод transition.

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

Интерфейсные дуги

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

В новом платежном процессе роль интерфейсной дуги исполняет функция Transition у объекта $flow, который является экземпляром отвечающего за предоставление API процесса.

Декомпозиция

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

В IDEF-0 декомпозиция выглядит следующим образом:

image alt text

Декомпозиция применялась в платежном процессе повсеместно, но рассмотрим на примере процесса проверки свойств пользователя.

image alt text

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

image alt text

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

  1. prepareToCheckPhone —подготовка данных;

  2. requestBackendForCheckPhone — запрос в бекенд;

  3. checkUserPhone — анализ результатов.

До переноса вовне все эти блоки перегружали логику проверки свойств пользователя, а теперь процесс стал значительно проще и понятней даже очень молодому разработчику.

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

// check-phone.js
module.exports = new ProcessFlow({
    initialStage: 'prepareInputData',
    finalStages: [
        'phoneValid',
        'phoneInvalid'
    ],

    stages: {
        /**
         * Функциональный блок для подготовки данных к проверке телефона
         * @param {Object} $flow служебный объект, экземпляр текущего процесса
         * @param {Object} inputData входящие данные
         */
        prepareInputData($flow, inputData) {
            /**
             * Формат данных необходимый модулю провеки номера телефона, может отличаться от формата,
             * которым оперирует конечный процесс, по этому данные нужно подготовить.
             * Так же завязываться на структуру данных модуля проверки телефона в конечном процессе не стоит,
             * модуль может поменяться, что может привести к серьезным изменениям всего процесса
             */
            $flow.transition('checkPhone', {
                phone: inputData
            });
        },

        /**
         * Функциональный блок проверки номера телефона
         * @param {Object} $flow служебный объект, экземпляр текущего процесса
         * @param {Object} inputData входящие данные
         */
        checkPhone($flow, inputData) {
            const someBackend = require('some-backend-module');
            someBackend.checkPhone(inputData.phone)
                .then((result) => {
                    $flow.transition('processCheckResult', result);
                })
                .catch((err) => {
                    $flow.transition('phoneInvalid', {
                        err: err
                    });
                });
        },

        /**
         * Функциональный блок анализа результатов проверки
         * @param {Object} $flow служебный объект, экземпляр текущего процесса
         * @param {Object} inputData входящие данные
         */
        processCheckResult($flow, inputData) {
            if (inputData.isPhoneValid) {
                $flow.transition('phoneValid');
                return;
            }
            $flow.transition('phoneInvalid');
        }
    }
});

// check-user.js
const checkPhoneProcess = require('./check-phone');

module.exports = new ProcessFlow({
    // Указываем, какой функциональный блок отвечает за вход в процесс
    initialStage: 'checkUserName',
    // Описываем выходы из процесса
    finalStages: [
        'userCheckedSuccessful',
        'userCheckFailed'
    ],
    stages: {
        /**
         * Функциональный блок для проверки имени пользователя
         * @param {Object} $flow служебный объект, экземпляр текущего процесса
         * @param {Object} inputData входящие данные
         */
        checkUserName($flow, inputData) {
            if (inputData.userName) {
                $flow.transition('checkUserBalance', inputData);
                return;
            }
            $flow.transition('userCheckFailed', {
                reason: 'invalid-user-name'
            });
        },

        /**
         * Функциональный блок для проверки баланса пользователя
         * @param {Object} $flow служебный объект, экземпляр текущего процесса
         * @param {Object} inputData входящие данные
         */
        checkUserBalance($flow, inputData) {
            if (inputData.balance > 0) {
                $flow.transition('checkUserPhone', inputData);
                return;
            }
            $flow.transition('userCheckFailed', {
                reason: 'invalid-user-balance'
            });
        },

        /**
         * Функциональный блок проверки номера телефона
         * @param {Object} $flow служебный объект, экземпляр текущего процесса
         * @param {Object} inputData входящие данные
         */
        checkUserPhone($flow, inputData) {
            const phone = inputData.operatorCode + inputData.number;
            checkPhoneProcess.start(phone, {
                // описываем поведение в точках выхода процесса проверки телефона
                phoneValid() {
                    $flow.transition('userCheckedSuccessful');
                },
                phoneInvalid() {
                    $flow.transition('userCheckFailed', {
                        reason: 'invalid-user-phone'
                    });
                }
            });
        }
    }
});

Каждый процесс платежа Яндекс.Денег является экземпляром класса ProcessFlow, который предоставляет API управления процессом. У него есть метод start, который вызывает функциональный блок, описанный в initialStage. В качестве аргументов метод start принимает входные данные и обработчики выходов процесса.

Принципы ограничения сложности

Процессы обычно содержат в себе сложную бизнес-логику, поэтому в коде приходится ограничивать их сложность в соответствии с рекомендациями IDEF-0:

  • Не более 6 функциональных блоков на каждом уровне. Это ограничение подталкивает разработчика к использованию иерархии при описании сложной логики;

  • Нижний предел в 3 блока гарантирует, что создание процесса оправданно;

  • Количество выходящих из одного блока интерфейсных дуг ограничено.

На уже знакомой иллюстрации процесса "как было" видно 7 функциональных блоков, что увеличивает соблазн написать все плоско, не заморачиваясь с иерархией.

image alt text

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

Пасхалка: автоматическая отрисовка схем

В крупных компаниях бизнес-процессы порой устаревают быстрее, чем их успевают нарисовать аналитики. Увы, мы в этом плане не исключение, поэтому пришлось научиться рисовать быстрее.

Благодаря IDEF-0 и строгим правилам описания процессов в коде, мы можем с помощью статического анализа кода построить диаграмму связей как функциональных блоков, так и процессов между собой. Например, подойдет продукт Esprima. В результате анализа кода этим инструментом формируется объект со всеми функциональными блоками и переходами, а визуализация происходит в браузере с помощью библиотеки GoJS:

image alt text

На схеме изображены процессы check-user и check-phone с указанием зависимости. Если их развернуть, получится следующее:

image alt text

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

Так стоила ли овчинка выделки

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

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

Есть и побочный эффект — бизнес-аналитикам больше не приходится рисовать статические диаграммы, поэтому потребление кофе и чая с какао резко возросло.

Автор: Яндекс.Деньги

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js