Conexión de clientes por medio de un servidor haciendo uso de Sockets en NestJS
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
aSocket
para poder encontrar a un cliente dado susocket.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
Comentarios: