English 中文 Español Deutsch 日本語 Português
Многопоточный асинхронный WebRequest на MQL5 своими руками

Многопоточный асинхронный WebRequest на MQL5 своими руками

MetaTrader 5Эксперты | 8 ноября 2018, 11:14
4 994 50
Stanislav Korotky
Stanislav Korotky

Реализация торговых алгоритмов часто требует анализа информации из различных внешних источников, в частности из Internet. MQL5 предоставляет функцию WebRequest для отправки HTTP-запросов во "внешний мир", но она, к сожалению, обладает одним заметным недостатком. Эта функция является синхронной, а потому блокирует работу эксперта на все время выполнения запроса. Напомним, что для каждого эксперта в MetaTrader 5 выделен один единственный поток, который последовательно выполняет имеющиеся вызовы функций API в коде, а также обработчики поступающих событий (таких как тики, изменения стакана в BookEvent, таймер, торговые транзакции, события графика и т.д.). В каждый момент времени выполняется только один фрагмент кода, а все остальные "задачи" ожидают своего момента в очередях, пока текущий фрагмент не вернет управление ядру.

Например, если эксперт должен обрабатывать новые тики в реальном времени, и в дополнение к этому - периодически проверять экономические новости на одном или нескольких сайтах, то выполнить оба требования без какого-либо ущерба друг другу невозможно. Как только в коде выполняется WebRequest, эксперт остается "застывшим" на строке с вызовом функции, а события о новых тиках пропускаются. Даже с учетом того, что пропущенные тики можно затем прочитать с помощью функции CopyTicks, момент для принятия решения может быть упущен. Вот как данная ситуация иллюстрируется с помощью UML диаграммы последовательности:

Диаграмма последовательности обработки событий с блокирующим кодом в одном потоке

Рис.1 Диаграмма последовательности обработки событий с блокирующим кодом в одном потоке

В связи с этим, желательно создать инструмент для асинхронного, неблокирующего выполнения HTTP-запросов, своего рода WebRequestAsync. Очевидно, что для этого необходимо где-то "разжиться" дополнительными потоками. В MetaTrader 5 это наиболее просто сделать, запустив некие вспомогательные эксперты, в которые можно было бы отправлять информацию о HTTP-запросах, вызывать там WebRequest и спустя некоторое время получать в ответ результаты. Пока запрос обрабатывается в таком вспомогательном эксперте, наш основной эксперт остается доступным для оперативных и интерактивных действий. Для такого случая UML диаграмма последовательности может выглядеть следующим образом:

Диаграмма последовательности с делегированием асинхронной обработки событий в другие потоки

Рис.2 Диаграмма последовательности с делегированием асинхронной обработки событий в другие потоки


1. Проектирование

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

Архитектура библиотеки multiweb: клиентский MQL код - сервер (менеджер пула помощников) - эксперты-помощники

Рис. 3 Архитектура библиотеки multiweb: клиентский MQL код <-> сервер (менеджер пула помощников) <-> эксперты-помощники

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

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

Как известно, в MetaTrader 5 существует два варианта функции WebRequest, и мы будем рассматривать второй из них, как наиболее универсальный.

int WebRequest
( 
  const string      method,           // метод HTTP 
  const string      url,              // url-адрес 
  const string      headers,          // заголовки  
  int               timeout,          // таймаут 
  const char        &data[],          // массив тела HTTP-сообщения 
  char              &result[],        // массив с данными ответа сервера 
  string            &result_headers   // заголовки ответа сервера 
);

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

int WebRequestAsync
( 
  const string      method,           // метод HTTP 
  const string      url,              // url-адрес 
  const string      headers,          // заголовки  
  int               timeout,          // таймаут 
  const char        &data[],          // массив тела HTTP-сообщения 
);

int WebRequestAsyncResult
( 
  char              &result[],        // массив с данными ответа сервера 
  string            &result_headers   // заголовки ответа сервера 
);

Названия и прототипы функций — условные. На самом деле нам нужно передавать эту информацию между разными MQL программами, и обычные вызовы функций для этого не подходят. Для "общения" MQL программ между собой в MetaTrader 5 предусмотрена система обмена пользовательскими событиями. Её то мы и будем использовать. Важно отметить, что обмен событиями производится на основе идентификации получателя с помощью ChartID — у каждого графика он уникален. Эксперт на графике может быть только один, но в случае индикаторов такого ограничения нет, и потому пользователь должен сам позаботиться, чтобы на одном графике не было более одного индикатора, общающегося с менеджером.

Для того, чтобы передача данных заработала, необходимо каким-то образом упаковать все параметры "функций" в параметры пользовательских событий. Как параметры запроса, так и результаты могут содержать довольно большие объемы информации, которые физически не поместятся в ограниченные рамки событий. Например, даже если бы мы решили передавать HTTP-метод и URL в строковом параметре события sparam, ограничение длины в 63 символа стало бы препятствием в большинстве рабочих случаев. Значит систему обмена событий требуется дополнить неким разделяемым хранилищем информации, и в параметрах событий передавать только ссылки на записи в этом хранилище. К счастью, MetaTrader 5 предоставляет такое хранилище в виде пользовательских ресурсов. В принципе, динамически создаваемые из MQL ресурсы — это всегда изображения. Но по сути, изображение — это контейнер двоичной информации, куда можно записать все, что угодно.

Чтобы упростить задачу воспользуемся готовым решением для записи и чтения произвольных данных в пользовательские ресурсы — классами из Resource.mqh и ResourceData.mqh, разработанными участником MQL5 сообщества fxsaber.

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

Нам не нужно углубляться во внутреннее устройство этих вспомогательных классов. Главное заключается в том, что мы можем положиться на готовый класс RESOURCEDATA как на "черный ящик" и использовать его конструктор и пару подходящих для нас функций. Более подробно мы рассмотрим это чуть позже. А сейчас вернемся к проработке общей концепции.

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

  1. Для выполнения асинхронного веб-запроса клиентская MQL программа должна с помощью классов, которые мы разработаем, упаковать параметры запроса в локальный ресурс и послать пользовательское событие менеджеру со ссылкой на ресурс; ресурс создается внутри клиентской программы и не удаляется вплоть до получения результатов (когда он становится ненужным);
  2. Менеджер находит в пуле свободный эксперт-помощник и переправляет ему ссылку на ресурс; при этом данный экземпляр помечается временно занятым и не может быть выбран для последующих запросов, пока не будет отработан текущий запрос;
  3. В эксперте-помощнике, получившем пользовательское событие, происходит распаковка параметров веб-запроса из внешнего ресурса клиента;
  4. Эксперт-помощник вызывает стандартный блокирующий WebRequest и дожидается ответа (заголовок и/или веб-документ);
  5. Эксперт-помощник упаковывает результаты запроса в свой локальный ресурс и отправляет пользовательское событие менеджеру с ссылкой на этот ресурс;
  6. Менеджер пересылает данное событие клиенту и помечает соответствующий помощник вновь свободным;
  7. Клиент получает сообщение от менеджера и распаковывает результат запроса из внешнего ресурса помощника;
  8. Клиент и помощник могут удалить свои локальные ресурсы.

На шагах 5 и 6 можно организовать передачу результата более эффективно за счет того, что эксперт-помощник отправит результат напрямую в окно клиента, минуя менеджер.

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

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

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

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

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

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

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


2. Реализация

Для упрощения разработки создадим единый заголовочный файл multiweb.mqh, в котором опишем все классы: часть из них будет общей для клиента и "серверов", а часть — унаследованной, специфической для каждой из этих ролей.

2.1. Базовые классы (начало)

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

class WebWorker
{
  protected:
    long chartID;
    bool busy;
    const RESOURCEDATA<uchar> *resource;
    const string prefix;
    
    const RESOURCEDATA<uchar> *allocate()
    {
      release();
      resource = new RESOURCEDATA<uchar>(prefix + (string)chartID);
      return resource;
    }
    
  public:
    WebWorker(const long id, const string p = "WRP_"): chartID(id), busy(false), resource(NULL), prefix("::" + p)
    {
    }

    ~WebWorker()
    {
      release();
    }
    
    long getChartID() const
    {
      return chartID;
    }
    
    bool isBusy() const
    {
      return busy;
    }
    
    string getFullName() const
    {
      return StringSubstr(MQLInfoString(MQL_PROGRAM_PATH), StringLen(TerminalInfoString(TERMINAL_PATH)) + 5) + prefix + (string)chartID;
    }
    
    virtual void release()
    {
      busy = false;
      if(CheckPointer(resource) == POINTER_DYNAMIC) delete resource;
      resource = NULL;
    }

    static void broadcastEvent(ushort msg, long lparam = 0, double dparam = 0.0, string sparam = NULL)
    {
      long currChart = ChartFirst(); 
      while(currChart != -1)
      {
        if(currChart != ChartID())
        {
          EventChartCustom(currChart, msg, lparam, dparam, sparam); 
        }
        currChart = ChartNext(currChart);
      }
    }
};

Переменные:

  • chartID — идентификатор графика, к которому прикреплена MQL программа;
  • busy — признак того, занят ли данный экземпляр обработкой веб-запроса;
  • resource — объект ресурса - хранилища произвольных данных; класс RESOURCEDATA берется из ResourceData.mqh;
  • prefix — уникальный префикс для каждой роли; префикс используется в названии ресурсов; в конкретном клиенте рекомендуется делать свою уникальную настройку, как это будет продемонстрировано далее; эксперты-помощники по-умолчанию используют префикс "WRR_" (сокращенно от Web Request Result);

Метод allocate, который будет использоваться в производных классах, создает объект ресурса типа RESOURCEDATA<uchar> в переменной resource, причем в имени ресурса помимо префикса также участвует идентификатор графика. Освобождение ресурса можно выполнить с помощью метода release.

Особо следует отметить метод getFullName, который возвращает полное имя ресурса, включающее название и каталог расположения текущей MQL программы. Именно по такому полному имени можно обращаться к ресурсам из сторонних программ (но только на чтение). Например, если эксперт multiweb.mq5 размещен в каталоге MQL5\Experts и запущен на графике с идентификатором 129912254742671346, то ресурс в нем получит полное имя "\Experts\multiweb.ex5::WRR_129912254742671346". Такого вида строки мы и будем передавать в качестве ссылки на ресурсы с помощью строкового параметра sparam пользовательских событий.

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

Для работы с запросом и связанным с ним ресурсом в клиентской программе определим класс ClientWebWorker, производный от WebWorker (здесь и далее код приводится с сокращениями, полные версии — в приложенных файлах).

class ClientWebWorker : public WebWorker
{
  protected:
    string _method;
    string _url;
    
  public:
    ClientWebWorker(const long id, const string p = "WRP_"): WebWorker(id, p)
    {
    }

    string getMethod() const
    {
      return _method;
    }

    string getURL() const
    {
      return _url;
    }
    
    bool request(const string method, const string url, const string headers, const int timeout, const uchar &body[], const long managerChartID)
    {
      _method = method;
      _url = url;

      // allocate()? and what's next?
      ...
    }
    
    static void receiveResult(const string resname, uchar &initiator[], uchar &headers[], uchar &text[])
    {
      Print(ChartID(), ": Reading result ", resname);
      
      ...
    }
};

Прежде всего, обратим внимание, что метод request является фактической реализацией шага 1, рассмотренного выше. Здесь производится отправка веб-запроса в менеджер, и объявление метода следует прототипу гипотетического WebRequestAsync. Статический метод receiveResult выполняет обратное действие с шага 7. В качестве первого входного параметра resname он получает полное имя внешнего ресурса, в котором хранятся результаты запроса, а байтовые массивы initiator, headers и text должны заполниться внутри метода распакованными данными из ресурса.

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

Метод receiveResult является статическим, т.к. не использует никаких переменных объекта — все результаты возвращаются вызывающему коду через параметры.

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


2.2. Упаковка запросов и результатов запросов в ресурсы

Как мы помним, ресурсы предполагается обрабатывать на нижнем уровне с помощью класса RESOURCEDATA. Он является шаблонным — то есть принимает параметр с типом данных, которые мы будем записывать и считывать в или из ресурса. Поскольку наши данные содержат в том числе и строки, единицей хранения логично выбрать наименьший по размеру тип uchar. Таким образом, контейнером данных будет объект класса RESOURCEDATA<uchar>. При создании ресурса в его конструкторе указывается уникальное (для программы) имя name:

RESOURCEDATA<uchar>(const string name)

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

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

void operator=(const uchar &array[]) const

где array — это некий массив, который мы должны подготовить.

Чтение данных из ресурса производится с помощью функции:

int Get(uchar &array[]) const

Здесь array - это выходной параметр, куда будет помещено содержимое исходного массива.

Теперь обратимся к прикладному аспекту использования ресурсов для передачи данных о HTTP-запросах и их результатов. Создадим класс-прослойку между ресурсами и нашим основным кодом - ResourceMediator. Он будет на стороне клиента упаковывать параметры method, url, headers, timeout, data в байтовый массив array и затем записывать в ресурс, а на стороне сервера - распаковывать параметры из ресурса. Аналогичным образом этот класс будет упаковывать на стороне сервера параметры result и result_headers в байтовый массив array и записывать в ресурс, чтобы затем прочитать как массив и распаковать на стороне клиента.

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

Например, если мы просто воспользуемся функцией StringToCharArray, чтобы преобразовать URL в массив байтов, то при выполнении обратной операции с помощью CharArrayToString необходимо задать длину массива. В противном случае — если её не указать — из массива будут прочитаны не только байты URL, но и следующего за ним поля с заголовком. Напомню, что все данные мы складываем в единый массив перед записью в ресурс. Мета-информация о длине полей также должна преобразовываться в последовательность байтов, для чего используются объединения (union).

#define LEADSIZE (sizeof(int)*5) // 5 fields in web-request

class ResourceMediator
{
  private:
    const RESOURCEDATA<uchar> *resource; // underlying asset
    
    // meta-data in header is represented as 5 ints `lengths` and/or byte array `sizes`
    union lead
    {
      struct _l
      {
        int m; // method
        int u; // url
        int h; // headers
        int t; // timeout
        int b; // body
      }
      lengths;
      
      uchar sizes[LEADSIZE];
      
      int total()
      {
        return lengths.m + lengths.u + lengths.h + lengths.t + lengths.b;
      }
    }
    metadata;
  
    // represent int as byte array and vice versa
    union _s
    {
      int x;
      uchar b[sizeof(int)];
    }
    int2chars;
    
    
  public:
    ResourceMediator(const RESOURCEDATA<uchar> *r): resource(r)
    {
    }
    
    void packRequest(const string method, const string url, const string headers, const int timeout, const uchar &body[])
    {
      // fill metadata with parameters data lengths
      metadata.lengths.m = StringLen(method) + 1;
      metadata.lengths.u = StringLen(url) + 1;
      metadata.lengths.h = StringLen(headers) + 1;
      metadata.lengths.t = sizeof(int);
      metadata.lengths.b = ArraySize(body);
      
      // allocate resulting array to fit metadata plus parameters data
      uchar data[];
      ArrayResize(data, LEADSIZE + metadata.total());
      
      // put metadata as byte array at the beginning of the array
      ArrayCopy(data, metadata.sizes);
      
      // put all data fields into the array, one by one
      int cursor = LEADSIZE;
      uchar temp[];
      StringToCharArray(method, temp);
      ArrayCopy(data, temp, cursor);
      ArrayResize(temp, 0);
      cursor += metadata.lengths.m;
      
      StringToCharArray(url, temp);
      ArrayCopy(data, temp, cursor);
      ArrayResize(temp, 0);
      cursor += metadata.lengths.u;
      
      StringToCharArray(headers, temp);
      ArrayCopy(data, temp, cursor);
      ArrayResize(temp, 0);
      cursor += metadata.lengths.h;
      
      int2chars.x = timeout;
      ArrayCopy(data, int2chars.b, cursor);
      cursor += metadata.lengths.t;
      
      ArrayCopy(data, body, cursor);
      
      // store the array in the resource
      resource = data;
    }
    
    ...

Метод packRequest первым делом записывает размеры всех полей в структуру metadata. Затем содержимое этой структуры, но в виде массива байтов, копируется в начало массива data, который впоследствии и будет помещен в ресурс. Размер массива data резервируется исходя из общей длины всех полей и размера самой структуры с мета-данными. Параметры типа string преобразуются в массивы с помощью StringToCharArray и копируются в результирующий массив по соответствующему смещению, которое все время поддерживается в актуальном состоянии в переменной cursor. Параметр timeout преобразуется в массив символов с помощью объединения int2chars. Параметр body копируется в массив как есть, поскольку он уже предсталяет собой массив требуемого типа. Наконец, непосредственно перенос содержимого общего массива в ресурс производится в строке (напомним, оператор = перегружен в классе RESOURCEDATA):

      resource = data;

Обратная операция по извлечению параметров запроса из ресурса выполняется в методе unpackRequest.

    void unpackRequest(string &method, string &url, string &headers, int &timeout, uchar &body[])
    {
      uchar array[];
      // fill array with data from resource  
      int n = resource.Get(array);
      Print(ChartID(), ": Got ", n, " bytes in request");
      
      // read metadata from the array
      ArrayCopy(metadata.sizes, array, 0, 0, LEADSIZE);
      int cursor = LEADSIZE;

      // read all data fields, one by one      
      method = CharArrayToString(array, cursor, metadata.lengths.m);
      cursor += metadata.lengths.m;
      url = CharArrayToString(array, cursor, metadata.lengths.u);
      cursor += metadata.lengths.u;
      headers = CharArrayToString(array, cursor, metadata.lengths.h);
      cursor += metadata.lengths.h;
      
      ArrayCopy(int2chars.b, array, 0, cursor, metadata.lengths.t);
      timeout = int2chars.x;
      cursor += metadata.lengths.t;
      
      if(metadata.lengths.b > 0)
      {
        ArrayCopy(body, array, 0, cursor, metadata.lengths.b);
      }
    }
    
    ...

Здесь основную работу выполняет строка с вызовом resource.Get(array). Далее из массива array поэтапно читаются байты метаданных и на их основе — все последующие поля.

Аналогичным образом упаковываются и распаковываются результаты выполнения запросов с помощью методов packResponse и unpackResponse (полный код — в приложении).

    void packResponse(const string source, const uchar &result[], const string &result_headers);
    void unpackResponse(uchar &initiator[], uchar &headers[], uchar &text[]);

Теперь мы можем вернуться к исходному коду ClientWebWorker и завершить методы request и receiveResult.

class ClientWebWorker : public WebWorker
{
    ...

    bool request(const string method, const string url, const string headers, const int timeout, const uchar &body[], const long managerChartID)
    {
      _method = method;
      _url = url;

      ResourceMediator mediator(allocate());
      mediator.packRequest(method, url, headers, timeout, body);
    
      busy = EventChartCustom(managerChartID, 0 /* TODO: specific message */, chartID, 0.0, getFullName());
      return busy;
    }
    
    static void receiveResult(const string resname, uchar &initiator[], uchar &headers[], uchar &text[])
    {
      Print(ChartID(), ": Reading result ", resname);
      const RESOURCEDATA<uchar> resource(resname);
      ResourceMediator mediator(&resource);
      mediator.unpackResponse(initiator, headers, text);
    }
};

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

Остается вопрос — кто и когда будет вызывать методы WebWorker-ов и как для них получить значения некоторых служебных параметров, таких как managerChartID в методе request? Чуть забегая вперед, ответим, что управление всеми объектами классов WebWorker логично поручить неким более высокоуровневым классам, которые поддерживали бы актуальные списки объектов и обменивались сообщениями между программами "от имени" объектов, включая и сообщения поиска менеджера. Но прежде чем мы перейдем на этот новый уровень небходимо завершить аналогичную подготовку в "серверной" части.


2.3. Базовые классы (продолжение)

Точно также как на стороне клиента асинхронными запросами занимается класс ClientWebWorker, на стороне "сервера" — в менеджере — для этих целей объявим собственный производный от WebWorker.

class ServerWebWorker : public WebWorker
{
  public:
    ServerWebWorker(const long id, const string p = "WRP_"): WebWorker(id, p)
    {
    }
    
    bool transfer(const string resname, const long clientChartID)
    {
      // respond to the client with `clientChartID` that the task in `resname` was accepted
      // and pass the task to this specific worker identified by `chartID` 
      busy = EventChartCustom(clientChartID, TO_MSG(MSG_ACCEPTED), chartID, 0.0, resname)
          && EventChartCustom(chartID, TO_MSG(MSG_WEB), clientChartID, 0.0, resname);
      return busy;
    }
    
    void receive(const string source, const uchar &result[], const string &result_headers)
    {
      ResourceMediator mediator(allocate());
      mediator.packResponse(source, result, result_headers);
    }
};

Метод transfer осуществляет делегирование обработки запроса конкретному экземпляру эксперта-помощника согласно шагу 2 в общей последовательности взаимодействия. Параметр resname — это имя ресурса, полученное от клиента, а clientChartID — идентификатор окна клиента. Все эти параметры мы получим из пользовательских событий. Сами пользовательские события, в частности — MSG_WEB, будут описаны ниже.

Метод receive создает локальный ресурс в текущем объекте WebWorker-е (вызов allocate) и записывает туда название исходного ресурса-инициатора запроса (source), а также полученные из интернета данные (result) и HTTP-заголовки (result_headers) с помощью объекта mediator класса ResourceMediator. Это — часть шага 5 из общей последовательности.

Итак, мы определили классы WebWorker-ов и для клиента, и для "сервера". В обоих случаях данные объекты будут, скорее всего, создаваться в больших количествах. Например, один клиент может скачивать сразу несколько документов, а на стороне менеджера изначально желательно распределить достаточное количество помощников, так как запросы могут поступать одновременно от многих клиентов. Для организации массива объектов определим специальный базовый класс WebWorkersPool. Сделаем его шаблонным, поскольку тип хранимых объектов будет различаться на клиенте и на "сервере" — соответственно, ClientWebWorker и ServerWebWorker.

template<typename T>
class WebWorkersPool
{
  protected:
    T *workers[];
    
  public:
    WebWorkersPool() {}
    
    WebWorkersPool(const uint size)
    {
      // allocate workers; in clients they are used to store request parameters in resources
      ArrayResize(workers, size);
      for(int i = 0; i < ArraySize(workers); i++)
      {
        workers[i] = NULL;
      }
    }
    
    ~WebWorkersPool()
    {
      for(int i = 0; i < ArraySize(workers); i++)
      {
        if(CheckPointer(workers[i]) == POINTER_DYNAMIC) delete workers[i];
      }
    }
    
    int size() const
    {
      return ArraySize(workers);
    }
    
    void operator<<(T *worker)
    {
      const int n = ArraySize(workers);
      ArrayResize(workers, n + 1);
      workers[n] = worker;
    }
    
    T *findWorker(const string resname) const
    {
      for(int i = 0; i < ArraySize(workers); i++)
      {
        if(workers[i] != NULL)
        {
          if(workers[i].getFullName() == resname)
          {
            return workers[i];
          }
        }
      }
      return NULL;
    }
    
    T *getIdleWorker() const
    {
      for(int i = 0; i < ArraySize(workers); i++)
      {
        if(workers[i] != NULL)
        {
          if(ChartPeriod(workers[i].getChartID()) > 0) // check if exist
          {
            if(!workers[i].isBusy())
            {
              return workers[i];
            }
          }
        }
      }
      return NULL;
    }
    
    T *findWorker(const long id) const
    {
      for(int i = 0; i < ArraySize(workers); i++)
      {
        if(workers[i] != NULL)
        {
          if(workers[i].getChartID() == id)
          {
            return workers[i];
          }
        }
      }
      return NULL;
    }
    
    bool revoke(const long id)
    {
      for(int i = 0; i < ArraySize(workers); i++)
      {
        if(workers[i] != NULL)
        {
          if(workers[i].getChartID() == id)
          {
            if(CheckPointer(workers[i]) == POINTER_DYNAMIC) delete workers[i];
            workers[i] = NULL;
            return true;
          }
        }
      }
      return false;
    }
    
    int available() const
    {
      int count = 0;
      for(int i = 0; i < ArraySize(workers); i++)
      {
        if(workers[i] != NULL)
        {
          count++;
        }
      }
      return count;
    }
    
    T *operator[](int i) const
    {
      return workers[i];
    }
    
};

Суть методов проста. Конструктор и деструктор выделяют и освобождают массив обработчиков заданного размера. Группа методов findWorker и getIdleWorker осуществляет поиск объектов в массиве по различным критериям. Оператор operator<< позволяет динамически добавлять объекты, а метод revoke — динамически удалять.

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

template<typename T>
class ClientWebWorkersPool: public WebWorkersPool<T>
{
  protected:
    long   managerChartID;
    short  managerPoolSize;
    string name;
    
  public:
    ClientWebWorkersPool(const uint size, const string prefix): WebWorkersPool(size)
    {
      name = prefix;
      // try to find WebRequest manager chart
      WebWorker::broadcastEvent(TO_MSG(MSG_DISCOVER), ChartID());
    }
    
    bool WebRequestAsync(const string method, const string url, const string headers, int timeout, const char &data[])
    {
      T *worker = getIdleWorker();
      if(worker != NULL)
      {
        return worker.request(method, url, headers, timeout, data, managerChartID);
      }
      return false;
    }
    
    void onChartEvent(const int id, const long &lparam, const double &dparam, const string &sparam)
    {
      if(MSG(id) == MSG_DONE) // async request is completed with result or error
      {
        Print(ChartID(), ": Result code ", (long)dparam);
    
        if(sparam != NULL)
        {
          // read data from the resource with name in sparam
          uchar initiator[], headers[], text[];
          ClientWebWorker::receiveResult(sparam, initiator, headers, text);
          string resname = CharArrayToString(initiator);
          
          T *worker = findWorker(resname);
          if(worker != NULL)
          {
            worker.onResult((long)dparam, headers, text);
            worker.release();
          }
        }
      }
      
      ...
      
      else
      if(MSG(id) == MSG_HELLO) // manager is found as a result of MSG_DISCOVER broadcast
      {
        if(managerChartID == 0 && lparam != 0)
        {
          if(ChartPeriod(lparam) > 0)
          {
            managerChartID = lparam;
            managerPoolSize = (short)dparam;
            for(int i = 0; i < ArraySize(workers); i++)
            {
              workers[i] = new T(ChartID(), name + (string)(i + 1) + "_");
            }
          }
        }
      }
    }
    
    bool isManagerBound() const
    {
      return managerChartID != 0;
    }
};

Переменные:

  • managerChartID — идентификатор окна, где найден работающий менеджер;
  • managerPoolSize — начальный размер массива объектов-обработчиков;
  • name — общий префикс для ресурсов во всех объектах пула;


2.4. Обмен сообщениями

В конструкторе ClientWebWorkersPool мы видим вызов WebWorker::broadcastEvent(TO_MSG(MSG_DISCOVER), ChartID()), который рассылает событие MSG_DISCOVER всем окнам, передавая идентификатор текущего окна в параметре события. MSG_DISCOVER — это некое зарезервированное значение: его вместе с другими типами сообщений, которыми наши программы будут обмениваться, следует определить в начале этого же заголовочного файла.

#define MSG_DEINIT   1 // tear down (manager <-> worker)
#define MSG_WEB      2 // start request (client -> manager -> worker)
#define MSG_DONE     3 // request is completed (worker -> client, worker -> manager)
#define MSG_ERROR    4 // request has failed (manager -> client, worker -> client)
#define MSG_DISCOVER 5 // find the manager (client -> manager)
#define MSG_ACCEPTED 6 // request is in progress (manager -> client)
#define MSG_HELLO    7 // the manager is found (manager -> client)

В комментариях помечено направление, в котором посылается то или иное сообщение.

Макрос TO_MSG предназначен для трансформации перечисленных идентификаторов в реальные коды событий относительно произвольной, выбираемой пользователем, базовой величины. Её будем получать через входной параметр MessageBroadcast.

sinput uint MessageBroadcast = 1;
 
#define TO_MSG(X) ((ushort)(MessageBroadcast + X))

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

Входной параметр MessageBroadcast появится во всех наших MQL программах, в которые включен файл multiweb.mqh, то есть и в клиентах, и в менеджере. При запуске менеджера и клиентов следует указывать одно и то же значение MessageBroadcast.

Вернемся к классу ClientWebWorkersPool. Особое место занимает метод onChartEvent. Он будет вызываться из стандартного обработчика события OnChartEvent. Тип события передается в параметре id. Поскольку мы получаем от системы коды с учетом выбранного базового значения, для его перевода обратно в диапазон MSG_*** используем "зеркальный" макрос MSG:

#define MSG(x) (x - MessageBroadcast - CHARTEVENT_CUSTOM)

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

Как мы видим, метод onChartEvent в ClientWebWorkersPool обрабатывает некоторые из упомянутых выше сообщений. Так, в ответ на широковещательную рассылку MSG_DISCOVER менеджер должен ответить сообщением MSG_HELLO. В этом случае в параметре lparam передается идентификатор окна менеджера, а в параметре dparam для справки — количество доступных помощников. Когда менеджер обнаружен, пул заполняет до тех пор пустой массив workers реальными объектами требуемого типа. При этом в конструктор объектов передается идентификатор текущего окна, а также уникальное имя для ресурса в каждом объекте — оно состоит из общего префикса name и порядкового номера в массиве.

После того как поле managerChartID получило осмысленное значение, появляется возможность отправлять менеджеру запросы. В классе ClientWebWorker для этого был зарезервирован метод request, и его использование демонстрируется в методе WebRequestAsync из пула. Метод WebRequestAsync сперва находит свободный объект-обработчик с помощью getIdleWorker и затем вызывает для него worker.request(method, url, headers, timeout, data, managerChartID). Внутри метода request у нас был комментарий относительно выбора специального кода сообщений для отправки события. Теперь, после рассмотрения подсистемы событий, мы можем сформировать окончательную версию метода ClientWebWorker::request:

class ClientWebWorker : public WebWorker
{
    ...

    bool request(const string method, const string url, const string headers, const int timeout, const uchar &body[], const long managerChartID)
    {
      _method = method;
      _url = url;

      ResourceMediator mediator(allocate());
      mediator.packRequest(method, url, headers, timeout, body);
    
      busy = EventChartCustom(managerChartID, TO_MSG(MSG_WEB), chartID, 0.0, getFullName());
      return busy;
    }
    
    ...
};

MSG_WEB — сообщение о выполнении веб-запроса. Получив его, менеджер должен будет найти свободный эксперт-помощник и передать в него имя клиентского ресурса (sparam) с характеристиками запроса, а также идентификатор окна клиента chartID (lparam).

Помощник выполнит непосредственно запрос и вернет результаты клиенту с помощью события MSG_DONE (в случае успеха) или код ошибки с помощью MSG_ERROR (в случае проблем). Код результата (или ошибки) передается в dparam, а сам результат упакован в ресурс, находящийся в эксперте-помощнике, под именем, переданном в sparam. В ветви MSG_DONE мы видим, как из ресурса извлекаются данные с помощью вызова рассмотренной ранее функции ClientWebWorker::receiveResult(sparam, initiator, headers, text). Далее по имени ресурса инициатора запроса ищется клиентский объект-обработчик (findWorker) и на найденном объекте вызывается пара методов:

    T *worker = findWorker(resname);
    if(worker != NULL)
    {
      worker.onResult((long)dparam, headers, text);
      worker.release();
    }

Метод release нам известен — он освобождает отработавший ненужный ресурс. А вот onResult — это что-то новенькое. На самом деле, если заглянуть в полный исходный код, в классе ClientWebWorker имеется 2 виртуальных функции без реализации: onResult и onError. Это делает класс абстрактным. Клиентский код должен описать свой производный класс от ClientWebWorker и предоставить их реализацию. По самим названиям методов можно понять, что onResult вызывается в случае успешного получения результатов, а onError — в случает ошибки. Таким образом обеспечивается обратная связь между рабочими классами асинхронных запросов и использующих их кодом клиентской программы. Иными словами, клиентской программе не нужно ничего знать о сообщениях, которые ядро использует внутри: все взаимодействия клиентского кода с разрабатываемым API производятся штатными средствами ООП MQL5.

Давайте рассмотрим исходный код клиента, multiwebclient.mq5.


2.5. Клиентский эксперт

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

sinput string Method = "GET";
sinput string URL = "https://google.com/,https://ya.ru,https://www.startpage.com/";
sinput string Headers = "User-Agent: n/a";
sinput int Timeout = 5000;

#include <multiweb.mqh>

Все параметры предназначены, в конечном счете, для настройки осуществляемых HTTP-запросов. В поле URL можно перечислить через запятую несколько адресов, чтобы оценить параллельность и скорость исполнения запросов. Параметр URL разделяется на адреса с помощью функции StringSplit в OnInit, примерно так:

int urlsnum;
string urls[];
  
void OnInit()
{
  // get URLs for test requests
  urlsnum = StringSplit(URL, ',', urls);
  ...
}

Кроме этого в OnInit необходимо создать пул объектов-обработчиков запросов (ClientWebWorkersPool), но чтобы это сделать мы должны прежде описать свой класс — наследник ClientWebWorker.

class MyClientWebWorker : public ClientWebWorker
{
  public:
    MyClientWebWorker(const long id, const string p = "WRP_"): ClientWebWorker(id, p)
    {
    }
    
    virtual void onResult(const long code, const uchar &headers[], const uchar &text[]) override
    {
      Print(getMethod(), " ", getURL(), "\nReceived ", ArraySize(headers), " bytes in header, ", ArraySize(text), " bytes in document");
      // uncommenting this leads to potentially bulky logs
      // Print(CharArrayToString(headers));
      // Print(CharArrayToString(text));
    }

    virtual void onError(const long code) override
    {
      Print("WebRequest error code ", code);
    }
};

Он фактически ничего не делает кроме вывода в лог информации о статусе и полученных данных. Теперь можно создать пул таких объектов в OnInit.

ClientWebWorkersPool<MyClientWebWorker> *pool = NULL;

void OnInit()
{
  ...
  pool = new ClientWebWorkersPool<MyClientWebWorker>(urlsnum, _Symbol + "_" + EnumToString(_Period) + "_");
  Comment("Click the chart to start downloads");
}

Как видите, пул параметризуется классом MyClientWebWorker, что дает возможность создавать наши объекты из кода библиотеки. Размер массива выбирается равным числу введенных адресов. Это оптимально в целях демонстрации: меньшее количество означало бы очередь на обработку и дискредитацию идеи параллельного исполнения, а большее количество — лишняя трата ресурсов. В реальных проектах размер пула, конечно же, не обязан быть равным числу задач, но это потребует дополнительной алгоритмической обвязки.

Префикс для ресурсов задан как сочетание имени рабочего символа и периода графика.

Последний штрих, касающийся инициализации — поиск окна менеджера. Как Вы помните, этим занимается сам пул — класс ClientWebWorkersPool. От клиентского кода требуется лишь убедиться, что менеджер найден. Для этих целей зададимся некоторым разумным временем, в течение которого сообщение о поиске менеджера и обратное "приветствие" должны гарантированно достичь целей. Пусть это будет 5 секунд. Создадим таймер на это время:

void OnInit()
{
  ...
  // wait for manager negotiation for 5 seconds maximum
  EventSetTimer(5);
}

А в обработчике таймера проверим наличие менеджера и выведем алерт, если соединение не установилось.

void OnTimer()
{
  // if the manager did not respond during 5 seconds, it seems missing
  EventKillTimer();
  if(!pool.isManagerBound())
  {
    Alert("WebRequest Pool Manager (multiweb) is not running");
  }
}

В обработчике OnDeinit следует не забыть удалить объект пула.

void OnDeinit(const int reason)
{
  delete pool;
  Comment("");
}

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

void OnChartEvent(const int id, const long &lparam, const double &dparam, const string &sparam) 
{
  if(id == CHARTEVENT_CLICK) // initiate test requests by simple user action
  {
    ...
  }
  else
  {
    // this handler manages all important messaging behind the scene
    pool.onChartEvent(id, lparam, dparam, sparam);
  }
}

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

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

void OnChartEvent(const int id, const long &lparam, const double &dparam, const string &sparam) 
{
  if(id == CHARTEVENT_CLICK) // initiate test requests by simple user action
  {
    if(pool.isManagerBound())
    {
      uchar Body[];

      for(int i = 0; i < urlsnum; i++)
      {
        pool.WebRequestAsync(Method, urls[i], Headers, Timeout, Body);
      }
    }
    ...

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


2.6. Эксперт-менеджер, и он же эксперт-помощник

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

Прежде всего напомним, что эксперт, работающий либо в роли менеджера, либо в роли помощника, у нас на самом деле один — это файл multiweb.mq5. Как и в случае с клиентом, подключаем заголовочный файл и декларируем входные параметры:

sinput uint WebRequestPoolSize = 3;
sinput ulong ManagerChartID = 0;

#include <multiweb.mqh>

WebRequestPoolSize — это количество вспомогательных окон, которые менеджер должен создавать и запускать на них помощников.

ManagerChartID — идентификатор окна менеджера. Этот параметр имеет смысл только для роли помощника и будет заполняться самим менеджером при автоматическом запуске помощников из исходного кода. Заполнение ManagerChartID вручную при запуске менеджера трактуется как ошибка.

Алгоритм построен вокруг двух глобальных переменных:

bool manager;
WebWorkersPool<ServerWebWorker> pool;

Логический флаг manager содержит признак того, в какой роли выступает текущий экземпляр эксперта. Переменная pool — это массив объектов-обработчиков входящих задач. WebWorkersPool типизирован уже рассмотренным выше классом ServerWebWorker. Массив не инициализируется заранее, потому что его заполнение зависит от роли.

Роль менеджера получает первый запущенный экземпляр, что определяется в OnInit.

const string GVTEMP = "WRP_GV_TEMP";

int OnInit()
{
  manager = false;
  
  if(!GlobalVariableCheck(GVTEMP))
  {
    // when first instance of multiweb is started, it's treated as manager
    // the global variable is a flag that the manager is present
    if(!GlobalVariableTemp(GVTEMP))
    {
      FAILED(GlobalVariableTemp);
      return INIT_FAILED;
    }
    
    manager = true;
    GlobalVariableSet(GVTEMP, 1);
    Print("WebRequest Pool Manager started in ", ChartID());
  }
  else
  {
    // all next instances of multiweb are workers/helpers
    Print("WebRequest Worker started in ", ChartID(), "; manager in ", ManagerChartID);
  }
  
  // use the timer for delayed instantiation of workers
  EventSetTimer(1);
  return INIT_SUCCEEDED;
}

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

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

void OnTimer()
{
  EventKillTimer();
  if(manager)
  {
    if(!instantiateWorkers())
    {
      Alert("Workers not initialized");
    }
    else
    {
      Comment("WebRequest Pool Manager ", ChartID(), "\nWorkers available: ", pool.available());
    }
  }
  else // worker
  {
    // this is used as a host of resource storing response headers and data
    pool << new ServerWebWorker(ChartID(), "WRR_");
  }
}

В случае роли помощника в массив просто добавляется один объект-обработчик ServerWebWorker. Случай менеджера более сложный, и поэтому вынесен в функцию instantiateWorkers. Рассмотрим её отдельно.

bool instantiateWorkers()
{
  MqlParam Params[4];
  
  const string path = MQLInfoString(MQL_PROGRAM_PATH);
  const string experts = "\\MQL5\\";
  const int pos = StringFind(path, experts);
  
  // start itself again (in another role as helper EA)
  Params[0].string_value = StringSubstr(path, pos + StringLen(experts));
  
  Params[1].type = TYPE_UINT;
  Params[1].integer_value = 1; // 1 worker inside new helper EA instance for returning results to the manager or client

  Params[2].type = TYPE_LONG;
  Params[2].integer_value = ChartID(); // this chart is the manager

  Params[3].type = TYPE_UINT;
  Params[3].integer_value = MessageBroadcast; // use the same custom event base number
  
  for(uint i = 0; i < WebRequestPoolSize; ++i)
  {
    long chart = ChartOpen(_Symbol, _Period);
    if(chart == 0)
    {
      FAILED(ChartOpen);
      return false;
    }
    if(!EXPERT::Run(chart, Params))
    {
      FAILED(EXPERT::Run);
      return false;
    }
    pool << new ServerWebWorker(chart);
  }
  return true;
}

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

#include <fxsaber\Expert.mqh>

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

В параметре 0 указывается путь к исполняемому файлу эксперта, т.е. к самому себе. Параметр 1 — это WebRequestPoolSize. В каждом помощнике он равен 1. Напомним, что в помощнике объект-обработчик нужен для хранения ресурса с результатами HTTP-запроса. Каждый помощник обрабатывает запрос блокирующим WebRequest, то есть задействован максимум один объект-обработчик. Параметр 2 — идентификатор окна менеджера ManagerChartID. Параметр 3 — базовое значение для кодов сообщений (параметр MessageBroadcast берется из multiweb.mqh).

Далее в цикле создаются пустые графики с помощью ChartOpen и в них запускаются эксперты-помощники с помощью EXPERT::Run(chart, Params). Для каждого нового окна создается объект-обработчик ServerWebWorker(chart) и добавляется в пул. Следует отметить, что в менеджере объекты-обработчики — это не более чем ссылки на идентификаторы окон помощников и их статусы, поскольку в самом менеджере HTTP-запросы не выполняются и ресурсы под них не создаются.

Обработка приходящих задач выполняется на базе пользовательских событий в OnChartEvent.

void OnChartEvent(const int id, const long &lparam, const double &dparam, const string &sparam) 
{
  if(MSG(id) == MSG_DISCOVER) // a worker EA on new client chart is initialized and wants to bind to this manager
  {
    if(manager && (lparam != 0))
    {
      // only manager responds with its chart ID, lparam is the client chart ID
      EventChartCustom(lparam, TO_MSG(MSG_HELLO), ChartID(), pool.available(), NULL);
    }
  }
  else
  if(MSG(id) == MSG_WEB) // a client has requested a web download
  {
    if(lparam != 0)
    {
      if(manager)
      {
        // the manager delegates the work to an idle worker
        // lparam is the client chart ID, sparam is the client resource
        if(!transfer(lparam, sparam))
        {
          EventChartCustom(lparam, TO_MSG(MSG_ERROR), ERROR_NO_IDLE_WORKER, 0.0, sparam);
        }
      }
      else
      {
        // the worker does actually process the web request
        startWebRequest(lparam, sparam);
      }
    }
  }
  else
  if(MSG(id) == MSG_DONE) // a worker identified by chart ID in lparam has finished its job
  {
    WebWorker *worker = pool.findWorker(lparam);
    if(worker != NULL)
    {
      // here we're in the manager, and the pool hold stub workers without resources
      // so this release is intended solely to clean up busy state
      worker.release();
    }
  }
}

Прежде всего, в ответ на сообщение MSG_DISCOVER, получаемого от клиента с идентификатором lparam, менеджер отвечает ему сообщением MSG_HELLO, в котором указывает идентификатор своего окна.

При поступлении MSG_WEB в lparam должен находиться идентификатор окна клиента, приславшего запрос, а в sparam — имя ресурса с упакованными параметрами запроса. Работая в роли менеджера, код пытается передать задачу с этими параметрами свободному помощнику, вызвав функцию transfer (она будет рассмотрена ниже), и тем самым перевести выбранный объект в статус занятого ("busy"). При отсутствии свободных помощников клиенту отправляется событие MSG_ERROR с кодом ERROR_NO_IDLE_WORKER. В роли помощника необходимо непосредственно выполнить HTTP-запрос, что делается в функции startWebRequest.

Событие MSG_DONE поступает в менеджер от помощника, когда тот закачал запрошенный документ. Найдя по идентификатору помощника в lparam соответствующий объект, менеджер снимает с него статус "busy", вызвав метод release. Напомним, что результат своей работы помощник отсылает клиенту напрямую.

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

Функция transfer ищет свободный объект и вызывает его метод transfer (который был рассмотрен выше).

bool transfer(const long returnChartID, const string resname)
{
  ServerWebWorker *worker = pool.getIdleWorker();
  if(worker == NULL)
  {
    return false;
  }
  return worker.transfer(resname, returnChartID);
}

Функция startWebRequest определена следующим образом:

void startWebRequest(const long returnChartID, const string resname)
{
  const RESOURCEDATA<uchar> resource(resname);
  ResourceMediator mediator(&resource);

  string method, url, headers;
  int timeout;
  uchar body[];

  mediator.unpackRequest(method, url, headers, timeout, body);

  char result[];
  string result_headers;
  
  int code = WebRequest(method, url, headers, timeout, body, result, result_headers);
  if(code != -1)
  {
    // create resource with results to pass back to the client via custom event
    ((ServerWebWorker *)pool[0]).receive(resname, result, result_headers);
    // first, send MSG_DONE to the client with resulting resource
    EventChartCustom(returnChartID, TO_MSG(MSG_DONE), ChartID(), (double)code, pool[0].getFullName());
    // second, send MSG_DONE to the manager to set corresponding worker to idle state
    EventChartCustom(ManagerChartID, TO_MSG(MSG_DONE), ChartID(), (double)code, NULL);
  }
  else
  {
    // error code in dparam
    EventChartCustom(returnChartID, TO_MSG(MSG_ERROR), ERROR_MQL_WEB_REQUEST, (double)GetLastError(), resname);
    EventChartCustom(ManagerChartID, TO_MSG(MSG_DONE), ChartID(), (double)GetLastError(), NULL);
  }
}

Используя посредник ресурсов ResourceMediator, функция распаковывает параметры запроса и вызывает стандартную функцию MQL WebRequest. Если последняя выполнилась без MQL-ошибок, результаты отправляются клиенту. Для этого они упаковываются в локальный ресурс с помощью вызова метода receive (рассмотрен ранее) и его имя передается с сообщением MSG_DONE в параметре sparam функции EventChartCustom. Обратите внимание, что HTTP-ошибки (например, неверная страница 404, или ошибка веб-сервера 501) попадают сюда же — клиент получит HTTP-код в параметре dparam и ответные HTTP-заголовки в ресурсе, что позволит проанализировать ситуацию.

Если вызов WebRequest завершился MQL-ошибкой, клиенту отправляется сообщение MSG_ERROR с кодом ERROR_MQL_WEB_REQUEST, а результат GetLastError помещается в dparam. Поскольку локальный ресурс в этом случае не заполняется, в параметре sparam напрямую передается имя исходного ресурса, чтобы на клиентской стороне можно было по-прежнему идентифицировать конкретный экземпляр объекта-обработчика с ресурсом.

Диаграмма классов библиотеки multiweb для асинхронного и параллельного вызова WebRequest

Рис.4. Диаграмма классов библиотеки multiweb для асинхронного и параллельного вызова WebRequest


3. Демонстрация возможностей и тестирование

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

Предварительно следует открыть настройки терминала и на закладке Экспертов ввести в список разрешенных URL для WebRequest все сервера, к которым планируется обращаться.

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

Теперь запустим на другом графике клиентский эксперт multiwebclient и щелкнем один раз мышью на графике. С установками по умолчанию он инициирует 3 параллельных веб-запроса и пишет диагностику в лог, включая размер полученных данных и время работы. Если специальный параметр TestSyncRequests оставлен со значением true, дополнительно к параллельным веб-запросам через менеджер  выполняются последовательные запросы тех же страниц с помощью стандартного WebRequest. Это сделано с целью сравнения скорости исполнения двух вариантов. Как правило, параллельная обработка быстрее последовательной в несколько раз - от sqrt(N) до N, где N - количество доступных помощников.

Пример лога приведен ниже.

01:16:50.587    multiweb (EURUSD,H1)    OnInit 129912254742671339
01:16:50.587    multiweb (EURUSD,H1)    WebRequest Pool Manager started in 129912254742671339
01:16:52.345    multiweb (EURUSD,H1)    OnInit 129912254742671345
01:16:52.345    multiweb (EURUSD,H1)    WebRequest Worker started in 129912254742671345; manager in 129912254742671339
01:16:52.757    multiweb (EURUSD,H1)    OnInit 129912254742671346
01:16:52.757    multiweb (EURUSD,H1)    WebRequest Worker started in 129912254742671346; manager in 129912254742671339
01:16:53.247    multiweb (EURUSD,H1)    OnInit 129912254742671347
01:16:53.247    multiweb (EURUSD,H1)    WebRequest Worker started in 129912254742671347; manager in 129912254742671339
01:17:16.029    multiweb (EURUSD,H1)    Pool manager transfers \Experts\multiwebclient.ex5::GBPJPY_PERIOD_M5_1_129560567193673862
01:17:16.029    multiweb (EURUSD,H1)    129912254742671345: Reading request \Experts\multiwebclient.ex5::GBPJPY_PERIOD_M5_1_129560567193673862
01:17:16.029    multiweb (EURUSD,H1)    129912254742671345: Got 64 bytes in request
01:17:16.029    multiweb (EURUSD,H1)    129912254742671345: GET https://google.com/ User-Agent: n/a 5000 
01:17:16.030    multiweb (EURUSD,H1)    Pool manager transfers \Experts\multiwebclient.ex5::GBPJPY_PERIOD_M5_2_129560567193673862
01:17:16.030    multiweb (EURUSD,H1)    129912254742671346: Reading request \Experts\multiwebclient.ex5::GBPJPY_PERIOD_M5_2_129560567193673862
01:17:16.030    multiwebclient (GBPJPY,M5)      Accepted: \Experts\multiwebclient.ex5::GBPJPY_PERIOD_M5_1_129560567193673862 after 0 retries
01:17:16.031    multiweb (EURUSD,H1)    129912254742671346: Got 60 bytes in request
01:17:16.031    multiweb (EURUSD,H1)    129912254742671346: GET https://ya.ru User-Agent: n/a 5000 
01:17:16.031    multiweb (EURUSD,H1)    Pool manager transfers \Experts\multiwebclient.ex5::GBPJPY_PERIOD_M5_3_129560567193673862
01:17:16.031    multiwebclient (GBPJPY,M5)      Accepted: \Experts\multiwebclient.ex5::GBPJPY_PERIOD_M5_2_129560567193673862 after 0 retries
01:17:16.031    multiwebclient (GBPJPY,M5)      Accepted: \Experts\multiwebclient.ex5::GBPJPY_PERIOD_M5_3_129560567193673862 after 0 retries
01:17:16.031    multiweb (EURUSD,H1)    129912254742671347: Reading request \Experts\multiwebclient.ex5::GBPJPY_PERIOD_M5_3_129560567193673862
01:17:16.032    multiweb (EURUSD,H1)    129912254742671347: Got 72 bytes in request
01:17:16.032    multiweb (EURUSD,H1)    129912254742671347: GET https://www.startpage.com/ User-Agent: n/a 5000 
01:17:16.296    multiwebclient (GBPJPY,M5)      129560567193673862: Result code 200
01:17:16.296    multiweb (EURUSD,H1)    Result code from 129912254742671346: 200, now idle
01:17:16.297    multiweb (EURUSD,H1)    129912254742671346: Done in 265ms
01:17:16.297    multiwebclient (GBPJPY,M5)      129560567193673862: Reading result \Experts\multiweb.ex5::WRR_129912254742671346
01:17:16.300    multiwebclient (GBPJPY,M5)      129560567193673862: Got 16568 bytes in response
01:17:16.300    multiwebclient (GBPJPY,M5)      GET https://ya.ru
01:17:16.300    multiwebclient (GBPJPY,M5)      Received 3704 bytes in header, 12775 bytes in document
01:17:16.715    multiwebclient (GBPJPY,M5)      129560567193673862: Result code 200
01:17:16.715    multiwebclient (GBPJPY,M5)      129560567193673862: Reading result \Experts\multiweb.ex5::WRR_129912254742671347
01:17:16.715    multiweb (EURUSD,H1)    129912254742671347: Done in 686ms
01:17:16.715    multiweb (EURUSD,H1)    Result code from 129912254742671347: 200, now idle
01:17:16.725    multiwebclient (GBPJPY,M5)      129560567193673862: Got 45236 bytes in response
01:17:16.725    multiwebclient (GBPJPY,M5)      GET https://www.startpage.com/
01:17:16.725    multiwebclient (GBPJPY,M5)      Received 822 bytes in header, 44325 bytes in document
01:17:16.900    multiwebclient (GBPJPY,M5)      129560567193673862: Result code 200
01:17:16.900    multiweb (EURUSD,H1)    Result code from 129912254742671345: 200, now idle
01:17:16.900    multiweb (EURUSD,H1)    129912254742671345: Done in 873ms
01:17:16.900    multiwebclient (GBPJPY,M5)      129560567193673862: Reading result \Experts\multiweb.ex5::WRR_129912254742671345
01:17:16.903    multiwebclient (GBPJPY,M5)      129560567193673862: Got 13628 bytes in response
01:17:16.903    multiwebclient (GBPJPY,M5)      GET https://google.com/
01:17:16.903    multiwebclient (GBPJPY,M5)      Received 790 bytes in header, 12747 bytes in document
01:17:16.903    multiwebclient (GBPJPY,M5)      > > > Async WebRequest workers [3] finished 3 tasks in 873ms

Обратите внимание, что общее время выполнения всех запросов равно времени выполнения самого медленного запроса.

Очевидно, что если в менеджере задать количество помощников равным 1, запросы будут обрабатывать последовательно.


Заключение

В данной статье был рассмотрен набор классов и готовых экспертов для выполнения HTTP-запросов в неблокирующем режиме. Это позволяет получать данные из Internet в нескольких параллельных потоках и повысить эффективность экспертов, которые должны помимо HTTP-запросов выполнять аналитические расчеты в реальном времени. Кроме того данную библиотеку можно использовать не только в экспертах, но и в индикаторах, где стандартный WebRequest запрещен. Для реализации всей архитектуры пришлось воспользоваться широким спектром возможностей MQL: передачей пользовательских событий, созданием ресурсов, динамическим открытием окон и запуском экспертов на них.

На момент написания статьи создание вспомогательных окон для запуска экспертов-помощников является единственным вариантом распараллеливания HTTP-запросов, однако в планах фирмы MetaQuotes находится разработка специальных фоновых MQL-программ — сервисов, под которые уже зарезервирована папка MQL5/Services. Когда эта технология появится в терминале, данную библиотеку, вероятно, можно будет улучшить, заменив вспомогательные окна на сервисы.

Список прилагаемых файлов:

  • MQL5/Include/multiweb.mqh — библиотека
  • MQL5/Experts/multiweb.mq5 — эксперт-менеджер и эксперт-помощник 
  • MQL5/Experts/multiwebclient.mq5 — демонстрационный эксперт-клиент
  • MQL5/Include/fxsaber/Resource.mqh — вспомогательный класс для работы с ресурсами
  • MQL5/Include/fxsaber/ResourceData.mqh — вспомогательный класс для работы с ресурсами
  • MQL5/Include/fxsaber/Expert.mqh — вспомогательный класс для запуска экспертов
  • MQL5/Include/TypeToBytes.mqh — библиотека преобразования данных
Прикрепленные файлы |
MQL5.zip (16.86 KB)
Последние комментарии | Перейти к обсуждению на форуме трейдеров (50)
Stanislav Korotky
Stanislav Korotky | 1 мар. 2021 в 13:56
Mihail Matkovskij:
Какое смещение (input int MessageBroadcast) для пользовательских событий должно быть, если в программе используются элементы UI из стандартной библиотеки (Include\Controls\)? Как это можно узнать?

Я не большой знаток библиотеки. Судя по определению событий в файле Defines.mqh, там максимальный зарезервированный индекс - 100.

Mihail Matkovskij
Mihail Matkovskij | 1 мар. 2021 в 14:21
Stanislav Korotky:

Я не большой знаток библиотеки. Судя по определению событий в файле Defines.mqh, там максимальный зарезервированный индекс - 100.

//+------------------------------------------------------------------+
//| Events                                                           |
//+------------------------------------------------------------------+
#define ON_CLICK                (0)   // clicking on control event
#define ON_DBL_CLICK            (1)   // double clicking on control event
#define ON_SHOW                 (2)   // showing control event
#define ON_HIDE                 (3)   // hiding control event
#define ON_CHANGE               (4)   // changing control event
#define ON_START_EDIT           (5)   // start of editing event
#define ON_END_EDIT             (6)   // end of editing event
#define ON_SCROLL_INC           (7)   // increment of scrollbar event
#define ON_SCROLL_DEC           (8)   // decrement of scrollbar event
#define ON_MOUSE_FOCUS_SET      (9)   // the "mouse cursor entered the control" event
#define ON_MOUSE_FOCUS_KILL     (10)  // the "mouse cursor exited the control" event
#define ON_DRAG_START           (11)  // the "control dragging start" event
#define ON_DRAG_PROCESS         (12)  // the "control is being dragged" event
#define ON_DRAG_END             (13)  // the "control dragging end" event
#define ON_BRING_TO_TOP         (14)  // the "mouse events priority increase" event
#define ON_APP_CLOSE            (100) // "closing the application" event

Очевидно, что от 15 до 99 разработчики оставили запас для новых id событий. Но помимо этого мне бросилась в глаза еще одна константа.

#define CONTROLS_MAXIMUM_ID                 (10000)  // maximum number of IDs in application

Максимальный номер или максимальное количество идентификаторов в приложении. Что это может значить?...

Stanislav Korotky
Stanislav Korotky | 1 мар. 2021 в 15:44
Mihail Matkovskij:

Очевидно, что от 15 до 99 разработчики оставили запас для новых id событий. Но помимо этого мне бросилась в глаза еще одна константа.

Максимальный номер или максимальное количество идентификаторов в приложении. Что это может значить?...

Судя по словам - идентификаторы контролов. Контролы - не события.

Mihail Matkovskij
Mihail Matkovskij | 1 мар. 2021 в 20:11
Stanislav Korotky:

Судя по словам - идентификаторы контролов. Контролы - не события.

Возможно... Но там не написано какие идентификаторы. Поставил MessageBroadcast 10000 и всё работает. Можно поставить хоть 1000000 и больше, тип ushort позволяет. Хотя и со 100 конфликтов не будет. В любом случае у любого элемента UI есть свой id, который проверяется в условиях обработки событий. В общем, вероятность нормальной работы довольно большая.

Mihail Matkovskij
Mihail Matkovskij | 1 мар. 2021 в 20:30
Было бы неплохо если бы главный эксперт мог получать загруженность процессора, чтобы знать, сколько задач можно добавить на выполнение. Но такой функции я, к сожалению, не нашел в документации.
Применение OpenCL для тестирования свечных моделей Применение OpenCL для тестирования свечных моделей
В данной статье мы рассмотрим алгоритм реализации тестера свечных моделей на языке OpenCL в режиме "OHLC на M1". А также сравним его быстродействие cо встроенным тестером стратегий, запущенным в режиме быстрой и медленной оптимизации.
Разворотные паттерны: Тестируем паттерн "Двойная вершина/дно" Разворотные паттерны: Тестируем паттерн "Двойная вершина/дно"
В практике торговли трейдеры часто ищут точки разворота трендов и тенденций, так как именно в момент зарождения тренда цена имеет наибольший потенциал движения. Именно поэтому, в практике технического анализа рассматриваются различные разворотные паттерны. Одним из наиболее известных и часто применяемых паттернов является двойная вершина/дно. В данной статье предлагается вариант машинного обнаружения паттерна, а также тестируется его доходность на исторических данных.
Реверсирование: формализуем точку входа и пишем алгоритм ручной торговли Реверсирование: формализуем точку входа и пишем алгоритм ручной торговли
Это последняя статья из серии, посвященной такой торговой стратегии, как реверсирование. В ней мы попробуем решить проблему, которая приводила к нестабильности результатов тестирования в предыдущих статьях. А также напишем и протестируем свой алгоритм для ручной торговли на любом рынке с помощью реверсирования.
Гэп - доходная стратегия или 50/50? Гэп - доходная стратегия или 50/50?
Исследование явления гэпа — ситуации существенной разницы между ценой закрытия предыдущего таймфрейма и ценой открытия следующего, и в какую сторону пойдёт дневной бар. Применение системной DLL функции GetOpenFileName.