Noticias de Stack Builders

Ideas y notas de nuestro equipo


Luis Fernando Alvarez

Programación en tiempo real fuertemente tipada con TypeScript


Las aplicaciones en tiempo real han tenido un auge en los últimos años, y los conceptos subyacentes han sido utilizados para crear software colaborativo de una manera eficaz. Aplicaciones de chat, plataformas de juegos e incluso la suite de aplicaciones de Google usan comunicación en tiempo real para mejorar la experiencia y colaboración entre los usuarios. Hay mucha documentación en línea que trata de la implementación de este tipo de aplicaciones, desde la creación del servidor, distribución de mensajes, y suscripción a eventos utilizando JavaScript. Sin embargo, la mayoría de estos artículos olvidan que se puede enviar prácticamente cualquier objeto a través de un socket, y que el receptor podría usar ese mensaje de una manera incorrecta. En este artículo exploraremos cómo hacer más robusta y segura la comunicación por sockets. Pero primero revisemos algunos conceptos.

WebSockets: Una introducción de 2 minutos

Los WebSockets son parte del API nativo de JavaScript, y permiten comunicación de dos vías entre el navegador del usuario y un servidor. Sin embargo, la parte interesante radica en que con esta tecnología se pueden enviar o recibir mensajes sin sondear al servidor manualmente por una respuesta.

Comencemos con un ejemplo sencillo. Supongamos que tenemos una aplicación de chat donde implementaremos un evento chat_message, el cual se dispara cada vez que un usuario envía un mensaje. Construiremos este ejemplo con socket.io, pero puedes usar cualquier librería de sockets para construir tu propia implementación. Nuestro servidor en JavaScript se vería de la siguiente manera:

// server.js
const app = require('express')();
const http = require("http").createServer(app);
const io = require("socket.io")(http);

io.on("connection", (socket) => {
  socket.on("chat_message", (message) => {
    io.emit("chat_message", message);
  })
});

Como se puede apreciar, el servidor tiene solo una simple responsabilidad: Escuchar mensajes de chat de los clientes y reenviarlos a todos los clientes que escuchan ese evento. Esto significa que el servidor puede recibir datos de cualquier tipo, y los reenviará a todos los clientes suscritos a ese evento en particular. Miremos ahora una implementación de cliente simple:

// client.js
const io = require("socket.io-client")();

const messageList = document.querySelector("#chatbox");
const messageInput = document.querySelector("#my-message");
const sendButton = document.querySelector("#send");

// Escuchando un mensaje
io.on("chat_message", (message) => {
  messageList.appendChild(`<p>${message}</p>`);
});

// Enviando un mensaje al hacer click en el botón
sendButton.addEventListener("click", () => {
  io.emit("chat_message", messageInput.value);
});

En general podemos decir que una aplicación en tiempo real está basada en emisores y suscriptores de eventos:

  • Emisor: Una función que distribuye un evento específico. En una aplicación de chat este emisor sería ejecutado cuando enviamos un nuevo mensaje al servidor. Este mensaje tendrá un identificador de evento y un contenido.
  • Suscriptor: Una función que se ejecuta luego de que un evento específico sucede. Usando nuestra aplicación de chat como ejemplo, un suscriptor se ejecutaría luego de obtener un nuevo mensaje de usuario. Este mensaje será procesado de acuerdo a las necesidades de la aplicación.

Se pueden tener otras variaciones de este patrón dependiendo de las necesidades de la aplicación. Por ejemplo, podríamos necesitar emitir mensajes solo a ciertos clientes, en cuyo caso tendríamos que llevar cuenta de los identificadores únicos de los clientes, y enviar mensajes de manera selectiva. Para el código de este artículo usaremos un ejemplo donde simplemente distribuiremos los mensajes a todos los clientes.

¿Y por qué necesitamos seguridad de tipos?

Este es un ejemplo típico de la interacción entre el cliente y el servidor en el contexto de WebSockets. En este caso no se podría enviar algo diferente a una cadena de texto (string) como mensaje de socket, ya que estamos enviando el contenido de messageInput. Sin embargo podemos ver dos problemas con esta implementación:

  1. El mantenimiento es complicado: Si necesitamos cambiar el nombre del evento chat_message por algo diferente, deberíamos cambiar todos los sitios en el código donde el evento sea llamado o suscrito. En nuestro ejemplo actual solo tenemos un archivo y solo algunas ocurrencias de este texto, pero con más archivos el mantenimiento puede salirse de control.

  2. No es seguro: Solo estamos enviando cadenas de texto por el socket en este ejemplo, sin embargo nada me impide hacer algo como esto:

io.emit("chat_message", {user: "Marty McFly", message: "Hola Doc!"})`

El problema es menos aparente en la implementación del servidor donde solo reenviamos el mensaje a los clientes, pero en realidad se vuelve un gran problema en los suscriptores. En este caso, si recibimos el mensaje y lo imprimimos en nuestro DOM lo que obtendríamos es una cadena como [object Object].

Entonces, en nuestro artículo exploraremos cómo hacer nuestros sockets más fáciles de mantener y seguros a nivel de tipos

¡TypeScript al rescate!

Desde su creación, TypeScript ha ayudado a que muchas aplicaciones sean más seguras en cuanto a tipos con tan solo algunos simples cambios. Si es que todavía no lo has probado, ¡inténtalo! Te evitará muchos dolores de cabeza.

Dicho esto, instalemos @types/socket.io y verifiquemos si los tipos de la librería son seguros. Los tipos para on y emit son:

on(event: string, listener: Function): Namespace;
emit(event: string, ...args: any[]): Namespace;

Con on podemos enviar cualquier cadena como evento, y cualquier función como suscriptor. Con emit el caso es similar, con un any explícito en la lista de argumentos. Esto significa que podemos enviar cualquier evento, y escucharlo de la manera que queramos. Esto tiene sentido desde el punto de vista de la librería, donde queremos dar flexibilidad a nuestros usuarios, pero como consumidor tenemos que tomar algunas precauciones. Ahora miremos los tipos para @types/socket.io-client:

on( event: string, fn: Function ):Emitter;
emit( event: string, ...args: any[] ):Emitter;

El caso es prácticamente el mismo. Esto significa que necesitaremos manejar la seguridad de tipos desde adentro de nuestra aplicación. La pregunta es: ¿Cómo podemos hacer esto de una manera concisa y fácil de mantener?

Encapsulando el servidor

Es una buena práctica encapsular librerías externas para mejorar el mantenimiento de una aplicación, ya que si decidimos cambiar a otra librería de sockets solo tendríamos que cambiar las operaciones de la misma en un solo lugar. Veamos nuestro código, ahora escrito en TypeScript y con algo de encapsulamiento:

import {Server} from "http";
import socketIO, {Socket} from "socket.io";

let io = null;

export function createSocketServer(server: Server) {
  io = socketIO(server);
  io.on("connection", (socket: Socket) => {
    // Crea los listeners aquí
  });
}

No hay mucho cambio, ¿cierto? Solo agregamos unos pocos tipos para mantener nuestro código seguro. La línea let io = null; no es ideal en una aplicación de gran escala, ya que deberíamos tratar el servidor de sockets como un singleton. Sin embargo para nuestro ejemplo es suficiente.

Ahora pensemos en esto por un momento. Necesitamos tener una ubicación central para todos nuestros sockets y una forma más segura de definirlos. Podríamos tener algo como esto:

type SocketMessage = "chat_message";

type SocketActionFn<T> = (message: T) => void;

interface WrappedServerSocket<T> {
  event: string;
  callback: SocketActionFn<T>;
}

function broadcast<T>(event: SocketMessage) {
  return (message: T) => io.emit(event, message);
}

function createSocket<T>(event: SocketMessage, action?: SocketActionFn<T>): WrappedServerSocket<T> {
  const callback = action || broadcast(event);
  return { event, callback };
}

Paremos un poco y analicemos qué está sucediendo aquí:

Implementamos una función createSocket, la cual envolverá nuestras operaciones. El primer argumento es event, de tipo SocketMessage. Por ahora solo puede tomar un valor de chat_message, pero como el grupo de eventos crecerá en el futuro esto nos ayudará a no usar valores incorrectos en la función.

El segundo argumento para la función es action, el cual es de tipo SocketActionFn. Esta será la acción de servidor que usaremos para procesar el mensaje. Esto será útil si no solo queremos distribuir el mensaje, sino realizar una acción adicional como persistir los datos, o revisar una condición adicional. Si no usamos una acción, la operación por defecto será broadcast.

Finalmente retornaremos un objeto con el evento y la función a ejecutarse. Usaremos esto para construir una lista de objetos WrappedServerSocket, solo para poder iterar sobre ella e inicializar todos los suscriptores de manera concisa.

Estamos usando genéricos de TypeScript en nuestro código, y es lo que mantiene a nuestro ejemplo seguro. Si usamos createSocket con un tipo string, esto significa que nuestra acción será de tipo SocketActionFn<string>.

Finalmente, nuestro servidor se verá de esta manera:

import {Server} from "http";
import socketIO, {Socket} from "socket.io";

let io = null;

export function createSocketServer(server: Server) {
  io = socketIO(server);
  io.on("connection", (socket: Socket) => {
    registeredEvents.forEach(({event, callback}) => {
      socket.on(event, callback);
    });
  });
}

const chatMessageEvent = createSocket<string>("chat_message");

const registeredEvents = [chatMessageEvent];

¡Listo! Nuestro servidor es seguro. Digamos que queremos registrar un nuevo evento llamado user_connected, el cual recibe un objeto de usuario con esta forma:

interface User {
  id: string;
  name: string;
}

Para permitir esto, solo añadiríamos user_connected a nuestro tipo SocketMessage y usaríamos createSocket para definir la función de retorno. Algo como esto:

type SocketMessage = "chat_message" | "user_connected";

// Para distribuir el mensaje omitimos el segundo argumento
const userConnectedEvent = createSocket<User>("user_connected");

// Si queremos realizar una acción distinta, la enviamos en el segundo argumento
const userConnectedLogEvent = createSocket<User>("user_connected", (user) => {
  // Compila correctamente, user se infiere correctamente
  console.log(user.id);
  // Error de tipos. El índice type no existe en el tipo 'User'
  console.log(user.type);
})

Pero este solo es un lado de la moneda. Ahora revisemos cómo hacerlo en el lado del cliente:

Encapsulando el cliente

La implementación del cliente es similar a la del servidor:

import { SocketMessage, User } from "../contracts/events";

import socketIOClient from "socket.io-client";

const socketClient = socketIOClient();

interface EmitterCallback<T> {
  (data: T): void;
}

interface WrappedClientSocket<T> {
  emit: (data: T) => SocketIOClient.Socket;
  on: (callback: EmitterCallback<T>) => SocketIOClient.Emitter;
  off: (callback: EmitterCallback<T>) => SocketIOClient.Emitter;
}

function createSocket<T>(event: SocketMessage): WrappedClientSocket<T> {
  return {
    emit: (data) => socketClient.emit(event, data),
    on: (callback) => socketClient.on(event, callback),
    off: (callback) => socketClient.off(event, callback),
  };
}

const chatMessageEvent: WrappedClientSocket<string> = createSocket("chat_message");
const userConnectedSocket: WrappedClientSocket<User> = createSocket("user_connected");

Similar a nuestro servidor, creamos un objeto contenedor de nuestras operaciones, de tipo WrappedClientSocket. La función createSocket retorna un objeto, donde las llaves son las operaciones y los valores funciones genéricas. Podemos hacer esto al mantener EmitterCallback genérico y pasando nuestro tipo T desde la función createSocket.

Usaríamos nuestros sockets de esta manera:

// Ninguna de estas líneas será analizada por tipos
// 'on' y 'off' no infieren `message` como una cadena
// Podemos pasar cualquier función al segundo argumento
socketClient.on("chat_message", (message) => console.log(message));
socketClient.off("chat_message", (message) => console.log(message));
socketClient.emit("chat_message", "Hey Doc!");

// En lugar, hagamos esto:
// 'on' y 'off' infieren correctamente 'message' como una cadena
// Solo podemos pasar `string` a 'emit'
chatMessageEvent.on((message) => console.log(message));
chatMessageEvent.off((message) => console.log(message));
chatMessageEvent.emit("Hey Doc!");

//Esto fallará con: Argument of type number is not assignable to parameter of type string.
chatMessageEvent.emit(1);

Como se puede apreciar, nuestra implementación de sockets es segura ahora. ¡No más errores como Uncaught TypeError: Cannot read property 'user' of undefined en tiempo de ejecución!

Conclusiones

En este artículo pudimos ver qué tan fácil es agregar tipos a operaciones inseguras. Adoptar TypeScript en una aplicación es un proceso sencillo que puede ser realizado de manera incremental sin muchos costos adicionales. Se puede iniciar con opciones del compilador sencillas y usando solo algunos tipos para las funciones más críticas.

Es recomendable también mantener los tipos consistentes entre el frontend y el backend mediante contratos. Esto significa que en nuestro caso el tipo SocketMessage y la interfaz User deberían ser compartidos entre backend y frontend mediante un módulo de TypeScript o una referencia de proyecto. Nuestra líder técnica Fernanda Andrade dio una charla en TSConf 2019 acerca de ello (Don’t break the contract: Keep consistency with Full Stack Type Safety - En Inglés), por lo que te invito a revisarla para más información.

Usar tipos para prevenir errores en tiempo de ejecución significa que escribiremos código limpio, menos defensivo, y, lo que es más importante, revisado en tiempo de compilación.

¡Feliz tipado!

¿Tienes lo necesario para ser un Stack Builder?