Sincronizando cambios con workers en JavaScript

[English version]Este enlace se abrirá en una ventana nueva

En mi opinión, uno de los problemas de los que más frecuentemente nos olvidamos cuando desarrollamos aplicaciones es de que éstas sean capaces de funcionar sin conexión. Estamos tan acostumbrados a la programación de aplicaciones web, que nos hemos olvidado de considerar aquellos casos en los que el servidor no se encuentra disponible, o la conexión no es tan buena como debería ser. A decir verdad, estos problemas no son tan importantes cuando nuestra aplicación es sólo web, pero sí que deberíamos preguntarnos que pasa cuando lo que hemos desarrollado es, en realidad, una PWA, una app híbrida o incluso una aplicación de escritorio basada en Electron.

Por tanto, si nos encontramos en un escenario como estos, deberíamos tener en cuenta como desarrollarlo o qué problemas podríamos encontrar. Una primera aproximación podría ser reducir el problema al típico de productor-consumidor, sin embargo, la realidad es que lo que tenemos es un caso de sincronización en el que deberíamos tener en cuenta otras consideraciones como, por ejemplo, ¿hay un único origen de verdad? ¿pueden modificar distintos usuarios finales el mismo dato?

Por otra parte, cómo sabéis, estos procesos de sincronización o de productor-consumidor no deberían realizarse en el mismo "hilo" que pinta. No queremos que por "sincronizar" cada poco tiempo estemos afectando a como pintamos. Este problema es bastante conocido en entornos como el desarrollo de apps móviles nativas, sin embargo, cuando nuestro entorno de ejecución es el navegador y el lenguaje de programación es JavaScript las cosas no parecen tan fáciles.

A lo largo de este post, intentaré daros información de como podríamos resolver este problema. Si te da pereza seguir leyendo puedes ir a este repositorio en Github,Este enlace se abrirá en una ventana nueva donde puedes encontrar un ejemplo, si quieres saber algo más te invito a que continues leyendo.

Una pequeña descripción del problema

Pensad en el problema como un escenario en el que:

  • Tenemos diferentes clientes, todos ellos producen datos siendo estos, por ejemplo, formularios que se rellenan, contestar mensajes o incluso enviar imágenes o videos.
  • Estos clientes son también consumidores, puesto que queremos que todos ello tengan la misma información y, actualizada, lo antes posible.

En una solución típica, podríamos enviar datos al servidor e implementar cualquier tecnología que queramos para sincronizar: long polling, web sockets, etc. Pero ¿qué pasa si el entorno es móvil?, ¿si tenemos una conexión a internet lenta o, si incluso, no tenemos conexión? ¿estamos preocupados de la experiencia de usuario final?

Si tu respuesta es afirmativa, desde mi punto de vista la mejora opción para resolver el problema es partir del supuesto de que no hay servidor. ¡Dejadme explicaroslo! Necesitáis almacenar información, leerla, pero desde el punto de vista de vuestra lógica de aplicación no deberíais preocuparos de la sincronización con el servidor. Si conocéis Redux deberíais tener una idea clara de lo que son los efectos colaterales, si no lo tenéis muy claro o no os apasiona demasiada, pensad en ello como:

  • Vuestro proceso principal es el responsable de pintar la información, mostrarla en pantalla, realizar cualquier lógica relacionada con vuestros datos e, incluso, almacenarla en algún sitio.
  • Pero, a la vez, debería haber otro proceso que es responsable de enviar esos datos al servidor y, también, de recibirlos de él. Actualizando en el proceso vuestro repositorio persistente.

Procesos_AfectadosProcesos_Afectados

Por tanto, para dar una solución desde el punto de vista de frontend deberíamos enfocarnos en:

  • Implementar nuestra lógica de frontend: ¿Qué hace nuestra aplicación?
  • Decidir cómo hacemos la persistencia local: No hay muchas opciones en los motores de JavaScript, el uso de Local Storage no es válido, así que no nos queda mucho más remedio que probar con IndexedDBEste enlace se abrirá en una ventana nueva.
  • Implementar un algoritmo que permita procesar la cola: Enviar datos asumiendo que puede no haber conexión requiere implementar una cola de actualizaciones y otros algoritmos que podrían suponer un identificador universal de la entidad. Podríamos tener un id local, pero como ese id es, típicamente, una secuencia no podemos utilizarlo en todo el mundo. Necesitamos algo único en nuestro universo.
  • Procesar la cola: No se puede hacer en el hilo principal (el que pinta), en JavaScript y con navegadores modernos la opción sería utilizar Web WorkersEste enlace se abrirá en una ventana nueva.

Lógicamente, la lógica de frontend esta fuera del alcance de este post. ¡Es vuestra lógica!, así que lo que me voy a centrar en los puntos siguientes es en como plantear esa persistencia, y como procesar la cola utilizando Web Workers. Tened en cuenta que en un entorno real hay muchas más complejidades, como, por ejemplo, seguridad, colisión de datos – actualización de la misma información en distintas aplicaciones por usuarios distintos, etc. -, aspectos éstos que no voy a cubrir en este ejemplo.

Persistencia de datos

Tal y como os comentaba antes, la solución de persistencia pasa por IndexedDB. Pero, la verdad, es que este API es un poco de bajo nivel, un poco complejo, así que si fuera uno de vosotros optaría por utilizar algunas APIS de alto nivel como DexieEste enlace se abrirá en una ventana nueva. Esta es la aproximación que he seguido en el repo que os he compartido.

Desde mi punto de vista una de las principales ventajas del almacenamiento NoSQL – como IndexedDB – es que no tenemos que ser exactamente conscientes de los datos que tenemos que leer o almacenar, sino solo disponer de algún mecanismo que nos permita leer y escribir y que tenga la suficiente información como para poder hacer lo que necesitamos y, ¿eso qué es?

  • Almacenar datos y leerlos.
  • Pero, además, tenemos que incluir mecanismos que nos permitan que nuestro algoritmo de envío a servidor pueda trabajar. Eso implica, entre otras cosas mantener una cola – que cambios queremos incluir – y cómo vamos a modificar nuestra persistencia con los cambios remotos.

Veámoslo en detalle en los siguientes apartados

Mecanismo de almacenamiento

Básicamente necesitamos crear una Base de datos que no sea exactamente consciente de lo que vamos a guardar, pero que nos permita realizar las operaciones CRUD básicas y aquellas relacionadas con la gestión de la cola y los identificadores universales. Para ello, nos basta simplemente un poco de TypeScript que extienda el modelo de Dexie. En concreto,

  • Necesitamos una tabla de datos donde almacenamos la información.
  • Y, además, una cola en la que guardamos las peticiones pendientes de procesado

Imagen1Imagen1

Como podéis ver en el constructor se crean tres tablas – fijaos que en IndexedDB sólo especificamos índices, no la estructura real de la tabla -.

  • Tabla de datos (Data): Contiene un índice autogenerado id, y otra serie de índices para acceder por URI, par URI y fecha de última modificación y, finalmente, un índice orderKey definido por el usuario. A fin de cuentas, cuando mostramos datos lo que queremos es ordenar por ellos, ¿no?
  • Tabla de cola (Queue): Que contiene sólo dos índices, un ID autogenerado y el identificador universal del recurso.
  • Tabla de Sistema (System): Un mecanismo algo patatero de tener un identificador universal de mi aplicación para crear la URI. Es un ejemplo, no lo utilicéis en vuestro entorno real.

Adicionalmente, hay algunos hooks que nos permiten introducir el timestamp – fecha de última cambio – siempre que creamos o actualizamos una entidad.

Un momento, si os fijáis no estoy hablando de datos reales, sino de metadatos que tienen cierta información. Bueno, en realidad, es la magia de TypeScript e IndexedDB, no hace falta que tengamos nuestro modelo completamente definido, pero si debemos asegurarnos de que tenemos lo mínimo para que sea reutilizable y aprovechar las capacidades de herencia que nos ofrece el lenguaje

Imagen2Imagen2

Imagen3Imagen3

Para convertir el objeto almacenado al modelo lógico real (importante si tenéis getter, setter o funciones personalizadas, vamos lo que debe ser un modelo) debemos tener decodificadores que en mi ejemplo se pasan como parámetros al constructor y se emplearan cuando leamos los datos.

Imagen4Imagen4

Os podríais preguntar como manejamos la cola. En realidad, no es algo muy complejo y sólo tenemos que tenerlo en cuenta en los procesos de creación, actualización y borrado.

Imagen5Imagen5

Sin duda, podríamos haber utilizado hooks para rellenar la cola, pero queremos evitar bucles. No queremos que la sincronización remota introduzca nuevamente algo en la cola, ¿no? Obviamente, existen mecanismos para evitarlo, pero introducirían muchas condiciones de contorno que harían que nuestro código no fuera lo suficiente limpio ni legible.

Así cuándo queramos sincronizar nuestros datos con la información remota, sólo tenemos que acceder a la base de datos y aquellos métodos que se orienten al proceso de sincronización

Imagen6Imagen6

En el ejemplo que os pegado, este código permite aplicar una acción de sincronización en nuestra base de datos, eliminando aquellas entradas, creando o actualizando las necesarias. Notad que siempre estoy utilizando el Identificador Universal de la Entidad en lugar del id local.

El procesamiento de la cola es un poquito más complicado, tendremos que trabajar con promesas (enviar un dato al servidor, típicamente, implicará una petición fetch). En el ejemplo que de mi repo, las operaciones se ejecutan, además, de forma secuencial y tenenmos que garantizarnos que ante el éxito se elimine la entrada de la cola (queremos evitar el doble procesamiento)

Imagen7Imagen7

Como podéis ver, nos limitamos a leer la cola, obtener la información que queremos enviar, enviarla y si el proceso termina bien eliminamos la entrada de la cola. Si me permitís, una pequeña aclaración, en las operaciones siempre se enviará el timestamp – esa es la magia de JavaScript – puesto que lo almacenamos junto con la tabla de datos, de esta forma siempre podríamos implementar en el servidor mecanismos de comprobación adicional orientados a evitar modificaciones repetidas.

Gestionando el almacenamiento desde nuestra aplicación

Queremos que sea fácil y reutilizable. Puesto que en el ejemplo estoy utilizando REACT me voy a centrar en proporcionar el acceso a la Base de datos a través del contexto y custom hooks.

El contexto nos permite esconder la creación de la base de datos utilizando un componente que realiza toda la parte "compleja", almacenándola en un estado y disponibilizándola a lo largo de nuestra aplicación.

Imagen8Imagen8

Un caso de uso de este contexto sería algo tan simple como:

Imagen9Imagen9

Una vez creado y para acceder a las operaciones reales (leer, borrar, actualizar, crear) podríamos recuperarlo y manipularlo de forma directa. No obstante, no es una aproximación que nos permita garantizar la reusabilidad de nuestro código. Así que desde el punto de vista de REACT lo más limpio sería crear un custom hook, que nos permita aislar toda la complejidad de una forma lo más simple posible

Imagen10Imagen10

Como podéis ver, estoy usando de forma profusa los interfaces que he definido en TypeScript garantizando así un código bien definido y correctamente reutilizable. El uso del hook useMemo se orienta a permitir que el resultado de nuestro hook se pueda utilizar dentro de un useEffect sin problemas colaterales.

Imagen11Imagen11

Procesando la cola y recibiendo datos.

Para realizar estas tareas, voy a utilizar – como os decía antes – Web Workers, si no tenéis muy claro lo que son, simplemente pensad en ellos como procesos independientes que se ejecutan en vuestro navegador y que no afectan ni al proceso de pintado ni a la lógica de negocio. Si bien podría plantearse un único worker, considero que es mejor crear dos puesto que tienen que hacer, en realidad, cosas ligeramente distintas.

  • Uno es el responsable del envío de datos al servidor remoto, eso implica leer la cola de la base de datos y enviar las entidades al extremo remoto.
  • El otro se responsabilizará de leer los datos del servidor, almacenando la información recibida en nuestro sistema local.

Más allá de enviar y recibir datos tenemos otras necesidades que cubrir como el sistema de logs, o como conectar los workers a nuestra aplicación de una forma directa.

Enviando datos al servidor remoto

Pensad que es un proceso repetitivo que lo tenemos que hacer mientras nuestra aplicación este viva, para ello el uso de timeouts es más que suficiente y la única "complejidad" viene de utilizar un worker.

Imagen12Imagen12

El código es bastante sencillito, el método syncToRemote se limita a leer la cola y enviar los datos al servidor mediante el API fetch.

Leyendo del servidor

Lo primero que deberíamos preguntarnos es si queremos que el servidor nos envíe todos los datos en cada petición de sincronización. Estoy bastante convencido de que vuestra respuesta será negativa. Siendo así, que debemos hacer, como debemos diseñar nuestro protocolo de comunicación para tener esto en cuenta. Básicamente, algo como lo que os pinto a continuación:

Flujo_ActualizacionFlujo_Actualizacion

Podéis ver que,

  • Obtenemos el estado de la Base de datos local (URI y último cambio de cada entidad). Enviamos la información al servidor.
  • El servidor al recibir los datos, decide que novedades hay, que entidades tienen un timestamp mayor que el pasado o que entradas son nuevas (aquellas que el servidor tiene pero que no se identifican en la petición)

En el repo podéis ver un código de ejemplo como el siguiente

Imagen13Imagen13

La función en el storage que nos permite recuperar el estado se llama getEntityStatus y se limita a proporcionarnos lo que tenemos

Imagen14Imagen14

Con los datos recibidos ya nos limitamos a sincronizar la información y utilizar un postMessage especial para informar a nuestra lógica de aplicación de la existencia de cambios que deberían mostrarse a los usuarios.

Imagen15Imagen15

Conectandolo con nuestro código

Para utilizar estos workers, mi aproximación es, en aras de una mayor reusabilidad del código, utilizar custom hooks.

Imagen16Imagen16

El hook crea los workers – a través de una función, pero eso es por un tema de testing -, implementando todos los procesos de inicialización y terminación y recibiendo de los workers los datos de log y cuando debemos sincronizar los datos.

El uso del hook es extremadamente sencillo, sólo deberíamos utilizarlo en el lugar adecuado con los parámetros de configuración adecuados.

Imagen17Imagen17

Gestionando logs

La verdad es que siempre que desarrollemos una aplicación deberíamos tener algún mecanismo de log, no deberíamos pensar en la consola, no nos ayuda a saber que les pasa a los usuarios. Un sistema de logging podría implicar enviar datos a un servidor remoto, en un orden concreto, etc. ¿un sistema de colas como el comentado?

En cualquier caso, cuando utilicemos Web Workers si queremos imprimir logs, deberíamos tener en cuenta estos problemas, así que una buena aproximación podría ser crear un sistema de logs basado en eventos (postMessage), tal y como os muestro a continuación

Imagen18Imagen18

En el código de ejemplo del repo, he utilizado simplemente un log en consola, pero confío en que la idea general, os podría proporcionar una mejor comprensión de cómo hacerlo adecuadamente.

Juntando todo

Una vez que la infraestructura está creada podemos utilizarla desde nuestra aplicación, en esta imagen os muestro la aplicación de ejemplo que podéis descargar del repo y los errores relacionados con los endpoints remotos no disponibles

Imagen19Imagen19

Si veis la base de datos IndexedDB podéis ver información interesante sobre como se almacenan los datos y como se emplean los índices

Imagen20Imagen20

O, incluso, la cola

Imagen21Imagen21

Algunas cosas más.

Además de daros información de como los workers podrían utilizarse para realizar tareas de sincronización, creo que también podéis encontrar información interesante en el repo, por ejemplo, como debemos aislar modelos, lógica de vista y presentación, etc.