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

    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 может быть получен не только на этапе проверки номера телефона, но и на моменте проверки имени. Раньше это было до смешного не очевидно.


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


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


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


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

    • +30
    • 9,1k
    • 8
    Яндекс.Деньги
    174,00
    Как мы делаем Деньги
    Поделиться публикацией

    Комментарии 8

      0
      С учетом объемов работы это означало написать процесс заново.

      Я так и не понял, вы всё-таки переписали весь сервис с нуля, или просто сделали большой рефакторинг с использованием idef-0?
        +1
        Переписали процесс платежа с нуля, но процесс платежа это только часть сервиса Яндекс.Деньги
        –2
        Вот посмотрел на код. Он понятен, все такое. НО! А тестить пробовали? Код, который не возвращает явного результата своей работы — читай имеет возвращаемый тип void checkUserPhone. Можно было бы вернуть инстанс измененного ProcessFlow. А так нам нужно дополнительно мочить используемые сущности. Грязные фукнции — зло.

        Динамические require — адовое зло.

                    const someBackend = require('some-backend-module');
                    someBackend.checkPhone(inputData.phone)
        

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

        Плюс я бы на вашем месте все-таки внедрил бы flow или ts. Так хотя бы будет понятно какого типа у вас $flow и inputData. А так нужно постоянно проверять — есть ли нужное свойство, не свалимся с ошибкой. Плюс один глобальный объект to Rule them all — плохая практика. Все равно что в объекте request express держать глобальный объект data и пихать в него всю инфу. Опять же при запуске параллельном нескольких тестов — теряется главное свойство герметичность тестов. Т.е. чтобы один тест влиял на другие тесты.

        Idef-0 Это ж те же самые reducers. Берите пример с фп и тестить проще
          +1

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


          const checkUserProcess = require('./check-user');
          const processFlowStubHelper = require('process-flow-stub-helper');
          
          const checkUserProcessStub = processFlowStubHelper(checkUserProcess);
          
          it('should check userName successful', done => {
              checkUserProcessStub.runStage('checkUserName', {
                  userName: 'testUser',
                  balance: 0
              })
              .onTransition((stageName, outputData) => {
                  stageName.should.be.eq('checkUserBalance');
                  outuputData.should.be.deep.eq({
                      userName: 'testUser',
                      balance: 0
                  });
                  done();
              });
          });

          Вызываем необходимый функциональный блок с определенными данными и проверяем выходы этого блока. Код тестов получается достаточно простой и понятный.
          Глобальных объектов в ProcessFlow нет, все объекты приходят в качестве аргументов функции и являются для нее локальными. Сам ProcessFlow поставляется модулем и подключается через require, там где он нужен. Что вы имели в виду под глобальным объектом?

          0
          Здравствуйте, когда поправите плагин для opencart 2.3? Написал вам в github неделю назад никакого ответа. https://github.com/yandex-money/yandex-money-cms-opencart2/issues/106
            0

            Скорее всего что-то пошло не по плану в процессе установки модуля. Поможем разобраться, напишите на cms@yamoney.ru.

            0

            Возможно не совсем по теме статьи, но интересно узнать — а вы не пробовали использовать TypeScript в проекте? На большом проекте как у вас он добавляет дополнительных ограничений, чтобы не выстрелить себе в ногу? :)

              0

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

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

            Самое читаемое