С момента появления async
/await
в Typescript вышло много статей, превозносящих этот подход в разработке (hackernoon, blog.bitsrc.io, habr.com). Мы используем их с самого начала на стороне клиента (когда ES6 Generators поддерживало меньше 50% браузеров). И сейчас хочется поделиться опытом, потому что параллельное выполнение — это еще не все, что хорошо бы знать на этом пути.
Мне не очень нравится итоговая статья: что-то может быть непонятным. Частично из-за того, что проприетарный код не могу предоставить — только обрисовать общий подход. Поэтому:
- не стесняясь, закрывайте вкладку не дочитав
- если все-таки осилите — спрашивайте неясные детали
- от самых стойких и докопавшихся до сути с радостью приму советы и критику.
Список основных технологий:
- Проект написан в основном на Typescript с использованием нескольких Javascript библиотек. Основная библиотека — ExtJS. По модности она уступает React, но для Enterprise продукта с богатым интерфейсом подходит лучше всего: много готовых компонентов, хорошо проработанные таблицы из коробки, богатая экосистема сопутствующих продутов для упрощения разработки.
- Асинхронный многопоточный сервер.
- В качестве транспорта между клиентом и сервером используется RPC через Websocket. Реализация похожа на .NET WCF.
- Любой объект является сервисом.
- Любой объект может передаться как по значению так и по ссылке.
- Интерфейс запроса данных напоминает GraphQL от Facebook, только на Typescript.
- Связь двустороняя: инициализация обновления данных может быть запущена как с клиента, так и с севрера.
- Асинхронный код пишется последовательно — через использование
async
/await
функций Typesrcipt'а. - API сервера генерируется на Typescript: если оно изменяется, билд в случае ошибки сразу покажет ее.
Что на выходе
Расскажу, как с этим работаем и что сделали для безопасного неконкурентного выполнения асинхронного кода: свои декораторы Typesrcipt, реализующие функционал очередей. От самых основ до решения race condition и других сложностей, которые возникают в процессе разработки.
Как структурированы данные, получаемые с сервера
Сервер возвращает родительский объект, который содержит в своих свойствах данные (другие объекты, коллекции объектов, строки etc.) в виде графа. Это обусловлено в том числе самим приложением:
- оно у нас делает анализ данных/ML направленным графом узлов-обработчиков.
- каждый узел в свою очередь может содержать свой вложенный граф
- графы имеют зависимости: узлы могут быть "отнаследованы", и по их "классу" созданы новые узлы.
Но структура запроса в виде графа может быть применена почти в любом приложении и GraphQL, насколько мне известно, также об этом упоминает в своей спецификации.
Пример структуры данных:
// Родительский объект
interface IParent {
ServerId: string;
Nodes: INodes; // INodes - коллекция узлов обработки данных INode
}
// Интерфейс коллекции узлов графа
interface INodes<TNode extends INode> extends ICollection {
IndexOf(item: TNode): number;
Item(index: number): TNode;
// ... Другие стандартные методы коллекции
}
// Интерфейс узла графа
interface INode extends IItem {
Guid: string;
Name: string;
DisplayName: string;
Links: ILinks; // ILinks - коллекция связей узла
Info: INodeInfo; // Вложенный объект с какой-то информацией
}
// Интерфейс связи между узлами графа
interface ILink {
Guid: string;
DisplayName: string;
SourceNode: INode; // Ссылка на узел-источник данных
TargetNode: INode; // Ссылка на узел, получающий данные
}
interface INodeInfo {
Component: IComponent;
ConfigData: IData;
}
Как клиент получает данные
Все просто: при запросе какого-либо свойства объекта нескалярного типа RPC возвращает Promise
:
let Nodes = Parent.Nodes; // Nodes -> Promise<INodes>
Асинхронность без "Callback Hell".
Для организации "последовательного" асинхронного кода используются async
/await
функционал Typescript:
async function ShowNodes(parent: IParent): Promise<void> {
// Получаем коллекцию узлов
let Nodes = await parent.Nodes;
// Пробегаемся по всем и отображаем их
await Nodes.forEachParallel(async function(node): Promise<void> {
await RenderNode(node); // Вызываем асинхронную функцию получения данных узла и его отрисовки
});
}
Нет смысла подробно останавливаться на нем, на хабре уже есть достаточно подробный материал. Они появились в Typescript еще в 2016 году. Мы используем этот подход с тех пор, как он появился в feature ветке репозитория Typescript, поэтому давно набили шишек и теперь работаем с удовольствием. С некоторых пор уже и в production.
Коротко суть для тех, кто еще не знаком с предметом:
Как только вы добавляете к функции ключевое слово async
, она автоматически будет возвращать Promise<Возвращаемый_тип>
. Особенности таких функций:
- Выражения внутри
async
функций сawait
(которые возвращаютPromise
) будут останавливать выполнение функции и продолжать после разрешения ожидаемыхPromise
. - При возникновении исключения в
async
функции, возвращаемыйPromise
будет отклонен с этим исключением. - При компиляции в Javascript коде будут генераторы для стандарта ES6 (функции
function*
вместоasync function
иyield
вместоawait
) или же страшный код соswitch
для ES5 (конечный автомат).await
— это ключевое слово, которое дожидается результата промиса. В момент встречи в ходе выполнения кода функцияShowNodes
останавливается, и во время ожидания данных Javascript может выполнять какой-то другой код.
В коде выше у коллекции есть метод forEachParallel
, который параллельно вызывает асинхронный коллбек для каждого узла. При этом await
перед Nodes.forEachParallel
дождется всех коллбеков. Внутри реализации — Promise.all
:
/**
* Вызвать функцию для каждого элемента списка не дожидаясь завершения предыдующей иетерации
* @param items Список
* @param callbackfn Функция
* @param [thisArg] Ссылка на объект, который будет передан в качестве this в callbackfn
*/
export async function forEachParallel<T>(items: IItemArray<T>, callbackfn: (value: T, index: int, items: IItemArray<T>) => Promise<void | any>, thisArg?: any): Promise<void> {
let xCount = items ? await items.Count : 0;
if (!xCount)
return;
let xActions = new Array<Promise<void | any>>(xCount);
for (let i = 0; i < xCount; i++) {
let xItem = items.Item(i);
xActions[i] = ExecuteCallback(xItem, callbackfn, i, items, thisArg);
}
await Promise.all(xActions);
}
/** Получить асинхронно item и выполнить callbackfn */
async function ExecuteCallback<T>(item: Promise<T> | T, callbackfn: (value: T, index: int, items: IItemArray<T>) => Promise<void | any>, index: int, items: IItemArray<T>, thisArg?: any): Promise<void> {
let xItem = await item;
await callbackfn.call(thisArg, xItem, index, items);
}
Это синтаксический сахар: подобные методы стоит использовать не только для своих коллекций, но и определить для стандартных массивов Javascript'а.
Функция ShowNodes
выглядит крайне неоптимальной: при запросе очередной сущности мы ее каждый раз дожидаемся. Удобство состоит в том, что подобный код можно писать быстро, поэтому такой подход хорош при быстром прототипировании. В финальном варианте нужно использовать язык запросов для сокращения количества обращений к серверу.
Язык запросов
Есть несколько функций, которые используются для "сборки" запроса данных от сервера. Они "говорят" серверу, какие узлы графа данных нужно вернуть в ответе:
/**
* Функция получает в качестве параметра объект item и возвращает в Promise его же,
* забирая только необходимые переданные свойства properties
*/
selectAsync<T extends IItem>(item: T, properties: () => any[]): Promise<T>;
/**
* Получает коллекцию items, забирая у каждого ее элемента свойства properties
*/
selectAsyncAll<T extends ICollection>(items: T[], properties: () => any[]): Promise<T[]>;
/** Функция используется в selectAsync для получения вложенных объектов */
select<T>(item: T, properties: () => any[]): T;
/** Функция используется в selectAsync для получения вложенных объектов */
selectAll<T>(items: T[], properties: () => any[]): T[];
Теперь посмотрим на применение этих функций для запроса нужных вложенных данных одним обращением на сервер:
async function ShowNodes(parentPoint: IParent): Promise<void> {
// Запрашиваем одно свойство у объекта типа IParent - коллекцию узлов через selectAsync (возвращает
// Promise, дожидаемся его).
let Parent = await selectAsync(parentPoint, parent => [
// Для каждого узла из коллекции запрашиваем только требуемые нам данные
selectAll(parent.Nodes, nodes => [node.Name, node.DisplayName]) // [node.Name, node.DisplayName] - список запрашиваемых у очередного узла колекции свойств
]);
// Далее синхронно пробегаемся и отрисовываем Parent.Nodes
...
}
Пример чуть более сложного запроса с получением глубоко вложенной информации:
// Можно запросить сразу коллекцию parent.Nodes через selectAsyncAll, чтобы сократить код
let Parent = await selectAsyncAll(parent.Nodes, nodes => [
// У каждого узла кэшируем:
select(node, node => [
node.Name,
node.DisplayName,
selectAll(node.Links, link => [
link.Guid,
link.DisplayName,
select(link.TargetNode, targetNode => [targetNode.Guid])
]),
select(node.Info, info => [info.Component]) // Забираем из интерфейса IInfo просто ссылку на IComponent, данные которого сейчас, например не требуются, но потом будут нужны для другого запроса
])
]);
Язык запросов помогает избежать лишних запросов на сервер. Но код никогда не бывает идеальным, и в нем непременно будут места нескольких конкурентных запросов и, как следствие, race condition.
Race Condition и пути решения
Так как мы подписываемся на события сервера и пишем код с большим количеством асинхронных запросов, то может возникать состояние гонки, когда async
функция FuncOne
прерывается, ожидая Promise
. В это время может придти событие сервера (или от следующего действия пользователя) и, выполнившись конкурентно, изменить модель на клиенте. Тогда FuncOne
после резолвинга промиса может обратиться, например, к уже удаленным ресурсам.
Представим себе такую упрощенную ситуацию: у объекта IParent
есть серверный делегат Parent.OnSynchronize
.
/** Синхронизация узлов */
Parent.OnSynchronize.AddListener(async function(): Promise<void> {
// Добавляем новые. Пробегаемся по узлам, удаляем уничтоженные.
});
Он вызывается при обновлении списка узлов INodes
на сервере. Тогда при следующем сценарии возможно состояние гонки:
- Вызываем асинхронное удаление узла с клиента, дожидаясь завершения для удаления клиентского объекта
async function OnClickRemoveNode(node: INode): Promise<void> { let removedOnServer: boolean = await Parent.RemoveNode(node); // Код удаления клиенского объекта if (removedOnServer) .... }
- Через
Parent.OnSynchronize
приходит событие обновления списка узлов. Parent.OnSynchronize
обрабатывается и удаляет клиентский объект.async OnClickRemoveNode()
продолжает выполняться после первогоawait
и происходит попытка удалить уже удаленный клиентский объект.
Можно сделать в OnClickRemoveNode
проверку существования клиентского объекта. Это упрощенный пример и в нем подобная проверка — нормальна. Но что если цепочка вызовов сложнее? Поэтому использование подобного подхода после каждого await
— плохая практика:
- Раздутый таким образом код сложен для поддержки и расширения.
- Код работает не так, как задумывалось: инициируется удаление в
OnClickRemoveNode
, а фактическое удаление клиентского объекта происходит в другом месте. Нарушения определенной разработчиком последовательности не должно быть, иначе будут регрессионные ошибки. - Это недостаточно надежно: если забыть где-то сделать проверку, то будет ошибка. Опасность прежде всего в том, что забытая проверка может не привести к ошибке локально и в тестовом окружении, а у пользователей при большей сетевой задержке — будет возникать.
- А если контроллер, к которому принадлежат эти обработчики, может быть уничтожен? После каждого
await
проверять его уничтоженность?
Возникает еще один вопрос: что, если подобных конкурентных методов много? Представьте, что есть еще:
- Добавление узла
- Обновление узла
- Добавление/удаление связей
- Метод преобразования нескольких узлов
- Сложное поведение приложения: изменяем состояние одного узла и сервер запускает обновление зависимых от него узлов.
Требуется архитектурная реализация, которая в принципе исключает возможность ошибок из-за race condition, параллельных действий пользователя и т.п. Правильное решение для устранения одновременного изменения модели с клиента или сервера — реализация критической секции с очередью вызовов. Здесь будут полезны декораторы Typescript для декларативной пометки таких конкурентных асинхронных функций контроллера.
Обозначим требования и ключевые особенности таких декораторов:
- Внутри должна быть реализована очередь вызовов асинхронных функций. В зависимости от типа декоратора вызов функции может быть поставлен в очередь или отклонен при наличии в ней других вызовов.
- У помечаемых функций потребуется контекст выполнения для привязки к очереди. Нужно либо явно создавать очередь, либо делать это автоматически на основе View, к которому принадлежит контроллер.
- Необходима информация об уничтоженности экземпляра контроллера (например, свойство
IsDestroyed
). Чтобы декораторы запрещали выполнение вставших в очередь вызовов после уничтожения контроллера. - Для View контроллера добавим функционал накладывания полупрозрачной маски для исключения действий в момент выполнения очереди и визуального обозначения выполняющейся обработки.
- Все декораторы должны завершаться вызовом
Promise.done()
. В этом методе нужно реализоватьhandler
необработанных исключений. Весьма полезная вещь:
- исключения, возникшие в
Promise
-ах, не отлавливаются стандартным обработчиком ошибок (который, например, отображает окошко с текстом и stak trace'ом), поэтому их можно не заметить (если не мониторить консоль все время при разработке). А пользователь их вообще не увидит — это затруднит поддержку. Примечание: есть возможность подписаться для обработки на событиеunhandledrejection
, но все равно его пока что поддерживают только Chrome и Edge:
window.addEventListener('unhandledrejection', function(event) { // handling... });
- так как декораторами мы помечаем самую верхнюю
async
функцию-обработчик события, то получаем весь stack trace ошибки.
- исключения, возникшие в
Теперь приведем примерный список таких декораторов с описанием и затем покажем, как они могут быть применены.
/**
* Поведение:
* 1. Помеченная функция будет выполняться эксклюзивно
* 2. Если в очереди есть другие функции, вызов будет отклонен.
*
* Помечать действия, инициированные пользователем: нажатия на кнопки, обработчики действий горячих клавиш и другой пользовательский ввод
*/
@Lock
/**
* Поведение:
* Помеченная функция ставится в очередь, вызов никогда не будет отклонен.
*
* Помечать функции, которые всегда должны быть выполнены: обработчики серверных событий, обновление и др.
*/
@LockQueue
/**
* Аналогичное LockQueue поведение. Отличие - будет выполнен только последний вставший в очередь вызов
*
* Удобно помечать функции, для которых имеет значение только последний вызов. Например, обработчик полной синхронизации.
*/
@LockBetween
/**
* Поведение:
* Помеченная функция будет выполнена только один раз, спустя заданный интервал.
* Удобно для дискретных действий обновления. Например: пользователь вводит в поле текст, и мы раз в 300 мсек. делаем фильтрацию контента на основе введенного текста.
*/
@LockDeferred(300)
// Интерфейс, который должны поддерживать объекты, чьи обработчики помечаются этими декораторами:
interface ILockTarget {
/**
* Функция, которая вернет View, являющийся контекстом очереди. Для тех случаев, когда есть несколько конкурентных обработчиков у разных контроллеров, но привязанных к одному отображению, которое и является контекстом их общей очереди
*/
GetControllerView?(): IView;
/** Равен true после уничтожения экземпляра контроллера */
IsDestroyed: boolean;
}
Описания довольно абстрактные, но как только вы увидите пример использования с пояснениями, все станет понятнее:
class GraphController implements ILockTarget {
/** Отображение, которое будет маскировано при выполнении очереди. Оно же будет контекстом очереди */
private View: IView;
public GetControllerView(): IView {
return this.View;
}
/** Обработка удаления узла по клику пользователя. */
@Lock
private async OnClickRemoveNode(): Promise<void> {
...
}
/** Удаление связи по клику мыши. */
@Lock
private async OnClickRemoveLink(): Promise<void> {
...
}
/** Добавление пользователем нового узла */
@Lock
private async OnClickAddNewNode(): Promise<void> {
...
}
/** Обработчик события сервера "Обновление узла" */
@LockQueue
private async OnServerUpdateNode(): Promise<void> {
...
}
/** Обработчик события сервера "Добавление связи" */
@LockQueue
private async OnServerAddLink(): Promise<void> {
...
}
/** Обработчик события сервера "Добавление узла" */
@LockQueue
private async OnServerAddNode(): Promise<void> {
...
}
/** Обработчик события сервера - удаление узла */
@LockQueue
private async OnServerRemoveNode(): Promise<void> {
...
}
/** Обработчик события сервера - полная синхронизация всех узлов и связей */
@LockBetween
private async OnServerSynchronize(): Promise<void> {
...
}
/** Обработчик события сервера - обновление статуса узла (выполнен/warning/error/...) */
@LockQueue
private async OnServerUpdateNodeStatus(): Promise<void> {
...
}
/** Фильтрация данных с обращением на сервер */
@LockDeferred(300)
private async OnSearchFieldChange(): Promise<void> {
...
}
}
Теперь разберем пару типичных сценариев возможных ошибок и их устранения декораторами:
- Пользователь инициирует какое-либо действие:
OnClickRemoveNode
,OnClickRemoveLink
. Для правильной обработки необходимо, чтобы в очереди не было других выполняющихся обработчиков (будь то клиентских или серверных). Иначе возможна, например, такая ошибка:
- Модель на клиенте еще обновляется до актуального серверного состояния
- Инициируем удаление объекта до завершения обновления (в очереди есть выполняющийся обработчик
OnServerSynchronize
). Но этого объекта на самом деле уже нет — просто полная синхронизация еще не завершилась и он еще отображается на клиенте.
Поэтому все действия, инициированные пользователем, декораторLock
должен отклонить при наличии в очереди других обработчиков с тем же контекстом очереди. С учетом того, что сервер асинхронный, это особенно важно. Да, Websocket шлет запросы последовательно, но если клиент нарушит последовательность, получим ошибку на сервере.
- Инициируем добавление узла:
OnClickAddNewNode
. От сервера приходят событияOnServerSynchronize
,OnServerAddNode
.
OnClickAddNewNode
занял очередь (если бы в ней что-то было, декораторLock
этого метода отклонил бы вызов)OnServerSynchronize
,OnServerAddNode
встали в очередь, выполнились последовательно послеOnClickAddNewNode
, не конкурируя с ним.
- В очереди есть вызовы
OnServerSynchronize
иOnServerUpdateNode
. Допустим, что в процессе выполнения первого пользователь закрываетGraphController
. Тогда второй вызовOnServerUpdateNode
автоматически не должен быть выполнен, чтобы не совершить действия на уничтоженном контроллере, что гарантированно приведет к ошибке. Для этого в интерфейсеILockTarget
естьIsDestroyed
— декоратор проверяет флаг, не выполняя следующий обработчик из очереди.
Profit: не нужно после каждогоawait
писатьif (!this.IsDestroyed())
. - Запускается изменение нескольких узлов. От сервера приходят события
OnServerSynchronize
,OnServerUpdateNode
. Конкурентное их выполнение приведет к невоспроизводимым ошибкам. Но т.к. они помечены декораторами постановки в очередьLockQueue
иLockBetween
, то выполнятся последовательно. - Представьте, что узлы могут иметь внутри себя вложенные графы узлов. Есть
GraphController #1
, а внутри одного из его узлов — вложенный графGraphController #2
. Причем,GraphController
-ы не уничтожаются при закрытии, а скрываются (так быстрее — пользователю не нужно каждый раз при переключении ждать загрузки), т.е. продолжают получать все события обновления. Мы их:
- Запоминаем
- Не выполняем сразу
- При отображении скрытого
GraphController #2
, у которого пришли эти события, выполняем их в очереди.
OnSearchFieldChange
вызывается каждый раз, когда пользователь вводит букву в поле фильтрации. Эта функция делает запрос на сервер и показывает какие-то отфильтрованные данные. Декоратор@LockDeferred(300)
откладывает ее выполнение на 300 мс: сколько бы раз за это время не вводи букв, запрос будет сделан не чаще, чем раз в 300 мс. Частый кейс, но реализация удобнее с таким декларативным подходом. Дополнительные бонусы:
- Если откладываем дольше, например на 500 мс, то пользователь может успеть закрыть контроллер с этой фильтрацией. Но ошибки не будет — декоратор в этом случае просто не выполнит
OnSearchFieldChange
, как и другие приведенные выше. - На время откладывания и выполнения
OnSearchFieldChange
очередь будет занята — другие действия с объектами не приведут к ошибкам, аналогично описанным выше.
- Если откладываем дольше, например на 500 мс, то пользователь может успеть закрыть контроллер с этой фильтрацией. Но ошибки не будет — декоратор в этом случае просто не выполнит
Что нужно знать при использовании декораторов
- Возможен Deadlock: если из асинхронного обработчика
Handler1
, который выполняется в очереди, вызывать сawait
другой обработчикHandler2
, помеченныйLockQueue
, получим бесконечное ожиданиеHandler2
—Handler1
никогда не завершит выполнение. - Есть ситуации, в которых на одном View нельзя выполнять все в одной очереди. Пример: методы изменения модели и обработчики изменения статуса должны быть в разных очередях, если вторые происходят слишком часто — иначе они займут почти все время и приложение будет постоянно закрываться маской и создавать ощущение тормозов.
Профилирование запросов к серверу
У нас есть декораторы, которые помечают все асинхронные методы, причем начиная с самых верхних синхронных обработчиков событий. Что ж:
- Добавим сохранение выполнения каждого метода в хэш-таблице
<Class>
.<Method>
=><Time>
(в отладочной версии). - Расчитываем среднее и общее времена выполнения методов.
- Получаем удобное профилирование скорости всех асинхронных запросов приложения.
Десерт
Отлично, у нас есть декораторы, которые не будут давать выполняться обработчикам из очереди на удаленных контроллерах, к которым принадлежат обработчики. Но что делать с текущим выполняемым асинхронным методом? Что если во время его выполнения контроллер будет уничтожен? Пример кода:
class GraphController implements ILockTarget {
private View: IView;
public GetControllerView(): IView {
return this.View;
}
/** Запуск обработки очень сложных вычислений. */
@Lock
private async RunBigDataCalculations(): Promise<void> {
await Start();
await UpdateSmth();
await End();
await CleanUp();
}
/** Изменение состояния узла. */
@LockQueue
private async OnChangeNodeState(node: INode): Promise<void> {
await GetNodeData(node);
await UpdateNode(node);
}
}
Вот возможная ситуация:
- Запускаем
RunBigDataCalculations
. - Выполняем
await Start();
- Пользователь закрывает контроллер/переходит на другой(с закрытием текущего)
- Закончили выполнять
await Start();
, пытаемся выполнитьawait UpdateSmth();
на уничтоженном контроллере и получаем ошибку.
Или:
- Запускаем
RunBigDataCalculations
. - Приходит серверное событие
OnChangeNodeState
, которое выполняется в другой очереди и не блокирует интерфейс (т.к. частое событие). - Начинаем выполнять
await GetNodeData(node);
- Пользователь закрывает контроллер/переходит на другой(с закрытием текущего)
- Закончили выполнять
await GetNodeData(node);
, пытаемся выполнитьawait UpdateNode(node);
на уничтоженном контроллере и получаем ошибку.
И с этим тоже надо как-то жить. Нам потребуется:
- Поддержка отложенного уничтожения:
/**
* Интерфейс контроллера с незавершенной очередью асинхронных вызовов, который поддерживает уничтожение своих ресурсов
*/
export interface IQueuedDisposableLockTarget extends ILockTarget {
/** Объект находится в состоянии уничтожения. Lock декораторы также не должны позволять выполняться методам контроллера при IsDisposing() === true */
IsDisposing(): boolean;
SetDisposing(): void;
}
- Функция отложенного уничтожения при наличии в очереди обработчика:
function QueuedDispose(controller: IQueuedDisposableLockTarget): void {
// Получаем очередь из контекста контроллера
let xQueue = GetQueue(controller);
// 1. Смотрим, есть ли в ней что-то, если нет - уничтожаем сразу
if (xQueue.Empty) {
controller.Dispose();
return;
}
// 2. Если есть, то пометим контроллер флагом "будет уничтожен", чтобы не выполнять асинхронные методы, поставленные в очередь.
controller.SetDisposing();
// 3. И в finally уничтожим его
xQueue.finally(() => {
debug.assert(!IsDisposed(controller), "Кто-то уничтожил контроллер до его отложенного уничтожения, возможна ошибка");
controller.Dispose();
});
}
Таким образом, помеченные функции выполнятся до конца и не приведут к ошибкам. Но при использовании QueuedDispose
обязательно нужно иметь в виду:
- Вызывающий уничтожение код не должен требовать немедленного уничтожения контроллера. Либо он должен работать с учетом этого.
- Перед вызовом
QueuedDispose
вы скорее всего скроетеcontroller
. В таком случае библиотека должна работать без ошибок — у ExtJS с этим иногда есть проблемы.
Исходники
К сожалению, прямо сейчас исходный код декораторов критической секции не могу предоставить, т.к. он проприетарный и в нем много лишнего нашего кода. Возможно, вам вообще не нужен этот велосипед? Но вдруг я ошибаюсь, добавлю голосовалку.
Если сотня другая наберется, то подчищу код и выложу тут:
Комментарии (3)
jehy
27.09.2019 11:55TLDR: чтобы наши асинхронные функции на клиенте не конфликтовали, мы рассовали их выполнение по очередям в зависимости от вьюхи.
Вроде, остальная часть поста — вода в декораторах.
DarthVictor