Tiempo real con serverless usando Websocket en AWS

Índice

Hola a todos, Así que hoy vamos a discutir sobre la implementación de websocket con serverless mediante el uso de Node.js con Typescript para la comunicación en tiempo real.

Si intenta buscarlo en Internet, encontrará fragmentos de detalles de aplicación , pero no hay nada concreto. Así que, aquí estoy construyendo un ecosistema websocket completo con todo el código y detalles de configuración.

Para el desarrollo, necesita tener serverless, npm y/o yarn instalados globalmente. Vea cómo instalar YARN y NPM. También prefiero usar NVM para gestionar mis versiones de Node.

Desarrollo

Paso 1. Configurar AWS

Configurar AWS CLI si aún no lo ha hecho, hay un artículo muy bueno aquí en crear y configurar las credenciales de AWS. A continuación, tenemos que configurar nuestra aplicación.

Segundo paso. Configurar proyecto

Crear y configurar un proyecto serverless con typescript:

$ sls create --template aws-nodejs-typescript --path <PROJECT-NAME>

Donde <PROJECT-NAME> es el nombre de su proyecto.

Esto genera un serverless boilerplate para nuestra aplicación. A continuación, tenemos que navegar a nuestro nuevo proyecto cd <PROJECT-NAME>e instalar nuestras dependencias ejecutando yarn install.

Los archivos más importantes que actualizaremos a medida que desarrollemos nuestra aplicación son handler.ts y serverless.ts. El archivo handler.ts maneja nuestras funciones lambda o referencias a nuestras funciones lambda. Debería verse así:

import { APIGatewayProxyHandler } from 'aws-lambda';
import 'source-map-support/register';

export const hello: APIGatewayProxyHandler = async (event, _context) => {
  return {
    statusCode: 200,
    body: JSON.stringify({
      message: 'Go Serverless Webpack (Typescript) v1.0! Your function executed successfully!',
      input: event,
    }, null, 2),
  };
}

El archivo serverless.ts nos proporciona una plantilla para modelar y aprovisionar los recursos de nuestra aplicación para AWS CloudFormation tratando la infraestructura como código. Esto nos ayuda a crear, actualizar e incluso eliminar recursos fácilmente. Debería verse así:

import type { Serverless } from 'serverless/aws';

const serverlessConfiguration: Serverless = {
  service: {
    name: 'serverless-websocket-ts',
    // app and org for use with dashboard.serverless.com
    // app: your-app-name,
    // org: your-org-name,
  },
  frameworkVersion: '>=1.72.0',
  custom: {
    webpack: {
      webpackConfig: './webpack.config.js',
      includeModules: true
    }
  },
  // Add the serverless-webpack plugin
  plugins: ['serverless-webpack'],
  provider: {
    name: 'aws',
    runtime: 'nodejs12.x',
    apiGateway: {
      minimumCompressionSize: 1024,
    },
    environment: {
      AWS_NODEJS_CONNECTION_REUSE_ENABLED: '1',
    },
  },
  functions: {
    hello: {
      handler: 'handler.hello',
      events: [
        {
          http: {
            method: 'get',
            path: 'hello',
          }
        }
      ]
    }
  }
}

module.exports = serverlessConfiguration;

Además, debido a las depreciaciones, tendrá que establecer la propiedad de servicio directamente al nombre del servicio:

{ 
  service: 'serverless-websocket-ts',
  ...
}

Y actualice el objeto provider.apiGateway de la siguiente manera:

provider: {
...
apiGateway: {
shouldStartNameWithService: true,
...
},
...
},

Podemos ejecutar nuestra función en la raíz de nuestro proyecto $ serverless invoke local --function hello y deberías ver la respuesta:

{
    "statusCode": 200,
    "body": "{n  "message": "Go Serverless Webpack (Typescript) v1.0! Your function executed           successfully!",n  "input": "n}"
}

El framework sin servidor invoca localmente la función hello y ejecuta el método exportado hello en el archivo handler.ts. El comando serverless invoke local nos permite ejecutar nuestras funciones Lambda localmente antes de desplegarlas.

A continuación, tenemos que instalar algunas dependencias para nuestra aplicación:

$ yarn add -D serverless-offline serverless-dotenv-plugin serverless-bundle
$ yarn add aws-sdk uuid @types/uuid
  • aws-sdk – nos permite comunicarnos con los servicios de AWS
  • uuid – genera identificadores únicos para las entradas de nuestra base de datos
  • serverless-offline – este plugin nos permite ejecutar nuestra aplicación y funciones Lambda localmente
  • serverless-dotenv-plugin – para permitirnos cargar variables .env en nuestro entorno Lambda
  • serverless-bundle – este plugin empaqueta de forma óptima nuestras funciones Typescript y garantiza que no tengamos que preocuparnos de instalar Babel , Typescript , Webpack , ESLint y muchos otros paquetes. Esto significa que no necesitamos mantener nuestras propias configuraciones de webpack. Así que podemos seguir adelante y eliminar el archivo webpack.config.js y la referencia a ella en serverless.ts propiedad personalizada:
// FROM
custom: {
webpack: {
webpackConfig: './webpack.config.js',
includeModules: true
}
}// TO
custom: {

}

Paso 3. Recursos de configuración

Ahora necesitamos configurar las variables region, stage, table_throughputs, table_throughput y connections_table . También configura dynamodb y serverless-offline para el desarrollo local.

import type { Serverless } from 'serverless/aws';

const serverlessConfiguration: Serverless = {
  service: 'serverless-todo',
  frameworkVersion: '>=1.72.0',
  custom: {
    region: '${opt:region, self:provider.region}',
    stage: '${opt:stage, self:provider.stage}',
    connections_table: '${self:service}-connections-table-${opt:stage, self:provider.stage}',
    table_throughputs: {
      prod: 5,
      default: 1,
    },
    table_throughput: '${self:custom.TABLE_THROUGHPUTS.${self:custom.stage}, self:custom.table_throughputs.default}',
    dynamodb: {
      stages: ['dev'],
      start: {
        port: 8008,
        inMemory: true,
        heapInitial: '200m',
        heapMax: '1g',
        migrate: true,
        seed: true,
        convertEmptyValues: true,
        // Uncomment only if you already have a DynamoDB running locally
        // noStart: true
      }
    },
    ['serverless-offline']: {
      httpPort: 3000,
      babelOptions: {
        presets: ["env"]
      }
    }
  },
  plugins: [
      'serverless-bundle',
      'serverless-offline',
      'serverless-dotenv-plugin',
  ],
  package: {
    individually: true,
  },
  provider: {
    name: 'aws',
    runtime: 'nodejs12.x',
    stage: 'dev',
    region: 'eu-west-1',
    apiGateway: {
      shouldStartNameWithService: true,
      minimumCompressionSize: 1024,
    },
    environment: {
      AWS_NODEJS_CONNECTION_REUSE_ENABLED: '1',
    },
  },
  functions: {
    hello: {
      handler: 'handler.hello',
      events: [
        {
          http: {
            method: 'get',
            path: 'hello',
          }
        }
      ]
    }
  },
  
}

module.exports = serverlessConfiguration;

A continuación, tenemos que actualizar la propiedad provider.environment en el archivoserverless.ts de la siguiente manera:

// serverless.ts
provider: {
  ...
  environment: {
    ...
    REGION: '${self:custom.region}',
    STAGE: '${self:custom.stage}',
    CONNECTIONS_TABLE: '${self:custom.connections_table}',
  },
  ...
},

A continuación, tenemos que añadir resources que se añaden a su pila CloudFormation cuando se despliega nuestra aplicación. Necesitamos definir los recursos de AWS en una propiedad titulada resources.

Puede obtener más información sobre cómo trabajar con tablas y datos en DynamoDB.

Utilizando el poder de Javascript, podemos evitar grandes archivos de definición de servicios dividiéndolos en archivos y utilizando importaciones dinámicas. Esto es importante porque a medida que nuestra aplicación crece, los archivos de definición separados facilitan el mantenimiento.

Para este proyecto, organizaremos nuestros recursos por separado y los importaremos a serverless.ts. Para ello, primero debemos crear un directorio resources en nuestro directorio raíz y, a continuación, crear un archivo dynamodb-tables.ts para nuestras tablas de DynamoDB:

// At project root
$ touch resources/dynamodb-tables.ts 

A continuación, actualizamos el archivo dynamodb-tables.ts del siguiente modo:

export default {
    ConnectionsTable: {
        Type: 'AWS::DynamoDB::Table',
        Properties: {
            TableName: '${self:provider.environment.CONNECTIONS_TABLE}',
            AttributeDefinitions: [
                { AttributeName: 'connectionId', AttributeType: 'S' }
            ],
            KeySchema: [
                { AttributeName: 'connectionId', KeyType: 'HASH' }
            ],
            ProvisionedThroughput: {
                ReadCapacityUnits: '${self:custom.table_throughput}',
                WriteCapacityUnits: '${self:custom.table_throughput}'
            },
            SSESpecification: {
                SSEEnabled: true
            },
            TimeToLiveSpecification: {
                AttributeName: 'ttl',
                Enabled: true
            }
        }
    },
}

E importar en serverless.ts – y establecer en las propiedades de los recursos como se indica a continuación,

// DynamoDB
import dynamoDbTables from './resources/dynamodb-tables';

const serverlessConfiguration: Serverless = {
  service: 'serverless-todo',
  ...
  resources: {
    Resources: dynamoDbTables,
  }
  ...
}

module.exports = serverlessConfiguration;

Para ejecutar DynamoDB localmente, primero debemos instalar el complemento serverless-dynamodb-local:

$ yarn add -D serverless-dynamodb-local 

A continuación, debemos actualizar la matriz de plugins serverless.ts:

plugins: [
'serverless-bundle',
'serverless-dynamodb-local',
'serverless-offline',
'serverless-dotenv-plugin',
],

Para utilizar el complemento, debemos instalar DynamoDB Local ejecutando sls dynamodb install en la raíz del proyecto. Ejecutandosls dynamodb start se iniciará localmente:

Paso 4. Integración de Websocket

En este paso, vamos a crear un manejador de socket web, para conectar con los eventos de websocket.

Puedes consultar aquí en detalle los eventos websocket.

// create a websocket folder
mkdir websocket

cd websocket
touch handler.ts
touch index.ts
touch schemas.ts
touch broadcast.ts

handler.ts será como se indica a continuación:


// handler.ts

import type { ValidatedEventAPIGatewayProxyEvent } from '@libs/apiGateway';
import { formatJSONResponse } from '@libs/apiGateway';
import { middyfy } from '@libs/lambda';
import * as AWS from 'aws-sdk';
import { getAllConnections, sendMessage } from './broadcast';
import schema from './schema';

const config: any = { region: "us-east-1" };
if (process.env.STAGE === process.env.DYNAMODB_LOCAL_STAGE) {
  config.accessKeyId = process.env.DYNAMODB_LOCAL_ACCESS_KEY_ID;
  config.secretAccessKey = process.env.DYNAMODB_LOCAL_SECRET_ACCESS_KEY;
  config.endpoint = process.env.DYNAMODB_LOCAL_ENDPOINT;
}

AWS.config.update(config);

const dynamodb = new AWS.DynamoDB.DocumentClient();

const connectionTable = process.env.CONNECTIONS_TABLE;

const websocketHandler: ValidatedEventAPIGatewayProxyEvent<typeof schema> = async (event) => {
  console.log(event);
  const { body, requestContext: { connectionId, routeKey }} = event;
  console.log(body, routeKey, connectionId);
  
  switch (routeKey) {
    case '$connect':
      await dynamodb.put({
        TableName: connectionTable,
        Item: {
          connectionId,
          // Expire the connection an hour later. This is optional, but recommended.
          // You will have to decide how often to time out and/or refresh the ttl.
          ttl: parseInt((Date.now() / 1000).toString() + 3600)
        }
      }).promise();
      break;

    case '$disconnect':
      await dynamodb.delete({
        TableName: connectionTable,
        Key: { connectionId }
      }).promise();
      break;

    case '$default':
    default:
      const connections = await getAllConnections();
      await Promise.all(
        connections.map(connectionId => sendMessage(connectionId, body))
      );
      break;
  }

  return formatJSONResponse({ statusCode: 200 });
}

export const wsHandler = middyfy(websocketHandler);

La API-Gateway proporciona 4 tipos de rutas relacionadas con el ciclo de vida de un cliente ws:

EventoAcción
Conectarllamada a la conexión de un cliente ws
$desconectarse ejecuta al desconectar un cliente ws (puede que no se ejecute en algunas situaciones)
$por defectosi no hay ningún controlador para el evento
si se especifica el nombre de la ruta para un gestor

Basándonos en eso, hemos creado un switch case, donde cada caso hará la acción apropiada relacionada con ese evento.

EventoAcción
Conectarcreará una entrada en connections-table, con connectionId & ttl.
$desconectareliminará la conexión de la tabla de conexiones de dynamodb.
$por defectotransmitirá los datos a todas las conexiones.

schema. ts se encargará de validar el esquema.

// schema.ts

export default {
  type: "object",
  properties: {
    name: { type: 'string' }
  },
  required: ['name']
} as const;

index. ts mapeará los eventos websocket con handler:

// index.ts

import { handlerPath } from '@libs/handlerResolver';

export const wsHandler = {
  handler: `${handlerPath(__dirname)}/handler.wsHandler`,
  events: [
    {
      websocket: '$connect'
    },
    {
      websocket: '$disconnect',
    },
    {
      websocket: '$default'
    }
  ]
};

Necesitamos registrar wsHandler export en src/functions/index.ts

// src/functions/index.ts

export { default as hello } from './hello';
export { wsHandler } from './websocket';

broadcast.ts contiene 2 funciones importantes como se indica a continuación

import * as AWS from 'aws-sdk';

const config: any = { region: "us-east-1" };
if (process.env.STAGE === process.env.DYNAMODB_LOCAL_STAGE) {
    config.accessKeyId = process.env.DYNAMODB_LOCAL_ACCESS_KEY_ID;
    config.secretAccessKey = process.env.DYNAMODB_LOCAL_SECRET_ACCESS_KEY;
    config.endpoint = process.env.DYNAMODB_LOCAL_ENDPOINT;
}

AWS.config.update(config);

const dynamodb = new AWS.DynamoDB.DocumentClient();

const connectionTable = process.env.CONNECTIONS_TABLE;

export async function sendMessage(connectionId, body) {
    try {
        const endpoint = process.env.APIG_ENDPOINT;
        const apig = new AWS.ApiGatewayManagementApi({
            apiVersion: '2018-11-29',
            endpoint
        });
        await apig.postToConnection({
            ConnectionId: connectionId,
            Data: JSON.stringify(body)
        }).promise();
    } catch (err) {
        // Ignore if connection no longer exists
        if (err.statusCode !== 400 && err.statusCode !== 410) {
            throw err;
        }
    }
}

export async function getAllConnections() {
    const { Items, LastEvaluatedKey } = await dynamodb.scan({
        TableName: connectionTable,
        AttributesToGet: ['connectionId']
    }).promise();

    const connections = Items.map(({ connectionId }) => connectionId);
    if (LastEvaluatedKey) {
        connections.push(...await getAllConnections());
    }

    return connections;
}
FunciónExplicación
enviarMensajeacepta connectionId & cuerpo como parámetros. Lee APIG_ENDPOINT de la variable de entorno y crea una instancia de ApiGatewayManagementApi y luego envía el cuerpo al connectionId dado usando postToConnection.
getAllConnectionsdevuelve todas las conexiones

Paso 4. Ejecución local de Websocket

En este paso, ejecutaremos websocket localmente, usando serverless offline:

Antes de empezar, compare y confirme el archivo serverless.ts como se indica a continuación

// serverless.ts

import type { AWS } from '@serverless/typescript';

import hello from '@functions/hello';
import { wsHandler } from '@functions/websocket';
import dynamoDbTables from './dynamodb-tables';


const serverlessConfiguration: AWS = {
  service: 'serverless-websocket-ts',
  frameworkVersion: '2',
  custom: {
    region: '${opt:region, self:provider.region}',
    stage: '${opt:stage, self:provider.stage}',
    prefix: '${self:service}-${self:custom.stage}',
    connections_table: '${self:service}-connections-table-${opt:stage, self:provider.stage}',
    ['serverless-offline']: {
      httpPort: 3000,
    },
    ['bundle']: {
      linting: false
    },
    table_throughputs: {
      prod: 5,
      default: 1,
    },
    table_throughput: '${self:custom.TABLE_THROUGHPUTS.${self:custom.stage}, self:custom.table_throughputs.default}',
    dynamodb: {
      stages: ['dev'],
      start: {
        port: 8008,
        inMemory: true,
        heapInitial: '200m',
        heapMax: '1g',
        migrate: true,
        seed: true,
        convertEmptyValues: true,
        // Uncomment only if you already have a DynamoDB running locally
        // noStart: true
      }
    }
  },
  plugins: [
    'serverless-bundle',
    'serverless-dynamodb-local',
    'serverless-offline',
    'serverless-dotenv-plugin',
  ],
  package: {
    individually: true,
  },
  provider: {
    name: 'aws',
    runtime: 'nodejs14.x',
    apiGateway: {
      minimumCompressionSize: 1024,
      shouldStartNameWithService: true,
    },
    environment: {
      AWS_NODEJS_CONNECTION_REUSE_ENABLED: '1',
      REGION: '${self:custom.region}',
      STAGE: '${self:custom.stage}',
      APIG_ENDPOINT: 'http://localhost:3001',
      CONNECTIONS_TABLE: '${self:custom.connections_table}',
    },
    lambdaHashingVersion: '20201221',
  },
  // import the function via paths
  functions: { 
    hello,
    wsHandler
  },

  resources: {
    Resources: dynamoDbTables,
  }
};

module.exports = serverlessConfiguration;
$ serverless offline start

Serverless: Bundling with Webpack...
Serverless: Watching for changes...
Dynamodb Local Started, Visit: http://localhost:8008/shell
Issues checking in progress...
No issues found.
Serverless: DynamoDB - created table serverless-websocket-ts-connections-table-dev
offline: Starting Offline: dev/us-east-1.
offline: Offline [http for lambda] listening on http://localhost:3002
offline: Function names exposed for local invocation by aws-sdk:
           * hello: serverless-websocket-ts-dev-hello
           * wsHandler: serverless-websocket-ts-dev-wsHandler   
offline: route '$connect'
offline: route '$disconnect'
offline: route '$default'
offline: Offline [websocket] listening on ws://localhost:3001

   ┌─────────────────────────────────────────────────────────────────────────┐
   │                                                                         │
   │   POST | http://localhost:3000/dev/hello                                │
   │   POST | http://localhost:3000/2015-03-31/functions/hello/invocations   │
   │                                                                         │
   └─────────────────────────────────────────────────────────────────────────┘

offline: Offline [http for websocket] listening on http://localhost:3001
offline: [HTTP] server ready: http://localhost:3000  
offline:
offline: Enter "rp" to replay the last request

Paso 5: Demostración de Websocket

Espero que este post le resulte útil.

Unimedia Technology

En Unimedia Technology contamos con un equipo de Desarrolladores BackEnd que pueden ayudarle a desarrollar sus Aplicaciones más complejas

Recuerda que en Unimedia somos expertos en tecnologías emergentes, así que no dudes en ponerte en contacto con nosotros si necesitas asesoramiento o servicios. Estaremos encantados de ayudarte.

Unimedia Technology

Su socio de desarrollo de software

Somos una consultora tecnológica de vanguardia especializada en arquitectura y desarrollo de software a medida.

Nuestros servicios

Suscríbase a nuestras actualizaciones

Mantente al día, informado y ¡demos forma juntos al futuro de la tecnología!

Let’s make your vision a reality!

Simply fill out this form to begin your journey towards innovation and efficiency.

Hagamos realidad tu visión.

Sólo tienes que rellenar este formulario para iniciar tu viaje hacia la innovación y la eficiencia.