Componente servidor de servicios web basados en el protocolo WebSocket

Para organizar un componente de servidor común a todos los proyectos, crearemos una carpeta independiente Web dentro de MQL5/Experts/MQL5Book/p7/. Lo ideal sería colocar Web como subcarpeta dentro de Shared Projects. El hecho es que MQL5/Shared Projects está disponible en la distribución estándar de MetaTrader 5 y reservado para proyectos de almacenamiento en la nube. Por lo tanto, más adelante, utilizando la funcionalidad de proyectos compartidos, sería posible subir todos los archivos de nuestros proyectos al servidor (no sólo los archivos web, sino también los programas MQL).

Más adelante, cuando creemos un archivo mqproj con programas cliente MQL5, añadiremos todos los archivos de esta carpeta a la sección del proyecto Settings and Files, ya que todos estos archivos forman parte integral del proyecto: la parte del servidor.

Dado que se ha asignado un directorio independiente para el servidor del proyecto, es necesario garantizar la posibilidad de importar módulos de nodejs en este directorio. Por defecto, nodejs busca los módulos en la subcarpeta /node_modules del directorio actual, y ejecutaremos el servidor desde el proyecto. Por lo tanto, estando en la carpeta donde colocaremos los archivos web del proyecto, ejecute el comando:

mklink /j node_modules {drive:/path/to/folder/nodejs}/node_modules

Como resultado, aparecerá un enlace «simbólico» de directorio llamado node_modules, apuntando a la carpeta original del mismo nombre en el nodejs instalado.

La forma más sencilla de comprobar la funcionalidad de WebSockets es el servicio eco. Su modelo de funcionamiento consiste en devolver al remitente cualquier mensaje recibido. Veamos cómo sería posible organizar un servicio de este tipo con una configuración mínima. Se incluye un ejemplo en el archivo wsintro.js.

En primer lugar, conectamos el paquete (módulo) ws, que proporciona la funcionalidad WebSocket para nodejs y que instalamos junto con el servidor web.

// JavaScript
const WebSocket = require('ws');

La función require opera de forma similar a la directiva #include de MQL5, pero además devuelve un objeto módulo con la API de todos los archivos del paquete ws. Gracias a ello, podemos llamar a los métodos y propiedades del objeto WebSocket. En este caso, necesitamos crear un servidor WebSocket en el puerto 9000.

// JavaScript
const port = 9000;
const wss = new WebSocket.Server({ portport });

Aquí vemos la llamada al constructor habitual de MQL5 mediante el operador new, pero se pasa como parámetro un objeto sin nombre (estructura), en el que, como en un mapa, se puede almacenar un conjunto de propiedades con nombre y sus valores. En este caso, solo se utiliza una propiedad port, y su valor se establece igual a la variable (más exactamente, una constante) port descrita con anterioridad. Básicamente, podemos pasar el número de puerto (y otros ajustes) en la línea de comandos al ejecutar el script.

El objeto servidor se introduce en la variable wss. En caso de éxito, indicamos a la ventana de la línea de comandos que el servidor está en ejecución (esperando conexiones).

// JavaScript
console.log('listening on port: ' + port);

La llamada a console.log es similar a la habitual Print en MQL5. Tenga en cuenta también que en JavaScript las cadenas pueden encerrarse no sólo entre comillas dobles, sino también entre comillas simples, e incluso entre comillas simples inversas `this is a ${template}text`, lo que añade algunas características útiles.

A continuación, para el objeto wss, asignamos un manejador de eventos «connection», que hace referencia a la conexión de un nuevo cliente. Obviamente, la lista de eventos de objeto admitidos está definida por los desarrolladores del paquete, en este caso, el paquete ws que utilizamos. Todo esto se refleja en la documentación.

El manejador está vinculado por el método on, que especifica el nombre del evento y el manejador en sí.

// JavaScript
wss.on('connection', function(channel)
{
   ...
});

El manejador es una función sin nombre (anónima) definida directamente en el lugar donde se espera un parámetro de referencia para que el código de devolución de llamada se ejecute en una nueva conexión. La función se hace anónima porque sólo se utiliza aquí, y JavaScript permite este tipo de simplificaciones en la sintaxis. La función sólo tiene un parámetro que es el objeto de la nueva conexión. Podemos elegir libremente el nombre del parámetro, que en este caso es channel.

Dentro del manejador, se debe establecer otro manejador para el evento «mensaje» relacionado con la llegada de un nuevo mensaje en un canal específico.

// JavaScript
   channel.on('message', function(message)
   {
      console.log('message: ' + message);
      channel.send('echo: ' + message);
   });
   ...

También utiliza una función anónima con un único parámetro, el objeto mensaje recibido. Lo imprimimos en el registro de la consola para depuración. Pero lo más importante ocurre en la segunda línea: al llamar a channel.send, enviamos un mensaje de respuesta al cliente.

Para completar la imagen, vamos a añadir nuestro propio mensaje de bienvenida al manejador de «conexión». Cuando esté completo, tendrá este aspecto:

// JavaScript
wss.on('connection', function(channel)
{
   channel.on('message', function(message)
   {
      console.log('message: ' + message);
      channel.send('echo: ' + message);
   });
   console.log('new client connected!');
   channel.send('connected!');
});

Es importante entender que, mientras que vincular el manejador de «mensaje» está más arriba en el código que enviar el «hola», el manejador de mensaje será llamado más tarde, y sólo si el cliente envía un mensaje.

Hemos revisado un esquema de script para organizar un servicio de eco. Sin embargo, sería bueno probarlo. Esto puede hacerse de la forma más eficiente utilizando un navegador normal, pero para ello será necesario complicar ligeramente el script: convertirlo en el servidor web más pequeño posible que devuelva una página web con el cliente WebSocket más pequeño posible.

Servicio eco y página web de prueba

El script del servidor eco que vamos a ver ahora se encuentra en el archivo wsecho.js. Uno de los puntos principales es que conviene admitir no sólo protocolos abiertos en el servidor http/ws, sino también protocolos protegidos https/wss. Esta posibilidad se ofrecerá en todos nuestros ejemplos (incluidos los clientes basados en MQL5), pero para ello es necesario realizar algunas acciones en el servidor.

Debería empezar con un par de archivos que contengan claves de encriptación y certificados. Los archivos suelen obtenerse de fuentes autorizadas, es decir, centros certificadores, pero a efectos informativos, puede generarlos usted mismo. Por supuesto, no pueden utilizarse en servidores públicos, y las páginas con dichos certificados provocarán advertencias en cualquier navegador (el icono de la página a la izquierda de la barra de direcciones aparece resaltado en rojo).

La descripción del dispositivo de los certificados y del proceso para generarlos por sí mismos queda fuera del alcance del libro, pero en él se incluyen dos archivos ya preparados: MQL5Book.crt y MQL5Book.key (existen otras extensiones) con una duración limitada. Estos archivos deben pasarse al constructor del objeto servidor web para que el servidor funcione a través del protocolo HTTPS.

Pasaremos el nombre de los archivos de certificado en la línea de comandos de lanzamiento del script. Por ejemplo, así:

node wsecho.js MQL5Book

Si ejecuta el script sin un parámetro adicional, el servidor funcionará utilizando el protocolo HTTP.

node wsecho.js

Dentro del script, los argumentos de la línea de comandos están disponibles a través del objeto integrado process.argv, y los dos primeros argumentos siempre contienen, respectivamente, el nombre del servidor node.exe y el nombre del script que se va a ejecutar (en este caso, wsecho.js), por lo que los descartamos mediante el método splice.

// JavaScript
const args = process.argv.slice(2);
const secure = args.length > 0 ? 'https' : 'http';

Dependiendo de la presencia del nombre del certificado, la variable secure obtiene el nombre del paquete que debe cargarse a continuación para crear el servidor: https o http. En total, tenemos 3 dependencias en el código:

// JavaScript
const fs = require('fs');
const http1 = require(secure);
const WebSocket = require('ws');

Ya conocemos el paquete ws; los paquetes https y http proporcionan una implementación del servidor web, y el paquete fs integrado permite trabajar con el sistema de archivos.

La configuración del servidor web tiene el formato del objeto options. Aquí vemos cómo el nombre del certificado de la línea de comandos se sustituye en cadenas con comillas de barra oblicua utilizando la expresión ${args[0]}. A continuación, se lee el par de archivos correspondiente mediante el método fs.readFileSync.

// JavaScript
const options = args.length > 0 ?
{
   key : fs.readFileSync(`${args[0]}.key`),
   cert : fs.readFileSync(`${args[0]}.crt`)
} : null;

El servidor web se crea llamando al método createServer, al que pasamos el objeto de opciones y una función anónima: un manejador de solicitudes HTTP. El manejador tiene dos parámetros: el objeto req con una solicitud HTTP y el objeto res con el que debemos enviar la respuesta (encabezados HTTP y página web).

// JavaScript
http1.createServer(optionsfunction (reqres)
{
   console.log(req.methodreq.url);
   console.log(req.headers);
   
   if(req.url == '/') req.url = "index.htm";
   
   fs.readFile('./' + req.url, (errdata) =>
   {
      if(!err)
      {
         var dotoffset = req.url.lastIndexOf('.');
         var mimetype = dotoffset == -1 ? 'text/plain' :
         {
            '.htm' : 'text/html',
            '.html' : 'text/html',
            '.css' : 'text/css',
            '.js' : 'text/javascript'
         }[ req.url.substr(dotoffset) ];
         res.setHeader('Content-Type',
            mimetype == undefined ? 'text/plain' : mimetype);
         res.end(data);
      }
      else
      {
         console.log('File not fount: ' + req.url);
         res.writeHead(404"Not Found");
         res.end();
      }
  });
}).listen(secure == 'https' ? 443 : 80);

La página principal del índice (y la única) es index.htm (por escribir ahora). Además, el manejador puede enviar archivos js y css, lo que nos será útil en el futuro. Dependiendo de si el modo protegido está activado, el servidor se inicia llamando al método listen en los puertos estándar 443 u 80 (cámbielos por otros si ya están ocupados en su ordenador).

Para aceptar conexiones en el puerto 9000 para sockets web, necesitamos desplegar otra instancia de servidor web con las mismas opciones. Pero en este caso, el servidor está ahí con el único propósito de gestionar una solicitud HTTP para «actualizar» la conexión al protocolo Web Sockets.

// JavaScript
const server = new http1.createServer(options).listen(9000);
server.on('upgrade', function(reqsockethead)
{
   console.log(req.headers); // TODO: we can add authorization!
});

Aquí, en el manejador de eventos «upgrade», aceptamos cualquier conexión que ya haya pasado el «handshake» e imprimimos los encabezados en el registro, pero potencialmente podríamos solicitar autorización al usuario si estuviéramos haciendo un servicio cerrado (de pago).

Por último, creamos un objeto servidor WebSocket, como en el ejemplo introductorio anterior, con la única diferencia de que al constructor se le pasa un servidor web ya preparado. Se cuentan todos los clientes que se conectan y se les da la bienvenida por número de secuencia.

// JavaScript
var count = 0;
   
const wsServer = new WebSocket.Server({ server });
wsServer.on('connection', function onConnect(client)
{
   console.log('New user:', ++count);
   client.id = count
   client.send('server#Hellouser' + count);
   
   client.on('message', function(message)
   {
      console.log('%d : %s', client.idmessage);
      client.send('user' + client.id + '#' + message);
   });
   
   client.on('close', function()
   {
      console.log('User disconnected:', client.id);
   });
});

Para todos los eventos, incluyendo conexión, desconexión y mensaje, la información de depuración se muestra en la consola.

Bueno, el servidor web con compatibilidad de servidor web socket está listo. Ahora tenemos que crear una página web de cliente index.htm para ello.

<!DOCTYPE html>
<html>
  <head>
  <title>Test Server (HTTP[S]/WS[S])</title>
  </head>
  <body>
    <div>
      <h1>Test Server (HTTP[S]/WS[S])</h1>
      <p><label>
         Message: <input id="message" name="message" placeholder="Enter a text">
      </label></p>
      <p><button>Submit</button> <button>Close</button></p>
      <p><label>
         Echo: <input id="echo" name="echo" placeholder="Text from server">
      </label></p>
    </div>
  </body>
  <script src="wsecho_client.js"></script>
</html>

La página es un formulario con un único campo de entrada y un botón para enviar un mensaje.

Página web del servicio de eco en WebSocket

Página web del servicio de eco en WebSocket

La página utiliza el script wsecho_client.js, que proporciona la respuesta del cliente websocket. En los navegadores, los web sockets están integrados como objetos JavaScript «nativos», por lo que no es necesario conectar nada externo: basta con llamar al constructor web socket con el protocolo y el número de puerto deseados.

// JavaScript
const proto = window.location.protocol.startsWith('http') ?
              window.location.protocol.replace('http', 'ws') : 'ws:';
const ws = new WebSocket(proto + '//' + window.location.hostname + ':9000');

La URL se forma a partir de la dirección de la página web actual (window.location.hostname), por lo que la conexión del socket web se realiza al mismo servidor.

A continuación, el objeto ws permite reaccionar a eventos y enviar mensajes. En el navegador, el evento de conexión abierto se denomina «open»; se conecta a través de la propiedad onopen. La misma sintaxis, ligeramente diferente de la implementación del servidor, también se utiliza para el evento de llegada de un nuevo mensaje: el manejador para él se asigna a la propiedad onmessage.

// JavaScript
ws.onopen = function()
{
   console.log('Connected');
};
   
ws.onmessage = function(message)
{
   console.log('Message: %s', message.data);
   document.getElementById('echo').value = message.data
};

El texto del mensaje entrante se muestra en el elemento de formulario con el id «echo». Tenga en cuenta que el objeto del evento del mensaje (parámetro del manejador) no es el mensaje que está disponible en la propiedad data. Se trata de una función de implementación de JavaScript.

La reacción a los botones del formulario se asigna utilizando el método addEventListener para cada uno de los dos objetos de etiqueta button. Aquí vemos otra forma de describir una función anónima en JavaScript: paréntesis con una lista de argumentos que puede estar vacía, y el cuerpo de la función después de la flecha puede ser (arguments) => { ... }.

// JavaScript
const button = document.querySelectorAll('button'); // request all buttons
// button "Submit"  
button[0].addEventListener('click', (event) =>
{
   const x = document.getElementById('message').value;
   if(xws.send(x);
});
// button "close"
button[1].addEventListener('click', (event) =>
{
   ws.close();
   document.getElementById('echo').value = 'disconnected';
   Array.from(document.getElementsByTagName('button')).forEach((e) =>
   {
      e.disabled = true;
   });
});

Para enviar mensajes, llamamos al método ws.send, y para cerrar la conexión llamamos al método ws.close.

Esto completa el desarrollo del primer ejemplo de scripts cliente-servidor para demostrar el servicio de eco. Puede ejecutar wsecho.js utilizando uno de los comandos mostrados anteriormente y, a continuación, abrir en su navegador la página en http://localhost o https://localhost (dependiendo de la configuración del servidor). Después de que aparezca el formulario en la pantalla, intente chatear con el servidor y asegúrese de que el servicio está funcionando.

Complicando gradualmente este ejemplo, allanaremos el camino para el servicio web de copia de señales de trading. Pero el siguiente paso será un servicio de chat, cuyo principio es similar al del servicio de señales de trading: los mensajes de un usuario se transmiten a otros usuarios.

Servicio de chat y página web de prueba

El nuevo script del servidor se llama wschat.js, y repite mucho de wsecho.js. Enumeremos las principales diferencias. En el manejador de solicitudes HTTP del servidor web, cambie la página inicial de index.htm a wschat.htm.

// JavaScript
http1.createServer(optionsfunction (reqres)
{
   if(req.url == '/') req.url = "wschat.htm";
   ...
});

Para almacenar la información sobre los usuarios conectados al chat, describiremos el array de map clients. Map es un contenedor asociativo estándar de JavaScript, en el que se pueden escribir valores arbitrarios utilizando claves de un tipo arbitrario, incluyendo objetos.

// JavaScript
const clients = new Map();                    // added this line
var count = 0;

En el nuevo manejador de eventos de conexión de usuario, añadiremos el objeto client, recibido como parámetro de función, en el mapa bajo el número de secuencia del cliente actual.

// JavaScript
wsServer.on('connection', function onConnect(client)
{
   console.log('New user:', ++count);
   client.id = count
   client.send('server#Hellouser' + count);
   clients.set(countclient);                // added this line
   ...

Dentro de la función onConnect, establecemos un manejador para el evento sobre la llegada de un nuevo mensaje para un cliente específico, y es dentro del manejador anidado donde enviamos los mensajes. No obstante, esta vez recorremos todos los elementos del mapa (es decir, todos los clientes) y enviamos el texto a cada uno de ellos. El bucle se organiza con las llamadas al método forEach para un array del mapa, y la siguiente función anónima que se realizará para cada elemento (elem) se pasa al método en su lugar. El ejemplo de este bucle demuestra claramente el paradigma de programación functional-declarative que prevalece en JavaScript (en contraste con el enfoque imperative de MQL5).

// JavaScript
   client.on('message', function(message)
   {
      console.log('%d : %s', client.idmessage);
      Array.from(clients.values()).forEach(function(elem// added a loop
      {
         elem.send('user' + client.id + '#' + message);
      });
   });

Es importante señalar que enviamos una copia del mensaje a todos los clientes, incluido el autor original. Podría filtrarse, pero a efectos de depuración, es mejor tener confirmación de que el mensaje se ha enviado.

La última diferencia con el anterior servicio de eco es que cuando un cliente se desconecta, hay que eliminarlo del mapa.

// JavaScript
   client.on('close', function()
   {
      console.log('User disconnected:', client.id);
      clients.delete(client.id);                   // added this line
   });

En cuanto a la sustitución de la página index.htm por wschat.htm, aquí añadimos un «campo» para mostrar el autor del mensaje (origin) y conectamos un nuevo script de navegador wschat_client.js. Analiza los mensajes (utilizamos el símbolo «#» para separar al autor del texto) y rellena los campos del formulario con la información recibida. Dado que nada ha cambiado desde el punto de vista del protocolo WebSocket, no proporcionaremos el código fuente.

Página web del servicio de chat en WebSocket

Página web del servicio de chat en WebSocket

Puede iniciar nodejs con el servidor de chat wschat.js y conectarse a él desde varias pestañas del navegador. Cada conexión recibe un número único que aparece en el encabezado. El texto del campo Message se envía a todos los clientes al hacer clic en Submit. A continuación, los formularios de cliente muestran tanto el autor del mensaje (etiqueta de la parte inferior izquierda) como el propio texto (campo de la parte inferior central).

Por lo tanto, nos hemos asegurado de que el servidor web con soporte web socket está listo. Pasemos a escribir la parte cliente del protocolo en MQL5.