В этой статье хочу познакомить уважаемых читателей с ещё одним велосипедом подходом к организации асинхронного кода. Сразу оговорюсь, что существует масса решений от лёгких потоков и разных предложений по 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
Ожидание внешнего события
Тут есть два принципиальным момента:
- as.setCancel( func( as ) ) — возможность установки обработчика внешней отмены задачи
- 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