Conexión de clientes por medio de un servidor haciendo uso de Sockets en NestJS

12 de sep. de 2024

El presente blog expone una forma de permitir la comunicación en tiempo real de dos clientes haciendo uso de sockets y un servidor como intermediario. La intención de este material, es brindar un esquema escalable para la realización de aplicaciones que requieran la interacción en tiempo real de clientes y además la administración de usuarios, trabajo colaborativo y demás por medio de la expansión de la complejidad del nodo servidor.

Para realizar la explicación, se implementará una aplicación que permitirá a dos jugadores enviarse lanzamientos con una intensidad arbitraria entre sí.

Demo

Componentes del sistema

El sistema está compuesto por los siguientes componentes:

  • Servidor
  • Cliente

Tecnologías empleadas

WebSocketGateway

Se utilizó esta tecnología para establecer un socket en el servidor a la espera de las conexiones de los clientes.

Instalación

npm i --save @nestjs/websockets @nestjs/platform-socket.io

Este comando instalará las siguientes dependencias de producción:

"dependencies": {
    "@nestjs/platform-socket.io": "^10.3.9",
    "@nestjs/websockets": "^10.3.9",
  }

Socket.io

Se utilizó esta tecnología para permitir a los clientes conectarse con el servidor.

Instalación

npm add socket.io-client

Este comando instalará la siguiente dependencia de producción:

"dependencies": {
    "socket.io-client": "^4.7.5"
  }

Servidor

El servidor cumplirá las siguientes funciones básicas:

  • Permitir la conexión de clientes
  • Permitir la interacción de clientes
  • Logs de actividad de los usuarios

Para poder implementar las funcionalidades mencionadas anteriormente, se requiere definir un WebSocketGateway y un Servicio.

GameGateway

GameGateway es el WebSocketGateway empleado para permitir a los clientes conectarse con el servidor.

//./src/game/game.gateway.ts
import {
  WebSocketGateway,
  OnGatewayInit,
  OnGatewayConnection,
  OnGatewayDisconnect,
  SubscribeMessage,
  WebSocketServer,
  MessageBody,
  ConnectedSocket,
} from '@nestjs/websockets';

import { Logger } from '@nestjs/common';
import { Server, Socket } from 'socket.io';
import { GameService } from './game.service';
import { ShotDTO } from './dtos/shot.dto';

@WebSocketGateway()
export class GameGateway
  implements OnGatewayInit, OnGatewayConnection, OnGatewayDisconnect
{
  @WebSocketServer() server: Server;
  private logger: Logger = new Logger(
    '@WebSocketServer() server: Server;GameGateway',
  );

  constructor(private readonly gameService: GameService){}

  afterInit(server: Server) {
      this.logger.log('Gateway Initialized');
  }

  handleConnection(client: Socket, ...args: any[]) {
      this.logger.log(`Client connected: ${client.id}`);
      this.gameService.addPlayer(client);
  }

  handleDisconnect(client:Socket) {
      this.logger.log(`Client disconnected: ${client.id}`);
      this.gameService.removePlayer(client);
  }

  @SubscribeMessage('shot')
  handleShot(@MessageBody() data: ShotDTO, @ConnectedSocket() client: Socket):void{
    const { intensity } = data;
    this.logger.log(`Shot by ${client.id} with shot intensity: ${intensity}`);
    this.gameService.shot(client, intensity);
  }

}

Puntos importantes:

  • Se hace uso de un Logger para realizar registros de las conexiones de los clientes y también de los lanzamientos.
  • Los sockets cliente son almacenados haciendo uso del método addPlayer(socket:Socket)del servicio.

GameService

GameService es el servicio que se encarga de toda la lógica referente al juego como ser: registro de jugadores y lanzamientos.

//./src/game/game.service.ts
import { Injectable, Logger } from '@nestjs/common';
import { Scope } from 'eslint';
import { Socket } from 'socket.io';

export class GameService {
  private players: Map<string, Socket> = new Map();
  private logger: Logger = new Logger('GameService');

  addPlayer(client: Socket): void {
    this.players.set(client.id, client);
    this.logger.log(`Player added: ${client.id}`);
  }

  removePlayer(client: Socket): void {
    this.players.delete(client.id);
    this.logger.log(`Player removed: ${client.id}`);
  }

  shot(client: Socket, shotIntensity: number): void {
    this.getShot(client, shotIntensity);
  }

  getShot(client: Socket, shotIntensity: number): void{
    const opponent:Socket = this.getOpponent(client.id);
    if(opponent){
      this.logger.log('The one getting the shot is:', opponent.id);
      opponent.emit('get_shot', { intensity: shotIntensity });
    }else{
      client.emit('error', 'No opponent connected to get shot');
    }
  }

  getOpponent(clientId: string): Socket | undefined {
    for (const [id, player] of this.players.entries()) {
      if (id != clientId) {
        return player;
      }
    }

    return undefined;
  }
}

Puntos importantes

  • Se hace uso de un mapa de string a Socket para poder encontrar a un cliente dado su socket.id
  • Siempre que se desea encontrar el oponente de un socket dado, se realiza la busqueda en el mapa
  • Se realizan registros de los lanzamientos realizados por los jugadores

Cliente

El cliente consiste en una API REST sencilla que permite generar conexiones al servidor haciendo uso de endpoints POST

El cliente podrá realizar las siguientes tareas:

  • Conectarse con el servidor
  • Enviar lanzamientos a su contrincante por medio del servidor

Para poder llevar a cabo las tareas mencionadas anteriormente, se requiere de un controlador y un servicio que haga uso de sockets para interactuar con el servidor.

PlayerController

//./src/player/player.controller.ts
import { Controller, Post, Body } from '@nestjs/common';

import { PlayerService } from './player.service';
import { HostAddrDTO } from './dto/player_addr.dto';
import { ShotDTO } from './dto/shot.dto';

@Controller('player')
export class PlayerController {

    constructor(private readonly playerService:PlayerService){}
    
    @Post('connection')
    playerConnection(@Body() hostAdd:HostAddrDTO){
        const { hostAddr } = hostAdd;
        //console.log("HostAddrDTO recibido: ", hostAdd);
        this.playerService.connectToServer(hostAddr);
    }

    @Post('shot')
    playerShot(@Body() shot:ShotDTO){
        const { intensity } = shot;
        this.playerService.sendShot(intensity);
    }
}

Puntos importantes

  • Se hace uso de un endpoint POST que requiere de la dirección del servidor para conectarse con él.
  • Se hace uso de un endpoint POST que requiere de la información del lanzamiento para realizar un envío al contrincante.

PlayerService

//./src/player/player.service.ts
import { Injectable, OnModuleInit } from '@nestjs/common';
import { Socket, io } from 'socket.io-client';

@Injectable()
export class PlayerService {
  private socket: Socket;

  connectToServer(server: string) {

    if(this.isSocketConnected()){
      console.log('The socket is already connected');
      console.log(this.socket);
    }

    console.log(`Trying to connect to: ${server}`);
    this.socket = io(server);

    this.socket.on('connect', () => {
      console.log('Connected to server');
    });

    this.socket.on('disconnect', () => {
      console.log('Disconnected from server');
    });

    this.socket.on('connect_error', (error: any) => {
      console.log('Connection error:', error.message);
    });

    this.socket.on('error', (error:any) =>{
      console.log('Socket error:', error.message);
    });

    this.socket.on('get_shot', (data: any) => {
      console.log(`Received shot with intensity: ${data.intensity}`);
    })
  }

  isSocketConnected():boolean{
    return this.socket && this.socket.connected;
  }

  sendShot(intensity:number) {
    this.socket.emit('shot', { "intensity": intensity });
  }

}

Puntos importantes

  • Se implementa la lógica de conexión y envío de lanzamientos.

Parametrización del puerto del cliente

A fin de dinamizar la inicialización del cliente, se implementó la parametrización del puerto en el cual escucha el cliente para poder adaptar el servicio en contextos en los que los puertos por defecto se encuentren en uso.

La parametrización se llevó a cabo por medio de variables de entorno cuyo valor se recupera al momento de iniciar el cliente como se muestra a continuación:

//./src/main.ts
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import { ConfigService } from '@nestjs/config';

async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  const configService = app.get(ConfigService);
  const port = configService.get<number>('PORT') || 3000;
  await app.listen(port);
  console.log(`Application is running on: ${await app.getUrl()}`);
}
bootstrap();

Conclusión

De esta manera, finaliza la presentación del esquema base para la interacción en tiempo real de clientes a través de un servidor utilizando Sockets en NestJS. Cabe destacar que el proyecto desarrollado es altamente escalable, lo que permite aumentar la complejidad del nodo intermedio y crear aplicaciones colaborativas como chats de texto y audio, videojuegos, y otras soluciones innovadoras con un alto potencial de impacto en el mercado actual.

Agradezco su atención y espero que este material les sea de gran utilidad.

Repositorios

A continuación se adjuntan los repositorios tanto del servidor como del cliente:

Repositorio Servidor

https://github.com/GyroElSurfista/TenisAppServer

Repositorio Cliente

https://github.com/GyroElSurfista/TenisAppClient

¿Te gustó el contenido o lo que hacemos? ¡Cualquier colaboración es agradecida para mantener los servidores o crear proyectos!

Comentarios:

¡Genial! Te has suscrito con éxito.
¡Genial! Ahora, completa el checkout para tener acceso completo.
¡Bienvenido de nuevo! Has iniciado sesión con éxito.
Éxito! Su cuenta está totalmente activada, ahora tienes acceso a todo el contenido.