Asynchrones Typoskript in Rich Internet-Anwendungen und Dekorateuren zur Bekämpfung

Seit dem Aufkommen von async / await hat Typescript viele Artikel veröffentlicht, die diesen Entwicklungsansatz hervorheben ( hackernoon , blog.bitsrc.io , habr.com ). Wir verwenden sie von Anfang an auf der Clientseite (wenn ES6-Generatoren weniger als 50% der Browser unterstützen). Und jetzt möchte ich meine Erfahrungen teilen, denn parallele Ausführung ist nicht alles, was auf diesem Weg gut zu wissen wäre.


Der letzte Artikel gefällt mir nicht wirklich: Etwas kann unverständlich sein. Teilweise aufgrund der Tatsache, dass ich keinen proprietären Code bereitstellen kann - nur um den allgemeinen Ansatz zu skizzieren. Deshalb:


  • Zögern Sie nicht, den Tab zu schließen, ohne ihn zu lesen
  • Wenn Sie es schaffen, fragen Sie nach unklaren Details
  • Ich nehme gerne Ratschläge und Kritik von den hartnäckigsten und gründlichsten bis zu dem Punkt an.

Liste der Kerntechnologien:


  • Das Projekt ist hauptsächlich in Typescript mit mehreren Javascript-Bibliotheken geschrieben. Die Hauptbibliothek ist ExtJS. Es ist in seiner Modalität schlechter als React, eignet sich jedoch am besten für ein Unternehmensprodukt mit einer umfangreichen Benutzeroberfläche: viele vorgefertigte Komponenten, sofort gut gestaltete Tabellen, ein umfangreiches Ökosystem verwandter Produkte zur Vereinfachung der Entwicklung.
  • Asynchroner Multithread-Server.
  • RPC über Websocket wird als Transport zwischen Client und Server verwendet. Die Implementierung ähnelt .NET WCF.
    • Jedes Objekt ist eine Dienstleistung.
    • Jedes Objekt kann sowohl nach Wert als auch nach Referenz übertragen werden.
  • Die Datenanforderungsschnittstelle ähnelt GraphQL von Facebook, nur auf Typescript.
  • Zweiwege-Kommunikation: Die Initialisierung der Datenaktualisierung kann sowohl vom Client als auch vom Server aus gestartet werden.
  • Asynchroner Code wird nacheinander mithilfe der asynchronen / wartenden Funktionen von Typesrcipt geschrieben.
  • Die Server-API wird in Typescript generiert: Wenn sie sich ändert, zeigt der Build sie im Fehlerfall sofort an.

Was ist die Ausgabe


Ich werde Ihnen sagen, wie wir damit arbeiten und was wir für eine sichere, nicht wettbewerbsfähige Ausführung von asynchronem Code getan haben: unsere Typesrcipt-Dekoratoren, die die Funktionalität von Warteschlangen implementieren. Von den Grundlagen bis zur Lösung der Rennbedingungen und anderer Schwierigkeiten, die während des Entwicklungsprozesses auftreten.


Wie die vom Server empfangenen Daten strukturiert sind


Der Server gibt ein übergeordnetes Objekt zurück, das in seinen Eigenschaften Daten (andere Objekte, Sammlungen von Objekten, Zeilen usw.) in Form eines Diagramms enthält. Dies ist unter anderem auf die Anwendung selbst zurückzuführen:


  • Es macht die Datenanalyse / ML zu einem gerichteten Graphen von Handlerknoten.
  • Jeder Knoten kann sein eigenes eingebettetes Diagramm enthalten
  • Diagramme haben Abhängigkeiten: Knoten können "geerbt" werden, und neue Knoten werden durch ihre "Klasse" erstellt.

Die Abfragestruktur in Form eines Diagramms kann jedoch in fast jeder Anwendung angewendet werden, und GraphQL erwähnt dies meines Wissens auch in seiner Spezifikation.


Beispieldatenstruktur:


 //   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; } 

Wie erhält ein Kunde Daten?


Es ist ganz einfach: Wenn Sie eine Eigenschaft eines Objekts eines nicht skalaren Typs anfordern, gibt RPC Promise :


 let Nodes = Parent.Nodes; // Nodes -> Promise<INodes> 

Asynchronität ohne "Callback Hell".


Um einen "sequentiellen" asynchronen Code zu organisieren, wird die Typescript-Funktion async / await verwendet:


 async function ShowNodes(parent: IParent): Promise<void> { //    let Nodes = await parent.Nodes; //       await Nodes.forEachParallel(async function(node): Promise<void> { await RenderNode(node); //          }); } 

Es macht keinen Sinn, im Detail darauf einzugehen, auf der Nabe gibt es bereits genügend detailliertes Material . Sie erschienen bereits 2016 in Typescript. Wir verwenden diesen Ansatz, seit er im Feature-Zweig des Typescript-Repositorys erscheint. Deshalb haben wir schon lange Probleme und arbeiten jetzt mit Vergnügen. Seit einiger Zeit und in Produktion.


Kurz gesagt, die Essenz für diejenigen, die mit dem Thema nicht vertraut sind:


Sobald Sie der Funktion das Schlüsselwort async hinzufügen, wird automatisch das Promise<_> . Merkmale solcher Funktionen:


  • Ausdrücke in async Funktionen mit await (die Promise ) stoppen die Ausführung der Funktion und werden nach dem Auflösen des erwarteten Promise fortgesetzt.
  • Wenn in der async Funktion eine Ausnahme auftritt, wird das zurückgegebene Promise mit dieser Ausnahme abgelehnt.
  • Beim Kompilieren in Javascript-Code gibt es Generatoren für den ES6-Standard ( function* anstelle der async function und yield statt Warten) oder beängstigenden Code mit switch für ES5 (Zustandsmaschine). await ist ein Schlüsselwort, das auf das Ergebnis eines Versprechens wartet. Zum Zeitpunkt des Meetings stoppt die ShowNodes Funktion während der Ausführung des Codes, und während Javascript auf Daten wartet, führt es möglicherweise einen anderen Code aus.

Im obigen Code verfügt die Auflistung über eine forEachParallel Methode, die für jeden Knoten parallel einen asynchronen Rückruf aufruft. Warten Sie gleichzeitig, bevor Nodes.forEachParallel auf alle Rückrufe wartet. Innerhalb der Implementierung - 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); } 

Dies ist syntaktischer Zucker: Solche Methoden sollten nicht nur für ihre Sammlungen verwendet werden, sondern auch für Standard-Javascript-Arrays.


Die ShowNodes Funktion sieht äußerst nicht optimal aus: Wenn wir eine andere Entität anfordern, warten wir jedes Mal darauf. Der Vorteil besteht darin, dass ein solcher Code schnell geschrieben werden kann, sodass dieser Ansatz für das Rapid Prototyping gut ist. In der endgültigen Version müssen Sie die Abfragesprache verwenden, um die Anzahl der Anrufe an den Server zu verringern.


Abfragesprache


Es gibt verschiedene Funktionen, mit denen eine Datenanforderung vom Server "erstellt" wird. Sie "teilen" dem Server mit, welche Knoten des Datengraphen in der Antwort zurückgegeben werden sollen:


 /** *       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[]; 

Schauen wir uns nun die Anwendung dieser Funktionen an, um die erforderlichen eingebetteten Daten mit einem Aufruf an den Server anzufordern:


 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 ... } 

Ein Beispiel für eine etwas komplexere Abfrage mit tief eingebetteten Informationen:


 //     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,   ,   ,        ]) ]); 

Die Abfragesprache hilft, unnötige Anforderungen an den Server zu vermeiden. Aber der Code ist niemals perfekt und wird sicherlich mehrere Wettbewerbsanforderungen und infolgedessen die Rennbedingungen enthalten.


Rennbedingungen und Lösungen


Da wir Serverereignisse abonnieren und Code mit einer großen Anzahl von asynchronen Anforderungen schreiben, kann eine Race-Bedingung auftreten, wenn die asynchrone FuncOne Funktion unterbrochen wird und auf Promise wartet. Zu diesem Zeitpunkt kann ein Serverereignis (oder die nächste Benutzeraktion) auftreten und nach einer wettbewerbsfähigen Ausführung das Modell auf dem Client ändern. Dann kann FuncOne nach der Lösung des Versprechens beispielsweise auf bereits gelöschte Ressourcen FuncOne .


Stellen Sie sich eine solche vereinfachte Situation vor: Das IParent Objekt verfügt über einen IParent .


 /**   */ Parent.OnSynchronize.AddListener(async function(): Promise<void> { //  .   ,  . }); 

Es wird aufgerufen, wenn die Liste der INodes Knoten auf dem Server aktualisiert wird. Dann ist im folgenden Szenario eine Rennbedingung möglich:


  1. Wir veranlassen das asynchrone Entfernen des Knotens vom Client und warten auf den Abschluss, um das Clientobjekt zu löschen
     async function OnClickRemoveNode(node: INode): Promise<void> { let removedOnServer: boolean = await Parent.RemoveNode(node); //     if (removedOnServer) .... } 
  2. Über Parent.OnSynchronize wird das Knotenlistenereignis aktualisiert.
  3. Parent.OnSynchronize verarbeitet und löscht das Parent.OnSynchronize .
  4. async OnClickRemoveNode() nach dem ersten async OnClickRemoveNode() weiter ausgeführt, und es wird versucht, ein bereits gelöschtes async OnClickRemoveNode() zu löschen.

Sie können die Existenz eines OnClickRemoveNode in OnClickRemoveNode . Dies ist ein vereinfachtes Beispiel, in dem eine ähnliche Prüfung normal ist. Was aber, wenn die Anrufkette komplizierter ist? Daher ist es eine schlechte Praxis, nach jedem await einen ähnlichen Ansatz zu verwenden:


  • Der so aufgeblähte Code ist kompliziert zu unterstützen und zu erweitern.
  • Der Code funktioniert nicht wie beabsichtigt: Das Löschen in OnClickRemoveNode , und das eigentliche Löschen des OnClickRemoveNode erfolgt an anderer Stelle. Es sollte keine Verletzung der vom Entwickler definierten Sequenz vorliegen, da sonst Regressionsfehler auftreten.
  • Dies ist nicht zuverlässig genug: Wenn Sie vergessen, irgendwo eine Überprüfung durchzuführen, liegt ein Fehler vor. Zunächst besteht die Gefahr, dass eine vergessene Prüfung lokal und in einer Testumgebung nicht zu einem Fehler führt und bei Benutzern mit einer längeren Netzwerkverzögerung auftritt.
  • Und wenn der Controller, zu dem diese Handler gehören, zerstört werden kann? Nach jedem await , um seine Zerstörung zu überprüfen?

Eine andere Frage stellt sich: Was ist, wenn es viele ähnliche Wettbewerbsmethoden gibt? Stellen Sie sich vor, es gibt mehr:


  • Hinzufügen eines Knotens
  • Knotenaktualisierung
  • Links hinzufügen / entfernen
  • Konvertierungsmethode für mehrere Knoten
  • Komplexes Verhalten der Anwendung: Wir ändern den Status eines Knotens und der Server aktualisiert die davon abhängigen Knoten.

Es ist eine architektonische Implementierung erforderlich, die im Prinzip die Möglichkeit von Fehlern aufgrund von Rennbedingungen, parallelen Benutzeraktionen usw. ausschließt. Die richtige Lösung, um die gleichzeitige Änderung des Modells vom Client oder Server zu vermeiden, besteht darin, einen kritischen Abschnitt mit einer Anrufwarteschlange zu implementieren. Typoskript-Dekoratoren sind hier nützlich , um solche wettbewerbsfähigen asynchronen Steuerungsfunktionen deklarativ zu kennzeichnen.


Wir skizzieren die Anforderungen und Hauptmerkmale solcher Dekorateure:


  1. Im Inneren sollte eine Warteschlange mit Aufrufen von asynchronen Funktionen implementiert werden. Abhängig von der Art des Dekorateurs kann ein Funktionsaufruf in die Warteschlange gestellt oder abgelehnt werden, wenn andere Aufrufe darin enthalten sind.
  2. Markierte Funktionen erfordern einen Ausführungskontext, um an die Warteschlange gebunden zu werden. Sie müssen entweder explizit eine Warteschlange erstellen oder dies automatisch basierend auf der Ansicht tun, zu der der Controller gehört.
  3. Informationen zur Zerstörung der Controller-Instanz sind erforderlich (z. B. die IsDestroyed Eigenschaft). Um zu verhindern, dass Dekorateure Anrufe in die Warteschlange stellen, nachdem der Controller zerstört wurde.
  4. Für den View-Controller fügen wir die Funktionalität zum Anwenden einer durchscheinenden Maske hinzu, um Aktionen zum Zeitpunkt der Ausführung der Warteschlange auszuschließen und die laufende Verarbeitung visuell anzuzeigen.
  5. Alle Dekorateure müssen mit einem Aufruf von Promise.done() . Bei dieser Methode müssen Sie den handler behandelte Ausnahmen implementieren. Eine sehr nützliche Sache:
    • Ausnahmen, die in Promise aufgetreten sind, werden vom Standard-Fehlerbehandler nicht erfasst (der beispielsweise ein Fenster mit Text und einer Stak-Ablaufverfolgung anzeigt), sodass Sie sie möglicherweise nicht bemerken (wenn Sie die Konsole während der Entwicklung nicht ständig überwachen). Und der Benutzer wird sie überhaupt nicht sehen - dies erschwert die Unterstützung. Hinweis: Es ist möglich, sich für das nicht behandelte unhandledrejection , es wird jedoch nur von Chrome und Edge unterstützt:

       window.addEventListener('unhandledrejection', function(event) { // handling... }); 
    • Da wir die höchste async Ereignishandlerfunktion als Dekoratoren markieren, erhalten wir den gesamten Stack-Trace-Fehler.

Nun geben wir eine ungefähre Liste solcher Dekorateure mit einer Beschreibung und zeigen dann, wie sie angewendet werden können.


 /** * : * 1.      * 2.      ,   . * *  ,  :   ,         */ @Lock /** * : *     ,     . * *  ,     :   ,   . */ @LockQueue /** *  LockQueue .  -         * *   ,       . ,   . */ @LockBetween /** * : *       ,   . *     . :     ,     300 .       . */ @LockDeferred(300) // ,    ,     : interface ILockTarget { /** * ,   View,   .   ,        ,     ,        */ GetControllerView?(): IView; /**  true     */ IsDestroyed: boolean; } 

Die Beschreibungen sind ziemlich abstrakt, aber sobald Sie ein Anwendungsbeispiel mit Erklärungen sehen, wird alles klarer:


 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> { ... } } 

Jetzt werden wir einige typische Szenarien möglicher Fehler und deren Beseitigung durch Dekorateure analysieren:


  1. Der Benutzer initiiert eine Aktion: OnClickRemoveNode , OnClickRemoveLink . Für eine ordnungsgemäße Verarbeitung müssen sich keine anderen ausführenden Handler in der Warteschlange befinden (weder Client noch Server). Andernfalls ist beispielsweise ein solcher Fehler möglich:
    • Das Modell auf dem Client wird weiterhin auf den aktuellen Serverstatus aktualisiert
    • Wir initiieren das Löschen des Objekts, bevor die Aktualisierung abgeschlossen ist (in der OnServerSynchronize wird ein OnServerSynchronize Handler ausgeführt). Dieses Objekt ist jedoch nicht mehr vorhanden - nur die vollständige Synchronisierung ist noch nicht abgeschlossen und wird weiterhin auf dem Client angezeigt.
      Daher sollte der Lock Decorator alle vom Benutzer initiierten Aktionen ablehnen, wenn sich andere Handler in der Warteschlange mit demselben Warteschlangenkontext befinden. Da der Server asynchron ist, ist dies besonders wichtig. Ja, Websocket sendet Anforderungen nacheinander, aber wenn der Client die Sequenz unterbricht, wird auf dem Server ein Fehler angezeigt.
  2. Wir initiieren das Hinzufügen eines Knotens: OnClickAddNewNode . OnServerSynchronize , OnServerAddNode Ereignisse kommen vom Server.
    • OnClickAddNewNode nahm die Warteschlange (wenn sich etwas darin befand, lehnte der Lock Dekorator dieser Methode den Aufruf ab).
    • OnServerSynchronize , OnServerAddNode , wird nach OnClickAddNewNode nacheinander OnClickAddNewNode und konkurriert nicht damit.
  3. Die Warteschlange enthält OnServerSynchronize und OnServerUpdateNode . Angenommen, der Benutzer schließt während der Ausführung des ersten den GraphController . Dann sollte der zweite Aufruf von OnServerUpdateNode nicht automatisch ausgeführt werden, um keine Maßnahmen auf dem zerstörten Controller zu ergreifen, was garantiert zu einem Fehler führt. Zu diesem ILockTarget verfügt die ILockTarget Schnittstelle über IsDestroyed - der Dekorateur überprüft das Flag, ohne den nächsten Handler aus der Warteschlange auszuführen.
    Gewinn: Sie müssen nicht schreiben, if (!this.IsDestroyed()) nach jedem if (!this.IsDestroyed()) .
  4. Änderungen an mehreren Knoten werden gestartet. OnServerSynchronize , OnServerUpdateNode Ereignisse kommen vom Server. Ihre wettbewerbsfähige Ausführung führt zu nicht reproduzierbaren Fehlern. Aber seitdem LockQueue sie von LockQueue und LockBetween markiert sind, werden sie nacheinander ausgeführt.
  5. Stellen Sie sich vor, dass Knoten verschachtelte Knotendiagramme enthalten können. GraphController #1 , — GraphController #2 . , GraphController - , ( — ), .. . :
    • GraphController #2 , , .
  6. OnSearchFieldChange , . - . @LockDeferred(300) 300 : , , 300 . , . :
    • , 500 , . — OnSearchFieldChange , .
    • OnSearchFieldChange — , .


  1. Deadlock: Handler1 , , await Handler2 , LockQueue , Handler2Handler1 .
  2. , 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); } } 

:


  1. RunBigDataCalculations .
  2. await Start();
  3. / ( )
  4. await Start(); , await UpdateSmth(); .

Oder:


  1. RunBigDataCalculations .
  2. OnChangeNodeState , (.. ).
  3. await GetNodeData(node);
  4. / ( )
  5. 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 .


, , .. . , ? , .


, :


vk.com
Telegram

Source: https://habr.com/ru/post/de464773/


All Articles