自从async
/ await
问世以来,Typescript发表了许多赞美这种开发方法的文章( hackernoon , blog.bitsrc.io和habr.com )。 我们从客户端的一开始就使用它们(当ES6 Generators支持不到50%的浏览器时)。 现在,我想分享一下我的经验,因为并行执行并不是沿这条路径了解的全部内容。
我不太喜欢最后一篇文章:某些东西可能令人难以理解。 部分原因是我无法提供专有代码-仅概述一般方法。 因此:
- 不要犹豫,不阅读就关闭标签页
- 如果您进行管理,请询问不清楚的细节
- 我将很高兴接受从最持久,最彻底的发现到现在为止的建议和批评。
核心技术列表:
- 该项目主要使用几个Javascript库以Typescript编写。 主库是ExtJS。 它在时尚性上不如React,但最适合具有丰富接口的企业产品:许多现成的组件,精心设计的表格,丰富的相关产品生态系统可简化开发。
- 异步多线程服务器。
- 通过Websocket进行的RPC用作客户端和服务器之间的传输。 该实现类似于.NET WCF。
- 任何对象都是服务。
- 任何对象都可以通过值和引用来传输。
- 数据请求界面类似于Facebook上的GraphQL,仅在Typescript上。
- 双向通信:可以从客户端和服务器启动数据更新的初始化。
- 异步代码是通过使用Typesrcipt的
async
/ await
函数顺序编写的。 - 服务器API是在Typescript中生成的:如果更改,则在发生错误时,构建将立即显示它。
输出是什么
我将告诉您我们如何使用它以及为安全,非竞争性地执行异步代码所做的工作:我们的Typesrcipt装饰器实现了队列的功能。 从基础到解决种族状况以及在开发过程中出现的其他困难。
从服务器接收的数据的结构如何
服务器以图形的形式返回其属性中包含数据(其他对象,对象集合,行等)的父对象。 这主要归因于应用程序本身:
- 它使数据分析/ ML成为处理程序节点的有向图。
- 每个节点又可以包含自己的嵌入式图
- 图具有依赖性:可以“继承”节点,并通过其“类”创建新节点。
但是图形形式的查询结构几乎可以在任何应用程序中使用,据我所知,GraphQL在其规范中也提到了这一点。
数据结构示例:
客户端如何接收数据
很简单:当您请求非标量类型的对象的属性时,RPC返回Promise
:
let Nodes = Parent.Nodes;
没有“回调地狱”的异步。
为了组织“顺序”异步代码,使用Typescript async
/ await
功能:
async function ShowNodes(parent: IParent): Promise<void> {
对其进行详细讨论是没有意义的,因为轮毂上已经有足够的详细材料 。 他们早在2016年就出现在Typescript中。 自从它出现在Typescript存储库的功能分支中以来,我们一直在使用这种方法,这就是为什么我们已经遇到麻烦并现在很愉快地工作的原因。 已有一段时间,并且已经投入生产。
简要地说,对于那些不熟悉该主题的人来说,其本质是:
将async
关键字添加到函数后,它将自动返回Promise<_>
。 此类功能的特点:
- 带有
await
async
函数内部的表达式(返回Promise
)将停止函数的执行,并在解决了预期的Promise
之后继续执行。 - 如果
async
函数中发生异常,则此异常将拒绝返回的Promise
。 - 使用Javascript代码进行编译时,将使用ES6标准的生成器 (用
function*
代替async function
并使用yield
代替await
)或带有ES5 switch
可怕代码(状态机)。 await
是等待promise结果的关键字。 在会议时,在执行代码期间, ShowNodes
函数停止,并且在等待数据时,Javascript可能执行其他代码。
在上面的代码中,集合具有forEachParallel
方法,该方法为每个节点并行调用异步回调。 同时,在Nodes.forEachParallel
将等待所有回调之前等待。 在实现内部Promise.all
:
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
函数看起来极其不理想:当我们请求另一个实体时,我们每次都等待它。 方便之处在于可以快速编写此类代码,因此此方法适合快速原型制作。 在最终版本中,您需要使用查询语言来减少对服务器的调用次数。
查询语言
有几种功能可用于“构建”服务器的数据请求。 他们“告诉”服务器响应中要返回的数据图节点:
selectAsync<T extends IItem>(item: T, properties: () => any[]): Promise<T>; selectAsyncAll<T extends ICollection>(items: T[], properties: () => any[]): Promise<T[]>; select<T>(item: T, properties: () => any[]): T; selectAll<T>(items: T[], properties: () => any[]): T[];
现在,让我们看一下这些函数的应用程序,它们通过调用服务器来请求必要的嵌入式数据:
async function ShowNodes(parentPoint: IParent): Promise<void> {
一个带有深层嵌入信息的稍微复杂的查询的示例:
查询语言有助于避免对服务器的不必要请求。 但是代码从来都不是完美的,它肯定会包含一些竞争性要求,并因此导致竞争状况。
比赛条件和解决方案
由于我们订阅服务器事件并使用大量异步请求编写代码,因此当async
FuncOne
函数FuncOne
中断并等待Promise
时,可能会出现竞争条件。 这时,可能会发生服务器事件(或来自下一个用户操作),并且已经竞争执行了该请求,更改了客户端上的模型。 然后, FuncOne
在兑现承诺后,可以转向例如已删除的资源。
想象一下这样的简化情况: IParent
对象具有IParent
服务器委托。
Parent.OnSynchronize.AddListener(async function(): Promise<void> {
在服务器上的INodes
节点列表更新时调用。 然后,在以下情况下,可能会出现竞争状况:
- 我们导致从客户端异步移除节点,等待删除客户端对象的完成
async function OnClickRemoveNode(node: INode): Promise<void> { let removedOnServer: boolean = await Parent.RemoveNode(node);
- 通过
Parent.OnSynchronize
,将发生节点列表事件的更新。 Parent.OnSynchronize
处理并删除客户端对象。- 第一次
await
后, async OnClickRemoveNode()
继续执行,并尝试删除已经删除的客户端对象。
您可以在OnClickRemoveNode
检查客户端对象是否存在。 这是一个简化的示例,其中类似的检查是正常的。 但是,如果呼叫链更加复杂怎么办? 因此,在每次await
之后使用类似的方法是不好的做法:
- 如此肿的代码很难支持和扩展。
- 该代码无法正常工作:
OnClickRemoveNode
的删除OnClickRemoveNode
,而客户端对象的实际删除操作发生在其他位置。 不应违反开发人员定义的顺序,否则会出现回归错误。 - 这还不够可靠:如果您忘记在某处进行检查,则将出现错误。 危险是,首先,遗忘的检查可能不会在本地和测试环境中导致错误,并且对于具有较长网络延迟的用户,它会发生。
- 并且这些处理程序所属的控制器是否可以销毁? 每次
await
检查其破坏之后?
另一个问题出现了:如果有很多类似的竞争方法怎么办? 想象还有更多:
- 添加节点
- 节点更新
- 添加/删除链接
- 多节点转换方法
- 应用程序的复杂行为:我们更改一个节点的状态,服务器开始更新依赖于该节点的节点。
需要一种架构实现,原则上消除了由于竞争条件,并行用户操作等导致错误的可能性。 消除从客户端或服务器上同时更改模型的正确解决方案是实现带有呼叫队列的关键部分。 在这里 , Typescript装饰器对于声明性标记此类竞争性异步控制器功能很有用。
我们概述了此类装饰器的要求和主要功能:
- 在内部,应该实现对异步函数的调用队列。 根据装饰器的类型,如果函数调用中有其他调用,则该函数调用可能排队或被拒绝。
- 标记的函数将需要执行上下文来绑定到队列。 您必须显式创建一个队列,或者根据控制器所属的视图自动执行该队列。
- 需要有关销毁控制器实例的信息(例如,
IsDestroyed
属性)。 为了防止装饰器在控制器销毁后进行排队调用。 - 对于View控制器,我们添加了应用半透明蒙版的功能,以排除执行队列时的操作,并直观地指示正在进行的处理。
- 所有装饰器必须以对
Promise.done()
的调用Promise.done()
。 在此方法中,您需要实现未处理异常的handler
。 一个非常有用的东西:
现在,我们提供此类装饰器的大致列表以及说明,然后说明如何应用它们。
@Lock @LockQueue @LockBetween @LockDeferred(300)
这些描述非常抽象,但是一旦您看到带有解释的用法示例,一切就会变得更加清晰:
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> { ... } @LockQueue private async OnServerUpdateNodeStatus(): Promise<void> { ... } @LockDeferred(300) private async OnSearchFieldChange(): Promise<void> { ... } }
现在,我们将分析几种可能的错误并由装饰器消除的典型情况:
- 用户启动一个操作:
OnClickRemoveNode
, OnClickRemoveLink
。 为了进行适当的处理,队列中必须没有其他执行处理程序(客户端或服务器)。 否则,例如,可能会发生这样的错误:
- 客户端上的模型仍更新为当前服务器状态
- 我们在更新完成之前启动对象的删除(
OnServerSynchronize
运行了OnServerSynchronize
处理程序)。 但是实际上该对象不再存在-只是完全同步尚未完成,它仍显示在客户端上。
因此,如果用户在队列中具有相同队列上下文的其他处理程序,则由用户启动的所有操作都应拒绝Lock
装饰器。 鉴于服务器是异步的,这一点尤其重要。 是的,Websocket会按顺序发送请求,但是如果客户端中断了顺序,我们会在服务器上收到错误消息。
- 我们开始添加一个节点:
OnClickAddNewNode
。 OnServerSynchronize
, OnServerAddNode
事件来自服务器。
OnClickAddNewNode
接受了队列(如果队列中有东西,则此方法的Lock
装饰器将拒绝该调用)OnServerSynchronize
, OnServerAddNode
,在OnClickAddNewNode
之后顺序执行,而不与之竞争。
- 该队列具有
OnServerSynchronize
和OnServerUpdateNode
。 假设在执行第一个过程中,用户关闭了GraphController
。 然后,不应自动执行对OnServerUpdateNode
的第二次调用,以免对被破坏的控制器执行操作,这肯定会导致错误。 为此, ILockTarget
接口具有IsDestroyed
装饰器检查标志,而不执行队列中的下一个处理程序。
利润:每次await
后都不需要写if (!this.IsDestroyed())
。 - 开始对多个节点的更改。
OnServerSynchronize
, OnServerUpdateNode
事件来自服务器。 他们的竞争执行将导致不可复制的错误。 但是因为 LockQueue
它们由LockQueue
和LockBetween
标记, LockQueue
按顺序执行它们。 - 想象一下,节点内部可以有嵌套的节点图。
GraphController #1
, — GraphController #2
. , GraphController
- , ( — ), .. . :
OnSearchFieldChange
, . - . @LockDeferred(300)
300 : , , 300 . , . :
- , 500 , . —
OnSearchFieldChange
, . OnSearchFieldChange
— , .
- 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 { IsDisposing(): boolean; SetDisposing(): void; }
function QueuedDispose(controller: IQueuedDisposableLockTarget): void {
, . QueuedDispose
:
- . .
QueuedDispose
controller
. — ExtJS .
, , .. . , ? , .
, :
vk.com
Telegram