El primer objetivo es configurar una página web HTML sencilla que proporcione un formulario y una lista de mensajes. Vamos a utilizar el marco web Node.JS express con este fin. Asegurarse tener Node.JS esta instalado.
Primero creemos un package.json archivo de manifiesto que describe nuestro proyecto. Te recomiendo colocarlo en un directorio vacío dedicado (llamaré al mío) socket.io).
package.json
Express inicializa la app para que sea un controlador de funciones que puede proporcionar a un servidor HTTP (como se ve en la línea 5).
Definimos un controlador de ruta / al que se llama cuando llegamos a nuestro sitio web.
Hacemos que el servidor http escuche en el puerto 8080.
Si ejecuta node index.js, debería ver lo siguiente:
Obteniendo en el navegador...localhost:8080 y no el 3000
Sirviendo HTML
Hasta ahora en index.js llamamos a res.send y le pasamos una cadena de HTML. Nuestro código se vería muy confuso si simplemente colocáramos el HTML de toda nuestra aplicación allí, por lo que crearemos un fichero index.html y lo serviremos en su lugar.
Refactoricemos nuestro controlador de rutas para usar sendFile en su lugar.
Si se reinicia el proceso (al presionar Control+C y ejecutar node index.js nuevamente) y actualiza la página, debería verse así:
Integración de Socket.IO
Socket.IO se compone de dos partes:
-Un servidor que se integra (o se monta en) el servidor HTTP Node.JS (el paquete socket.io)
- Una biblioteca cliente que se carga en el lado del navegador (el paquete socket.io-client)
Durante el desarrollo, socket.io nos sirve el cliente automáticamente, como veremos, por lo que por ahora sólo tenemos que instalar un módulo:
npm install socket.io
Eso instalará el módulo y agregará la dependencia al paquete.json. Ahora editemos index.js para agregarlo:
Observe que inicializo una nueva instancia de socket.io pasando el objeto servidor (el servidor HTTP). Luego escucho el evento de conexión de los connection entrantes y lo registro en la consola.
Ahora en index.html agregue el siguiente fragmento antes de
(etiqueta final del cuerpo):
Eso es todo lo que se necesita para cargar el cliente socket.io, que expone un global io (y el punto final (endpoint) GET /socket.io/socket.io.js), y luego conectarse.
Si desea utilizar la versión local del archivo JS del lado del cliente, puede encontrarla en node_modules/socket.io/client-dist/socket.io.js.
Truco:
También puede utilizar un CDN en lugar de los archivos locales (e.g. ).
Note que no se esta especificando ninguna URL cuando se llamo a io(), ya que de forma predeterminada intenta conectarme al host que sirve a la página.
Si ahora reinicia el proceso (presionando Control+C y ejecutando node index.js nuevamente) y luego actualiza la página web, debería ver la consola imprimir “a user connected”.
Intenta abrir varias pestañas y verás varios mensajes.
Cada socket también activa un evento de desconexión especial:
Luego, si actualiza una pestaña varias veces, podrá verla en acción.
Emitir eventos
La idea principal detrás de Socket.IO es que puedes enviar y recibir cualquier evento que desees, con cualquier dato que desees. Cualquier objeto que pueda codificarse como lo hará JSON y también se admiten datos binarios.
Hagámoslo de modo que cuando el usuario escribe un mensaje, el servidor lo recibe como un evento de mensaje de chat. La sección de script en index.html ahora debería verse de la siguiente manera:
Y en index.js imprimimos el evento de mensaje de chat:
io.on('connection', (socket) => {
,socket.on('chat message', (msg) => {
enter code here
console.log('message: ' + msg); });
});
Broadcasting
El próximo objetivo es que emitamos el evento desde el servidor al resto de usuarios.
Para enviar un evento a todos, Socket.IO nos brinda el método io.emit().
// this will emit the event to all connected sockets
io.emit('hello', 'world');
Si desea enviar un mensaje a todos excepto a un determinado socket emisor, tenemos el indicador de transmisión para emitir desde ese socket:
Antes de continuar, hagamos un recorrido rápido por la API proporcionada por Socket.IO:
Common API
Los siguientes métodos están disponibles tanto para el cliente como para el servidor.
Basic emit
Como hemos visto en el paso 4, puedes enviar cualquier dato al otro lado con socket.emit():
Desde el Cliente al Servidor:
lado Cliente -> socket.emit('hello', 'world');
lado Servidor -> io.on('connection', (socket) => {
socket.on('hello', (arg) => {
console.log(arg); // 'world'
});
});
y a la inversa
Desde el Servidor al Cliente:
lado Servidor: io.on('connection', (socket) => {
socket.emit('hello', 'world');
});
lado Cliente: socket.on('hello', (arg) => {
console.log(arg); // 'world'
});
Puede enviar cualquier cantidad de argumentos y se admiten todas las estructuras de datos serializables, incluidos objetos binarios como ArrayBuffer, TypedArray o Buffer (solo Node.js):
Los eventos son geniales, pero en algunos casos es posible que desees una API de solicitud y respuesta más clásica. En Socket.IO, esta función se denomina "reconocimientos".
Viene en dos sabores:
Con función de devolución de llamada
Puede agregar una devolución de llamada como último argumento de la emisión (), y esta devolución de llamada se llamará una vez que la otra parte haya reconocido el evento:
From client to server
Client
socket.timeout(5000).emit('request', { foo: 'bar' }, 'baz', (err, response) => {
if (err) {
// the server did not acknowledge the event in the given delay
} else {
console.log(response.status); // 'ok'
}
});
Server:
io.on('connection', (socket) => {
socket.timeout(5000).emit('request', { foo: 'bar' }, 'baz', (err, response) => {
if (err) {
// the client did not acknowledge the event in the given delay
} else {
console.log(response.status); // 'ok'
}
});
});
El método emitWithAck() proporciona la misma funcionalidad, pero devuelve una promesa que se resolverá una vez que la otra parte reconozca el evento:
From client to server
Client:
try {
const response = await socket.timeout(5000).emitWithAck('request', { foo: 'bar' }, 'baz');
console.log(response.status); // 'ok'
} catch (e) {
// the server did not acknowledge the event in the given delay
}
Como hemos visto en el paso 5, puedes transmitir un evento a todos los clientes conectados con io.emit():
io.emit('hello', 'world');
Rooms
En la jerga de Socket.IO, una habitación (room) es un canal arbitrario al que los sockets pueden unirse y salir. Se puede utilizar para transmitir eventos a un subconjunto de clientes conectados:
¡Eso es básicamente todo! Para referencia futura, toda la API se puede encontrar aquí (servidor) y aquí (cliente).
Handling disconnections
Ahora, resaltemos dos propiedades realmente importantes de Socket.IO:
un cliente Socket.IO no siempre está conectado
un servidor Socket.IO no almacena ningún evento
Lo que significa que su aplicación debe poder sincronizar el estado local del cliente con el estado global del servidor después de una desconexión temporal.
Nota: Lo que significa que su aplicación debe poder sincronizar el estado local del cliente con el estado global del servidor después de una desconexión temporal.
En el contexto de nuestra aplicación de chat, esto implica que un cliente desconectado podría perderse algunos mensajes:
Veremos en los próximos pasos cómo podemos mejorar esto.
Recuperación del estado de conexión
Primero, manejemos las desconexiones fingiendo que no hubo desconexión: esta característica se llama "recuperación del estado de conexión" -> "Connection state recovery".
Esta función almacenará temporalmente todos los eventos que envía el servidor e intentará restaurar el estado de un cliente cuando se vuelva a conectar:
restaurar sus habitaciones
envía cualquier evento perdido
Debe estar habilitado en el lado del servidor:
Incorporar en el index.js:
const io = new Server(server, {
connectionStateRecovery: {}
});
Pero esta es una característica increíble, ¿por qué no está habilitada de forma predeterminada?
Hay varias razones para esto:
no siempre funciona; por ejemplo, si el servidor falla abruptamente o se reinicia, es posible que el estado del cliente no se guarde
no siempre es posible habilitar esta función al ampliarla
Truco:
Dicho esto, es realmente una gran característica ya que no es necesario sincronizar el estado del cliente después de una desconexión temporal (por ejemplo, cuando el usuario cambia de WiFi a 4G).
Exploraremos una solución más general en el siguiente paso.
Entrega del servidor
Hay dos formas comunes de sincronizar el estado del cliente tras la reconexión:
o el servidor envía el estado completo
o el cliente realiza un seguimiento del último evento que ha procesado y el servidor envía las piezas faltantes
Ambas son soluciones totalmente válidas y elegir una dependerá de su caso de uso. En este tutorial iremos con este último.
Primero, persistamos los mensajes de nuestra aplicación de chat. Hoy en día hay muchas opciones excelentes, usaremos SQLite aquí.
Instalemos los paquetes necesarios:
npm install sqlite sqlite3
Simplemente almacenaremos cada mensaje en una tabla SQL:
index.js:
const express = require('express');
const { createServer } = require('node:http');
const { join } = require('node:path');
const { Server } = require('socket.io');
const sqlite3 = require('sqlite3');
const { open } = require('sqlite');
async function main() {
// open the database file
const db = await open({
filename: 'chat.db',
driver: sqlite3.Database
});
// create our 'messages' table (you can ignore the 'client_offset' column for now)
await db.exec(`
CREATE TABLE IF NOT EXISTS messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
client_offset TEXT UNIQUE,
content TEXT
);
`);
const app = express();
const server = createServer(app);
const io = new Server(server, {
connectionStateRecovery: {}
});
app.get('/', (req, res) => {
res.sendFile(join(__dirname, 'index.html'));
});
io.on('connection', (socket) => {
socket.on('chat message', async (msg) => {
let result;
try {
// store the message in the database
result = await db.run('INSERT INTO messages (content) VALUES (?)', msg);
} catch (e) {
// TODO handle the failure
return;
}
// include the offset with the message
io.emit('chat message', msg, result.lastID);
});
});
server.listen(3000, () => {
console.log('server running at http://localhost:3000');
});
}
main();
Luego, el cliente realizará un seguimiento del desplazamiento:
Como puede ver en el video de arriba, funciona tanto después de una desconexión temporal como de una actualización de página completa.
Truco:
La diferencia con la función "Recuperación del estado de conexión" es que es posible que una recuperación exitosa no necesite llegar a su base de datos principal (puede recuperar los mensajes de una secuencia Redis, por ejemplo).
Bien, ahora hablemos de la entrega al cliente (client delivery).
Client delivery
Veamos cómo podemos asegurarnos de que el servidor siempre reciba los mensajes enviados por los clientes.
INFO:
De forma predeterminada, Socket.IO
proporciona una garantía de entrega "como máximo una vez" (también conocida como "disparar y olvidar"), lo que significa que no habrá reintento en caso de que el mensaje no llegue al servidor.
Buffered events
Cuando se desconecta un cliente, cualquier llamada a socket.emit() se almacena en búfer hasta la reconexión:
Buffered events
En el vídeo de arriba, el mensaje "en tiempo real" se almacena en el búfer hasta que se restablece la conexión.
Este comportamiento podría ser totalmente suficiente para su aplicación. Sin embargo, hay algunos casos en los que se podría perder un mensaje:
la conexión se corta mientras se envía el evento
el servidor falla o se reinicia mientras se procesa - el evento
la base de datos no está disponible temporalmente
At least once
Podemos implementar una garantía de "al menos una vez:
manualmente con acuse de recibo:
function emit(socket, event, arg) {
socket.timeout(5000).emit(event, arg, (err) => {
if (err) {
// no ack from the server, let's retry
emit(socket, event, arg);
}
});
}
En ambos casos, el cliente volverá a intentar enviar el mensaje hasta obtener un acuse de recibo del servidor:
io.on('connection', (socket) => {
socket.on('hello', (value, callback) => {
// once the event is successfully handled
callback();
});
})
Truco:
Con la opción de reintentos (retries) se garantiza el orden de los mensajes, ya que los mensajes se ponen en cola y se envían uno por uno. Este no es el caso de la primera opción.
Exactly once
El problema con los reintentos es que el servidor ahora podría recibir el mismo mensaje varias veces, por lo que necesita una forma de identificar de forma única cada mensaje y almacenarlo solo una vez en la base de datos.
Veamos cómo podemos implementar una garantía "exactamente una vez" en nuestra aplicación de chat.
Comenzaremos asignando un identificador único a cada mensaje del lado del cliente:
Nota:
El atributo socket.id es un identificador aleatorio de 20 caracteres que se asigna a cada conexión.
También podríamos haber usado getRandomValues() para generar un desplazamiento único.
Y luego almacenamos este desplazamiento junto con el mensaje en el lado del servidor:
index.js
// [...]
io.on('connection', async (socket) => {
socket.on('chat message', async (msg, clientOffset, callback) => {
let result;
try {
result = await db.run('INSERT INTO messages (content, client_offset) VALUES (?, ?)', msg, clientOffset);
} catch (e) {
if (e.errno === 19 /* SQLITE_CONSTRAINT */ ) {
// the message was already inserted, so we notify the client
callback();
} else {
// nothing to do, just let the client retry
}
return;
}
io.emit('chat message', msg, result.lastID);
// acknowledge the event
callback();
});
if (!socket.recovered) {
try {
await db.each('SELECT id, content FROM messages WHERE id > ?',
[socket.handshake.auth.serverOffset || 0],
(_err, row) => {
socket.emit('chat message', row.content, row.id);
}
)
} catch (e) {
// something went wrong
}
}
});
// [...]
De esta manera, la restricción UNIQUE en la columna cliente_offset evita la duplicación del mensaje.
Precaucion:
No olvides reconocer el evento, de lo contrario el cliente seguirá intentándolo (hasta tiempos de reintento).
Nuevamente, la garantía predeterminada ("como máximo una vez") -> ("at most once") puede ser suficiente para su aplicación, pero ahora sabe cómo hacerla más confiable.
Scaling horizontally (Escalado horizontalmente)
Ahora que nuestra aplicación es resistente a interrupciones temporales de la red, veamos cómo podemos escalarla horizontalmente para poder admitir miles de clientes simultáneos.
Nota:
- El escalado horizontal (también conocido como "escalado") significa agregar nuevos servidores a su infraestructura para hacer frente a nuevas demandas
- El escalado vertical (también conocido como "escalado") significa agregar más recursos (potencia de procesamiento, memoria, almacenamiento, ...) a su infraestructura existente
Primer paso: usemos todos los núcleos disponibles del host. De forma predeterminada, Node.js ejecuta su código Javascript en un solo subproceso, lo que significa que incluso con una CPU de 32 núcleos, solo se utilizará un núcleo. Afortunadamente, el módulo de clúster Node.js proporciona una manera conveniente de crear un subproceso de trabajador por núcleo.
También necesitaremos una forma de reenviar eventos entre los servidores Socket.IO. A este componente lo llamamos "Adaptador".
Entonces, instalemos el adaptador del clúster:
npm install @socket.io/cluster-adapter
Ahora lo conectamos:
index.js
const express = require('express');
const { createServer } = require('node:http');
const { join } = require('node:path');
const { Server } = require('socket.io');
const sqlite3 = require('sqlite3');
const { open } = require('sqlite');
const { availableParallelism } = require('node:os');
const cluster = require('node:cluster');
const { createAdapter, setupPrimary } = require('@socket.io/cluster-adapter');
if (cluster.isPrimary) {
const numCPUs = availableParallelism();
// create one worker per available core
for (let i = 0; i < numCPUs; i++) {
cluster.fork({
PORT: 3000 + i
});
}
// set up the adapter on the primary thread
return setupPrimary();
}
async function main() {
const app = express();
const server = createServer(app);
const io = new Server(server, {
connectionStateRecovery: {},
// set up the adapter on each worker thread
adapter: createAdapter()
});
// [...]
// each worker will listen on a distinct port
const port = process.env.PORT;
server.listen(port, () => {
console.log(`server running at http://localhost:${port}`);
});
}
main();
¡Eso es todo! Esto generará un subproceso de trabajador por CPU disponible en su máquina. Veámoslo en acción:
Como puede ver en la barra de direcciones, cada pestaña del navegador está conectada a un servidor Socket.IO diferente y el adaptador simplemente reenvía los eventos del mensaje de chat entre ellos.
Truco:
Actualmente existen 5 implementaciones oficiales de adaptadores:
el adaptador Redis
el adaptador Redis Streams
el adaptador MongoDB
el adaptador Postgres
el adaptador de clúster
Para que puedas elegir el que mejor se adapta a tus necesidades. Sin embargo, tenga en cuenta que algunas implementaciones no admiten la función de recuperación del estado de conexión; puede encontrar la matriz de compatibilidad aquí.
Nota:
en la mayoría de los casos, también deberá asegurarse de que todas las solicitudes HTTP de una sesión de Socket.IO lleguen al mismo servidor (también conocido como "sesión adhesiva"). Sin embargo, esto no es necesario aquí, ya que cada servidor Socket.IO tiene su propio puerto.
¡Y eso finalmente completa nuestra aplicación de chat! En este tutorial hemos visto cómo:
enviar un evento entre el cliente y el servidor
transmitir un evento a todos o a un subconjunto de clientes conectados
manejar desconexiones temporales
ampliar
Ahora debería tener una mejor descripción general de las funciones proporcionadas por Socket.IO. ¡Ahora es su momento de crear su propia aplicación en tiempo real!
Notas finales
Código final del servidor
index.js
const express = require('express');
const { createServer } = require('node:http');
const { join } = require('node:path');
const { Server } = require('socket.io');
const sqlite3 = require('sqlite3');
const { open } = require('sqlite');
const { availableParallelism } = require('node:os');
const cluster = require('node:cluster');
const { createAdapter, setupPrimary } = require('@socket.io/cluster-adapter');
if (cluster.isPrimary) {
const numCPUs = availableParallelism();
for (let i = 0; i < numCPUs; i++) {
cluster.fork({
PORT: 3000 + i
});
}
return setupPrimary();
}
async function main() {
const db = await open({
filename: 'chat.db',
driver: sqlite3.Database
});
await db.exec(`
CREATE TABLE IF NOT EXISTS messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
client_offset TEXT UNIQUE,
content TEXT
);
`);
const app = express();
const server = createServer(app);
const io = new Server(server, {
connectionStateRecovery: {},
adapter: createAdapter()
});
app.get('/', (req, res) => {
res.sendFile(join(__dirname, 'index.html'));
});
io.on('connection', async (socket) => {
socket.on('chat message', async (msg, clientOffset, callback) => {
let result;
try {
result = await db.run('INSERT INTO messages (content, client_offset) VALUES (?, ?)', msg, clientOffset);
} catch (e) {
if (e.errno === 19 /* SQLITE_CONSTRAINT */ ) {
callback();
} else {
// nothing to do, just let the client retry
}
return;
}
io.emit('chat message', msg, result.lastID);
callback();
});
if (!socket.recovered) {
try {
await db.each('SELECT id, content FROM messages WHERE id > ?',
[socket.handshake.auth.serverOffset || 0],
(_err, row) => {
socket.emit('chat message', row.content, row.id);
}
)
} catch (e) {
// something went wrong
}
}
});
const port = process.env.PORT;
server.listen(port, () => {
console.log(`server running at http://localhost:${port}`);
});
}
main();