Mecanografiado asincrónico en una rica aplicación de Internet y decoradores para combatirlo

Desde el advenimiento de async / await , Typescript ha publicado muchos artículos que ensalzan este enfoque de desarrollo ( hackernoon , blog.bitsrc.io , habr.com ). Los usamos desde el principio en el lado del cliente (cuando los generadores ES6 admitían menos del 50% de los navegadores). Y ahora quiero compartir mi experiencia, porque la ejecución paralela no es todo lo que sería bueno saber en este camino.


Realmente no me gusta el artículo final: algo puede ser incomprensible. En parte debido al hecho de que no puedo proporcionar código propietario, solo para describir el enfoque general. Por lo tanto:


  • no dudes en cerrar la pestaña sin leer
  • si lo logra, solicite detalles poco claros
  • Con mucho gusto aceptaré consejos y críticas de los más persistentes y completamente descubiertos hasta el punto.

Lista de tecnologías centrales:


  • El proyecto está escrito principalmente en Typecript usando varias bibliotecas Javascript. La biblioteca principal es ExtJS. React es inferior en moda a la moda, pero es más adecuado para un producto empresarial con una interfaz rica: muchos componentes listos para usar, tablas bien diseñadas listas para usar, un rico ecosistema de productos relacionados para simplificar el desarrollo.
  • Servidor multiproceso asíncrono.
  • RPC a través de Websocket se utiliza como transporte entre el cliente y el servidor. La implementación es similar a .NET WCF.
    • Cualquier objeto es un servicio.
    • Cualquier objeto puede transmitirse tanto por valor como por referencia.
  • La interfaz de solicitud de datos se asemeja a GraphQL de Facebook, solo en Typecript.
  • Comunicación bidireccional: la inicialización de la actualización de datos se puede iniciar tanto desde el cliente como desde el servidor.
  • El código asincrónico se escribe secuencialmente mediante el uso de las funciones async / en await de Typercipt.
  • La API del servidor se genera en Typecript: si cambia, la compilación lo mostrará inmediatamente en caso de error.

Cual es la salida


Le diré cómo trabajamos con esto y qué hicimos para la ejecución segura y no competitiva de código asincrónico: nuestros decoradores de Typercipt que implementan la funcionalidad de las colas. Desde lo básico hasta la solución de la condición de carrera y otras dificultades que surgen durante el proceso de desarrollo.


Cómo se estructuran los datos recibidos del servidor


El servidor devuelve un objeto principal que contiene datos (otros objetos, colecciones de objetos, filas, etc.) en sus propiedades en forma de gráfico. Esto se debe, entre otras cosas, a la aplicación en sí:


  • convierte el análisis de datos / ML en un gráfico dirigido de nodos manejadores.
  • cada nodo a su vez puede contener su propio gráfico incrustado
  • los gráficos tienen dependencias: los nodos pueden ser "heredados" y los nuevos nodos son creados por su "clase".

Pero la estructura de consulta en forma de gráfico se puede aplicar en casi cualquier aplicación, y GraphQL, hasta donde yo sé, también menciona esto en su especificación.


Estructura de datos de ejemplo:


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

¿Cómo recibe los datos un cliente?


Es simple: cuando solicita una propiedad de un objeto de un tipo no escalar, RPC devuelve Promise :


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

Asincronía sin un "infierno de devolución de llamada".


Para organizar un código asincrónico "secuencial", se utiliza la funcionalidad async / await async :


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

No tiene sentido detenerse en él en detalle, en el centro ya hay suficiente material detallado . Aparecieron en Typecript en 2016. Hemos estado utilizando este enfoque desde que apareció en la rama de características del repositorio de ScriptScript, es por eso que hemos tenido problemas durante mucho tiempo y ahora estamos trabajando con placer. Desde hace algún tiempo, y en producción.


Brevemente, la esencia para aquellos que no están familiarizados con el tema:


Tan pronto como agregue la palabra clave async a la función, devolverá automáticamente la Promise<_> . Características de tales funciones:


  • Las expresiones dentro de las funciones async con await (que devuelven Promise ) detendrán la ejecución de la función y continuarán después de resolver la Promise esperada.
  • Si se produce una excepción en la función async , la Promise devuelta será rechazada con esta excepción.
  • Al compilar en código Javascript, habrá generadores para el estándar ES6 ( function* lugar de async function y yield lugar de await ) o código aterrador con switch para ES5 (máquina de estado). await es una palabra clave que espera el resultado de una promesa. En el momento de la reunión, durante la ejecución del código, la función ShowNodes detiene y, mientras espera datos, Javascript puede ejecutar algún otro código.

En el código anterior, la colección tiene un método forEachParallel que llama a una devolución de llamada asíncrona para cada nodo en paralelo. Al mismo tiempo, await antes de Nodes.forEachParallel esperará todas las devoluciones de llamada. Dentro de la implementación - 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); } 

Este es el azúcar sintáctico: tales métodos deben usarse no solo para sus colecciones, sino también para las matrices Javascript estándar.


La función ShowNodes parece extremadamente no óptima: cuando solicitamos otra entidad, la esperamos cada vez. La conveniencia es que dicho código se puede escribir rápidamente, por lo que este enfoque es bueno para la creación rápida de prototipos. En la versión final, debe usar el lenguaje de consulta para reducir la cantidad de llamadas al servidor.


Lenguaje de consulta


Hay varias funciones que se utilizan para "construir" una solicitud de datos del servidor. Ellos "le dicen" al servidor qué nodos del gráfico de datos deben devolver en la respuesta:


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

Ahora veamos la aplicación de estas funciones para solicitar los datos integrados necesarios con una llamada al servidor:


 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 ejemplo de una consulta un poco más compleja con información profundamente incrustada:


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

El lenguaje de consulta ayuda a evitar solicitudes innecesarias al servidor. Pero el código nunca es perfecto, y ciertamente contendrá varias solicitudes competitivas y, como resultado, la condición de la carrera.


Condición de carrera y soluciones


Como nos suscribimos a eventos del servidor y escribimos código con una gran cantidad de solicitudes asíncronas, puede ocurrir una condición de carrera cuando la función FuncOne async FuncOne interrumpe, esperando Promise . En este momento, puede venir un evento del servidor (o de la siguiente acción del usuario) y, después de ejecutarse competitivamente, cambiar el modelo en el cliente. Entonces FuncOne después de resolver la promesa, puede recurrir, por ejemplo, a recursos ya eliminados.


Imagine una situación tan simplificada: el objeto IParent tiene un delegado de servidor IParent .


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

Se llama cuando se actualiza la lista de nodos INodes en el servidor. Luego, en el siguiente escenario, es posible una condición de carrera:


  1. Provocamos la eliminación asincrónica del nodo del cliente, esperando la finalización para eliminar el objeto del cliente
     async function OnClickRemoveNode(node: INode): Promise<void> { let removedOnServer: boolean = await Parent.RemoveNode(node); //     if (removedOnServer) .... } 
  2. A través de Parent.OnSynchronize , se Parent.OnSynchronize una actualización del evento de la lista de nodos.
  3. Parent.OnSynchronize procesa y elimina el objeto del cliente.
  4. async OnClickRemoveNode() continúa ejecutándose después de la primera await y se intenta eliminar un objeto de cliente ya eliminado.

Puede verificar la existencia de un objeto de cliente en OnClickRemoveNode . Este es un ejemplo simplificado y en él es normal una verificación similar. Pero, ¿y si la cadena de llamadas es más complicada? Por lo tanto, usar un enfoque similar después de cada await es una mala práctica:


  • El código tan hinchado es complicado de soportar y extender.
  • El código no funciona según lo previsto: OnClickRemoveNode la eliminación en OnClickRemoveNode y la eliminación real del objeto del cliente se produce en otro lugar. No debe haber una violación de la secuencia definida por el desarrollador, de lo contrario habrá errores de regresión.
  • Esto no es lo suficientemente confiable: si olvida hacer una verificación en algún lugar, entonces habrá un error. El peligro es, en primer lugar, que una verificación olvidada no conduzca a un error local y en un entorno de prueba, y para los usuarios con un retraso de red más largo, ocurrirá.
  • ¿Y si el controlador al que pertenecen estos controladores puede ser destruido? Después de cada await para comprobar su destrucción?

Surge otra pregunta: ¿qué pasa si hay muchos métodos competitivos similares? Imagina que hay más:


  • Agregar un nodo
  • Actualización de nodo
  • Agregar / quitar enlaces
  • Método de conversión de nodos múltiples
  • Comportamiento complejo de la aplicación: cambiamos el estado de un nodo y el servidor comienza a actualizar los nodos que dependen de él.

Se requiere una implementación arquitectónica, que en principio elimina la posibilidad de errores debido a condiciones de carrera, acciones paralelas del usuario, etc. La solución correcta para eliminar el cambio simultáneo del modelo del cliente o servidor es implementar una sección crítica con una cola de llamadas. Los decoradores mecanografiados serán útiles aquí para etiquetar declarativamente tales funciones de controlador asíncrono competitivas.


Describimos los requisitos y características clave de tales decoradores:


  1. En el interior, se debe implementar una cola de llamadas a funciones asincrónicas. Dependiendo del tipo de decorador, una llamada de función puede ser puesta en cola o rechazada si hay otras llamadas en ella.
  2. Las funciones marcadas requerirán un contexto de ejecución para unirse a la cola. Debe crear explícitamente una cola o hacerlo automáticamente en función de la Vista a la que pertenece el controlador.
  3. Se requiere información sobre la destrucción de la instancia del controlador (por ejemplo, la propiedad IsDestroyed ). Para evitar que los decoradores realicen llamadas en cola después de que se destruya el controlador.
  4. Para el controlador de Vista, agregamos la funcionalidad de aplicar una máscara translúcida para excluir acciones en el momento en que se ejecuta la cola e indicar visualmente el procesamiento en progreso.
  5. Todos los decoradores deben finalizar con una llamada a Promise.done() . En este método, debe implementar el handler excepciones no controladas. Una cosa muy útil:
    • Las excepciones que ocurrieron en Promise no son detectadas por el controlador de errores estándar (que, por ejemplo, muestra una ventana con texto y un rastro fijo), por lo que es posible que no las note (si no supervisa la consola todo el tiempo durante el desarrollo). Y el usuario no los verá en absoluto, esto dificultará el soporte. Nota: es posible suscribirse para manejar el evento de unhandledrejection , pero solo Chrome y Edge lo admiten:

       window.addEventListener('unhandledrejection', function(event) { // handling... }); 
    • Como marcamos la función de controlador de eventos async más alta como decoradores, obtenemos el error de seguimiento de la pila completa.

Ahora damos una lista aproximada de tales decoradores con una descripción y luego mostramos cómo se pueden aplicar.


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

Las descripciones son bastante abstractas, pero tan pronto como vea un ejemplo de uso con explicaciones, todo se aclarará:


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

Ahora analizaremos un par de escenarios típicos de posibles errores y su eliminación por parte de los decoradores:


  1. El usuario inicia una acción: OnClickRemoveNode , OnClickRemoveLink . Para un procesamiento adecuado, es necesario que no haya otros controladores de ejecución en la cola (ya sea cliente o servidor). De lo contrario, por ejemplo, tal error es posible:
    • El modelo en el cliente todavía se actualiza al estado actual del servidor
    • Iniciamos la eliminación del objeto antes de que se complete la actualización (hay un controlador OnServerSynchronize en OnServerSynchronize en la cola). Pero este objeto ya no está allí, solo que la sincronización completa aún no se ha completado y aún se muestra en el cliente.
      Por lo tanto, todas las acciones iniciadas por el usuario, el decorador de Lock deben rechazar si hay otros controladores en la cola con el mismo contexto de cola. Dado que el servidor es asíncrono, esto es especialmente importante. Sí, Websocket envía solicitudes secuencialmente, pero si el cliente rompe la secuencia, recibimos un error en el servidor.
  2. Iniciamos la adición de un nodo: OnClickAddNewNode . OnServerSynchronize , los eventos OnServerAddNode provienen del servidor.
    • OnClickAddNewNode tomó la cola (si hubiera algo en ella, el decorador de Lock de este método rechazaría la llamada)
    • OnServerSynchronize , OnServerAddNode , ejecutado secuencialmente después de OnClickAddNewNode , no compitiendo con él.
  3. La cola tiene OnServerUpdateNode OnServerSynchronize y OnServerUpdateNode . Supongamos que durante la ejecución del primero, el usuario cierra GraphController . Luego, la segunda llamada a OnServerUpdateNode no se debe realizar automáticamente para no tomar medidas en el controlador destruido, lo que garantiza un error. Para esto, la interfaz ILockTarget tiene IsDestroyed : el decorador comprueba el indicador sin ejecutar el siguiente controlador desde la cola.
    Beneficio: no es necesario escribir if (!this.IsDestroyed()) después de cada await .
  4. Se desencadenan cambios en múltiples nodos. OnServerSynchronize , los eventos OnServerUpdateNode provienen del servidor. Su ejecución competitiva conducirá a errores irreproducibles. Pero desde LockQueue están marcados por los LockBetween LockQueue y LockBetween , se ejecutarán secuencialmente.
  5. Imagine que los nodos pueden tener gráficos de nodos anidados dentro de ellos. 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(); .

:


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


All Articles