Серверная часть веб-сервисов на базе WebSocket-протокола

Для организации общей серверной части всех проектов заведем отдельную папку Web внутри MQL5/Experts/MQL5Book/p7/. В идеале было бы удобно расположить Web как подпапку в Shared Projects. Дело в том, что MQL5/Shared Projects имеется в стандартной поставке MetaTrader 5 и зарезервирована для проектов облачного хранилища. Поэтому впоследствии, за счет использования функционала разделяемых проектов, можно было бы загрузить все файлы наших проектов на сервер (не только веб-файлы, но и MQL-программы).

Позднее, когда мы создадим mqproj-файл с клиентскими программами на MQL5, мы добавим все файлы в данной папке в раздел проекта Settings and files, так как все эти файлы составляют неотъемлемую часть проекта — серверную часть.

Поскольку под сервер проекта выделен отдельный каталог, нужно обеспечить в нем доступность "импорта" модулей из nodejs. По умолчанию, nodejs "ищет" модули в подпаке /node_modules текущего каталога, а запускать сервер мы будем из проекта. Поэтому, находясь в папке, где мы разместим веб-файлы проекта, выполните команду:

mklink /j node_modules {диск:/путь/к/папке/nodejs}/node_modules

В результате, внутри нашего проекта появится "символическая" ссылка-каталог под названием node_modules, указывающая на исходную одноименную папку в установленном nodejs.

Самым простым способом проверить работоспособность WebSocket-ов считается эхо-сервис. Принцип его действия: вернуть любое полученное сообщение обратно отправителю. Рассмотрим, как можно было бы организовать такой сервис в минимальной конфигурации. Пример прилагается в файле wsintro.js.

Первым делом подключаем пакет (модуль) ws, который предоставляет функционал WebSocket-ов для nodejs, и который мы установили вместе с веб-сервером.

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

Функция require работает аналогично директиве #include MQL5, но дополнительно возвращает объект модуля с программным интерфейсом всех файлов пакета ws. Благодаря этому мы можем вызывать методы и свойства объекта WebSocket. В данном случае нам требуется создать WebSocket-сервер на порту 9000.

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

Здесь мы видим привычный по MQL5 вызов конструктора оператором new, но в качестве параметра передается безымянный объект (структура), в котором как в карте может храниться набор поименованных свойств и их значений. В данном случае используется только одно свойство port, и его значение устанавливается равным переменной (точнее, константе) port, описанной выше. В принципе, мы можем передавать номер порта (и другие настройки) в командной строке при запуске скрипта.

Объект сервера попадает в переменную wss. При успешном выполнении мы сигнализируем в окно командной строки о том, что сервер работает (ожидает подключений).

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

Вызов console.log, как легко понять, аналогичен привычным Print-ам в MQL5. По ходу отметим, что строки в JavaScript можно заключать не только в двойные, но и в одинарные кавычки, и даже в "косые" кавычки `this is a ${template} text`, которые добавляют кое-какие полезные возможности.

Далее назначим для объекта wss обработчик события "connection", то есть о подключении нового клиента. Очевидно, что перечень поддерживаемых событий объекта определен разработчиками пакета, в данном случае, используемого нами пакета ws. Все это отражено в документации.

Привязка обработчика производится методом on, в котором указывается название события и сам обработчик.

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

Обработчик представляет собой безымянную (анонимную) функцию, определенную непосредственно в том месте, где ожидается параметр-ссылка для обратного вызова кода, который следует выполнить при новом соединении. Функция сделана анонимной, потому что используется только здесь, а JavaScript позволяет делать такие упрощения в синтаксисе. У функции — единственный параметр — объект нового соединения. Имя для параметра мы вольны выбирать сами, и в данном случае это — channel.

Внутри обработчика следует установить другой обработчик — на этот раз для события "message" о приходе нового сообщения в конкретном канале.

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

Здесь также используется анонимная функция с единственным параметром — объектом полученного сообщения. Мы также выводим его в журнал консоли для отладки. Но самое главное происходит во второй строке: вызовом channel.send мы отправляем ответное сообщение клиенту.

Для полноты картины добавим в обработчик "connection" отправку своего приветственного сообщения. Целиком это выглядит так:

// 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!');
});

Важно понимать, что, хотя привязка обработчика "message" сделана выше по коду, чем отправка "приветствия", обработчик сообщений будет вызван позже, и только при условии, что клиент пришлет сообщение.

Мы рассмотрели набросок скрипта для организации эхо-сервиса. Однако нам желательно его протестировать. Наиболее просто и быстро это можно сделать с помощью обычного браузера, но для этого потребуется слегка усложнить скрипт: превратить его в минимально-возможный веб-сервер, отдающий веб-страницу с минимально-возможным WebSocket-клиентом.

Эхо-сервис и тестовая веб-страница

Скрипт эхо-сервера, который мы сейчас рассмотрим, находится в файле wsecho.js. Одним из основных моментов является то, что на сервере желательно поддержать не только открытые протоколы http/ws, но и защищенные https/wss. Такая возможность будет обеспечена во всех наших примерах (включая и клиенты на MQL5), но на сервере для этого необходимо выполнить кое-какие действия.

Начать следует с пары файлов, содержащих ключи шифрования и сертификаты. Файлы обычно получают от авторизованных источников, удостоверяющих центров, но для ознакомительных целей можно сгенерировать файлы самостоятельно. Их, разумеется, нельзя использовать на публичный серверах, и в любом браузере страницы с подобными сертификатами будут вызывать предупреждения (значок страницы слева от адресной строки подсвечивается красным).

Описание устройства сертификатов и процесса их генерации собственными силами выходит за рамки книги, но с книгой поставляются два готовых файла MQL5Book.crt и MQL5Book.key (бывают и другие расширения) с ограниченным сроком действия. Эти файлы нужно передать в конструктор объекта веб-сервера, чтобы сервер заработал по протоколу HTTPS.

Имя файлов сертификата мы будем передавать в командной строке запуска скрипта. Например, так:

node wsecho.js MQL5Book

Если запустить скрипт без дополнительного параметра, сервер будет работать по протоколу HTTP.

node wsecho.js

Внутри скрипта аргументы командной строки доступны через встроенный объект process.argv, причем первые два аргумента всегда содержат, соответственно, имя самого сервера node.exe и имя запускаемого скрипта (в данном случае, wsecho.js), поэтому мы их отбрасываем методом splice.

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

В зависимости от наличия имени сертификата переменная secure получает название пакета, который следует далее загрузить для создания сервера: https или http. Всего у нас в коде 3 зависимости:

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

Про пакет ws мы уже все знаем, пакеты https или http предоставят реализацию веб-сервера, а встроенный пакет fs обеспечивает работу с файловой системой.

Настройки веб-сервера оформлены в виде объекта options. Здесь мы видим, как в строках с косыми кавычками с помощью выражения ${args[0]} подставляется имя сертификата из командной строки. Затем соответствующая пара файлов читается методом fs.readFileSync.

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

Веб-сервер создается вызовом метода createServer, в который передается объект опций, а также анонимная функция — обработчик HTTP-запросов. У обработчика имеется два параметра: объект req с HTTP-запросом, и объект res, с помощью которого мы должны послать ответ (HTTP-заголовки и веб-страницу).

// 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);

Главная индексная страница (и единственная) называется у нас, как принято, index.htm (её сейчас предстоит написать). Кроме неё обработчик "умеет" "отдавать" js-файлы и css-файлы, что пригодится нам в дальнейшем. В зависимости от включения защищенного режима, сервер запускается вызовом метода listen на стандартных портах 443 или 80 (измените на другие, если эти уже заняты на вашем компьютере).

Чтобы принимать соединения на порту 9000 для веб-сокетов нам требуется "поднять" еще один экземпляр веб-сервера с такими же опциям. Но в данном случае сервер предназначен для единственной цели — обработать HTTP-запрос об "апгрейде" соединения до протокола веб-сокетов.

// JavaScript
const server = new http1.createServer(options).listen(9000);
server.on('upgrade', function(reqsockethead)
{
   console.log(req.headers); // TODO: можем добавить авторизацию!
});

Здесь в обработчике события "upgrade" мы принимаем любые соединения, которые уже прошли "рукопожатие", и выводим заголовки в лог, но потенциально мы могли бы запросить авторизацию пользователя, если бы делали закрытый (платный) сервис.

Наконец, мы создаем объект WebSocket-сервера, как в предыдущем ознакомительном примере, с единственным отличием, что в конструктор передается уже готовый веб-сервер. Все подключающиеся клиенты подсчитываются и приветствуются по порядковому номеру.

// 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);
   });
});

Для всех событий — подключение, отключение и сообщение — в консоль выводится отладочная информация.

Что ж, веб-сервер с поддержкой веб-сокет-сервера готов. Осталось создать для него клиентскую веб-страницу index.htm.

<!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>

Как нетрудно понять, страница представляет собой форму с единственным полем ввода и кнопкой для отправки сообщения.

Веб-страница эхо-сервиса на WebSocket

Веб-страница эхо-сервиса на WebSocket

Страница использует скрипт wsecho_client.js, который обеспечивает клиентскую реакцию веб-сокетов. В браузерах веб-сокеты встроены как "родные" объекты JavaScript, поэтому ничего внешнего подключать не надо: достаточно вызвать конструктор WebSocket с нужным протоколом и номером порта.

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

URL формируется из адреса текущей веб-страницы (window.location.hostname), поэтому веб-сокет-соединение устанавливается с тем же сервером.

Далее объект ws позволяет реагировать на события и отправлять сообщения. В браузере событие открытия соединения называется "open" и подключается через свойство onopen. Этот же, слегка отличный от серверной реализации, синтаксис применяется и для события прихода нового сообщения — обработчик для него присваивается свойству onmessage.

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

Текст входящего сообщения отображается в элементе формы с идентификатором "echo". Обратите внимание, что объект события сообщения (параметр обработчика) не есть само сообщение, которое доступно в свойстве data. Это особенность реализации в JavaScript.

Реакция на кнопки формы назначается с помощью метода addEventListener для каждого из двух объектов-тегов button. Здесь мы видим еще один способ описания анонимной функции в JavaScript: круглые скобки со списком аргументов, который может быть пустым, и тело функции после стрелки — (arguments) => { ... }.

// JavaScript
const button = document.querySelectorAll('button'); // запрашиваем все кнопки
// кнопка "отправить"   
button[0].addEventListener('click', (event) =>
{
   const x = document.getElementById('message').value;
   if(xws.send(x);
});
// кнопка "закрыть"
button[1].addEventListener('click', (event) =>
{
   ws.close();
   document.getElementById('echo').value = 'disconnected';
   Array.from(document.getElementsByTagName('button')).forEach((e) =>
   {
      e.disabled = true;
   });
});

Для отправки сообщений вызываем метод ws.send, и для закрытия соединения — метод ws.close.

На этом разработка первого примера клиент-серверных скриптов для демонстрации эхо-сервиса закончена. Вы можете запустить wsecho.js одной из показанных ранее команд и затем открыть в своем браузере страницу по адресу http://localhost или https://localhost (в зависимости от настроек сервера). После появления формы на экране попробуйте переписываться с сервером и убедитесь, что сервис работает.

Постепенно усложняя данный пример, мы подготовим почву и для веб-сервиса копирования торговых сигналов. Но следующим шагом будет чат-сервис, принцип работы которого напоминает сервис торговых сигналов: сообщения одного пользователя передаются другим пользователям.

Чат-сервис и тестовая веб-страница

Новый серверный скрипт называется wschat.js, и он во многом повторяет wsecho.js. Перечислим основные отличия. В обработчике HTTP-запросов веб-сервера поменяем начальную страницу с index.htm на wschat.htm.

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

Для хранения информации о подключившихся к чату пользователях опишем массив-карту clients. Map — это стандартный ассоциативный контейнер JavaScript, в который по ключам произвольного типа можно записывать произвольные значения, в том числе и объекты.

// JavaScript
const clients = new Map();                    // добавили эту строку
var count = 0;

В обработчике события подключения нового пользователя будем добавлять объект client, получаемый как параметр функции, в карту под текущим порядковым номером клиента.

// JavaScript
wsServer.on('connection', function onConnect(client)
{
   console.log('New user:', ++count);
   client.id = count
   client.send('server#Hellouser' + count);
   clients.set(countclient);                // добавили эту строку
   ...

Напомним, что внутри функции onConnect мы устанавливаем обработчик для события о приходе нового сообщения для конкретного клиента, и именно внутри вложенного обработчика производим рассылку сообщений. Только на этот раз пробегаемся в цикле по всем элементам карты (то есть по всем клиентам) и отправляем текст каждому из них. Цикл организован с помощью вызова метода forEach для массива из карты, причем в метод передается по месту очередная анонимная функция, которая будет выполняться для каждого элемента (elem). На примере данного цикла вновь наглядно демонстрируется преобладающая в JavaScript функционально-декларативная парадигма программирования (отличная от императивного подхода MQL5).

// JavaScript
   client.on('message', function(message)
   {
      console.log('%d : %s', client.idmessage);
      Array.from(clients.values()).forEach(function(elem// добавили цикл
      {
         elem.send('user' + client.id + '#' + message);
      });
   });

Важно отметить, что мы отправляем копию сообщения всем клиентам, включая и автора, изначально написавшего его. Его можно было бы отфильтровать, но для целей отладки лучше иметь подтверждение отправки сообщения.

Последним отличием от предыдущего эхо-сервиса является то, что при отключении клиента требуется удалить запись о нем из карты.

// JavaScript
   client.on('close', function()
   {
      console.log('User disconnected:', client.id);
      clients.delete(client.id);                   // добавили эту строку
   });

Что касается замены страницы index.htm на wschat.htm, то в ней мы добавили "поле" для отображения автора сообщения (origin) и подключили новый скрипт для браузера wschat_client.js. В нем производится разбор сообщений (мы применяем символ '#', чтобы отделить автора от текста) и заполнение полей формы полученной информацией. Поскольку с точки зрения WebSocket-протокола ничего не поменялось, мы не станем приводить исходный код.

Веб-страница чат-сервиса на WebSocket

Веб-страница чат-сервиса на WebSocket

Вы можете запустить nodejs с чат-сервером wschat.js, а затем подключиться к нему из нескольких закладок браузера. Каждое соединение получает уникальный номер, выводимый в заголовке. Текст из поля Message отправляется всем клиентам по нажатию Submit, и у них в форме выводится как автор сообщения (метка слева внизу), так и сам текст (поле в центре внизу).

Итак, мы убедились в готовности веб-сервера с поддержкой веб-сокетов. Обратимся к написанию клиентской части протокола на MQL5.