Introduction
Actualmente vivimos en una socidad donde necesitamos las cosas de forma inmediata. Necesitamos recibir feedback de que las cosas han llegado y queremos estar informados en todo momento si ha llegado algo nuevo para ser los primeros en conocerlo. Si dejamos aparte el tema de las notificaciones push a los navegadores o dispositovos móviles, lo siguiente que necesitamos es tener la sensación de que la información fluye en tiempo real.
Uno de los ejemplos más claros en aplicaciones que funcionan utilizando estos mecanismos de real time es Twich o un simple chat. Cuando mandamos un mensaje queremos que todo el mundo lo reciba o bien si alguien manda un mensaje queremos recibirlo sin necesidad de tener que refrescar el navegador.
A continuación muestro diferentes técnicas que se pueden utilizar para conseguir ese "real time" que todos buscamos. Una vez las conozcamos todas, cada uno podrá elegir la que mejor funcione en su caso de uso.
Polling
El polling sería el mecanismo más simple que se nos ocurriría. Simplemte ponemos un temporizador y cada N segundos, volvemos a preguntarle al servidor si hay algún mensaje nuevo. ¿ Pero que sucedería si nuestra petición al servidor tarda en responder más tiempo que el intervalo que le hemos puesto ? Iríamos encolando peticiones y podríamos llegar a hacer un DDoS a nuestro propio servidor. En caso de querer usar Long Polling la técnica más adecuada sería usando un setTimeOut al final de la función.
| async function getNewMsgs() { | |
| let json; | |
| try { | |
| const res = await fetch("/poll"); | |
| json = await res.json(); | |
| } catch (e) { | |
| console.error("polling error", e); | |
| } | |
| allChat = json.msg; | |
| render(); | |
| setTimeout(getNewMsgs, INTERVAL); | |
| } |
En el modelo de long polling podríamos decidir usar algunas estrategias como por ejemplo que si no hay nuevos mensajes que no devuelva nada o bien hacer algun tipo de filtro para devolver los últimos 5 mensajes, pero esto no dejaría de hacer un consumo constante de recursos haciendo una comprobación cada pocos segundos, no un consumo muy alto ya que es una simple comprobación, pero realmente podría ser innecesaria.
Otro de los problemas que encontramos con el long polling es que si por algún motivo el usuario abre nuestra aplicación en una pestaña y se olvida de ella, seguiríamos haciendo peticioes al servidor hasta que cerraramos la pestaña.
Para solucionar este problema una de las estrategias que se utiliza es comprobar el requestAnimationFrame, esta comprobación nos ayuda a decidir cuando la ventana está en uso y cuando no. Por lo tanto, podríamos hacer la comprobación de si existen mensajes cada 5 segundos solo en el caso de que la ventana esté activa.
| function rafTimer(time) { | |
| if (timeToMakeNextRequest <= time) { | |
| getNewMsgs(); | |
| timeToMakeNextRequest = time + INTERVAL; | |
| } | |
| requestAnimationFrame(rafTimer); | |
| } | |
| requestAnimationFrame(rafTimer); |
Otro de los principales problemas que nos encontramos con el Polling está relacionado con la tolerancia a fallos, en las implementaciones anteriores si la request nos da un error, seguirá repitiendose hasta el infinito y eso multiplacado por cada usuario, puede llegar a hacer que el servidor no se levante nunca.
¿Pero entonces, cómo hacemos para tener una buena experiencia de usuario y no matar al servidor antes de que arranque? La solución está aplicando una estrategia conocida como Back off strategy
Esta estrategia consiste en hacer polling al server y en el caso de que falle, incrementer el tiempo de la siguiente request. Es decir, la primera petición que falle esperará 4 segundos, la segunda 8, la tercera 16 y así hasta que el servidor se recupere. En el momento que recibimos un 200 Ok ya está, seguiremos haciendo peticiones cada 5 segundos.
| // replace getNewMsgs | |
| async function getNewMsgs() { | |
| try { | |
| const res = await fetch("/poll"); | |
| const json = await res.json(); | |
| if (res.status >= 400) { | |
| throw new Error("request did not succeed: " + res.status); | |
| } | |
| allChat = json.msg; | |
| render(); | |
| failedTries = 0; | |
| } catch (e) { | |
| // back off | |
| failedTries++; | |
| } | |
| } | |
| // replace at bottom | |
| const BACKOFF = 5000; | |
| let timeToMakeNextRequest = 0; | |
| let failedTries = 0; | |
| async function rafTimer(time) { | |
| if (timeToMakeNextRequest <= time) { | |
| await getNewMsgs(); | |
| timeToMakeNextRequest = time + INTERVAL + failedTries * BACKOFF; | |
| } | |
| requestAnimationFrame(rafTimer); | |
| } | |
| requestAnimationFrame(rafTimer); |
En estos casos podemos encontrar varias estrategías. Tienes que buscar la que mejor se adapta a tus necesidades.
Esta es la estrategia que yo uso: Cuando hay un fallo en una petición, espero unos segundos y lo vuelvo a intentar. Esta estrategia captura el mayor numero de errores, por ejemplo que el usuario cambi de wifi a 4G. Si vuelve a fallar vuelvo a intentarlo, esto cubre los problemas en los que los usuarios tienen problemas de conexión, cobertura, etc... Después de que falle dos veces, asumimos fallos en el servidor, por lo que le damos cierto tiempo al servidor para que se recupere y empiezo a usar la estrategia de Back Off.
Hay multiples librerías que ayudan a la gestión del retry y el backoff pero al final, todas gestionan estos puntos de fallo de la misma forma.
HTTP2
Para entender HTTP2 tenemos que entender que cuando hacemos un fetch('example.com') esperamos una respuesta. Esto significa que entramos en una relación one-to-one. Pedimos algo y se entrega algo.
¿Qué pasaría si hacemos una petición HTTP pero no cerramos la conexión? Esta es la premisa de lo que vamos a hacer ahora y se conoce como long-running HTTP o HTTP2 Push.
Si hacemos un poco de historia, desde hace mucho tiempo trabajabamos con HTTP 1.1. y basicamente hacía lo que decimos, pedimos algo y se nos devuelve.
Después de algún tiempo llego SPDY, un protocolo desarrollado por Google que hacía HTTP mucho más rápido.
Y finalmente en 2015 llegó el HTTP/2 ratificado como nuevo estandard HTTP. Con este nuevo estandard llegaron un montón de mejoras.
Estas son algunas de ellas:
- Multiplexado de peticiones.
- Estrategías de compresión de datos.
- Priorización de peticiones.
En la práctica, esto lo que nos permitía era recibir partes de lo que necesitabamos. Por ejemplo podíamos pedir el head y que nos lo devolviera, y en paralelo hacer una petición del body que tiene algunas piezas dinámicas y tardan más en cargar. La idea es que estás conectado a un stream de datos y lo sirves tan pronto está listo.
En los formatos anteriores tenías que esperar a que la resquest terminara para empezar la descarga.Pero ahora todo eso cambió.
Lo que vamos a hacer es abusar de este sistema y dejar la conexión abierta para comunicarnos utilizando pequeñas piezas en formato JSON.
HTTP3 es lo que está por llegar. La principal característica es que se está trabajando en la forma de transportar la información. En lugar de basarse en TCP, la cual hace una comprobación de los paquetes y tiene que comprobar su integridad, HTTP3 ( QUIC ) está basado en UDP, el cual tiene una mejor estrategía de recuperación y acepta paquetes que están desactualizados.
Centrándonos en la parte de comunicación entre un Back-end y front-end utilizando HTTP2 tenemos que tener en cuenta la seguridad. HTTP2 solo funciona bajo protocolo cifrado https. Es una limitación impuesta por el navegador.
Para este ejemplo podemos usar OpenSSL.
openssl req -new -newkey rsa:2048 -new -nodes -keyout key.pem -out csr.pem
openssl x509 -req -days 365 -in csr.pem -signkey key.pem -out server.crt
Ahora tenemos que adaptar nuestras request a trabajar con stream de datos y aceptar los datos del POST. Esto es algo que Express hace por nosotros pero al estar utilizando la librería de HTTP2 que nos proporciona Node.js, tenemos que gestionar los datos por nuestra cuenta.
| // above server.on('request') | |
| server.on("stream", (stream, headers) => { | |
| const method = headers[":method"]; | |
| const path = headers[":path"]; | |
| // streams will open for everything, we want just GETs on /msgs | |
| if (path === "/msgs" && method === "GET") { | |
| // immediately respond with 200 OK and encoding | |
| console.log("connected"); | |
| stream.respond({ | |
| ":status": 200, | |
| "content-type": "text/plain; charset=utf-8", | |
| }); | |
| // write the first response | |
| stream.write(JSON.stringify({ msg: getMsgs() })); | |
| stream.on("close", () => { | |
| console.log("disconnected"); | |
| }); | |
| } | |
| }); |
Cuando recibimos una request, inmediatamente respondemos con el header y decimos como está definido el encode, que protocolo estamos usando y un 200 Ok.
Finalmente tenemos que poner la configuración del cliente. Y así es como nos quedaría:
| // replace getNewMsgs | |
| async function getNewMsgs() { | |
| let reader; | |
| const utf8Decoder = new TextDecoder("utf-8"); | |
| try { | |
| const res = await fetch("/msgs"); | |
| reader = res.body.getReader(); | |
| } catch (e) { | |
| console.log("connection error", e); | |
| } | |
| presence.innerText = "🟢"; | |
| try { | |
| readerResponse = await reader.read(); | |
| const chunk = utf8Decoder.decode(readerResponse.value, { stream: true }); | |
| console.log(chunk); | |
| } catch (e) { | |
| console.error("reader failed", e); | |
| presence.innerText = "🔴"; | |
| return; | |
| } | |
| } |
- Seguimos usando fetch pero enlugar de res.json() abrimos un stream.
- Marcamos con rojo y verde cuando estamos conectado al stream
- Tenemos que decodificar la response que viene por el socket. utf8Decoder.
- Si revisamos la consola, vemos que no hay status code, eso es porque la conexión sigue abierta.
Tal y como está hecha la implementación solo devolverá el primer bloque de datos desde la API, por lo tanto tenemos que hacer un do/while
| // inside getNewMsgs, replace the second, bottom try/catch | |
| do { | |
| let readerResponse; | |
| try { | |
| readerResponse = await reader.read(); | |
| } catch (e) { | |
| console.error("reader failed", e); | |
| presence.innerText = "🔴"; | |
| return; | |
| } | |
| done = readerResponse.done; | |
| const chunk = utf8Decoder.decode(readerResponse.value, { stream: true }); | |
| if (chunk) { | |
| try { | |
| const json = JSON.parse(chunk); | |
| allChat = json.msg; | |
| render(); | |
| } catch (e) { | |
| console.error("parse error", e); | |
| } | |
| } | |
| } while (!done); | |
| // in theory, if our http2 connection closed, `done` would come back | |
| // as true and we'd no longer be connected | |
| presence.innerText = "🔴"; |
Esto seguirá ejecutándose hasta que recibamos un evento del navegador, algo como cerrar la pestaña o cerrar la tapa de nuestro portátil. Estamos tratando cada chunk como una respuesta de la API pero en realidad es un stream que proporcina la información del documento.
Para terminar filtramos las conexiones y las cerramos.
| // inside of server.on("streams") | |
| // under stream.write | |
| // replace stream.on("close") | |
| // keep track of the connection | |
| connections.push(stream); | |
| // when the connection closes, stop keeping track of it | |
| stream.on("close", () => { | |
| connections = connections.filter((s) => s !== stream); | |
| }); | |
| // inside server.on("request") | |
| // under const { user, text } = JSON.parse(data); | |
| msg.push({ | |
| user, | |
| text, | |
| time: Date.now(), | |
| }); | |
| // all done with the request | |
| res.end(); | |
| // notify all connected users | |
| connections.forEach((stream) => { | |
| stream.write(JSON.stringify({ msg: getMsgs() })); | |
| }); |
Ahora mismo ya tenemos una aplicacion que responde en realtime utilizando HTTP2
WebSocket
Vamos a hablar ahora del verdadero Tiempo real. Websocket se desarrollo inicialmente tanto para la parte del servidor como para la parte del cliente, nos permite tener una conexión abierta y hace que el cliente pueda enviar información al servidor y viceversa. Aquí es donde se define claramente el concepto de real time porque nos permite desde las dos partes conseguir comunicación.
En esta primera parte vamos a entrar a desarrollar la comunicación con websocket a mano, vamos a revisar los ficheros que se utilizan para manejar la comunicación en forma binaria entre el servidor y el cliente
Seguramente este tipo de implementación no la utilizarás nunca, pero es un buen ejemplo a nivel académico para ver como trabajan a bajo nivel las librerías más famosas del mercado.
Vamos a empezar modificando nuestro cliente para soportar websocket.
| const ws = new WebSocket("ws://localhost:8080", ["json"]); | |
| ws.addEventListener("open", () => { | |
| console.log("connected"); | |
| presence.innerText = "🟢"; | |
| }); |
Con este código lo que hacemos es establecer una conexión utilizando websocket con nuestro servidor. Ahora mismo, como el servidor no está adaptado fallará, pero es una buena forma de ver lo sencillo que es de implementar en la parte del cliente.
Ahora configuramos la parte del servidor para poder manejar las conexiones.
Lo primero que hacemos es recibir cualquier tipo deconexión. Así es como websocket funciona, primero hace una conexión normal TCP/IP y posteriormente actualiza esa conexión a una de tipo socket.
Por lo tanto tenemos que hacer algo similar a lo que hicimos anteriormente:
| // under server creation | |
| server.on("upgrade", function (req, socket) { | |
| if (req.headers["upgrade"] !== "websocket") { | |
| // we only care about websockets | |
| socket.end("HTTP/1.1 400 Bad Request"); | |
| return; | |
| } | |
| console.log("upgrade requested!"); | |
| }); |
Ahora que ya tenemos el cliente configurado para hablar con el servidor y el servidor listo para recibir conexiones, tenemos que verificar que los dos están hablando el mismo idioma, es decir, que los dos nos vamos a comunicar usando websocket. La forma en la que se hace esto desde el cliente es mandar una key en los headers y el navegador crea un hash que mezcla la key que el navegador manda con la key 258EAFA5-E914-47DA-95CA-C5AB0DC85B11 Y porqué está key ? Pues es una key aleatoria que está definida en las especificaciones de websocket.
Lo primero que podemos pensar es que esto se hace por un tema de seguridad ( yo lo hice ) pero realmente no es por eso, de hecho hay una especificación dentro de websocket que es wss:// enlugar de ws:// y en este caso sí que va encriptada.
La razón por la que mandamos esta key es para asegurarnos que los dos estamos hablando el mismo idioma y que realmente lo que queremos es establecer una conexión del tipo websocket.
Vamos a utilizar la librería generate-accept-value.js para escribir ciertos headers en el cliente y así podemos establecer la conexión entre el cliente y el servidor.
| const acceptKey = req.headers["sec-websocket-key"]; | |
| const acceptValue = generateAcceptValue(acceptKey); | |
| const headers = [ | |
| "HTTP/1.1 101 Web Socket Protocol Handshake", | |
| "Upgrade: WebSocket", | |
| "Connection: Upgrade", | |
| `Sec-WebSocket-Accept: ${acceptValue}`, | |
| "Sec-WebSocket-Protocol: json", | |
| "\r\n", | |
| ]; | |
| socket.write(headers.join("\r\n")); |
Y ya estaríamos conectados con el servidor utilizando websocket.
Ahora podemos usar socket.write y mandaremos datos al cliente o en el cliente podemos hacer un ws.send y mandar información al servidor.
socket.write(objToResponse({ msg: getMsgs() }));
Con estas líneas de código cuando el cliente se conecte recibirá todo histórico del chat pero no gestionará los mensajes que entran y salen, para ello solo tenemos que hacer esto:
| ws.addEventListener("message", (event) => { | |
| const data = JSON.parse(event.data); | |
| allChat = data.msg; | |
| render(); | |
| }); |
Ahora que ya tenemos configurado tanto el servidor como el cliente, podemos ver como funciona.
Si realizas algún cambio en el servidor y refrescas la parte de front-end, verás como el marcador rojo y verde de conexión, cambia. Es decir, por un instante se pone en rojo y en el momento que el servidor está levantado se establece la conexión y se pone en verde.
Y ya estaría, así sería como se puede configurar una conexión websocket a mano entre el cliente y el servidor, pero tal y como se indicaba anteriormente, el único sentido que tiene hacer esto así es meramente formativo y para entender como funciona. En el siguiente bloque se muestra como utilizar una de las herramienta más extendida para el trabajo con sockets y después de haber visto como se hace a mano, usando la librería te parecerá un juego de niños.
Vamos allá.
Socket.IO
Ahora que ya hemos visto como funciona websocket y hemos realizado una primera implementación a mano, vamos a ver como podemos hacer lo mismo pero utlizando una de las librerías más famosas. Es recomendable utilizar librerías ya que nos ayudan a cubrir casos de uso que seguro otra persona ha pensado antes y al mismo tiempo, tienen un soporte para los posible errores en nuestro código.
Actualmente hay dos librerías de gran calidad y que se utilizan de forma masiva. Socket.IO y ws
Ws es una solución minimalista. Si no necesias muchas de las funcionalidades que trae socket.io, ws es la librería que necesitas.
Socket.IO tiene multiples funcionalidades
- Gestión de "habitationes" o pools de conexiones
- Puede mandar y recibir objetos binarios, como fotos, audio y video.
- Si tu navegador no soporta websockets hace fallback automático a long-polling.
- Soporta añadir middlewares como por ejemplo, auth, rate limiting, logging, etc...
- Hay muchas librerías que te permiten suscribirte a bases de datos como MongoDB, Redis, PostgreSQL, etc...
Después de ver las principales funcionalidades, o por lo menos las que más me llaman a mi la atención, vamos a ver algo de código para entender mejor como funciona.
| const io = new Server(server, {}); | |
| io.on("connection", (socket) => { | |
| console.log(`connected: ${socket.id}`); | |
| socket.on("disconnect", () => { | |
| console.log(`disconnect: ${socket.id}`); | |
| }); | |
| }); |
Esto es todo desde la perspectiva del servidor. Simple verdad ? 😀
Vamos a ver como quedaría el cliente:
| const socket = io("http://localhost:8080"); | |
| socket.on("connect", () => { | |
| console.log("connected"); | |
| presence.innerText = "🟢"; | |
| }); | |
| socket.on("disconnect", () => { | |
| presence.innerText = "🔴"; | |
| }); |
La librería de io la cargamos directamente del CDN. Normalmente utilizarías el paquete npm y lo importarías, pero en este caso podemos leerlo directamente desde el CDN.
Este código no se diferencia mucho del que teníamos antes pero hay que destacar dos puntos:
- Si tu navegador no soporta websocket, automáticamente hace long-polling.
- La librería tiene automaticamente implementada la lógica de retry si tu conexión se cierra de forma inesperada.
Y bueno con esto estaría todo. Puedes revisar el código y entrar en más detalle en cada uno de los ejemplo en el respositorio de Git hub
Conclusiones
Pues hasta aquí las diferentes técnicas que podemos utilizar para conseguir funcionamiento en nuestras aplicaciones en tiempo real.
WS
ws es otra de las implementaciones lideres de WebSockets en Node.js. En algunas ocasiones es una buena elección ya que no necesitas toda la funcionalidad extra que te proporciona Socket.IO.
HTTP2 Push
Hay que pensar en esta implementación como un one way socket. Tu cliente se puede conectar con el servidor y el servidor puede mandar mediante push tantos mensajes como considere al cliente. La principal diferencia aquí es que la información no fluye en el otro sentido. El cliente no puede usar la misma conexión para mandar un mensaje.
WebRTC
WebRTC es una idea similar a HTTP2 push pero en este caso es punto a punto ( peer-to-peer ) enlugar de client-server.
SignalR
Hay que resaltar una tecnología de Microsoft que también está en el mercado aunque actualmente no se usa con Node.js SignalR es una tecnología real-time construida por .NET, está construida sobre WebSockets para establecer la comunicación en tiempo real. Si lo queremos pensar así, podriamos considerarlo un competidor de Socket.IO