Работа с сетевыми функциями, или MySQL без DLL: Часть II - программа для мониторинга изменения свойств сигналов

Serhii Shevchuk | 13 февраля, 2020

Содержание


Введение

В предыдущей части статьи мы ознакомились с реализацией коннектора MySQL. Пришло время разобрать примеры его применения, из которых самый простой и наглядный — это сбор значений свойств сигналов с дальнейшим просмотром их изменения. Для большинства счетов в терминале доступно более 100 сигналов, а у каждого сигнала более 20 параметров. Значит, данных будет предостаточно. Кроме того, реализованный пример может иметь практический смысл в том случае, если пользователю нужно наблюдать изменения свойств, которые не отображаются на веб-странице сигнала. Это может быть график изменения плеча, рейтинга, количества подписчиков и многое другое.

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

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

В ходе реализации мы выясним, почему в некоторых случаях желательно использовать режим постоянного соединения Keep Alive и множественные запросы.


Сервис сбора данных

Итак, задача сервиса состоит в следующем:

  1. Периодически запрашивать свойства всех доступных в терминале сигналов
  2. Сравнивать их значения с предыдущими
  3. В случае обнаружения отличий писать в базу данных весь массив значений
  4. При возникновении ошибок информировать пользователя

Создадим в редакторе новый сервис и дадим ему имя "signals_to_db.mq5". Входные параметры будут следующие:

input string   inp_server     = "127.0.0.1";          // MySQL server address
input uint     inp_port       = 3306;                 // TCP port
input string   inp_login      = "admin";              // Login
input string   inp_password   = "12345";              // Password
input string   inp_db         = "signals_mt5";        // Database name
input bool     inp_creating   = true;                 // Allow creating tables
input uint     inp_period     = 30;                   // Signal loading period
input bool     inp_notifications = true;              // Send error notifications

Здесь, кроме настроек сети, присутствует несколько опций:


Получение значений свойств сигналов

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

По крайней мере, так это происходит в версии терминала, актуальной на момент написания статьи.

Свойства сигналов, которые нас интересуют, бывают четырёх типов:

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

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

//--- Структуры описания свойств сигналов для каждого типа
struct STR_SIGNAL_BASE_DOUBLE
  {
   string                     name;
   ENUM_SIGNAL_BASE_DOUBLE    id;
  };
struct STR_SIGNAL_BASE_INTEGER
  {
   string                     name;
   ENUM_SIGNAL_BASE_INTEGER   id;
  };
struct STR_SIGNAL_BASE_DATETIME
  {
   string                     name;
   ENUM_SIGNAL_BASE_INTEGER   id;
  };
struct STR_SIGNAL_BASE_STRING
  {
   string                     name;
   ENUM_SIGNAL_BASE_STRING    id;
  };

Далее объявим массивы структур (ниже приведён пример для ENUM_SIGNAL_BASE_DOUBLE, для остальных типов аналогично):

const STR_SIGNAL_BASE_DOUBLE tab_signal_base_double[]=
  {
     {"Balance",    SIGNAL_BASE_BALANCE},
     {"Equity",     SIGNAL_BASE_EQUITY},
     {"Gain",       SIGNAL_BASE_GAIN},
     {"Drawdown",   SIGNAL_BASE_MAX_DRAWDOWN},
     {"Price",      SIGNAL_BASE_PRICE},
     {"ROI",        SIGNAL_BASE_ROI}
  };
Теперь, чтобы получить значения свойств выбранного сигнала, нужно всего лишь пройтись по четырём циклам:
   //--- Чтение свойств сигнала
   void              Read(void)
     {
      for(int i=0; i<6; i++)
         props_double[i] = SignalBaseGetDouble(ENUM_SIGNAL_BASE_DOUBLE(tab_signal_base_double[i].id));
      for(int i=0; i<7; i++)
         props_int[i] = SignalBaseGetInteger(ENUM_SIGNAL_BASE_INTEGER(tab_signal_base_integer[i].id));
      for(int i=0; i<3; i++)
         props_datetime[i] = datetime(SignalBaseGetInteger(ENUM_SIGNAL_BASE_INTEGER(tab_signal_base_datetime[i].id)));
      for(int i=0; i<5; i++)
         props_str[i] = SignalBaseGetString(ENUM_SIGNAL_BASE_STRING(tab_signal_base_string[i].id));
     }

В приведённом выше примере Read() является методом структуры SignalProperties, в которой собрано всё необходимое для работы со свойствами сигналов. Это буферы для каждого из типов, а также методы для чтения и сравнения текущих значений с предыдущими:

//--- Структура для работы со свойствами сигнала
struct SignalProperties
  {
   //--- Буферы свойств
   double            props_double[6];
   long              props_int[7];
   datetime          props_datetime[3];
   string            props_str[5];
   //--- Чтение свойств сигнала
   void              Read(void)
     {
      for(int i=0; i<6; i++)
         props_double[i] = SignalBaseGetDouble(ENUM_SIGNAL_BASE_DOUBLE(tab_signal_base_double[i].id));
      for(int i=0; i<7; i++)
         props_int[i] = SignalBaseGetInteger(ENUM_SIGNAL_BASE_INTEGER(tab_signal_base_integer[i].id));
      for(int i=0; i<3; i++)
         props_datetime[i] = datetime(SignalBaseGetInteger(ENUM_SIGNAL_BASE_INTEGER(tab_signal_base_datetime[i].id)));
      for(int i=0; i<5; i++)
         props_str[i] = SignalBaseGetString(ENUM_SIGNAL_BASE_STRING(tab_signal_base_string[i].id));
     }
   //--- Сравнение Id сигнала с переданным значением
   bool              CompareId(long id)
     {
      if(id==props_int[0])
         return true;
      else
         return false;
     }
   //--- Сравнение значений свойств сигнала с переданными по ссылке
   bool              Compare(SignalProperties &sig)
     {
      for(int i=0; i<6; i++)
        {
         if(props_double[i]!=sig.props_double[i])
            return false;
        }
      for(int i=0; i<7; i++)
        {
         if(props_int[i]!=sig.props_int[i])
            return false;
        }
      for(int i=0; i<3; i++)
        {
         if(props_datetime[i]!=sig.props_datetime[i])
            return false;
        }
      return true;
     }
   //--- Сравнение значений свойств сигнала с таким же, находящимся внутри переданного буфера (поиск по Id)
   bool              Compare(SignalProperties &buf[])
     {
      int n = ArraySize(buf);
      for(int i=0; i<n; i++)
        {
         if(props_int[0]==buf[i].props_int[0])  // Id
            return Compare(buf[i]);
        }
      return false;
     }
  };


Добавление в базу данных

Для работы с базой данных, в первую очередь, объявляем экземпляр класса CMySQLTransaction:

//--- Подключение класса транзакции MySQL
#include  <MySQL\MySQLTransaction.mqh>
CMySQLTransaction mysqlt;

Далее, в функции OnStart() необходимо установить параметры соединения. Для этого вызовем метод Config:

//+------------------------------------------------------------------+
//| Service program start function                                   |
//+------------------------------------------------------------------+
void OnStart()
  {
//--- Сконфигурируем класс транзакции MySQL
   mysqlt.Config(inp_server,inp_port,inp_login,inp_password);
   
   ...
  }

Следующим шагом будет создание имени таблицы. Так как набор сигналов зависит от брокера и типа торгового счёта, нужно их в этом задействовать. В названии сервера заменим точку, дефис и пробел на подчеркивание, добавим к нему логин счёта, и все буквы заменим на строчные. Таким образом, имея сервер брокера "MetaQuotes-Demo" и логин " 17273508", имя таблицы получится "metaquotes_demo__17273508".

В коде это выглядит так:

//--- Составим название таблицы
//--- для этого получаем имя торгового сервера
   string s = AccountInfoString(ACCOUNT_SERVER);
//--- заменяем пробел, точку и дефис на подчеркивания
   string ss[]= {" ",".","-"};
   for(int i=0; i<3; i++)
      StringReplace(s,ss[i],"_");
//--- складываем имя таблицы из имени сервера и логина торгового счёта
   string tab_name = s+"__"+IntegerToString(AccountInfoInteger(ACCOUNT_LOGIN));
//--- преобразуем все буквы в строчные
   StringToLower(tab_name);
//--- выведем результат в консоль
   Print("Table name: ",tab_name);

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

Чтением свойств из базы занимается функция DB_Read().

//+------------------------------------------------------------------+
//| Чтение свойств сигналов из базы данных                           |
//+------------------------------------------------------------------+
bool  DB_Read(SignalProperties &sbuf[],string tab_name)
  {
//--- составляем запрос
   string q="select * from `"+inp_db+"`.`"+tab_name+"` "+
            "where `TimeInsert`= ("+
            "select `TimeInsert` "+
            "from `"+inp_db+"`.`"+tab_name+"` order by `TimeInsert` desc limit 1)";
//--- отправляем запрос
   if(mysqlt.Query(q)==false)
      return false;
//--- если запрос выполнен успешно, получаем указатель на него
   CMySQLResponse *r = mysqlt.Response();
   if(CheckPointer(r)==POINTER_INVALID)
      return false;
//--- читаем количество рядов в принятом ответе
   uint rows = r.Rows();
//--- подготовим массив
   if(ArrayResize(sbuf,rows)!=rows)
      return false;
//--- читаем значения свойств в массив
   for(uint n=0; n<rows; n++)
     {
      //--- получаем указатель на текущий ряд
      CMySQLRow *row = r.Row(n);
      if(CheckPointer(row)==POINTER_INVALID)
         return false;
      for(int i=0; i<6; i++)
        {
         if(row.Double(tab_signal_base_double[i].name,sbuf[n].props_double[i])==false)
            return false;
        }
      for(int i=0; i<7; i++)
        {
         if(row.Long(tab_signal_base_integer[i].name,sbuf[n].props_int[i])==false)
            return false;
        }
      for(int i=0; i<3; i++)
         sbuf[n].props_datetime[i] = MySQLToDatetime(row[tab_signal_base_datetime[i].name]);
      for(int i=0; i<5; i++)
         sbuf[n].props_str[i] = row[tab_signal_base_string[i].name];
     }
   return true;
  }
Аргументы функции — это ссылка на буфер сигналов и имя таблицы, которое мы сформировали при инициализации. В теле функции первым делом составляем запрос. В данном случае нам нужно прочитать все свойства тех сигналов, у которых время добавления в базу имеет максимальное значение. Так как мы пишем массив всех значений одновременно, то для решения достаточно будет найти максимальное время в таблице и прочитать все строки, где время добавления равняется найденному. Например, если имя базы данных signals_mt5, а имя таблицы metaquotes_demo__17273508, то запрос будет иметь следующий вид:
select * 
from `signals_mt5`.`metaquotes_demo__17273508`
where `TimeInsert`= (
        select `TimeInsert`
        from `signals_mt5`.`metaquotes_demo__17273508` 
        order by `TimeInsert` desc limit 1)

Красным цветом выделен вложенный запрос, который вернёт максимальное значение колонки `TimeInsert`, то есть время последнего добавления в базу. Запрос, выделенный зеленым, вернёт все строки, где значение `TimeInsert` соответствует найденному.

Если транзакция прошла успешно, приступаем к чтению принятых данных. Для этого сначала получим указатель на класс ответа сервера CMySQLResponse, затем количество рядов в ответе, исходя из которого изменим размер буфера сигналов.

Теперь можно читать свойства. Для этого получаем указатель на текущий ряд, используя индекс. После чего читаем значения для каждого типа свойств. Например, для чтения свойств ENUM_SIGNAL_BASE_DOUBLE используем метод CMySQLRow::Double(), где первый аргумент — имя поля — это текстовое имя свойства.

Рассмотрим случай, когда отправка запроса завершилась с ошибкой. Для этого возвращаемся к исходному коду функции OnStart().
//--- Объявим буфер свойств сигналов
   SignalProperties sbuf[];
//--- Поднимаем данные из базы в буфер
   bool exit = false;
   if(DB_Read(sbuf,tab_name)==false)
     {
      //--- если функция чтения вернула ошибку
      //--- возможно, отсутствует таблица
      if(mysqlt.GetServerError().code==ER_NO_SUCH_TABLE && inp_creating==true)
        {
         //--- если нужно создать таблицу и это разрешено в настройках
         if(DB_CteateTable(tab_name)==false)
            exit=true;
        }
      else
         exit=true;
     }

При возникновении ошибки в первую очередь проверим, что это произошло не по причине отсутствие таблицы. Так бывает, если таблица ещё не создана, либо в процессе работы была удалена или переименована третьим лицом. Если мы получили ошибку ER_NO_SUCH_TABLE, значит, таблицу нужно создать (при условии, что это разрешено).

Функция создания таблицы DB_CteateTable() довольно простая:

//+------------------------------------------------------------------+
//| Создание таблицы                                                 |
//+------------------------------------------------------------------+
bool  DB_CteateTable(string name)
  {
//--- составляем запрос
   string q="CREATE TABLE `"+inp_db+"`.`"+name+"` ("+
            "`PKey`                        BIGINT(20)   NOT NULL AUTO_INCREMENT,"+
            "`TimeInsert`     DATETIME    NOT NULL,"+
            "`Id`             INT(11)     NOT NULL,"+
            "`Name`           CHAR(50)    NOT NULL,"+
            "`AuthorLogin`    CHAR(50)    NOT NULL,"+
            "`Broker`         CHAR(50)    NOT NULL,"+
            "`BrokerServer`   CHAR(50)    NOT NULL,"+
            "`Balance`        DOUBLE      NOT NULL,"+
            "`Equity`         DOUBLE      NOT NULL,"+
            "`Gain`           DOUBLE      NOT NULL,"+
            "`Drawdown`       DOUBLE      NOT NULL,"+
            "`Price`          DOUBLE      NOT NULL,"+
            "`ROI`            DOUBLE      NOT NULL,"+
            "`Leverage`       INT(11)     NOT NULL,"+
            "`Pips`           INT(11)     NOT NULL,"+
            "`Rating`         INT(11)     NOT NULL,"+
            "`Subscribers`    INT(11)     NOT NULL,"+
            "`Trades`         INT(11)     NOT NULL,"+
            "`TradeMode`      INT(11)     NOT NULL,"+
            "`Published`      DATETIME    NOT NULL,"+
            "`Started`        DATETIME    NOT NULL,"+
            "`Updated`        DATETIME    NOT NULL,"+
            "`Currency`       CHAR(50)    NOT NULL,"+
            "PRIMARY KEY (`PKey`),"+
            "UNIQUE INDEX `TimeInsert_Id` (`TimeInsert`, `Id`),"+
            "INDEX `TimeInsert` (`TimeInsert`),"+
            "INDEX `Currency` (`Currency`, `TimeInsert`),"+
            "INDEX `Broker` (`Broker`, `TimeInsert`),"+
            "INDEX `AuthorLogin` (`AuthorLogin`, `TimeInsert`),"+
            "INDEX `Id` (`Id`, `TimeInsert`)"+
            ") COLLATE='utf8_general_ci' "+
            "ENGINE=InnoDB "+
            "ROW_FORMAT=DYNAMIC";
//--- отправляем запрос
   if(mysqlt.Query(q)==false)
      return false;
   return true;
  }
В самом запросе, среди имён полей, которые являются именами свойств сигналов, присутствует также время добавления данных `TimeInsert`. Это локальное время терминала на момент получения обновлённых свойств. Кроме того, присутствует уникальный ключ для полей `TimeInsert` и `Id`, а также индексы, необходимые для ускорения выполнения запросов.

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

   if(exit==true)
     {
      if(GetLastError()==(ERR_USER_ERROR_FIRST+MYSQL_ERR_SERVER_ERROR))
        {
         // если это ошибка сервера
         Print("MySQL Server Error: ",mysqlt.GetServerError().code," (",mysqlt.GetServerError().message,")");
        }
      else
        {
         if(GetLastError()>=ERR_USER_ERROR_FIRST)
            Print("Transaction Error: ",EnumToString(ENUM_TRANSACTION_ERROR(GetLastError()-ERR_USER_ERROR_FIRST)));
         else
            Print("Error: ",GetLastError());
        }
      return;
     }

Здесь нужно заметить, что у нас может быть три вида ошибок.

От вида ошибки будет зависеть то, как формируется её описание. Методика определения ошибки следующая.

В случае, если транзакция прошла без ошибок, мы попадаем в основной цикл программы:

//--- установим отметку времени предыдущего чтения свойств сигналов
   datetime chk_ts = 0;

   ...
//--- Основной цикл работы сервиса
   do
     {
      if((TimeLocal()-chk_ts)<inp_period)
        {
         Sleep(1000);
         continue;
        }
      //--- пора читать свойства сигналов
      chk_ts = TimeLocal();

      ...

     }
   while(!IsStopped());

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

Допустим, что мы получили свойства сигналов, которые отличаются от предыдущих значений. Что происходит дальше:

      if(newdata==true)
        {
         bool bypass = false;
         if(DB_Write(buf,tab_name,chk_ts)==false)
           {
            //--- если нужно создать таблицу и это разрешено в настройках
            if(mysqlt.GetServerError().code==ER_NO_SUCH_TABLE && inp_creating==true)
              {
               if(DB_CteateTable(tab_name)==true)
                 {
                  //--- если создание таблицы прошло успешно, отправляем данные
                  if(DB_Write(buf,tab_name,chk_ts)==false)
                     bypass = true; // не удалось отправить
                 }
               else
                  bypass = true; // не удалось создать таблицу
              }
            else
               bypass = true; // нет таблицы и создавать её не разрешено
           }
         if(bypass==true)
           {
            if(GetLastError()==(ERR_USER_ERROR_FIRST+MYSQL_ERR_SERVER_ERROR))
              {
               // если это ошибка сервера
               PrintNotify("MySQL Server Error: "+IntegerToString(mysqlt.GetServerError().code)+" ("+mysqlt.GetServerError().message+")");
              }
            else
              {
               if(GetLastError()>=ERR_USER_ERROR_FIRST)
                  PrintNotify("Transaction Error: "+EnumToString(ENUM_TRANSACTION_ERROR(GetLastError()-ERR_USER_ERROR_FIRST)));
               else
                  PrintNotify("Error: "+IntegerToString(GetLastError()));
              }
            continue;
           }
        }
      else
         continue;

Здесь мы видим уже знакомый фрагмент кода с проверкой отсутствия таблицы и последующим её созданием. Это нужно для того, чтобы корректно обработать ситуацию с удалением таблицы третьей стороной во время работы сервиса. Также следует обратить внимание, что вместо привычного Print() используется PrintNotify(). Эта функция дублирует строку, выводимую в консоль, в виде уведомления, если это разрешено во входных параметрах:

//+------------------------------------------------------------------+
//| Печать в консоль и отправка уведомления                          |
//+------------------------------------------------------------------+
void PrintNotify(string text)
  {
//--- вывод в консоль
   Print(text);
//--- отправка уведомления
   if(inp_notifications==true)
     {
      static datetime ts = 0;       // время отправки последнего уведомления
      static string prev_text = ""; // текст последнего уведомления
      if(text!=prev_text || (text==prev_text && (TimeLocal()-ts)>=(3600*6)))
        {
         // идущие друг за другом одинаковые уведомления отправляем не чаще одного раза в 6 часов
         if(SendNotification(text)==true)
           {
            ts = TimeLocal();
            prev_text = text;
           }
        }
     }
  }

При обнаружении обновления свойств вызываем функцию записи в базу данных:

//+------------------------------------------------------------------+
//| Запись свойств сигналов в базу данных                            |
//+------------------------------------------------------------------+
bool  DB_Write(SignalProperties &sbuf[],string tab_name,datetime tc)
  {
//--- составляем запрос
   string q = "insert ignore into `"+inp_db+"`.`"+tab_name+"` (";
   q+= "`TimeInsert`";
   for(int i=0; i<6; i++)
      q+= ",`"+tab_signal_base_double[i].name+"`";
   for(int i=0; i<7; i++)
      q+= ",`"+tab_signal_base_integer[i].name+"`";
   for(int i=0; i<3; i++)
      q+= ",`"+tab_signal_base_datetime[i].name+"`";
   for(int i=0; i<5; i++)
      q+= ",`"+tab_signal_base_string[i].name+"`";
   q+= ") values ";
   int sz = ArraySize(sbuf);
   for(int s=0; s<sz; s++)
     {
      q+=(s==0)?"(":",(";
      q+= "'"+DatetimeToMySQL(tc)+"'";
      for(int i=0; i<6; i++)
         q+= ",'"+DoubleToString(sbuf[s].props_double[i],4)+"'";
      for(int i=0; i<7; i++)
         q+= ",'"+IntegerToString(sbuf[s].props_int[i])+"'";
      for(int i=0; i<3; i++)
         q+= ",'"+DatetimeToMySQL(sbuf[s].props_datetime[i])+"'";
      for(int i=0; i<5; i++)
         q+= ",'"+sbuf[s].props_str[i]+"'";
      q+=")";
     }
//--- отправляем запрос
   if(mysqlt.Query(q)==false)
      return false;
//--- если запрос выполнен успешно, получаем указатель на него
   CMySQLResponse *r = mysqlt.Response(0);
   if(CheckPointer(r)==POINTER_INVALID)
      return false;
//--- в ответ должен прийти пакет типа "Ок", в нём содержится количество затронутых рядов, выведем его
   if(r.Type()==MYSQL_RESPONSE_OK)
      Print("Добавлено ",r.AffectedRows()," записей");
//
   return true;
  }

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

После отправки запроса мы ожидаем от сервера ответ типа "Ок", из которого при помощи метода AffectedRows() получаем количество затронутых рядов, что и выведем в консоль. В случае неудачи функция вернёт false, что повлечёт за собой запись в консоль сообщения об ошибке и отправку его копии в виде уведомления, если это разрешено в настройках. При этом полученные свойства не будут скопированы в основной буфер, и через указанный период снова будет обнаружено изменение их значений и произведена попытка записать их в базу данных.

Сервис сбора свойств сигналов

Рис. 1. Запущенный сервис сбора свойств сигналов

На рисунке 1 изображен запущенный сервис signals_to_db, как это выглядит в окне "Навигатор". При этом стоит не забывать о необходимости выбора вкладки "Сигналы", о чём было сказано выше, иначе сервис не будет получать новые данные.


Приложение для просмотра динамики свойств

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

Так как приложение должно иметь продвинутый графический интерфейс, за основу возьмём пример "EasyAndFastGUI — библиотека для создания графических интерфейсов" автора Anatoli Kazharski.

Программа для просмотра

a)

Веб-страница сигнала

b)

Рис. 2. Пользовательский интерфейс программы: показан график изменения свойства Equity выбранного сигнала (a); тот же график свойства Equity на веб-странице сигнала (b)

На рисунке 2а представлен внешний вид пользовательского интерфейса программы. В левой части установлен диапазон дат, в правой показан график свойства Equity выбранного сигнала. Для сравнения, на рисунке 2b мы видим скриншот веб-страницы этого сигнала с графиком свойства Equity. Причина небольших расхождений — "дыры" в базе данных, образованные во время простоя ПК в выключенном состоянии, а также относительно большой период обновления значений свойств сигналов в терминале.


Постановка задачи

Итак, пусть наше приложение обладает следующим функционалом:


Реализация

Из графических элементов нам понадобится блок из двух календарей для установки даты "от" и "до", группа комбо-боксов для выбора значений из списков, и блок полей ввода для редактирования крайних значений свойств, которым нужно задать диапазон. Для отключения условий используем ключевое значение "All" для списков, расположенное в самом начале, а блоки полей ввода укомплектуем чекбоксом, который по-умолчанию будет отключен.

Диапазон дат должен быть задан всегда. Всё остальное можно настроить по необходимости. На рисунке 2a видно, что в блоке строковых свойств жестко задана валюта и брокер, а имя автора сигнала может быть любое (значение "All").

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

Для того, чтобы иметь больше информации о взаимодействии нашей программы с сервером MySQL, в строке статуса выведем счётчики принятых и отправленных байт, а также время выполнения последней транзакции (рисунок 2). Если же транзакция завершилась неудачей, выведем код ошибки (рисунок 3).


Вывод ошибки в строке статуса

Рис.3. Вывод кода ошибки в строке состояния и сообщения во вкладке "Эксперты"

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

Так как тема данной статьи не относится к графике, мы не будем подробно останавливаться на построении пользовательского интерфейса. Тем более, что работа с используемой библиотекой подробно описана в цикле статей её автора. Изменения относительно примера, взятого за основу, потерпели всего лишь несколько файлов, а именно:

    • MainWindow.mqh — построение графического интерфейса
    • Program.mqh — взаимодействие с графическим интерфейсом
    • Main.mqh — работа с базой данных (добавлен)


    Множественные запросы

    Запросы к базе данных, которые используются в ходе работы программы, можно условно разделить на три группы:

    • Запросы для получения значений списков комбобоксов
    • Запросы для получения крайних значений блоков полей ввода
    • Запрос данных для построения графика

    Если во втором и третьем случае можно обойтись одним запросом SELECT, то в первом — для каждого из списков нужно отправлять отдельный запрос. В то же время нельзя растягивать по времени получение данных, в идеале все значения должны быть обновлены одномоментно. Точно так же, как нельзя обновить только часть списков. Для этого воспользуемся множественным запросом. Даже если время транзакции, включая обработку и транспорт, затянется, обновление интерфейса произойдёт только после того, как будут приняты все ответы сервера. А в случае ошибки ислючается частичное обновление списков графических элементов интерфейса.

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

    select `Currency` from `signals_mt5`.`metaquotes_demo__17273508` 
    where `TimeInsert`>='2019-11-01 00:00:00' AND `TimeInsert`<='2019-12-15 23:59:59'  
    group by `Currency`; 
    select `Broker` from `signals_mt5`.`metaquotes_demo__17273508` 
    where `TimeInsert`>='2019-11-01 00:00:00' AND `TimeInsert`<='2019-12-15 23:59:59'  
    group by `Broker`; 
    select `AuthorLogin` from `signals_mt5`.`metaquotes_demo__17273508` 
    where `TimeInsert`>='2019-11-01 00:00:00' AND `TimeInsert`<='2019-12-15 23:59:59'  
    group by `AuthorLogin`; 
    select `Id` from `signals_mt5`.`metaquotes_demo__17273508` 
    where `TimeInsert`>='2019-11-01 00:00:00' AND `TimeInsert`<='2019-12-15 23:59:59'  
    group by `Id`; 
    select  Min(`Equity`) as EquityMin,             Max(`Equity`) as EquityMax, 
            Min(`Gain`) as GainMin,                 Max(`Gain`) as GainMax, 
            Min(`Drawdown`) as DrawdownMin,         Max(`Drawdown`) as DrawdownMax, 
            Min(`Subscribers`) as SubscribersMin,   Max(`Subscribers`) as SubscribersMax 
    from `signals_mt5`.`metaquotes_demo__17273508` 
    where `TimeInsert`>='2019-11-01 00:00:00' AND `TimeInsert`<='2019-12-15 23:59:59'

    Как видно, это последовательность из пяти запросов "SELECT", разделённых знаком ";". Первые четыре запрашивают списки уникальных значений указанных свойств (Currency, Broker, AuthorLogin и Id) в указанном промежутке времени. Пятый запрос предназначен для получения минимального и максимально значения четырех свойств (Equity, Gain, Drawdown и Subscribers) из того же промежутка времени.

    Если посмотреть перехват обмена данными с сервером MySQL, увидим следующее: запрос (1) был отправлен в одном TCP-пакете, ответы на него (2) были доставлены в разных TCP-пакетах (см. рисунок 4).

    Множественный запрос в анализаторе трафика

    Рис. 4. Множественный запрос в анализаторе трафика

    Нужно учитывать одну особенность множественных запросов. Если какой-то из вложенных "SELECT" вызвал ошибку, то следующие за ним обработаны не будут. Другими словами, сервер MySQL обработает запросы до первой ошибки.


    Фильтры

    Для удобства работы с сигналами добавим фильтры, которые позволят сократить список сигналов, оставив лишь те, что соответствуют установленным требованиям. Например, нас интересуют сигналы с определённой базовой валютой, заданным диапазоном прироста или ненулевым количеством подписчиков. Для этого в запросе применим оператор "WHERE":

    select `Broker` from `signals_mt5`.`metaquotes_demo__17273508` 
    where `TimeInsert`>='2019-11-01 00:00:00' AND `TimeInsert`<='2019-12-15 23:59:59' AND `Currency`='USD' AND `Gain`>='100' AND `Gain`<='1399'  
    group by `Broker`; 
    select `AuthorLogin` from `signals_mt5`.`metaquotes_demo__17273508` 
    where `TimeInsert`>='2019-11-01 00:00:00' AND `TimeInsert`<='2019-12-15 23:59:59' AND `Currency`='USD' AND `Gain`>='100' AND `Gain`<='1399'  
    group by `AuthorLogin`; 
    select `Id` from `signals_mt5`.`metaquotes_demo__17273508` 
    where `TimeInsert`>='2019-11-01 00:00:00' AND `TimeInsert`<='2019-12-15 23:59:59' AND `Currency`='USD' AND `Gain`>='100' AND `Gain`<='1399'  
    group by `Id`; 
    select  Min(`Equity`) as EquityMin,             Max(`Equity`) as EquityMax, 
            Min(`Gain`) as GainMin,                 Max(`Gain`) as GainMax, 
            Min(`Drawdown`) as DrawdownMin,         Max(`Drawdown`) as DrawdownMax, 
            Min(`Subscribers`) as SubscribersMin,   Max(`Subscribers`) as SubscribersMax 
    from `signals_mt5`.`metaquotes_demo__17273508` 
    where `TimeInsert`>='2019-11-01 00:00:00' AND `TimeInsert`<='2019-12-15 23:59:59' AND `Currency`='USD' AND `Gain`>='100' AND `Gain`<='1399' 

    Приведённый выше запрос предназначен для получения списков комбобоксов и крайних значений полей ввода при условии, что базовая валюта — "USD", а значение прироста лежит в диапазоне от 100 до 1399. Здесь нужно в первую очередь обратить внимание на отсутствие запроса значений для списка "Currency". Это логично, так как выбирая конкретное значение в списке комбобокса, мы тем самым исключаем остальные. В то же время для полей ввода запрос значений выполняется всегда, даже если они используются в условии. Это сделано для того, чтобы пользователь видел реальный диапазон значений. Допустим, было введено минимальное значение прироста 100, но исходя из того набора данных, что соответствует выбранным критериям, ближайшее минимальное значение равно 135. Это значит, что после получения ответа сервера значение 100 будет исправлено на 135.

    После запроса с указанными фильтрами список значений комбобокса "Signal ID" значительно уменьшится. Можно будет выбрать какой-нибудь сигнал и наблюдать изменение его свойств на графике.


    Режим постоянного соединения Keep Alive

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

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

    Для активации режима постоянно соединения установим его таймаут, равный 60 секунд.

       if(CheckPointer(mysqlt)==POINTER_INVALID)
         {
          mysqlt = new CMySQLTransaction;
          mysqlt.Config(m_mysql_server,m_mysql_port,m_mysql_login,m_mysql_password,60000);
          mysqlt.PingPeriod(10000);
         }

    Это означает, что при бездействии пользователя дольше 60 секунд соединение будет закрыто.

    Посмотрим, как это будет выглядеть в работе. Допустим, пользователь изменил некий параметр и больше ничего не трогал в течение минуты. Перехват сетевых пакетов будет выглядеть так:

    Пинг в режиме постоянного соединения

    Рис. 5. Перехват пакетов при работе в режиме Keep Alive

    На рисунке виден запрос (1), серия пингов с периодом 10 секунд (2) и закрытие соединения по истечении минуты после запроса (3). Если бы пользователь продолжал работу с приложением и запросы отправлялись чаще одного раза в минуту, соединение не было бы закрыто.

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

    show variables 
            where `Variable_name`='interactive_timeout'

    Чаще всего это значение равно 3600 секунд. И теоретически достаточно отправлять пинг с периодом, который меньше таймаута сервера, и таким образом предотвратить закрытие соединения с его стороны. Но в этом случае о потере связи мы узнаем только в момент отправки следующего запроса. А при установке значения 10 секунд о разрыве соединения, если это вдруг произойдёт, станет известно практически сразу.


    Получение данных

    Рассмотрим разбор ответа сервера на множественный запрос на примере реализации метода GetData, который предназначен для обновления содержимого выпадающих списков, крайних значений полей ввода, а также графика изменения выбранного свойства с течением времени.
    void CMain::GetData(void)
      {
       if(CheckPointer(mysqlt)==POINTER_INVALID)
         {
          mysqlt = new CMySQLTransaction;
          mysqlt.Config(m_mysql_server,m_mysql_port,m_mysql_login,m_mysql_password,60000);
          mysqlt.PingPeriod(10000);
         }
    //--- Сохраняем signal id
       string signal_id = SignalId();
       if(signal_id=="Select...")
          signal_id="";
    //--- Составляем запрос
       string   q = "";
       if(Currency()=="All")
         {
          q+= "select `Currency` from `"+m_mysql_db+"`.`"+m_mysql_table+"` where "+Condition()+" group by `Currency`; ";
         }
       if(Broker()=="All")
         {
          q+= "select `Broker` from `"+m_mysql_db+"`.`"+m_mysql_table+"` where "+Condition()+" group by `Broker`; ";
         }
       if(Author()=="All")
         {
          q+= "select `AuthorLogin` from `"+m_mysql_db+"`.`"+m_mysql_table+"` where "+Condition()+" group by `AuthorLogin`; ";
         }
       q+= "select `Id` from `"+m_mysql_db+"`.`"+m_mysql_table+"` where "+Condition()+" group by `Id`; ";
       q+= "select Min(`Equity`) as EquityMin, Max(`Equity`) as EquityMax";
       q+= ", Min(`Gain`) as GainMin, Max(`Gain`) as GainMax";
       q+= ", Min(`Drawdown`) as DrawdownMin, Max(`Drawdown`) as DrawdownMax";
       q+= ", Min(`Subscribers`) as SubscribersMin, Max(`Subscribers`) as SubscribersMax from `"+m_mysql_db+"`.`"+m_mysql_table+"` where "+Condition();
    //--- Выведем результат транзакции в строке состояния
       if(UpdateStatusBar(mysqlt.Query(q))==false)
          return;
    //--- Установим принятые значения в списки комбо-боксов и крайние значения полей ввода
       uint responses = mysqlt.Responses();
       for(uint j=0; j<responses; j++)
         {
          if(mysqlt.Response(j).Fields()<1)
             continue;
          if(UpdateComboBox(m_currency,mysqlt.Response(j),"Currency")==true)
             continue;
          if(UpdateComboBox(m_broker,mysqlt.Response(j),"Broker")==true)
             continue;
          if(UpdateComboBox(m_author,mysqlt.Response(j),"AuthorLogin")==true)
             continue;
          if(UpdateComboBox(m_signal_id,mysqlt.Response(j),"Id",signal_id)==true)
             continue;
          //
          UpdateTextEditRange(m_equity_from,m_equity_to,mysqlt.Response(j),"Equity");
          UpdateTextEditRange(m_gain_from,m_gain_to,mysqlt.Response(j),"Gain");
          UpdateTextEditRange(m_drawdown_from,m_drawdown_to,mysqlt.Response(j),"Drawdown");
          UpdateTextEditRange(m_subscribers_from,m_subscribers_to,mysqlt.Response(j),"Subscribers");
         }
       GetSeries();
      }

    Вначале формируем запрос. При этом, что касается списков комбо-боксов, то в запрос войдут только те, где текущее выбранное значение - "All". Сборка условий реализована в отдельном методе Condition():

    string CMain::Condition(void)
      {
    //--- Добавление границ времени
       string s = "`TimeInsert`>='"+time_from(TimeFrom())+"' AND `TimeInsert`<='"+time_to(TimeTo())+"' ";
    //--- Добавление остальных условий, если это требуется
    //--- Для выпадающих списков текущее значение не должно равняться "All"
       if(Currency()!="All")
          s+= "AND `Currency`='"+Currency()+"' ";
       if(Broker()!="All")
         {
          string broker = Broker();
          //--- в именах некоторых брокеров попадаются символы, которые нужно экранировать
          StringReplace(broker,"'","\\'");
          s+= "AND `Broker`='"+broker+"' ";
         }
       if(Author()!="All")
          s+= "AND `AuthorLogin`='"+Author()+"' ";
    //--- Для полей ввода должен быть установлен чек-бокс
       if(m_equity_from.IsPressed()==true)
          s+= "AND `Equity`>='"+m_equity_from.GetValue()+"' AND `Equity`<='"+m_equity_to.GetValue()+"' ";
       if(m_gain_from.IsPressed()==true)
          s+= "AND `Gain`>='"+m_gain_from.GetValue()+"' AND `Gain`<='"+m_gain_to.GetValue()+"' ";
       if(m_drawdown_from.IsPressed()==true)
          s+= "AND `Drawdown`>='"+m_drawdown_from.GetValue()+"' AND `Drawdown`<='"+m_drawdown_to.GetValue()+"' ";
       if(m_subscribers_from.IsPressed()==true)
          s+= "AND `Subscribers`>='"+m_subscribers_from.GetValue()+"' AND `Subscribers`<='"+m_subscribers_to.GetValue()+"' ";
       return s;
      }

    Если транзакция прошла успешно, получаем количество ответов, которые далее разбираем в цикле.

    Метод UpdateComboBox() предназначен для обновления данных в комбобоксах. Он получает указатель на ответ и соответствующее ему имя поля. Если поле существует в ответе, данные будут записаны в список комбо-бокса и метод вернёт true. Аргумент set_value содержит в себе значение из предыдущего списка, которое было выбрано пользователем на момент запроса. Его нужно будет найти в новом списке и установить, как текущее. Если же в новом списке указанного значение не окажется, будет установлено значение под индексом 1 (следующее за "Select...").

    bool CMain::UpdateComboBox(CComboBox &object, CMySQLResponse *p, string name, string set_value="")
      {
       int col_idx = p.Field(name);
       if(col_idx<0)
          return false;
       uint total = p.Rows()+1;
       if(total!=object.GetListViewPointer().ItemsTotal())
         {
          string tmp = object.GetListViewPointer().GetValue(0);
          object.GetListViewPointer().Clear();
          object.ItemsTotal(total);
          object.SetValue(0,tmp);
          object.GetListViewPointer().YSize(18*((total>16)?16:total)+3);
         }
       uint set_val_idx = 0;
       for(uint i=1; i<total; i++)
         {
          string value = p.Value(i-1,col_idx);
          object.SetValue(i,value);
          if(set_value!="" && value==set_value)
             set_val_idx = i;
         }
    //--- если нет указанного, но есть другие, выбираем самый верхний
       if(set_value!="" && set_val_idx==0 && total>1)
          set_val_idx=1;
    //---
       ComboSelectItem(object,set_val_idx);
    //---
       return true;
      }

    Метод UpdateTextEditRange() обновляет крайние значения группы полей ввода текста.

    bool CMain::UpdateTextEditRange(CTextEdit &obj_from,CTextEdit &obj_to, CMySQLResponse *p, string name)
      {
       if(p.Rows()<1)
          return false;
       else
          return SetTextEditRange(obj_from,obj_to,p.Value(0,name+"Min"),p.Value(0,name+"Max"));
      }

    Перед выходом из GetData() будет вызван метод GetSeries(), который выбирает данные по идентификатору сигнала и имени свойства:

    void CMain::GetSeries(void)
      {
       if(SignalId()=="Select...")
         {
          // если сигнал не выбран
          ArrayFree(x_buf);
          ArrayFree(y_buf);
          UpdateSeries();
          return;
         }
       if(CheckPointer(mysqlt)==POINTER_INVALID)
         {
          mysqlt = new CMySQLTransaction;
          mysqlt.Config(m_mysql_server,m_mysql_port,m_mysql_login,m_mysql_password,60000);
          mysqlt.PingPeriod(10000);
         }
       string   q = "select `"+Parameter()+"` ";
       q+= "from `"+m_mysql_db+"`.`"+m_mysql_table+"` ";
       q+= "where `TimeInsert`>='"+time_from(TimeFrom())+"' AND `TimeInsert`<='"+time_to(TimeTo())+"' ";
       q+= "AND `Id`='"+SignalId()+"' order by `TimeInsert` asc";
    
    //--- Отправляем запрос
       if(UpdateStatusBar(mysqlt.Query(q))==false)
          return;
    //--- Проверим количество ответов
       if(mysqlt.Responses()<1)
          return;
       CMySQLResponse *r = mysqlt.Response(0);
       uint rows = r.Rows();
       if(rows<1)
          return;
    //--- копируем колонку в буфер данных графика (false - не проверять типы)
       if(r.ColumnToArray(Parameter(),y_buf,false)<1)
          return;
    //--- формируем подписи оси X
       if(ArrayResize(x_buf,rows)!=rows)
          return;
       for(uint i=0; i<rows; i++)
          x_buf[i] = i;
    //--- Обновляем график
       UpdateSeries();
      }

    В целом, его реализация похожа на рассмотренный выше метод GetData(). Но есть две вещи, на которые стоит обратить внимание:

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

    Методы GetData() и GetSeries() вызываются при изменении любого из значений графических элементов:

    //+------------------------------------------------------------------+
    //| Обработчик события изменения значения в комбо-боксе "Broker"     |
    //+------------------------------------------------------------------+
    void CMain::OnChangeBroker(void)
      {
       m_duration=0;
       GetData();
      }
    
    ...
    
    //+------------------------------------------------------------------+
    //| Обработчик события изменения значения в комбо-боксе "SignalId"   |
    //+------------------------------------------------------------------+
    void CMain::OnChangeSignalId(void)
      {
       m_duration=0;
       GetSeries();
      }

    Выше показаны исходные коды обработчика комбобокса "Broker" и "Signal ID", остальные реализованы аналогично. При выборе другого брокера будет вызван метод GetData(), и уже из него GetSeries(). При выборе другого сигнала сразу вызывается GetSeries().

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

    Демонстрация работы приложения представлена на рисунке 6.

    Демонстрация работы приложения

    Рис. 6. Демонстрация работы приложения для просмотра динамики свойств сигналов



    Заключение

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

    Что же касается сетевых функций, то работа с MySQL — лишь малая часть того, что можно реализовать с их помощью, не прибегая к использованию динамических библиотек. Мы живём в веке сетевых технологий, и добавление группы функций Socket несомненно является серьёзным шагом в развитии языка MQL5.

    Содержимое вложенного архива: