Сервис торговых сигналов и тестовая веб-страница

Сервис торговых сигналов технически идентичен чат-сервису, однако его пользователи (а точнее клиентские соединения) должны выполнять одну из двух ролей:

  • поставщик сообщений;
  • потребитель сообщений;

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

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

Поставщик должен указать публичный идентификатор сигнала (PUB_ID), уникальный среди всех сигналов. В принципе, одно и то же лицо может потенциально генерировать более одного сигнала и, соответственно, должно иметь возможность получить несколько идентификаторов. В этом смысле мы не станем усложнять сервис, вводя раздельные идентификаторы поставщика (как конкретного лица) и идентификаторы его сигналов. Вместо этого будут поддерживаться только идентификаторы сигналов. Для реального сервиса сигналов этот вопрос нужно проработать, вместе с авторизацией, которую мы оставили за кадром.

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

Для этой цели поставщик должен предоставить серверу секретный ключ (PUB_KEY), известный только ему, но не общественности. Этот ключ потребуется для генерации ключа доступа конкретного подписчика.

Потребитель (подписчик) также должен иметь уникальный идентификатор (SUB_ID, и здесь также обойдемся без авторизации). Чтобы подписаться на интересующий его сигнал, пользователь должен сообщить поставщику сигнала свой идентификатор (на практике подразумевается, что на этом же этапе нужно подтвердить оплату, и обычно это все автоматизируется сервером). Поставщик формирует тем или иным способом слепок, состоящий из своего идентификатора, идентификатора подписчика и своего секретного ключа. В нашем сервисе это будет делаться вычислением хэша SHA256 от строки PUB_ID:PUB_KEY:SUB_ID, после чего полученные байты переводятся в строку шестнадцатеричного формата. Это и будет ключ доступа (SUB_KEY или ACCESS_KEY) к сигналу конкретного поставщика для конкретного подписчика. Поставщик (а в реальных системах — сам сервер автоматически) пересылает этот ключ подписчику.

Таким образом, подписчик при подключении к сервису должен будет указать свой идентификатор (SUB_ID), идентификатор желаемого сигнала (PUB_ID) и ключ доступа (SUB_KEY). Поскольку сервер знает секретный ключ поставщика, он может повторно рассчитать ключ доступа для данного сочетания PUB_ID и SUB_ID, и сравнить с предоставленным SUB_KEY. Совпадение означает продолжение нормального процесса с обменом сообщениями. Различие приведет к сообщению об ошибке и отключению псевдо-подписчика от сервиса.

Важно отметить, что в нашем демо, в угоду простоте, отсутствует нормальная регистрация пользователей и сигналов, и потому выбор идентификаторов — произвольный. Для нас лишь важно отслеживать уникальность идентификаторов, чтобы знать, кому и от кого посылать информацию онлайн. Так что, наш сервис не гарантирует, что идентификатор, например, "Super Trend" принадлежит вчера, сегодня и завтра одному и тому же пользователю. Резервирование имен производится по принципу: кто первый встал, того и тапки. Пока некий поставщик беспрерывно подключен под данным идентификатором, сигнал исходит от него. Если он отсоединится, то идентификатор станет доступен для выбора в любом следующем подключении.

Единственный идентификатор, который будет всегда "занят" — это "Server": его сервер использует для рассылки своих сообщений о статусах подключений.

Для генерации ключей доступа в папке сервера имеется простой JavaScript access.js. При его запуске в командной строке нужно единственным параметром передать строку указанного выше вида PUB_ID:PUB_KEY:SUB_ID (идентификаторы и секретный ключ между ними, соединенные символом ':')

Если параметр не указать, скрипт генерирует ключ доступа для неких демонстрационных идентификаторов (PUB_ID_001, SUB_ID_100) и секрета (PUB_KEY_FFF).

// JavaScript
const args = process.argv.slice(2);
const input = args.length > 0 ? args[0] : 'PUB_ID_001:PUB_KEY_FFF:SUB_ID_100';
console.log('Hashing "', input, '"');
const crypto = require('crypto');
console.log(crypto.createHash('sha256').update(input).digest('hex'));

Запустив скрипт командой:

node access.js PUB_ID_001:PUB_KEY_FFF:SUB_ID_100

мы получим такой результат:

fd3f7a105eae8c2d9afce0a7a4e11bf267a40f04b7c216dd01cf78c7165a2a5a

Между прочим, вы можете проверить и повторить данный алгоритм на чистом MQL5 с помощью функции CryptEncode.

Разобрав концептуальную часть, приступим к практической реализации.

Серверный скрипт сигнального сервиса разместим в файле MQL5/Experts/MQL5Book/p7/Web/wspubsub.js. Настройка серверов в нем совпадает с тем, что мы уже делали ранее. Но дополнительно потребуется подключить тот же модуль "crypto", который был использован в access.js. Начальная страница будет называться wspubsub.htm.

// JavaScript
const crypto = require('crypto');
...
http1.createServer(optionsfunction (reqres)
{
   ...
   if(req.url == '/')
   {
      req.url = "wspubsub.htm";
   }
   ...
});

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

// JavaScript
const publishers = new Map();
const subscribers = new Map();

В обеих картах ключом выступает идентификатор поставщика, однако в первой хранятся объекты самих поставщиков, а во второй — объекты подписанных на каждого поставщика подписчиков (массивы объектов).

Для передачи идентификаторов и ключей во время "рукопожатия" будем использовать специальный заголовок, разрешенный спецификацией WebSocket-ов, а именно Sec-Websocket-Protocol. Договоримся, что идентификаторы и ключи будут склеены символом '-': в случае поставщика ожидается строка вида X-MQL5-publisher-PUB_ID-PUB_KEY, а в случае подписчика — X-MQL5-subscriber-SUB_ID-PUB_ID-SUB_KEY.

Любые попытки подсоединиться к нашему сервису без заголовка Sec-Websocket-Protocol: X-MQL5-... будут пресекаться немедленным закрытием.

В объекте нового клиента (в параметре обработчика события "connection" — onConnect(client)) данный заголовок легко извлечь из свойства client.protocol.

Покажем процедуру регистрации и рассылки сообщений поставщика сигнала в упрощенном виде, без обработки ошибок (полный код прилагается). Важно отметить, что текст сообщений формируется в формате JSON (о котором мы более подробно поговорим в следующем разделе). В частности, отправитель сообщения передается в свойстве "origin" (причем, когда сообщение посылает сам сервис, в этом поле — строка "Server"), а прикладные данные от поставщика помещаются в свойство "msg", и это может быть не просто текст, но и вложенная структура любого содержания.

// JavaScript
const wsServer = new WebSocket.Server({ server });
wsServer.on('connection', function onConnect(client)
{
   console.log('New user:', ++countclient.protocol);
   if(client.protocol.startsWith('X-MQL5-publisher'))
   {
      const parts = client.protocol.split('-');
      client.id = parts[3];
      client.key = parts[4];
      publishers.set(client.idclient);
      client.send('{"origin":"Server", "msg":"Hello, publisher ' + client.id + '"}');
      client.on('message', function(message)
      {
         console.log('%s : %s', client.idmessage);
         
         if(subscribers.get(client.id))
            subscribers.get(client.id).forEach(function(elem)
         {
            elem.send('{"origin":"publisher ' + client.id + '", "msg":'
               + message + '}');
         });
      });
      client.on('close', function()
      {
         console.log('Publisher disconnected:', client.id);
         if(subscribers.get(client.id))
            subscribers.get(client.id).forEach(function(elem)
         {
            elem.close();
         });
         publishers.delete(client.id);
      });
   }
   ...

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

// JavaScript
   else if(client.protocol.startsWith('X-MQL5-subscriber'))
   {
      const parts = client.protocol.split('-');
      client.id = parts[3];
      client.pub_id = parts[4];
      client.access = parts[5];
      const id = client.pub_id;
      var p = publishers.get(id);
      if(p)
      {
         const check = crypto.createHash('sha256').update(id + ':' + p.key + ':'
            + client.id).digest('hex');
         if(check != client.access)
         {
            console.log(`Bad credentials: '${client.access}' vs '${check}'`);
            client.send('{"origin":"Server", "msg":"Bad credentials, subscriber '
               + client.id + '"}');
            client.close();
            return;
         }
   
         var list = subscribers.get(id);
         if(list == undefined)
         {
            list = [];
         }
         list.push(client);
         subscribers.set(idlist);
         client.send('{"origin":"Server", "msg":"Hello, subscriber '
            + client.id + '"}');
         p.send('{"origin":"Server", "msg":"New subscriber ' + client.id + '"}');
      }
      
      client.on('close', function()
      {
         console.log('Subscriber disconnected:', client.id);
         const list = subscribers.get(client.pub_id);
         if(list)
         {
            if(list.length > 1)
            {
               const filtered = list.filter(function(el) { return el !== client; });
               subscribers.set(client.pub_idfiltered);
            }
            else
            {
               subscribers.delete(client.pub_id);
            }
         }
      });
   }

Пользовательский интерфейс на клиентской странице wspubsub.htm просто предлагает перейти по ссылке на одну из двух страниц с формами для поставщиков (wspublisher.htm + wspublisher_client.js) или подписчиков (wssubscriber.htm + wssubscriber_client.js).

Веб-страницы тестовых клиентов сигнального сервиса

Веб-страницы тестовых клиентов сигнального сервиса

Их реализация наследует черты предыдущих рассмотренных JavaScript-клиентов, но с учетом кастомизации заголовка Sec-Websocket-Protocol: X-MQL5- и еще одного нюанса.

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

Поддержку JSON нам потребуется добавить и в свой клиент сигнального сервиса на MQL5. Но пока можно запустить на сервере wspubsub.js и протестировать избирательное подключение поставщиков и потребителей сигналов в соответствии с указанными ими реквизитами. Проделайте это самостоятельно.