diff --git a/.npmignore b/.npmignore new file mode 100644 --- /dev/null +++ b/.npmignore @@ -0,0 +1,1 @@ +*.tgz \ No newline at end of file diff --git a/build.gradle b/build.gradle --- a/build.gradle +++ b/build.gradle @@ -43,7 +43,7 @@ task _buildTs(dependsOn: _npmInstall, ty task _packageMeta(type: Copy) { inputs.property("version", version) from('.') { - include 'package.json', 'readme.md', 'license', 'history.md' + include 'package.json', '.npmignore', 'readme.md', 'license', 'history.md' } into distDir doLast { diff --git a/docs/cancellations.ru.md b/docs/cancellations.ru.md new file mode 100644 --- /dev/null +++ b/docs/cancellations.ru.md @@ -0,0 +1,257 @@ +# Cancellations. Отмена асинхронных операций + +Использование Promise позволяет организовать обработку результатов работы +асинхронных фукнций. Ключевые слова async/await позволяют работать с +асинхронными вызовами в стиле процедурного программирования, хотя по сути это +событиный подход. При всей своей красоте даннго подхода в нем умышленно +отсутсвует механизм отмены асинхронной операции, т.е. ее можно начать, но нельзя +отказаться от результатов ее выполнения, даже если это уже не требуется. + +Примером того, когда может потребоваться отмена является загрузка большого +файла, при которой пользователю отображается окно хода операции с возможностью +ее отмены. + +```ts +// имеется некоторый HTTP клиент +let client = new HttpClient(); + +// загружается большой файл, с использованием медленного канала +let data = await client.getJsonAsync('http://host/large-file.json'); + +``` + +Как поступить в данной ситуации, прежде всего нужно, чтобы сама операция +поддерживала возможность отмены, предположим, что для этого есть метод +`client.abort()`. + +```ts + +// имеется некоторый HTTP клиент +let client = new HttpClient(); + +// отображаем окно с информацией о хоже операции +let progressView = showProgress("Downloading, please wait..."); + +// код оборачивается в try/finally поскольку созданную форму нужно закрыть +try { + // загружается большой файл, с использованием медленного канала + // здесь, в отличии от предыдущего примера, мы не дожидаемся результата, + // а запоминаем обещание в переменную downloadTask + let downloadTask = client.getJsonAsync('http://host/large-file.json'); + + // связываем нажатие кнопки с отменой загрузки + progressView.once('cancel', () => client.abort()); + + // ожидаем окончания загрузки данных + + let data = await downloadTask; +} finally { + // независимот от результата закрываем форму + // при этом также происходит ануллирование подписок на события + progressView.close(); +} + +``` + +Технические приведенное решение выглядит не плохо, но проблемы появляются, когда +требуется организовать отмену нескольких операций, особенно если они вложенные. + +```ts +// обновление информации о человеке на форме +async function updatePersonInfo(info) { + let client = new RestApiClient(); + + // выплнение нескольких асинхронных операций + let org = await client.getOrgAsync(info.orgId); + let city = await client.getCityAsync(info.cityId); + + // обновление содержимого представления + renderContent({ + person: info, + org: org, + city: city + }); +} + +``` + +Чтобы реализовать возможность отмены такой операции требуется, чтобы в логике +самой операции была реализована поддержка отмены. Для реализации этого +потребуется чтобы у операции была информация о запросе отмены, причем данная +информация относится именно к текущей операции. + +Информацию о состоянии запроса на отмену назовет **маркер отмены (cancellation +token)**. Поскольку маркер отмены тесно связан с операцией, его удобно +передавать в виде параметра, тогда код операции будет выглядеть так: + +```ts +// обновление информации о человеке на форме +// ct - маркер отмены +async function updatePersonInfo(info, ct) { + let client = new RestApiClient(); + + // выплнение нескольких асинхронных операций + // маркер отмены просто передается далее по цепочке вызовов, без + // дополнительных действий + let org = await client.getOrg(info.orgId, ct); + let city = await client.getCity(info.cityId, ct); + + // обновление содержимого представления + renderContent({ + person: info, + org: org, + city: city + }); +} + +/////////////////////////////////////////////////////////////////////////////// +// ... где-то в другом месте +/////////////////////////////////////////////////////////////////////////////// + +// отображаем окно с информацией о хоже операции +let progressView = showProgress("Loading, please wait..."); + +// создаем маркер отмены для операции на основе события 'cancel'. +let ct = new Cancellation(cancel => progressView.on('cancel', cancel)); + +// код оборачивается в try/finally поскольку созданную форму нужно закрыть +try { + // асинхронно получаем информацию о человеке + let data = await getPersonInfo(personId, ct); + + // асинхронно обновляем представление + await updatePersonInfo(data, ct); +} finally { + // независимот от результата закрываем форму + // при этом также происходит ануллирование подписок на события + progressView.close(); +} + +``` + +Таким образом тот, кто начинает асинхронную операцию заранее определяет как эта +опреция будет отменена. + +Важно понимать, что для реализации отмены операции +могут выделаться ресурсы требующие явного освобождения (DOM, таймеры, события), +об их освобождении по окончанию операции (успешном или нет) должен позаботиться +инициатор этой операции. `Cancellation` выступает только в роли посредника для +доставки события отмены операции до конечного получателя, он не отслеживает и +не освобождает ресурсы, кроме того, асинхронная операция может его попросту +проигнорировать. + +## `ICancellation` Маркер отмены операции + +Интерфейс маркера отмены операции. Используется асинхронными операциями, чтобы +получить оповещение о требуемой отмене. + +### `isSupported(): boolean` + +Определяет, может ли быть запрошена отмена операции через данный маркер. + +### `isRequested(): boolean` + +Возвращает текущее состояние запроса на отмену. + +### `throwIfRequested(): void` + +Если отмена была запрошена, бросает в качестве исключения причину отмены. + +### `register(cb: (e:any) => void): IDestroyable` + +Метод, зарегистрировать обработчик на запрос отмены. Если отмена была запрошена +зарегистрированный обработчик будет вызван ровно один раз, независимо от того, +был ли он зарегистрирован до или после запроса отмены. + +Если отмена уже была запрошена, обработчик будет вызван сразу при регистрации, +при этом исключения, которые могу возникнуть в обработчике не будут обработаны, +а передадуться наверх. + +Вызов данного метода приводит к выделению ресурсов, поэтому операция, +зарегистрировавшая обработчик должна освободить подписку, которую вернет метод. + +```ts +async function getAsync(url: string, ct: ICancellation = Cancellation.none) { + // переменная в которой будет запомнена подписка на запрос отмены + let reg; + try { + // оборачиваем операцию загрузки в Promise + return await new Promise((resolve, reject) => { + // объект Xhr + const xhr = new XMLHttpRequest(); + xhr.open("GET", url); + + // регистрируем обработчики Promise + xhr.onload = () => resolve(xhr.responseText); + xhr.onerror = () => reject(xhr.statusText); + + // отправляем запрос + xhr.send(); + + // подписываемся на запрос отмены + reg = ct.register((e) => { + reject(e); + xhr.abort(); + }); + }); + } finally { + if (reg) + reg.destroy(); + } +} + +``` + +Использование метода `register()` предполагается для организации отмены операций +не поддерживающих маркеры отмены. + +## `Cancellation` Источник отмены + +Класс используется для создания маркеров отмены. Позволяет создать маркер при +начале асинхронной операции и связать его, например, с событием DOM. + +Также маркер можно создавать, когда требуется сложное условие отмены текущей и +всех нижележещих операций. + +Как правило в большинстве операций достаточно маркера переданного в параметрах, +этот же маркер может передаваться ниже. + +### `constructor(exec: (cancel: (reason:any) => void ) => void )` + +Создает новый маркер, при помощи параметра и инициализирует его при помощи +фукнции, переданной в параметре `exec`. + +```ts + +let htimer; +let ct = new Cancellation(cancel => { + htimer = setTimeout(() => cancel("The request is timed out."), 1000); +}); + +try { + let text = await getAsync(url, ct); +} finally { + // инициатор должен освобождать ресурсы + // передача недействительного htimer не приводит ни к каким последствиям + clearTimeout(htimer); +} + +``` + +## `Cancellation.none: ICancellation` + +Статическое свойство только для чтения, в котором находится специальный токен +запроса отмены. Этот токен означает, что отмена никогда не может произойти. + +Данный токен рекомендуется использовать как значение по-умолчанию для +параметров, принимающих токен отмены. + +```ts + +async function load(url: string, ct: ICancellation = Cancellation.none) { + ct.throwIfRequested(); + + // ... the rest of method +} + +``` \ No newline at end of file diff --git a/src/ts/Cancellation.ts b/src/ts/Cancellation.ts --- a/src/ts/Cancellation.ts +++ b/src/ts/Cancellation.ts @@ -1,6 +1,11 @@ -import { ICancellation } from "./interfaces"; +import { ICancellation, IDestroyable } from "./interfaces"; import { argumentNotNull } from "./safe"; +const destroyed = { + destroy() { + } +}; + export class Cancellation implements ICancellation { private _reason: any; private _cbs: Array<(e) => void>; @@ -23,18 +28,34 @@ export class Cancellation implements ICa return !!this._reason; } - register(cb: (e: any) => void): void { + register(cb: (e: any) => void): IDestroyable { argumentNotNull(cb, "cb"); if (this._reason) { cb(this._reason); + return destroyed; } else { if (!this._cbs) this._cbs = [cb]; else this._cbs.push(cb); + + let me = this; + return { + destroy() { + me._unregister(cb); + } + }; } } + + private _unregister(cb) { + if(this._cbs) { + let i = this._cbs.indexOf(cb); + if ( i>=0 ) + this._cbs.splice(i,1); + } + } private _cancel(reason) { if (this._reason) @@ -61,7 +82,8 @@ export class Cancellation implements ICa return false; }, - register(_cb: (e: any) => void): void { + register(_cb: (e: any) => void): IDestroyable { + return destroyed; } }; } \ No newline at end of file diff --git a/src/ts/components/Observable.ts b/src/ts/components/Observable.ts --- a/src/ts/components/Observable.ts +++ b/src/ts/components/Observable.ts @@ -8,7 +8,7 @@ interface Handler { } interface Initializer { - (notify: Handler, error?: (e: any) => void, complete?: () => void): (() => void) | void; + (notify: Handler, error?: (e: any) => void, complete?: () => void): void; } // TODO: think about to move this interfaces.ts and make it public @@ -20,23 +20,25 @@ interface IObserver { complete(): void } -class Observable implements IObservable, IDestroyable { +const noop = () => {}; + +class Observable implements IObservable { private _once = new Array>(); private _observers = new Array>(); - private _cleanup: (() => void) | void; private _complete: boolean private _error: any constructor(func?: Initializer) { - this._cleanup = func && func( - this._notifyNext.bind(this), - this._notifyError.bind(this), - this._notifyCompleted.bind(this) - ); + if (func) + func( + this._notifyNext.bind(this), + this._notifyError.bind(this), + this._notifyCompleted.bind(this) + ); } /** @@ -56,16 +58,8 @@ class Observable implements IObservab let observer: IObserver & IDestroyable = { next: next, - - error(e: any) { - if (error) - error(e); - }, - - complete() { - if (complete) - complete(); - }, + error: error ? error.bind(null) : noop, + complete: complete ? complete.bind(null) : noop, destroy() { me._removeObserver(this); @@ -133,30 +127,19 @@ class Observable implements IObservab return true; } - destroy() { - if (this._complete) - this._notifyCompleted(); - - let cleanup = this._cleanup; - if (cleanup) { - this._cleanup = null; - cleanup(); - } - } - protected onObserverException(e: any) { } private _removeOnce(d: IObserver) { let i = this._once.indexOf(d); if (i >= 0) - this._once.splice(i); + this._once.splice(i, 1); } private _removeObserver(d: IObserver) { let i = this._observers.indexOf(d); if (i >= 0) - this._observers.splice(i); + this._observers.splice(i, 1); } private _notify(guard: (observer: IObserver) => void) { diff --git a/src/ts/interfaces.ts b/src/ts/interfaces.ts --- a/src/ts/interfaces.ts +++ b/src/ts/interfaces.ts @@ -6,7 +6,7 @@ export interface ICancellation { throwIfRequested(): void; isRequested(): boolean; isSupported(): boolean; - register(cb: (e: any) => void): void; + register(cb: (e: any) => void): IDestroyable; } /** diff --git a/test/js/plan.js b/test/js/plan.js --- a/test/js/plan.js +++ b/test/js/plan.js @@ -1,1 +1,2 @@ -define(["./ActivatableTests", "./trace-test", "./TraceSourceTests"]); \ No newline at end of file +define(["./ActivatableTests", "./trace-test", "./TraceSourceTests", "./CancellationTests"]); +//define(["./CancellationTests"]); \ No newline at end of file diff --git a/test/ts/CancellationTests.ts b/test/ts/CancellationTests.ts new file mode 100644 --- /dev/null +++ b/test/ts/CancellationTests.ts @@ -0,0 +1,97 @@ +import * as tape from 'tape'; +import { Cancellation } from '@implab/core/Cancellation'; +import { ICancellation } from '@implab/core/interfaces'; +import { delay } from './TestTraits'; + +tape('standalone cancellation', async t => { + + let doCancel: (e) => void; + + let ct = new Cancellation(cancel => { + doCancel = cancel; + }); + + let counter = 0; + let reason = "BILL"; + + t.true(ct.isSupported(), "Cancellation must be supported"); + t.false(ct.isRequested(), "Cancellation shouldn't be requested"); + ct.throwIfRequested(); + t.pass("The exception shouldn't be thrown unless the cancellation is requested"); + + ct.register(() => counter++); + t.equals(counter, 0, "counter should be zero"); + + ct.register(() => counter++).destroy(); + + doCancel(reason); + + t.true(ct.isRequested(), "Cancellation should be requested"); + t.equals(counter, 1, "The registered callback should be triggered"); + + ct.register(() => counter++); + t.equals(counter, 2, "The callback should be triggered immediately"); + + let msg; + ct.register((e) => msg = e); + t.equals(msg, reason, "The cancellation reason should be passed to callback"); + + try { + msg = null; + ct.throwIfRequested(); + t.fail("The exception should be thrown"); + } catch (e) { + msg = e; + } + t.equals(msg, reason, "The cancellation reason should be catched"); + + t.end(); +}); + +tape('async cancellation', async t => { + + let ct = new Cancellation(cancel => { + cancel("STOP!"); + }); + + try { + await delay(0, ct); + t.fail("Should thow the exception"); + } catch (e) { + t.equals(e, "STOP!", "Should throw the cancellation reason"); + } + + t.end(); +}); + +tape('cancel with external event', async t => { + let ct = new Cancellation((cancel) => { + setTimeout(x => cancel('STOP!'), 0); + }) + + try { + await delay(10000, ct); + t.fail("Should thow the exception"); + } catch (e) { + t.equals(e, "STOP!", "Should throw the cancellation reason"); + } + + t.end(); +}); + +tape('operation normal flow', async t => { + + let htimeout; + let ct = new Cancellation((cancel) => { + htimeout = setTimeout(() => cancel("STOP!"), 1000); + }); + + try { + await delay(0, ct); + t.pass("Should pass"); + } finally { + clearTimeout(htimeout); + } + + t.end(); +}); \ No newline at end of file diff --git a/test/ts/TestTraits.ts b/test/ts/TestTraits.ts --- a/test/ts/TestTraits.ts +++ b/test/ts/TestTraits.ts @@ -26,7 +26,7 @@ export class TapeWriter implements IDest writeEvent(next: TraceEvent) { if (next.level >= TraceSource.LogLevel) { this._tape.comment("LOG " + next.arg); - } else if(next.level >= TraceSource.WarnLevel) { + } else if (next.level >= TraceSource.WarnLevel) { this._tape.comment("WARN " + next.arg); } else { this._tape.comment("ERROR " + next.arg); @@ -36,4 +36,28 @@ export class TapeWriter implements IDest destroy() { this._subscriptions.forEach(x => x.destroy()); } +} + +export async function delay(timeout: number, ct: ICancellation = Cancellation.none) { + let un: IDestroyable; + + try { + await new Promise((resolve, reject) => { + if (ct.isRequested()) { + un = ct.register(reject); + } else { + let ht = setTimeout(() => { + resolve(); + }, timeout); + + un = ct.register(e => { + clearTimeout(ht); + reject(e); + }); + } + }); + } finally { + if(un) + un.destroy(); + }; } \ No newline at end of file diff --git a/test/ts/TraceSourceTests.ts b/test/ts/TraceSourceTests.ts --- a/test/ts/TraceSourceTests.ts +++ b/test/ts/TraceSourceTests.ts @@ -1,6 +1,5 @@ -import * as TraceSource from '../../build/dist/log/TraceSource' +import * as TraceSource from '@implab/core/log/TraceSource' import * as tape from 'tape'; -import * as ConsoleWriter from '../../build/dist/log/writers/ConsoleWriter'; import { TapeWriter } from './TestTraits'; const sourceId = 'test/TraceSourceTests';