Hace un tiempo atras empecé a interiorizarme en el manejo de Web Sockets, para lo cual realizé un chat de pruebas, para lo cual necesité configurar Nginx para que se ocupe de realizar la redirección de puertos y la utilización de certificados SSL.
Bueno, el primer paso será obtener los certificados SSL, para lo cual usé Let’s Encript, luego se necesita crear el archivo de configuración correspondiente en: /etc/nginx/sites-available.
En este caso el archivo que cree se denomina chat-ws.greenborn.com.ar, con el siguiente contenido:
server {
listen PUERTO_ESCUCHA;
ssl on;
server_name DOMINIO;
ssl_certificate PATH_CERTIFICADO/fullchain.pem; # managed by Certbot
ssl_certificate_key PATH_CERTIFICADO/privkey.pem; # managed by Certbot
include PATH_LETS_ENCRIPT/options-ssl-nginx.conf; # managed by Certbot
ssl_dhparam PATH_LETS_ENCRIPT/ssl-dhparams.pem; # managed by Certbot
location / {
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_pass "http://127.0.0.1:PUERTO_INTERNO";
}
}
Claramente en el mismo se debe reemplazar:
PUERTO_ESCUCHA: Especificando el puerto que se usará publicamente para la conexion con el Web Socket
DOMINIO: El dominio asignado a la APP
PATH_CERTIFICADO: Ubicación del certificado SSL
PATH_LETS_ENCRIPT: Ubicación de la instalación de Lets Encript
PUERTO_INTERNO: Es el puerto que la APP estará escuchando
Luego será necesario crear el enlace simbólico del archivo de configuración, por ej con:
Con la idea de interiorizarme y aprender sobre los Web Sockets (WS) me decidí a realizar un nuevo proyecto usando NodeJS con framework Express y VueJS para el front.
¿Por que se eligieron dichas tecnologías?
Se optó por usar NodeJS por que tiene un muy buen manejo de WebSockets, ya que con el paquete express-ws podremos realizarlo de forma sencilla, dicho paquete se apoya en el framework Express; el cual me parece atractivo al no obligar al programador a usar un patrón de diseño en específico y es muy liviano.
NodeJS también tiene la ventaja, por sobre PHP, de poder almacenar información en memoria lo que nos permite prescindir de base de datos, ya que PHP al no guardar estados no lo permite y deberíamos encontrar alguna forma de mantener la persistencia de los datos.
El front podría haberlo desarrollado en Angular, framework con el cual vengo trabajando desde ya hace un tiempo, pero en cambio esta vez opté por VueJS, por que el mismo es más liviano, consume menos recursos en proceso de desarrollo (RAM principalmente), las APPs generadas son más livianas.
Y VueJS en si es más sencillo de usar y da menos problemas con respecto al manejo de dependencias, es más debugeable que Angular (aunque Angular mejoró bastante en dicho aspecto en el último tiempo)
Realmente no encuentro motivo para volver a Angular, por lo que Vue ya es mi framework favorito.
Otro punto que para mi suma es el no tener que usar TypeScript lenguaje que no termino de tomar muy en serio por que luego se transpila a JavaScript y para mi el tipado artificial que aplica solo suma una capa más sin aportar mucho, se pueden evitar los problemas de los lenguajes no tipados prestando un poco de atención.
¿Qué módulos conformarán el proyecto?
Como en muchos sistemas webs, el proyecto se dividirá en un Backend, el cual se encargará de proporcionar un endpoint al cual los clientes se podrán conectar por medio de WebSockets, llevará el listado de clientes conectados y retransmitirá los mensajes a todos los clientes conectados.
Y un front que se encargará solo de establecer la conexión e intercambiar mensajes.
El Backend
El código del backend es muy sencillo y de hecho hasta lo tendríamos en un solo archivo server.js:
let express = require('express');
let app = express();
let expressWs = require('express-ws')(app);
let uuid = require("uuid")
require("dotenv").config()
let registro_clientes = []
let reporte_conectados = []
expressWs.getWss().on('connection', function(ws) {
ws['id_conexion'] = uuid.v4()
});
app.ws('/', function(ws, req) {
ws.on('message', function(msg) {
let msgJson = null
try {
msgJson = JSON.parse( msg )
console.log(req.id)
if (msgJson.hasOwnProperty('accion')){
switch(msgJson.accion){
case 'registro':
msgJson.nombre = msgJson.nombre.replace(/]+(>|$)/g, "")
if (msgJson.nombre.length < 4){
ws.send(JSON.stringify({
accion: 'alerta',
msg: 'El nombre de usuario debe tener al menos 4 caracteres'
}))
return;
}
let registro = {
id: uuid.v4(),
nombre: msgJson.nombre,
accion: 'registro',
id_conexion: this.id_conexion
}
//comprobamos que no haya alguien registrado con el mismo nombre
for(let c=0; c < registro_clientes.length; c++){
if (registro.nombre == registro_clientes[c].nombre){
ws.send(JSON.stringify({
accion: 'alerta',
msg: 'El usuario especificado ya existe'
}))
return;
}
}
registro_clientes.push( registro )
reporte_conectados.push( registro.nombre )
console.log('se registro nuevo usuario', registro)
ws.send(JSON.stringify(registro))
let clientes = expressWs.getWss().clients
clientes.forEach(cliente => {
cliente.send(JSON.stringify({
accion: 'mensaje_sys',
msg: registro.nombre + ' Se ha unido a la sala'
}))
})
break;
case 'mensaje':
//comprobamos que el mensaje provenga de un cliente registrado
let encontrado = false
for(let c=0; c < registro_clientes.length; c++){
if (msgJson.autor.id == registro_clientes[c].id && msgJson.autor.nombre == registro_clientes[c].nombre ){
encontrado = true;
break;
}
}
//si es asi lo reenviamos al resto de los clientes
if (encontrado === true){
//se hace sanitizacion
msgJson.texto = msgJson.texto.replace(/]+(>|$)/g, "")
//se hace validacion
if (msgJson.texto.length > 500){
break;
}
let clientes = expressWs.getWss().clients
clientes.forEach(cliente => {
cliente.send(JSON.stringify(msgJson))
})
}
break;
}
}
} catch( error ){
console.log('error', error)
}
});
ws.on('close', function(code) {
console.log('desconectado', this.id_conexion)
let nombre = ''
for(let c=0; c < registro_clientes.length; c++){
if (registro_clientes[c].id_conexion == this.id_conexion){
nombre = registro_clientes[c].nombre
registro_clientes.splice(c,1)
reporte_conectados.splice(c,1)
break;
}
}
if (nombre != ''){
let clientes = expressWs.getWss().clients
clientes.forEach(cliente => {
cliente.send(JSON.stringify({
accion: 'mensaje_sys',
msg: nombre+' ha abandonado la sala'
}))
})
}
})
});
setInterval(()=>{
let clientes = expressWs.getWss().clients
clientes.forEach(cliente => {
cliente.send(JSON.stringify({
accion: 'reporte_online',
reporte: reporte_conectados
}))
})
}, 500)
app.listen(process.env.PUERTO);
console.log('puerto', process.env.PUERTO)
Creo que algo interesante a resaltar es que el manejo de la comunicación se realiza por medio de eventos.
Por ej nos podremos subscribir al evento “connection” que se dispara por cada nueva conexión establecida por el cliente
Este evento lo aprovechamos para poder generar identificadores únicos para cada cliente conectado.
Luego usamos Express como intermediario para atender los WS
app.ws('/', function(ws, req) {
....
Luego podremos atender el evento “message” que se disparará por cada mensaje recibido de algún cliente en particular.
Dichos mensajes usarán el formato JSON, por que creo que es la forma más facil de manejar datos estructurados, que puedan ser codificados y decodificados facilmente.
Y es necesario definir una estructura básica a la información ya que existen varios tipos de mensajes a ser enviados y recibidos.
Tipos de mensajes
reporte_online: Este mensaje se envía de forma períodica a todos los clientes conectados, y se usa para informar sobre los usuarios que actualmente están online, un ej del mismo sería: { “accion”:”reporte_online”, “reporte”: [ “Flor”, “Pepe”, “María”, “Juan” ] }
mensaje_sys: Se trata de un mensaje del sistema, que será mostrado a todos los clientes, se usa para informar por ej cuando alguien ingresa o egresa de la sala, ej del mismo sería: { “accion”:”mensaje_sys”, “msg”: “Flor ha abandonado la sala” }
alerta: Se usa para mostrar alertas o mensajes de error, por ej: { “accion”:”alerta”, “msg”: “El usuario especificado ya existe” }
registro: Se usa para registrar un nuevo usuario, ya que ni bien se ingresa al chat se solicita un nombre de usuario con el cual interectuar, para lo cual en el submit del formulario de nombre de usuario, se envia un mensaje de tipo “registro”, por ej: { “accion”:”registro”, “nombre”: “Pepe” }
mensaje: Se trata de un mensaje en sis mismo enviado por un usuario registrado, un ej: { “accion”:”mensaje”, “autor”: { “id”: “dsafdsfawe5433245-43534w”, “nombre”: “Marta” }, “texto” :”Hola Pepe!” }.
Algunas comprobaciones básicas
Se procede a sanitizar la entrada de caracteres de las entradas tanto al intentar elegir un nombre de usuario como en los mensajes en si mismos.
Excluyendo los caracteres que pudieran corresponder a tags html, ya que la idea es que se utilice solo texto, y quitar algunos caracteres problemáticos.
Si bien luego VueJS en el front por si solo salinitiza los datos para que no se incruste por ej código JS en los mensajes / CSS / HTML, creo que está bueno hacerlo de todos modos.
Para eso basta solo con usar: replace(/]+(>|$)/g, “”)
En el nombre de usuario se establece una longitud mínima del mismo y en el texto de los mensajes una longitud máxima.
Broadcast
Para el envio masivo de mensajes, primero se debe obtener el listado de clientes conectados, el cual se realiza usando:
let clientes = expressWs.getWss().clients
Luego se pueden recorrer los clientes usando un for, por ej:
El frontend en si mismo no tiene mucho, toda la lógica la implementé en un nuevo componente llamado VentanaChat.
El cual se encarga de establecer la conexión e intercambiar mensajes.
Para realizar la vista usé Bootstrap ya que creo que el framework de estilos más facil de usar, además existe el packete Bootstrap-vue3 que lo implementa y provee los componentes bàsicos para ser facilmente utilizable en Vue.
El establecimiento de la conexción con el WebSocket, que se realiza con:
conexion.value = new WebSocket( process.env.VUE_APP_API_URL )
Muy facil, no?
El “process.env.VUE_APP_API_URL” hace referencia a la variable VUE_APP_API_URL definida en un .env del proyecto, que se usa para poder configurar facilmente la url del endpoint
Y luego se atienden los eventos con:
conexion.value.onmessage = function(event) {
El proyecto en ejecución
Una vez que esten levantados tanto el back como el front, en el navegador lo podremos ver de la siguiente forma:
Se presentaron algunos problemas al intentar montar la demo, ya que fue necesario configurar Nginx para redireccionar un puerto y poder usar certificados SSL con la comunicación con el WebSocket, algo que trataré en un post aparte.
Luego de meditarlo por un tiempo, decidí finalmente reemplazar Apache por Nginx.
Se podría decir que finalmente he sido convencido por su promesa de tener un mejor rendimiento que Apache.
Cuestión que parece ser verdad.
Aunque eso no significa que pueda volver a Apache si el mismo mejora en dicho apartado, es el servidor web con el cual empecé a trabajar y aún le tengo cierto cariño.
Además con la actualización a PHP 8 y su módulo PHP-FPM, la verdad que estoy muy satisfecho con su rendimiento.
El archivo de configuración
Aquí agrego una muestra de un archivo de configuración básico que uso para el sitio, claramente Ud. deberá modificar los parámetros para que se adecué a sus necesidades
server {
root /RUTA/DIRECTORIO/SITIO/WEB;
index index.php index.html;
server_name DOMINIO.DE.SU.SITIO.COM.AR;
location / {
try_files $uri $uri/ @rewrite_url;
}
location @rewrite_url {
rewrite ^/(.*)$ /index.php?viewpage=$1 last;
}
location ~ \.php$ {
include snippets/fastcgi-php.conf;
fastcgi_pass unix:/var/run/php/php8.0-fpm.sock;
}
location ~* .(woff|eot|ttf|svg|mp4|webm|jpg|jpeg|png|gif|ico|css|js)$
{
expires 365d;
}
listen 443 ssl http2; # managed by Certbot
ssl_certificate /RUTA/CERTI/fullchain.pem; # managed by Certbot
ssl_certificate_key /RUTA/CERTI/privkey.pem; # managed by Certbot
include /etc/letsencrypt/options-ssl-nginx.conf; # managed by Certbot
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; # managed by Certbot
}
server {
if ($host = DOMINIO.DE.SU.SITIO.COM.AR) {
return 301 https://$host$request_uri;
} # managed by Certbot
server_name DOMINIO.DE.SU.SITIO.COM.AR;
listen 80;
return 404; # managed by Certbot
}
DOMINIO.DE.SU.SITIO.COM.AR: Deberá ser reemplazado por el dominio del sitio (se puede especificar sub-dominio)
/RUTA/DIRECTORIO/SITIO/WEB: Deberá ser reemplazado por la ruta en la cual se halle su sitio web.
Este archivo de configuración está recomendado para cualquier sitio basado en PHP, como bien podría ser una web desarrollada con WordPress.
Precondiciones
En este caso se asume que:
Ud. tiene acceso SSH para poder configurar el servidor.
El sistema operativo es Debian o alguno de sus derivados.
Tiene correctamente configurado su firewall tieniendo abierto los puertos 80 y 443.
Comentarios sobre secciones del archivo
Para que se realice la reescritura de URL (que en Apache se realizaba habilitando el mod_rewrite y configurando la reescritura en el .conf del sitio o en el .htaccess) se usa la sección:
En el archivo de configuración indicado, todo el contenido grisado y en itálica (a partir de la linea listen 443 ssl http2; # managed by Certbot inclusive) debería no ser incluido en el archivo, ya que deberá ser generado por el certbot.
A esta altura, se asume que ya habilitó el sitio agregando el correspondiente enlace simbólico.
Y que agregó el correspondiente registro de tipo A en la configuración de registros DNS de su dominio.
Por lo que el siguiente paso será reiniciar Nginx para que se apliquen los cambios, con:
# service nginx restart
Si, todo va bien, no debería indicarse ningún mensaje en consola.
Y luego ejecutar el certbot con:
# certbot
Allí mismo se indicarán una o varias opciones de acuerdo a los dominios y subdominios de los sitios alojados en el servidor, dicha lista se arma de acuerdo a los archivos de configuración de Nginx.
Se elige la opción correspondiente al dominio o subdominio a configurar, y si todo va bien, el certbot genera el certificado SSL y actualiza el archivo de configuración del vhost.
Si no se quiere usar HTTP2 ya estaría todo casi listo, pero por defecto dicho protocolo no está habilitado, por lo que deberá volver a abrir el archivo de configuración, buscar la línea listen 443 ssl; # managed by Certbot y reemplazarla por: listen 443 ssl http2; # managed by Certbot
Ya para finalizar será necesario volver a reiniciar el servicio nginx.