Протокол WebSocket-ов на MQL5

Мы уже ранее рассмотрели Теоретические основы протокола WebSockets. Полная спецификация довольно обширна, и подробное описание её реализации потребовало бы много места и времени. Поэтому мы приведем общую структуру уже готовых классов и их программных интерфейсов. Все файлы расположены в каталоге MQL5/Include/MQL5Book/ws/.

  • wsinterfaces.mqh — общее абстрактное описание всех интерфейсов (см. далее), констант и типов;
  • wstransport.mqh — класс MqlWebSocketTransport, реализующий низкоуровневый сетевой интерфейс передачи данных IWebSocketTransport на базе Socket-функций MQL5;
  • wsframe.mqh — классы WebSocketFrame и WebSocketFrameHixie, реализующие интерфейс IWebSocketFrame, за которым скрыты алгоритмы формирования (кодирования и декодирования) фреймов для протоколов Hybi и Hixie, соответственно;
  • wsmessage.mqh — классы WebSocketMessage и WebSocketMessageHixie, реализующие интерфейс IWebSocketMessage, формализующий формирование сообщений из фреймов для протоколов Hybi и Hixie, соответственно;
  • wsprotocol.mqh — классы WebSocketConnection, WebSocketConnectionHybi, WebSocketConnectionHixie, унаследованные от IWebSocketConnection; именно здесь происходит координированное управление формированием фреймов, сообщений, приветствия и разрыва соединения согласно спецификации, для чего используются вышеперечисленные интерфейсы;
  • wsclient.mqh — готовая реализация Web-Socket-клиента — шаблонный класс WebSocketClient, поддерживающий интерфейс IWebSocketObserver (для обработки событий), и ожидающий WebSocketConnectionHybi или WebSocketConnectionHixie в качестве параметризованного типа;
  • wstools.mqh — полезные утилиты в пространстве имен WsTools;

В наши будущие mqporj-проекты данные заголовочные файлы попадут автоматически как зависимости из директив #include.

Диаграмма классов WebSocket в MQL5

Диаграмма классов WebSocket в MQL5

Низкоуровневый сетевой интерфейс IWebSocketTransport имеет следующие методы.

interface IWebSocketTransport
{
   int write(const uchar &data[]); // запись массива байтов в сеть
   int read(uchar &buffer[]);      // чтение данных из сети в массив байтов
   bool isConnected(voidconst;   // проверка на наличие связи
   bool isReadable(voidconst;    // проверка на возможность чтения из сети
   bool isWritable(voidconst;    // проверка на возможность записи в сеть
   int getHandle(voidconst;      // системный дескриптор сокета
   void close(void);               // закрытие связи
};

Из названий методов нетрудно догадаться, с помощью каких Socket-функций MQL5 API они будут строиться. Но при необходимости, желающие могут воплотить данный интерфейс собственными средствами, например, через DLL.

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

Типы фреймов собраны в перечислении WS_FRAME_OPCODE.

enum WS_FRAME_OPCODE
{
   WS_DEFAULT = 0,
   WS_CONTINUATION_FRAME = 0x00,
   WS_TEXT_FRAME = 0x01,
   WS_BINARY_FRAME = 0x02,
   WS_CLOSE_FRAME = 0x08,
   WS_PING_FRAME = 0x09,
   WS_PONG_FRAME = 0x0A
};

Интерфейс для работы с фреймами содержит как статические, так и обычные методы, относящиеся к экземплярам фреймов. Статические методы выступают фабриками для создания фреймов необходимого типа передающей стороной (create) и приходящих фреймов (decode).

class IWebSocketFrame
{
public:
   class StaticCreator
   {
   public:
      virtual IWebSocketFrame *decode(uchar &data[], IWebSocketFrame *head = NULL) = 0;
      virtual IWebSocketFrame *create(WS_FRAME_OPCODE typeconst string data = NULL,
         const bool deflate = false) = 0;
      virtual IWebSocketFrame *create(WS_FRAME_OPCODE typeconst uchar &data[],
         const bool deflate = false) = 0;
   };
   ...

Наличие методов-фабрик в классах-наследниках делается обязательным за счет наличия шаблона Creator и возвращающего его экземпляр метода getCreator (предполагается возврат "синглтона").

protected:
   template<typename P>
   class Creatorpublic StaticCreator
   {
   public:
      // декодируем полученные двоичные данные в IWebSocketFrame
      // (в случае продолжения, предыдущий фрейм в 'head')
      virtual IWebSocketFrame *decode(uchar &data[],
         IWebSocketFrame *head = NULLoverride
      {
         return P::decode(datahead);
      }
      // создаем фрейм нужного типа (текст/закрытие/другой) с опциональным текстом
      virtual IWebSocketFrame *create(WS_FRAME_OPCODE typeconst string data = NULL,
         const bool deflate = falseoverride
      {
         return P::create(typedatadeflate);
      };
      // создаем фрейм нужного типа (двоичный/текст/закрытия/другой) с данными
      virtual IWebSocketFrame *create(WS_FRAME_OPCODE typeconst uchar &data[],
         const bool deflate = falseoverride
      {
         return P::create(typedatadeflate);
      };
   };
public:
   // требуем наличия экземпляра Creator
   virtual IWebSocketFrame::StaticCreator *getCreator() = 0;
   ...

Остальные методы интерфейса обеспечивают все необходимые манипуляции с данными во фреймах (кодирование/декодирование, получение данных и различных флагов).

   // закодировать "чистое" содержимое фрейма в данные для передачи по сети
   virtual int encode(uchar &encoded[]) = 0;
   
   // получить данные как текст
   virtual string getData() = 0;
   
   // получить данные как байты, вернуть размер
   virtual int getData(uchar &buf[]) = 0;
   
   // вернуть тип фрйема (opcode)
   virtual WS_FRAME_OPCODE getType() = 0;
  
   // проверка, является ли фрейм управляющим или с данными:
   // управляющие фреймы обрабатываются внутри классов
   virtual bool isControlFrame()
   {
      return (getType() >= WS_CLOSE_FRAME);
   }
   
   virtual bool isReady() { return true; }
   virtual bool isFinal() { return true; }
   virtual bool isMasked() { return false; }
   virtual bool isCompressed() { return false; }
};

Интерфейс IWebSocketMessage содержит методы для выполнения похожих действий, но уже на уровне сообщений.

class IWebSocketMessage
{
public:
   // получить массив фреймов, составляющих данное сообщение
   virtual void getFrames(IWebSocketFrame *&frames[]) = 0;
   
   // задать текст как содержимое сообщения
   virtual bool setString(const string &data) = 0;
  
   // вернуть содержимое сообщения как текст
   virtual string getString() = 0;
  
   // задать двоичные данные как содержимое сообщения
   virtual bool setData(const uchar &data[]) = 0;
   
   // вернуть содержимое сообщения в "сыром" двоичном виде
   virtual bool getData(uchar &data[]) = 0;
  
   // признак полноты сообщения (получены все фреймы)
   virtual bool isFinalised() = 0;
  
   // добавить фрейм в сообщение
   virtual bool takeFrame(IWebSocketFrame *frame) = 0;
};

С учетом интерфейсов фреймов и сообщений определен общий интерфейс WebSocket-соединений IWebSocketConnection.

interface IWebSocketConnection
{
   // открыть соединение с указанным URL и его частями,
   // и опциональными кастом-заголовками
   bool handshake(const string urlconst string hostconst string origin,
      const string custom = NULL);
   
   // низко-уровневое чтение фреймов с сервера
   int readFrame(IWebSocketFrame *&frames[]);
   
   // низко-уровневая отправка фрейма (например, закрытие или ping)
   bool sendFrame(IWebSocketFrame *frame);
   
   // низко-уровневая отправка сообщения
   bool sendMessage(IWebSocketMessage *msg);
   
   // пользовательская проверка новых сообщений (генерация событий)
   int checkMessages();
   
   // пользовательская отправка текста
   bool sendString(const string msg);
   
   // пользовательская отправка двоичных данных
   bool sendData(const uchar &data[]);
   
   // закрытие соединения
   bool disconnect(void);
};

Уведомления о разрыве связи и получении новых сообщений поступают через методы интерфейса IWebSocketObserver.

interface IWebSocketObserver
{
  void onConnected();
  void onDisconnect();
  void onMessage(IWebSocketMessage *msg);
};

В частности, класс WebSocketClient сделан наследником этого интерфейса и по умолчанию просто выводит информацию в журнал. Конструктор класса ожидает адрес для соединения с протоколом ws или wss.

template<typename T>
class WebSocketClientpublic IWebSocketObserver
{
protected:
   IWebSocketMessage *messages[];
   
   string scheme;
   string host;
   string port;
   string origin;
   string url;
   int timeOut;
   ...
public:
   WebSocketClient(const string address)
   {
      string parts[];
      URL::parse(addressparts);
   
      url = address;
      timeOut = 5000;
  
      scheme = parts[URL_SCHEME];
      if(scheme != "ws" && scheme != "wss")
      {
        Print("WebSocket invalid url scheme: "scheme);
        scheme = "ws";
      }
  
      host = parts[URL_HOST];
      port = parts[URL_PORT];
  
      origin = (scheme == "wss" ? "https://" : "http://") + host;
   }
   ...
  
   void onDisconnect() override
   {
      Print(" > Disconnected "url);
   }
  
   void onConnected() override
   {
      Print(" > Connected "url);
   }
  
   void onMessage(IWebSocketMessage *msgoverride
   {
      // NB: сообщение может быть двоичным, печатаем его просто для уведомления
      Print(" > Message "url" " , msg.getString());
      WsTools::push(messagesmsg);
   }
   ...
};

Класс WebSocketClient собирает все объекты сообщений в массив и заботится об их удалении, если это не сделает MQL-программа.

Установление соединения осуществляется в методе open.

template<typename T>
class WebSocketClientpublic IWebSocketObserver
{
protected:
   IWebSocketTransport *socket;
   IWebSocketConnection *connection;
   ...
public:
   ...
   bool open(const string custom_headers = NULL)
   {
      uint _port = (uint)StringToInteger(port);
      if(_port == 0)
      {
         if(scheme == "ws"_port = 80;
         else _port = 443;
      }
  
      socket = MqlWebSocketTransport::create(schemehost_porttimeOut);
      if(!socket || !socket.isConnected())
      {
         return false;
      }
  
      connection = new T(&thissocket);
      return connection.handshake(urlhostorigincustom_headers);
   }
   ...

Наиболее удобные способы отправки данных предоставляют перегруженные методы send для текста и двоичных данных.

   bool send(const string str)
   {
      return connection ? connection.sendString(str) : false;
   }
    
   bool send(const uchar &data[])
   {
      return connection ? connection.sendData(data) : false;
   }

Для проверки наличия новых поступивших сообщений можно вызывать метод checkMessages. В зависимости от его параметра blocking, метод будет в цикле ожидать сообщения вплоть до таймаута или сразу вернет управление, если сообщений нет. Сообщения поступят в обработчик IWebSocketObserver::onMessage.

   void checkMessages(const bool blocking = true)
   {
      if(connection == NULLreturn;
      
      uint stop = GetTickCount() + (blocking ? timeOut : 1);
      while(ArraySize(messages) == 0 && GetTickCount() < stop && isConnected())
      {
         // все фреймы собираются в соответствующие сообщения, и те становятся
         // доступными через уведомления о событии IWebSocketObserver::onMessage,
         // однако управляющие фреймы к этому моменту уже обработаны внутри и удалены
         if(!connection.checkMessages()) // пока нет сообщений, сделаем микро-паузу
         {
            Sleep(100);
         }
      }
   }

Альтернативный способ получения сообщений реализован в методе readMessage: он возвращает указатель на сообщение в вызывающий код (иначе говоря, прикладной обработчик onMessage не требуется). После этого уже MQL-программа ответственна за освобождение объекта.

   IWebSocketMessage *readMessage(const bool blocking = true)
   {
      if(ArraySize(messages) == 0checkMessages(blocking);
      
      if(ArraySize(messages) > 0)
      {
         IWebSocketMessage *top = messages[0];
         ArrayRemove(messages01);
         return top;
      }
      return NULL;
   }

Также класс позволяет изменить таймаут, проверить соединение и закрыть его.

   void setTimeOut(const int ms)
   {
      timeOut = fabs(ms);
   }
   
   bool isConnected() const
   {
      return socket && socket.isConnected();
   }
   
   void close()
   {
      if(isConnected())
      {
         if(connection)
         {
            connection.disconnect(); // это закроет socket после подтверждения сервера
            delete connection;
            connection = NULL;
         }
         if(socket)
         {
            delete socket;
            socket = NULL;
         }
      }
   }
};

Библиотека рассмотренных классов позволяет создать клиентские приложения для эхо- и чат-сервисов.