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:
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;
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> {
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
:
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:
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[];
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> {
Ein Beispiel für eine etwas komplexere Abfrage mit tief eingebetteten Informationen:
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:
- 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);
- Über
Parent.OnSynchronize
wird das Knotenlistenereignis aktualisiert. Parent.OnSynchronize
verarbeitet und löscht das Parent.OnSynchronize
.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:
- 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.
- 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.
- 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. - 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.
- 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:
Nun geben wir eine ungefähre Liste solcher Dekorateure mit einer Beschreibung und zeigen dann, wie sie angewendet werden können.
@Lock @LockQueue @LockBetween @LockDeferred(300)
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> { ... } @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:
- 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.
- 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.
- 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())
. - Ä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. - Stellen Sie sich vor, dass Knoten verschachtelte Knotendiagramme enthalten können.
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();
.
Oder:
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