FutoIn AsyncSteps: концепция и реализация асинхронной бизнес-логики

в 11:45, , рубрики: api, async, business logic, concept, javascript, node.js, open source, Веб-разработка

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

FutoIn — с одной стороны, это «клей» из стандартов/спецификаций разных мастей для унификации программных интерфейсов различных существующих проектов по устоявшимся типам, с другой — это концепция для построения и масштабирования компонентов проекта и инфраструктуры, написанных на разных технологиях, без потребности в добавления этого самого «клея».

AsyncSteps — это спецификация и реализация программного интерфейса для построения асинхронных программ в независимости от выбранного языка или технологии.

Цели, поставленные для концепции:

  • реализация (с оговорками) должна быть возможна на всех распространённых языках программирования с поддержкой объектов и анонимных функций. Репрезентативный минимум: С++, C#, Java, JavaScript, Lua (не ООП), PHP, Python;
  • написанная программа должна легко читаться (сравнимо с классическим вариантом);
  • должны поддерживаться исключения языка (Exceptions) с возможностью перехвата и разворачиванием асинхронного стека до самого начала;
  • требуется удобство для написания асинхронных библиотек с единым подходом для вызова, возврата результата и обработки ошибок;
  • предоставить простой инструмент для естественного распараллеливания независимых веток программы;
  • предоставить простой инструмент создания асинхронных циклов с классическим управлением (break, continue) и меткой для выхода из вложенных циклов;
  • предоставить место для хранения состояния исполняемой бизнес-логики;
  • возможность отменять абстрактную асинхронную задачу, правильно завершая выполнение (освобождая внешние ресурсы);
  • возможность легко интегрироваться с другими подходами асинхронного программирования;
  • возможность ограничивать время выполнения задачи и отдельно каждой подзадачи;
  • возможность создавать модель задачи для копирования (улучшения производительности критичных частей) или использования как объект первого класса для передачи логики в качестве параметра (а-ля callback);
  • сделать отладку асинхронной программы максимально комфортной.

Что же из этого вышло

Родилась и обновлялась спецификация (назвать стандартом без достаточного распространения и редакторской правки рука не поднимается) FTN12: FutoIn Async API. Сразу скажу, что написана она на английском — де-факто стандарте в международном IT сообществе, как латинский в медицине. Прошу не акцентировать на этом внимание.

Пройдя относительно короткий путь proof-of-concept на базе PHP (ещё не реализованы последние изменения спецификации), родился вариант на JavaScript под Node.js и browser. Всё доступно на GitHub под лицензией Apache-2. В NPM и Bower доступны под названием «futoin-asyncsteps».

И каким же образом сие использовать

Начнём с разминки для когнитивного понимания сути.

Сначала маленький пример псевдо-кода в синхронном варианте:

    variable = null

    try
    {
        print( "Level 0 func" )
        
        try
        {
            print( "Level 1 func" )
            throw "myerror"
        }
        catch ( error )
        {
            print( "Level 1 onerror: " + error )
            throw "newerror"
        }
    }
    catch( error )
    {
        print( "Level 0 onerror: " + error )
        variable = "Prm"
    }
    
    print( "Level 0 func2: " + variable )

А теперь, то же самое, но написанное асинхронно:

    add( // Level 0
        func( as ){
            print( "Level 0 func" )
            add( // Level 1
                func( as ){
                    print( "Level 1 func" )
                    as.error( "myerror" )
                },
                onerror( as, error ){
                    print( "Level 1 onerror: " + error )
                    as.error( "newerror" )
                }
            )
        },
        onerror( as, error ){
            print( "Level 0 onerror: " + error )
            as.success( "Prm" )
        }
    )
    add( // Level 0
        func( as, param ){
            print( "Level 0 func2: " + param )
            as.success()
        }
    )

Ожидаемый результат выполнения:

    Level 0 func
    Level 1 func
    Level 1 onerror: myerror
    Level 0 onerror: newerror
    Level 0 func2: Prm


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

Основные прототипы функций-обработчиков:

  • execute_callback( AsyncSteps as[, previous_success_args1, ...] ) — прототип функции выполнение шага
  • error_callback( AsyncSteps as, error ) — прототип функции обработки ошибок

Основные методы построения задачи:

  • as.add( execute_callback func[, error_callback onerror] ) — добавление шага
  • as.parallel( [error_callback onerror] ) — возвращает интерфейс AsyncSteps параллельного исполнения

Результат выполнения шага:

  • as.success( [result_arg, ...] ) — положительный результат выполнения. Аргументы передаются в следующий шаг. Действие по умолчанию — вызывать не требуется, если нет аргументов
  • as.error( name [, error_info] ) — установить as.state().error_info и бросить исключение. Асинхронный стек раскручивается через все onerror (и oncancel, но это пока опустим)

Результат, переданный через вызов AsyncSteps#success(), попадает в следующий по исполнению шаг в качестве аргументов после обязательного параметра as.

Разберёмся на колбасе кода реальном примере:

// CommonJS вариант. В browser'е доступно через глобальную переменную $as
var async_steps = require('futoin-asyncsteps');

// Создаём корневой объект-задачу, все функции поддерживают вызов по цепочке
var root_as = async_steps();

// Добавляем простой первый шаг
root_as.add(
    function( as ){
        // Передаём параметр в следующий шаг
        as.success( "MyValue" );
    }
)
// Второй шаг
.add(
    // Шаг программы, аналогичен блоку try
    function( as, arg ){
        if ( arg === 'MyValue' ) // true
        {
            // Добавляем вложенный шаг
            as.add( function( as ){
                // Поднимаем исключение с произвольным кодом MyError и необязательным пояснением
                as.error( 'MyError', 'Something bad has happened' );
            });
        }
    },
    // Второй необязательный параметр - обработчик ошибок, аналогичен блоку catch
    function( as, err )
    {
        if ( err === 'MyError' ) // true
        {
            // продолжаем выполнение задача, игнорируя ошибку
            as.success( 'NotSoBad' );
        }
    }
)
.add(
    function( as, arg )
    {
        if ( arg === 'NotSoBad' )
        {
            // То самое необязательное пояснение доступно через состояние задачи as.state.error_info
            console.log( 'MyError was ignored: ' + as.state.error_info );
        }

        // Добавляем переменные в состояние задачи, доступное на протяжении всего выполнения
        as.state.p1arg = 'abc';
        as.state.p2arg = 'xyz';
        
        // Следующие два шага, добавленные через p, будут выполнены параллельно.
        // Обратите внимание на результат выполнения, приведённый ниже
        var p = as.parallel();
        
        p.add( function( as ){
            console.log( 'Parallel Step 1' );
            
            as.add( function( as ){
                console.log( 'Parallel Step 1.1' );
                as.state.p1 = as.state.p1arg + '1';
                // Подразумеваемый вызов as.success()
            } );
        } )
        .add( function( as ){
            console.log( 'Parallel Step 2' );
            
            as.add( function( as ){
                console.log( 'Parallel Step 2.1' );
                as.state.p2 = as.state.p2arg + '2';
            } );
        } );
    }
)
.add( function( as ){
    console.log( 'Parallel 1 result: ' + as.state.p1 );
    console.log( 'Parallel 2 result: ' + as.state.p2 );
} );

// Добавляем задачу в очередь на выполнение, иначе "не поедет"
root_as.execute();

Результат:

MyError was ignored: Something bad has happened
Parallel Step 1
Parallel Step 2
Parallel Step 1.1
Parallel Step 2.1
Parallel 1 result: abc1
Parallel 2 result: xyz2

Усложняемся до циклов

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

Тем не менее, предусмотрены следующие типы циклов:

  • loop( func( as ) [, label] ) — до ошибки или as.break()
  • repeat( count, func( as, i ) [, label] ) — не более count итераций
  • forEach( map_or_array, func( as, key, value ) [, label] ) — проход по простому или ассоциативному массиву (или эквиваленту)

Досрочное завершение итерации и выход из цикла осуществляется через as.continue( [label] ) и as.break( [label] ) соответственно, которые реализованы на базе as.error( [label] )

Очередной пример, не нуждающийся в особых пояснениях:

// В этот раз в browser
$as().add(
	function( as ){
		as.repeat( 3, function( as, i ) {
			console.log( "> Repeat: " + i );
		} );
		
		as.forEach( [ 1, 2, 3 ], function( as, k, v ) {
			console.log( "> forEach: " + k + " = " + v );
		} );
		
		as.forEach( { a: 1, b: 2, c: 3 }, function( as, k, v ) {
			console.log( "> forEach: " + k + " = " + v );
		} );
	}
)
.loop( function( as ){
	call_some_library( as );
	as.add( func( as, result ){
		if ( !result )
		{
			// exit loop
			as.break();
		}
	} );
} )
.execute();

Результат:

> Repeat: 0
> Repeat: 1
> Repeat: 2
> forEach: 0 = 1
> forEach: 1 = 2
> forEach: 2 = 3
> forEach: a = 1
> forEach: b = 2
> forEach: c = 3

Ожидание внешнего события

Тут есть два принципиальным момента:

  1. as.setCancel( func( as ) ) — возможность установки обработчика внешней отмены задачи
  2. as.setTimeout( timeout_ms ) — установка максимального времени ожидания

Вызов любого из них потребует вызова явного вызова as.success() или as.error() для продолжения.

function dummy_service_read( success, error ){
    // Должна вызвать success() при наличии данны
    // или error() при ошибке
}

function dummy_service_cancel( reqhandle ){
    // Чёрная магия по отмене dummy_service_read()
}

var as = async_steps();
as.add( function( as ){
    setImmediate( function(){
        as.success( 'async success()' );
    } );
    
    as.setTimeout( 10 ); // ms
    // Нет неявного вызова as.success() из-за вызова setTimeout()
} ).add(
    function( as, arg ){
        console.log( arg );
        
        var reqhandle = dummy_service_read(
            function( data ){
                as.success( data );
            },
            function( err ){
                if ( err !== 'SomeSpecificCancelCode' )
                {
                    try {
                        as.error( err );
                    } catch ( e ) {
                        // Игнорируем исключение - мы не в теле функции-шага
                    }
                }
            }
        );
        
        as.setCancel(function(as){
            dummy_service_cancel( reqhandle );
        });
        // Нет неявного вызова as.success() из-за вызова setCancel()
        
        // OPTIONAL. Ожидание не больше 1 секунды
        as.setTimeout( 1000 );
    },
    function( as, err )
    {
        console.log( err + ": " + as.state.error_info );
    }
).execute();

setTimeout( function(){
    // вызывается на корневом объекте
    as.cancel();
}, 100 );

Сахар для отладки

Нужны ли комментарии?

.add(
    function( as, arg ){
        ...
    },
    function( as, err )
    {
        console.log( err + ": " + as.state.error_info );
        console.log( as.state.last_exception.stack );
    }
)

Если всё совсем плохо, то можно «развернуть» код в синхронное выполнение.

                async_steps.installAsyncToolTest();

                var as = async_steps();
                as.state.second_called = false;
                as.add(
                    function( as ){
                        as.success();
                    },
                    function( as, error ){
                        error.should.equal( "Does not work" );
                    }
                ).add(
                    function( as ){
                        as.state.second_called = true;
                        as.success();
                    }
                );
                
                as.execute();
                as.state.second_called.should.be.false;
                async_steps.AsyncTool.getEvents().length.should.be.above( 0 );
                
                async_steps.AsyncTool.nextEvent();
                as.state.second_called.should.be.true;                
                async_steps.AsyncTool.getEvents().length.should.equal( 0 );

Заключение

Для тех, кто начинает читать отсюда. Сверху изложено что-то вроде сжатого перевода README.md проекта и выдержек из спецификации FTN12: FutoIn Async API. Если перевариваете английский, то не стесняйтесь получить больше информации из оригиналов.

Идея и проект родились из потребности перенесения бизнес-логики в асинхронную среду. В первую очередь для обработки транзакций базы данных с SAVEPOINT и надёжным своевременным ROLLBACK в среде выполнения вроде Node.js.

FutoIn AsyncSteps — это своего рода швейцарский нож с жёстко структурированными шагами; с развёртыванием стека при обработке исключений практически в классическом виде; с поддержкой циклов, ограничения по времени выполнения, обработчиков отмены задачи в каждом вложенном шаге. Возможно, это именно то, что вы искали для своего проекта.

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

P.S. Примеры практического применения FutoIn Invoker и FutoIn Executor, о которых, возможно, тоже будет статья после первого релиза.

Автор: andvgal

Источник


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


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