Lo siento por el formato, el editor Markdown tiene sus fallos, cualquier duda que se les presente, la responderé.
- Comparto mi resolución de una prueba técnica, que requería implementar WebSockets Socket.IO.
- La idea es tomar el planteamiento, reducirlo en ideas más pequeñas y resolubles.
- Luego resolver una a una de sus partes más manejables.
- Finalmente implementar ciertas mejoras para el performance que se implementan mejor teniendo el proyecto realizado como un todo.
- Hare lo posible para no dar secciones enormes de código, sino manejar el flujo conceptual del desarrollo.
- Para ver el código completo y probarlo: https://github.com/carlosmperilla/challenge-legendaryum/
Contexto
Un videojuego multijugador 3D, en el que se deben recolectar monedas, en distintas salas.
Restricciones
- Las monedas no deben repetirse, es decir, al ser tomada una moneda, solo puede ser tomada por ese usuario y ninguno más.
- Las monedas viejas expiran tras una hora, y se deben generar nuevas monedas en el espacio 3D.
- Las salas son cuboides rectangulares.
- La configuración será en JSON, establecerá las salas, la cantidad de monedas por salas y los limites tridimensionales.
Feature Adicional: - API de consulta, para poder consultar monedas en todas las salas y por sala.
Tomate tu tiempo para leer con atención el problema, posiblemente ya se te ocurrieron múltiples formas de resolverlo, sin embargo te recomiendo encarecidamente que mires más allá de la solución y anotes las posibles problemáticas que se podrían presentar en la resolución.
Si ya lo hiciste: ¡Manos a la obra!
¿Qué es lo que sabemos?
- Sabemos que aunque la problemática es de un videojuego, estamos tratando con datos, por lo tanto la resolución esperada es Backend.
- La implementación debe ser Real-Time, y de baja latencia (con alta latencia la experiencia de cada usuario va a variar demasiado en el tiempo, sus monedas agarradas no van a ser validas y esto genera una pésima experiencia de usuario si es muy recurrente).
- Existe una configuración estructurada, previa y base para la generación de monedas.
- Se debe mantener una estructura en los datos consultados y estos deben ser útiles. Sin embargo este servicio (API-RESTful) no requiere ser Real-Time.
El Backend suele ser de más “libre” elección de lenguaje en comparación al Frontend. Además de JavaScript SocketIO posee implementaciones en:
- Java
- C++
- Swift
- Dart
- Python
- .NET
- Rust
Por lo tanto la siguiente implementación la podrías replicar en cualquiera de estos lenguajes. Sin embargo por practicidad y familiaridad usaremos Javascript con Node y Express para el presente tutorial.
Al ser Real-Time y de baja latencia, SocketIO es una excelente herramienta de uso, necesitamos mantener un canal abierto a eventos.
Como las monedas expiran y además requieren una consulta rápida y continua, nos conviene almacenar la información de las monedas en una BSD (Base de datos en memoria o Base de datos en cache). En este caso usaremos Redis, por ser bastante confiable y mantenible para este propósito y la magnitud del proyecto.
Comenzando con buenas practicas
- Iniciamos el nuevo proyecto con npm init.
- Instalamos las librerías necesarias para implementar Typescript.
- Estructuramos nuestro proyecto en carpetas.
¿Con cual estructura comenzar?
- Necesitamos una carpeta principal, donde guardar el archivo ejecutable para levantar el servidor. Sería lógico llamar a esta carpeta server. Aquí guardaremos los servicios principales socket, api y manejo del cache.
- Para mantener las buenas practicas, y dado que usaremos tipos. La carpeta que contendrá las interfaces y tipos más complejos se llamara types.
- Habrá código que reutilizaremos y/o que no corresponde a la lógica directa del comportamiento esperado de los servicios (socket, api y cache). Estas serán nuestras utilidades. Creamos una nueva carpeta llamada utils.
- Contamos con una configuración preestablecida, para llenar las salas de monedas. Esta configuración o configuraciones las guardaremos en una carpeta llamada config.
- Hasta el momento la estructura inicial sería algo como esto (generado con https://ascii-tree-generator.com/):
challenge/
├─ server/
│ ├─ socket/
│ │ ├─ index.ts
│ ├─ api/
│ │ ├─ index.ts
│ ├─ cache/
│ │ ├─ index.ts
│ ├─ index.ts
├─ types/
│ ├─ index.ts
├─ utils/
│ ├─ index.ts
├─ config/
│ ├─ basic.json
¿Qué contiene config/basic.json?
- Tal cual lo que nos piden, de lo general a lo particular: El nombre de la sala (room1), que contiene los limites, el limite entero de monedas maxCoins y el limite de dimension.
- Recordemos que deben ser un cuboides rectangulares (Para más info: https://es.wikipedia.org/wiki/Cuboide_(geometría)#Cuboide_rectangular).
- Por lo que estableciendo limites en las 3 variables independientes x, y, z; limitamos la dimensiones.
- También es valido usar
"x": {"min": 0, "max": 20}
, pero el Array te puede facilitar la iteración y es de fácil lectura para el caso actual.
"room1": {
"maxCoins": 1000,
"dimension": {
"x": [0, 20],
"y": [0, 20],
"z": [0, 20]
}
},
....
}```
Usaremos este archivo para generar las monedas y calcular sus posiciones.
Archivo **server/index.ts**
import express from “express”;
import { createServer } from ‘http’;
const PORT: number = 3000;
const app: Application = express();
const httpServer: Server = createServer(app);
httpServer.listen(PORT);```
Esto nos servirá para conectarnos por medio de WebSockets y cuando implementemos la API, consultarla.
Ya tenemos estructurado el proyecto y levantado ¿Por cuál servicio comenzar?
- Tenemos 3 servicios principales, api, socket y cache. ¿Por cual comenzar? Por el que resuelva las necesidades de la problemática.
- cache no puede ser, gestiona el almacenamiento, no debe interactuar directamente con los clientes.
-api es una feature adicional, de consulta y no es Real-Time por diseño.
-socket aquí debemos incluir la lógica de nuestro servicio principal.
<h1>¡Antes de seguir con los servicios!</h1>
- Para centrarnos en los servicios, no se explicaran las utilidades a detalle.
- Cada moneda como dato, posee su posición y su ID, el ID acelera radicalmente los cálculos, nos ahorra comprobaciones innecesarias, coordenada a coordenada.
Servicio Socket
Revisemos la lógica del código, si quieres ver a más detalle la implementación, el código fuente entero esta en la parte superior de este tutorial. Presta atención al código comentado, más adelante analizaremos el flujo de procesos.
import { Rooms } from "../../types/index.js";
import { Server } from 'http';
import { Server as SocketIOServer, Socket } from "socket.io";
import { getCurrentRooms, updateCurrentRooms } from "../../utils/index.js";
export default function socket(httpServer: Server): SocketIOServer {
const io: SocketIOServer = new SocketIOServer(httpServer);
io.on('connection', (socket: Socket) => {
socket.on('joinRoom', async (roomName: string) => {
const currentRooms: Rooms = await getCurrentRooms();
const roomNames: string[] = Object.keys(currentRooms);
if (roomNames.includes(roomName)) {
socket.rooms.forEach((room: string) => {
if (room !== socket.id) socket.leave(room);
});
socket.join(roomName);
socket.emit("coins", JSON.stringify(currentRooms[roomName].coins));
} else {
console.error("no es una room valida");
}
});
socket.on("coinPicked", async (idCoin: string) => {
const roomName: string | undefined = [...socket.rooms][1];
if (roomName !== undefined) {
await updateCurrentRooms(roomName, idCoin);
socket.to(roomName).emit("unAvailableCoin", idCoin);
}
});
});
return io;
}```
Como se ve, se escuchan dos eventos desde el servidor:
- **joinRoom** que avisa que el usuario se unió a la sala.
- **coinPicked** que avisa que el usuario agarró una moneda.
Y se escuchan dos eventos desde los clientes:
- **coins** que informa las monedas disponibles dela sala.
- **unAvailableCoin** que informa que una moneda ya no esta disponible.
No hablare a detalle del código de **getCurrentRooms** y **updateCurrentRooms**. Pero si desu comportamiento esperado:
### getCurrentRooms
- Obtiene la información del **cache**.
- Si la información es nula, "llena las salas":
- En base a la configuraciónse generan las monedas deforma estructurada.
- Se guarda en memoria.
- Si no es nula se guarda en memoria.
- Y se retorna esta informaciónen formato de JSON String.
### updateCurrentRooms
- Obtiene la información del **cache**.
- Filtra la moneda agarrada de las monedas por su ID.
- Almacena la información filtrada.
- Actualiza la informaciónen cache.
## Servicio Cache
- Similar al caso anterior, primero se importan los tipos e interfaces.
- Recomiendo descomentar la expiraciónde 30 segundos para testeo.
// Types
import { Rooms, CacheClients } from “…/…/types/index.js”;
import { RedisClientType } from ‘redis’;
import { Server as SocketIOServer } from ‘socket.io’;
import { createClient } from ‘redis’;
import { getCurrentRooms } from “…/…/utils/index.js”;
// const EXPIRATION_TIME = 30; // en segundos. 30 segundos para testing
const EXPIRATION_TIME = 60 * 60 // en segundos. 1 hora
const REDIS_URL:string = process.env.REDIS_URL || ‘redis://localhost:6379’;
/**
Inicializa el cache.
- Crea un cliente Publisher.
- Se establece un Hook para informar errores.
- Se configura el Publisher para notificar la expiración de datos.
- Crea un cliente Subscripber duplicando la configuración del Publisher.
@returns Promesa con los clientes, el Publisher y el Subscriber.
*/
const initCache = async (): Promise <cacheclients>=> {
// El cliente hay que adaptarlo y verlo en Docker
// const pub: RedisClientType = createClient({
// url: ‘redis://default:foobared@localhost:6379’
// });</cacheclients>
const pub: RedisClientType = createClient({
url: REDIS_URL
});
pub.on(‘error’, (err: Error) => console.log(‘Redis Client Error’, err));
await pub.connect();
pub.configSet(‘notify-keyspace-events’, ‘Ex’);
const sub: RedisClientType = pub.duplicate();
await sub.connect();
return {
pub,
sub
}
};
/**
- Persiste los datos en cache.
- Castea a String el objeto de habitaciones.
- Persiste los datos con una expiración constante.
- Regresa la información persistida casteada a string.
- @param pub - Publisher que persistira los datos.
- @param key - Llave del dato a persistir.
- @param value - Valor del dato a persistir.
- @returns Promesa con la información persistida casteada a string.
*/
const persistData = async (pub: RedisClientType, key: string, value: Rooms): Promise <string>=> {
const data: string = JSON.stringify(value);
await pub.set(key, data, { ‘EX’: EXPIRATION_TIME });
return data;
};</string>
/**
- Obtiene los datos en cache.
- Consulta los datos en cache.
- @param pub - Publisher que consultara los datos.
- @param key - Llave del dato persistido.
- @returns Promesa con la información persistida casteada a string. O null si la información no existe.
*/
const getData = async (pub: RedisClientType, key: string): Promise <string |="" null="">=> {
const data: string | null = await pub.get(key);
return data;
};</string>
/**
- Actualiza los datos en cache.
- Obtiene el TTL del dato a actualizar.
- Castea a string el dato actaulizado (newValue)
- Actualiza en cache el dato, estableciendo como expiración el TTL obtenido.
- @param pub - Publisher que consultara los datos.
- @param key - Llave del dato persistido.
- @param newValue - Valor que actualiza el dato persistido.
- @returns Promise <void>*/
const updateData = async (pub: RedisClientType, key: string, newValue: Rooms): Promise <void>=> {
// Actualiza la información, persistiéndola, y respetando el tiempo de vida (ttl).
const auxExpirationTime: number = await pub.ttl(key);
const data: string = JSON.stringify(newValue);
await pub.set(key, data, { ‘EX’: auxExpirationTime });
};</void></void>
/**
- Actualiza los datos en cache, cuando estos expiran.
- El Subscriber se suscribe al evento de expiración.
- Consulta y almacena los datos de las habitaciones.
- Al consultar se generan nuevos datos.
- Informa a cada habitación, emitiendo las nuevas posiciones de monedas.
- @param sub - Subscriber que capturara el evento de expiración.
- @param io - Instancia SocketIO para informar a los clientes conectados.
- @returns Promise <void>*/
const autoCoinUpdate = async (sub: RedisClientType, io: SocketIOServer): Promise <void>=> {
// Actualiza las monedas cuando expiran
sub.subscribe(‘keyevent@0:expired’, async (key: string) => {
const currentRooms: Rooms = await getCurrentRooms();
const roomNames: string[] = Object.keys(currentRooms);
for (const roomName of roomNames) {
io.in(roomName).emit(‘coins’, JSON.stringify(currentRooms[roomName].coins));
}
});
};</void></void>
const { pub, sub } = await initCache();
export {
pub,
sub,
persistData,
getData,
updateData,
autoCoinUpdate,
}```
- Como habras notado, es un CRUD (sin la D).
- persistData crea el cache con expiración.
- getData obtiene la información del cache.
- *updateData obtiene el tiempo de vida de la llave previa, para preservar el tiempo de expiración, y la actualiza con ese tiempo.
¿Qué funcionalidad aporta autoCoinUpdate?
- Actualiza las monedas automáticamente tras expirar.
- Y le notifica a los clientes de cada habitación sus nuevas monedas.
- Esto ultimo podría hacerte algo de ruido, ¿Este tipo de emisiones no debería ir en el Servicio Socket?
- Puede ser, podrías implementar un “EventEmitter” (Emisor de Eventos) personalizado.
- Sin embargo, dado que es una sola emisión y la arquitectura es pequeña, esta pequeña ruptura de responsabilidades fue tomada. Sin embargo en producción debe tenerse presente, si esta funcionalidad escala, requerirá una segregación de responsabilidades más marcada.
- Para el caso de uso actual solo haría el código más engorroso y de difícil lectura.
¿Por qué se exportan ‘pub’ y ‘sub’?
Para tener acceso al Publisher y Subscriber, sus estados de conexión y eventos podrían ser de utilidad para ciertas implementaciones que no requieran un manejo directo del cache, desde otros servicios.
Servicio API
- Cómo necesitamos los datos de las salas en lista, nuestro primer endpoint debe ser:
/rooms
- Podemos añadir un campo con el total de monedas por habitación, pero no es un requerimiento obligatorio.
- Cómo necesitamos los datos de las salas particulares, nuestro segundo endpoint debe ser en base al nombre de la habitación:
/rooms/:name
- Debemos regresar un error en caso de un acceso a un endpoint prohibido (no viene al caso en esta ocasión) o no solicitado.
La API no es central en esta aplicación, es solo un punto de consulta. Puedes usarlo para depurar rapidamente con el explorador o con el comando curl
. Así que puedes personalizarla a gusto, para practicar, con datos que se actualizan de forma recurrente.
Flujo final de la aplicación
Principal
- Se instancia el servidor.
- Se conecta el servicio de WebSockets al servidor.
- Se llenan/generan las salas con monedas.
- Se activa la actualización automática de monedas.
- Se conecta el servicio de API.
- Se levanta el servidor exponiendo el puerto 3000.
Socket
Comunicación a través de eventos. Emitidos y recibidos.
- Se canalizan todos los clientes que se conecten.
- A todo cliente que se una a una sala.
- Se le saca de su sala previa.
- Se le envían las monedas actuales.
- Para todo cliente que agarré una moneda.
- Se borra la moneda del cache.
- Se emite un evento a todos los demás clientes que esta moneda no esta disponible.
- Los clientes escuchan las monedas.
- Los clientes escuchan cuando una moneda no esta disponible.
API
Comunicación a través de peticiones y respuestas (Request and Response).
- Se realiza la petición a alguno de los endpoint valido.
- Regresa la información estruturada en formato JSON.
- Se realiza la petición a un endpoint invalido.
- Regresa un error 404, y un JSON descriptivo.
Cache
- Crea un cliente (Publisher) para la conexión Base de datos cache (Redis en este caso).
- Captura eventos de error, para depurar.
- Se conecta a la base de datos.
- Se configura para notificar eventos de expiración.
- Creamos un cliente (Subscriber) que nos servira para capturar este evento y hacer algo al respecto.
- Conectamos el Subscriber a la base de datos.