Typescript asynchrone dans une application Internet riche et des décorateurs pour le combattre

Depuis l'avènement de async / wait , Typescript a publié de nombreux articles qui vantent cette approche de développement ( hackernoon , blog.bitsrc.io , habr.com ). Nous les utilisons depuis le tout début côté client (lorsque les générateurs ES6 prenaient en charge moins de 50% des navigateurs). Et maintenant, je veux partager mon expérience, car l' exécution parallèle n'est pas tout ce qui serait bien de savoir le long de ce chemin.


Je n'aime pas vraiment le dernier article: quelque chose peut être incompréhensible. En partie à cause du fait que je ne peux pas fournir de code propriétaire - uniquement pour décrire l'approche générale. Par conséquent:


  • n'hĂ©sitez pas Ă  fermer l'onglet sans lire
  • si vous y parvenez, demandez des dĂ©tails peu clairs
  • J'accepterai volontiers les conseils et les critiques des plus persistants et minutieusement dĂ©couverts.

Liste des technologies de base:


  • Le projet est Ă©crit principalement en Typescript en utilisant plusieurs bibliothèques Javascript. La bibliothèque principale est ExtJS. Il est infĂ©rieur en termes de mode Ă  React, mais il convient le mieux Ă  un produit d'entreprise avec une interface riche: de nombreux composants prĂŞts Ă  l'emploi, des tables bien conçues et prĂŞtes Ă  l'emploi, un riche Ă©cosystème de produits connexes pour simplifier le dĂ©veloppement.
  • Serveur multithread asynchrone.
  • RPC via Websocket est utilisĂ© comme transport entre le client et le serveur. L'implĂ©mentation est similaire Ă  .NET WCF.
    • Tout objet est un service.
    • Tout objet peut ĂŞtre transmis Ă  la fois par valeur et par rĂ©fĂ©rence.
  • L'interface de demande de donnĂ©es ressemble Ă  GraphQL de Facebook, uniquement sur Typescript.
  • Communication bidirectionnelle: l'initialisation de la mise Ă  jour des donnĂ©es peut ĂŞtre lancĂ©e Ă  la fois depuis le client et depuis le serveur.
  • Le code asynchrone est Ă©crit sĂ©quentiellement grâce Ă  l'utilisation des fonctions async / await de Typesrcipt.
  • L'API du serveur est gĂ©nĂ©rĂ©e en Typescript: si elle change, la build l'affichera immĂ©diatement en cas d'erreur.

Quelle est la sortie


Je vais vous dire comment nous travaillons avec cela et ce que nous avons fait pour une exécution sûre et non compétitive du code asynchrone: nos décorateurs Typesrcipt qui implémentent la fonctionnalité des files d'attente. Des principes de base à la solution de la condition de concurrence et d'autres difficultés qui surviennent au cours du processus de développement.


Structure des données reçues du serveur


Le serveur renvoie un objet parent qui contient des données (autres objets, collections d'objets, lignes, etc.) dans ses propriétés sous la forme d'un graphique. Cela est dû, entre autres, à l'application elle-même:


  • il fait de l'analyse des donnĂ©es / ML un graphe orientĂ© des nĹ“uds de gestionnaire.
  • chaque nĹ“ud Ă  son tour peut contenir son propre graphique intĂ©grĂ©
  • les graphes ont des dĂ©pendances: les nĹ“uds peuvent ĂŞtre «hĂ©ritĂ©s», et de nouveaux nĹ“uds sont créés par leur «classe».

Mais la structure de requête sous la forme d'un graphique peut être appliquée dans presque toutes les applications, et GraphQL, pour autant que je sache, le mentionne également dans sa spécification.


Exemple de structure de données:


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

Comment un client reçoit-il des données


C'est simple: lorsque vous demandez une propriété d'un objet d'un type non scalaire, RPC renvoie Promise :


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

Asynchronie sans "Hell Callback".


Pour organiser un code asynchrone "séquentiel", la fonctionnalité async / await Typescript est utilisée:


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

Cela n'a aucun sens de s'y attarder en détail, sur le moyeu il y a déjà suffisamment de matériel détaillé . Ils sont apparus dans Typescript en 2016. Nous utilisons cette approche depuis qu'elle est apparue dans la branche des fonctionnalités du référentiel Typescript, c'est pourquoi nous avons des bosses depuis longtemps et maintenant nous travaillons avec plaisir. Depuis quelque temps maintenant, et en production.


En bref, l'essence pour ceux qui ne connaissent pas le sujet:


Dès que vous ajoutez le mot clé async à la fonction, il renverra automatiquement la Promise<_> . Caractéristiques de ces fonctions:


  • Les expressions Ă  l'intĂ©rieur des fonctions async avec await (qui renvoient Promise ) arrĂŞteront l'exĂ©cution de la fonction et continueront après la rĂ©solution de la Promise attendue.
  • Si une exception se produit dans la fonction async , la Promise retournĂ©e sera rejetĂ©e avec cette exception.
  • Lors de la compilation en code Javascript, il y aura des gĂ©nĂ©rateurs pour le standard ES6 ( function* au lieu de async function et yield au lieu d' await ) ou code effrayant avec switch pour ES5 (machine d'Ă©tat). await est un mot clĂ© qui attend le rĂ©sultat d'une promesse. Au moment de la rĂ©union, pendant l'exĂ©cution du code, la fonction ShowNodes s'arrĂŞte, et en attendant les donnĂ©es, Javascript peut exĂ©cuter un autre code.

Dans le code ci-dessus, la collection a une méthode forEachParallel qui appelle un rappel asynchrone pour chaque nœud en parallèle. En même temps, await avant que Nodes.forEachParallel n'attende tous les rappels. A l'intérieur de l'implémentation - 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); } 

C'est du sucre syntaxique: de telles méthodes doivent être utilisées non seulement pour leurs collections, mais aussi pour les tableaux Javascript standard.


La fonction ShowNodes semble extrêmement non optimale: lorsque nous demandons une autre entité, nous l'attendons à chaque fois. La commodité est qu'un tel code peut être écrit rapidement, donc cette approche est bonne pour le prototypage rapide. Dans la version finale, vous devez utiliser le langage de requête pour réduire le nombre d'appels au serveur.


Langue de la requĂŞte


Il existe plusieurs fonctions permettant de «créer» une demande de données à partir du serveur. Ils «indiquent» au serveur quels nœuds du graphe de données retourner dans la réponse:


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

Examinons maintenant l'application de ces fonctions pour demander les données intégrées nécessaires en un seul appel au serveur:


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

Un exemple d'une requête légèrement plus complexe avec des informations profondément intégrées:


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

Le langage de requête permet d'éviter les demandes inutiles au serveur. Mais le code n'est jamais parfait, et il contiendra certainement plusieurs demandes concurrentielles et, par conséquent, des conditions de concurrence.


Condition de course et solutions


Comme nous nous abonnons aux événements du serveur et écrivons du code avec un grand nombre de demandes asynchrones, une condition de FuncOne peut se produire lorsque la fonction async FuncOne interrompue, en attente de Promise . À ce moment, un événement de serveur (ou de la prochaine action de l'utilisateur) peut survenir et, après exécution concurrentielle, changer le modèle sur le client. Ensuite, FuncOne après avoir résolu la promesse, peut se tourner, par exemple, vers des ressources déjà supprimées.


Imaginez une telle situation simplifiée: l'objet IParent a un IParent serveur IParent .


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

Il est appelé lorsque la liste des nœuds INodes sur le serveur est mise à jour. Ensuite, dans le scénario suivant, une condition de concurrence est possible:


  1. Nous provoquons la suppression asynchrone du nœud du client, en attendant la fin pour supprimer l'objet client
     async function OnClickRemoveNode(node: INode): Promise<void> { let removedOnServer: boolean = await Parent.RemoveNode(node); //     if (removedOnServer) .... } 
  2. Par le biais de Parent.OnSynchronize , une mise à jour de l'événement de liste de nœuds se produit.
  3. Parent.OnSynchronize traité et supprime l'objet client.
  4. async OnClickRemoveNode() continue de s'exécuter après la première await et une tentative est faite pour supprimer un objet client déjà supprimé.

Vous pouvez vérifier l'existence d'un objet client dans OnClickRemoveNode . Il s'agit d'un exemple simplifié et une vérification similaire est normale. Mais que faire si la chaîne d'appel est plus compliquée? Par conséquent, l'utilisation d'une approche similaire après chaque await est une mauvaise pratique:


  • Le code tellement gonflĂ© est compliquĂ© Ă  prendre en charge et Ă  Ă©tendre.
  • Le code ne fonctionne pas comme prĂ©vu: la suppression dans OnClickRemoveNode et la suppression rĂ©elle de l'objet client se produit ailleurs. Il ne doit pas y avoir de violation de la sĂ©quence dĂ©finie par le dĂ©veloppeur, sinon il y aura des erreurs de rĂ©gression.
  • Ce n'est pas assez fiable: si vous oubliez de vĂ©rifier quelque part, il y aura une erreur. Le danger est, tout d'abord, qu'une vĂ©rification oubliĂ©e ne conduise pas Ă  une erreur localement et dans un environnement de test, et pour les utilisateurs avec un dĂ©lai rĂ©seau plus long, elle se produira.
  • Et si le contrĂ´leur auquel appartiennent ces gestionnaires peut ĂŞtre dĂ©truit? Après chaque await pour vĂ©rifier sa destruction?

Une autre question se pose: que se passe-t-il s'il existe de nombreuses méthodes de concurrence similaires? Imaginez qu'il y en ait plus:


  • Ajout d'un nĹ“ud
  • Mise Ă  jour du nĹ“ud
  • Ajouter / supprimer des liens
  • MĂ©thode de conversion Ă  nĹ“uds multiples
  • Comportement complexe de l'application: nous changeons l'Ă©tat d'un nĹ“ud et le serveur commence Ă  mettre Ă  jour les nĹ“uds qui en dĂ©pendent.

Une mise en œuvre architecturale est requise, ce qui élimine en principe la possibilité d'erreurs dues à la condition de concurrence, aux actions utilisateur parallèles, etc. La bonne solution pour éliminer le changement simultané du modèle du client ou du serveur est d'implémenter une section critique avec une file d'attente d'appels. Les décorateurs dactylographiés seront utiles ici pour baliser de manière déclarative ces fonctions de contrôleur asynchrones compétitives.


Nous décrivons les exigences et les principales caractéristiques de ces décorateurs:


  1. A l'intérieur, une file d'attente d'appels aux fonctions asynchrones doit être implémentée. Selon le type de décorateur, un appel de fonction peut être mis en file d'attente ou rejeté s'il contient d'autres appels.
  2. Les fonctions marquées nécessiteront un contexte d'exécution pour se lier à la file d'attente. Vous devez soit créer explicitement une file d'attente, soit le faire automatiquement en fonction de la vue à laquelle appartient le contrôleur.
  3. Des informations sont requises sur la destruction de l'instance de contrôleur (par exemple, la propriété IsDestroyed ). Pour empêcher les décorateurs de faire des appels en file d'attente après la destruction du contrôleur.
  4. Pour le contrôleur View, nous ajoutons la fonctionnalité d'appliquer un masque translucide pour exclure les actions au moment de l'exécution de la file d'attente et indiquer visuellement le traitement en cours.
  5. Tous les décorateurs doivent terminer par un appel à Promise.done() . Dans cette méthode, vous devez implémenter le handler exceptions non gérées. Une chose très utile:
    • les exceptions qui se sont produites dans Promise ne sont pas interceptĂ©es par le gestionnaire d'erreurs standard (qui, par exemple, affiche une fenĂŞtre avec du texte et une trace de piquage), vous pouvez donc ne pas les remarquer (si vous ne surveillez pas la console tout le temps pendant le dĂ©veloppement). Et l'utilisateur ne les verra pas du tout - cela rendra le support difficile. Remarque: il est possible de s'abonner pour gĂ©rer l'Ă©vĂ©nement de unhandledrejection , mais toujours seul Chrome et Edge le prennent en charge:

       window.addEventListener('unhandledrejection', function(event) { // handling... }); 
    • puisque nous marquons la fonction de gestionnaire d'Ă©vĂ©nements async la plus Ă©levĂ©e en tant que dĂ©corateurs, nous obtenons l'erreur de trace de la pile entière.

Maintenant, nous donnons une liste approximative de ces décorateurs avec une description, puis montrons comment ils peuvent être appliqués.


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

Les descriptions sont assez abstraites, mais dès que vous verrez un exemple d'utilisation avec des explications, tout deviendra plus clair:


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

Nous allons maintenant analyser quelques scénarios typiques d'erreurs possibles et leur élimination par les décorateurs:


  1. L'utilisateur lance une action: OnClickRemoveNode , OnClickRemoveLink . Pour un traitement correct, il est nécessaire qu'il n'y ait aucun autre gestionnaire en cours d'exécution dans la file d'attente (client ou serveur). Sinon, par exemple, une telle erreur est possible:
    • Le modèle sur le client est toujours mis Ă  jour vers l'Ă©tat actuel du serveur
    • Nous initialisons la suppression de l'objet avant la fin de la mise Ă  jour (la file d'attente a un gestionnaire OnServerSynchronize ). Mais cet objet n'est plus lĂ  - la synchronisation complète n'est pas encore terminĂ©e et elle est toujours affichĂ©e sur le client.
      Par conséquent, toutes les actions initiées par l'utilisateur, le décorateur de Lock doit rejeter s'il existe d'autres gestionnaires dans la file d'attente avec le même contexte de file d'attente. Étant donné que le serveur est asynchrone, cela est particulièrement important. Oui, Websocket envoie les demandes de manière séquentielle, mais si le client rompt la séquence, nous obtenons une erreur sur le serveur.
  2. Nous OnClickAddNewNode l'ajout d'un nœud: OnClickAddNewNode . OnServerSynchronize , les événements OnServerAddNode proviennent du serveur.
    • OnClickAddNewNode pris la file d'attente (s'il y avait quelque chose, le dĂ©corateur de Lock de cette mĂ©thode rejetterait l'appel)
    • OnServerSynchronize , OnServerAddNode en OnServerAddNode d' OnServerAddNode , exĂ©cutĂ© sĂ©quentiellement après OnClickAddNewNode , sans concurrence avec lui.
  3. La file d'attente a des OnServerSynchronize et OnServerUpdateNode . Supposons que lors de l'exécution du premier, l'utilisateur ferme le GraphController . Ensuite, le deuxième appel à OnServerUpdateNode ne doit pas être effectué automatiquement afin de ne pas prendre de mesures sur le contrôleur détruit, ce qui garantit une erreur. Pour cela, l'interface ILockTarget a IsDestroyed - le décorateur vérifie l'indicateur sans exécuter le prochain gestionnaire de la file d'attente.
    Bénéfice: pas besoin d'écrire if (!this.IsDestroyed()) après chaque await .
  4. Les modifications apportées à plusieurs nœuds sont déclenchées. OnServerSynchronize , les événements OnServerUpdateNode proviennent du serveur. Leur exécution compétitive entraînera des erreurs irréprochables. Mais depuis LockQueue sont marqués par les LockBetween LockQueue et LockBetween , ils seront exécutés séquentiellement.
  5. Imaginez que les nœuds puissent contenir des graphiques de nœuds imbriqués. GraphController #1 , — GraphController #2 . , GraphController - , ( — ), .. . :
    • GraphController #2 , , .
  6. OnSearchFieldChange , . - . @LockDeferred(300) 300 : , , 300 . , . :
    • , 500 , . — OnSearchFieldChange , .
    • OnSearchFieldChange — , .


  1. Deadlock: Handler1 , , await Handler2 , LockQueue , Handler2 — Handler1 .
  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(); .

:


  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/fr464773/


All Articles