Чтение, запись данных по незащищенному сокет-соединению

Исторически так сложилось, что сокеты по-умолчанию обеспечивают передачу данных по простому соединению — в открытом виде, и это позволяет техническими средствами анализировать весь трафик. В последние годы к вопросам безопасности стали относиться более серьезно и потому практически повсеместно внедрена технология TLS (Transport Layer Security): она обеспечивает шифрование на лету всех данных между отправителем и получателем. В частности, для интернет-соединений разница заключается в протоколах HTTP (простое соединение) и HTTPS (защищенное).

В MQL5 существуют разные наборы Socket-функций для работы с простыми и защищенными соединениями. В этом разделе мы познакомимся с простым режимом, а позднее доберемся и до защищенного.

Для чтения данных из сокета используется функция SocketRead.

int SocketRead(int socket, uchar &buffer[], uint maxlen, uint timeout)

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

Параметр buffer представляет собой ссылку на массив, в который будут прочитаны данные. Если массив динамический, его размер увеличивается на количество прочитанных байт, но он не может превышать INT_MAX (2147483647). Ограничить количество читаемых байтов можно параметром maxlen. Данные, которые не поместятся, останутся во внутреннем буфере сокета: их можно будет получить следующим вызовом SocketRead. Величина maxlen должна быть в пределах от 1 до INT_MAX (2147483647).

Параметр timeout задает время (в миллисекундах) ожидания завершения чтения. Если в течение этого времени не удается получить данные, попытки завершаются, и функция завершает работу: результат при этом равен -1.

-1 также возвращается и при ошибке, а её код в _LastError — такой как 5273 (ERR_NETSOCKET_IO_ERROR), например, означает, что соединение, установленное через SocketConnect, разорвано.

В случае успеха функция возвращает количество прочитанных байтов.

При выставлении таймаута чтения в 0, используется значение по умолчанию 120000 (2 минуты).

Для записи данных в сокет используется функция SocketSend.

К сожалению, названия функций SocketRead и SocketSend не являются "симметричными": обратной по смыслу операцией для "read" является "write", а для "send" — "receive". Это может оказаться непривычным для разработчиков со стажем, знакомым с сетевыми API на других платформах.

int SocketSend(int socket, const uchar &buffer[], uint maxlen)

В первом параметре передается дескриптор ранее созданного и открытого сокета. При передаче неверного хэндла в _LastError записывается ошибка 5270 (ERR_NETSOCKET_INVALIDHANDLE). В массиве buffer содержатся данные для отправки, их размер — в параметре maxlen (параметр введен для удобства отправки части данных из фиксированного массива).

В случае успеха функция возвращает количество байт, записанных в сокет, а в случае ошибки — -1.

Ошибки системного уровня (5273, ERR_NETSOCKET_IO_ERROR) сигнализируют о разрыве соединения.

В скрипте SocketReadWriteHTTP.mq5 продемонстрируем, как с помощью сокетов можно реализовать работу по протоколу HTTP, то есть запросить информацию о странице с веб-сервера. Это малая часть того, что "за сценой" для нас делает функция WebRequest.

Оставим во входных параметрах адрес по умолчанию — сайт "www.mql5.com". Номер порта выбран равным 80, поскольку это значение по умолчанию для незащищенных HTTP-соединений (хотя некоторые сервера могут использовать и другой порт: 81, 8080 и т.д.). Порты, зарезервированные под защищенные соединения (в частности, самый популярный 443), данным примером пока не поддерживаются. Также в параметре Server важно вводить именно название домена, а не конкретной страницы, потому что скрипт умеет запрашивать только главную страницу, то есть корневой путь "/".

input string Server = "www.mql5.com";
input uint Port = 80;

В главной функции скрипта создадим сокет и откроем на нем соединение с указанными параметрами (таймаут равен 5 секундам).

void OnStart()
{
   PRTF(Server);
   PRTF(Port);
   const int socket = PRTF(SocketCreate());
   if(PRTF(SocketConnect(socketServerPort5000)))
   {
      ...
   }
}

Далее вспомним принцип работы протокола HTTP. Клиент отправляет запросы в виде специально оформленных заголовков (строк с предопределенными названиями и значениями), включающими, в частности, адрес веб-страницы, а сервер в ответ присылает всю веб-страницу или статус операции, используя для этого также специальные заголовки. Клиент может запросить веб-страницу с помощью GET-запроса, отправить некие данные с помощью POST-запроса или проверить статус веб-страницы с помощью экономного HEAD-запроса. В принципе, HTTP-методов гораздо больше — с ними можно познакомиться в спецификации протокола HTTP.

Таким образом, скрипт должен сформировать и отправить через сокет-соединение HTTP-заголовок. В простейшем виде получить мета-информацию о странице позволяет следующий HEAD-запрос (мы могли бы заменить HEAD на GET, чтобы запросить страницу целиком, но там есть некоторые сложности — об этом позднее).

HEAD / HTTP/1.1
Host: _server_
User-Agent: MetaTrader 5
                                      // <- два перевода строки подряд \r\n\r\n

Косая наклонная черта после "HEAD" (или другого метода) — это кратчайший возможный путь на любом сервере — корневой каталог, который обычно приводит к показу главной страницы. Если нам нужна конкретная веб-страница, мы могли бы написать что-то вроде "GET /en/forum/ HTTP/1.1" и получили бы оглавление англоязычных форумов на mql5.com. Вместо строки "_server_" должен быть подставлен реальный домен.

Наличие "User-Agent:" не обязательно, но позволяет программе "представиться" серверу, без чего некоторые сервера могут отклонить запрос.

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

StringFormat("HEAD / HTTP/1.1\r\nHost: %s\r\n\r\n"Server)

Осталось отправить его на сервер. Для этой цели написана простая функция HTTPSend. Она принимает дескриптор сокета и строку заголовка.

bool HTTPSend(int socketconst string request)

   char req[];
   int len = StringToCharArray(requestreq0WHOLE_ARRAYCP_UTF8) - 1;
   if(len < 0return false;
   return SocketSend(socketreqlen) == len;

Внутри мы преобразуем строку в массив байтов и вызываем SocketSend.

Далее нам потребуется принять ответ сервера, для чего написана функция HTTPRecv. Она также ожидает дескриптор сокета и ссылку на строку, куда следует поместить данные, но устроена сложнее.

bool HTTPRecv(int socketstring &resultconst uint timeout)

   char response[];
   int len;         // целое со знаком нужно для признака ошибки -1
   uint start = GetTickCount();
   result = "";
   
   do 
   {
      ResetLastError();
      if(!(len = (int)SocketIsReadable(socket)))
      {
         Sleep(10); // ждем данных или таймаута
      }
      else          // читаем данные в имеющемся объеме
      if((len = SocketRead(socketresponselentimeout)) > 0)
      {
         result += CharArrayToString(response0len); // NB: без CP_UTF8 только 'HEAD'
         const int p = StringFind(result"\r\n\r\n");
         if(p > 0)
         {
            // HTTP-заголовок завершается двойным переводом строки, используем это
            // чтобы убедиться, что получен весь заголовок
            Print("HTTP-header found");
            StringSetLength(resultp); // отрезаем тело документа (на случай GET-запроса)
            return true;
         }
      }
   } 
   while(GetTickCount() - start < timeout && !IsStopped() && !_LastError);
   
   if(_LastErrorPRTF(_LastError);
   
   return StringLen(result) > 0;
}

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

Байты буфера сразу же конвертируются в строку и стыкуются в полный ответ в переменной result. Важно отметить, что функцию CharArrayToString с кодировкой по умолчанию мы можем использовать только для HTTP-заголовка, потому что в нем разрешены только латинские буквы и несколько специальных символов из ANSI.

Для приема полного веб-документа, который, как правило, имеет кодировку UTF-8 (но потенциально может иметь и другую — нелатинскую, которая указывается как раз в HTTP-заголовке) потребуется более хитрая обработка: сначала нужно собрать все присланные блоки в одном общем буфере, а потом всё целиком преобразовывать в строку с указанием CP_UTF8 (в противном случае, любой символ, закодированный двумя байтами, может быть "разрезан" при отправке и прибудет к нам в разных блоках — именно поэтому нельзя ожидать корректного потока байт UTF-8 в отдельно взятом фрагменте). Мы улучшим данный пример в следующих разделах.

Имея функции HTTPSend и HTTPRecv, завершим код OnStart.

void OnStart()
{
      ...
      if(PRTF(HTTPSend(socketStringFormat("HEAD / HTTP/1.1\r\nHost: %s \r\n"
         "User-Agent: MetaTrader 5\r\n\r\n"Server))))
      {
         string response;
         if(PRTF(HTTPRecv(socketresponse5000)))
         {
            Print(response);
         }
      }
      ...
}

В получаемом от сервера HTTP-заголовке интерес могут представлять следующие строки:

  • 'Content-Length:' — общая длина документа в байтах;
  • 'Content-Language:' — язык документа (например, "de-DE, ru");
  • 'Content-Type:' — кодировка документа (например, "text/html; charset=UTF-8");
  • 'Last-Modified:' — время последнего изменения документа, чтобы не скачивать то, что уже есть (в принципе, мы для этого можем в своем HTTP-запросе добавить заголовок 'If-Modified-Since:').

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

Запуск скрипта с параметрами по умолчанию выдает такой результат.

Server=www.mql5.com / ok
Port=80 / ok
SocketCreate()=1 / ok
SocketConnect(socket,Server,Port,5000)=true / ok
HTTPSend(socket,StringFormat(HEAD / HTTP/1.1
Host: %s
,Server))=true / ok
HTTP-header found
HTTPRecv(socket,response,5000)=true / ok
HTTP/1.1 301 Moved Permanently
Server: nginx
Date: Sun, 31 Jul 2022 10:24:00 GMT
Content-Type: text/html
Content-Length: 162
Connection: keep-alive
Location: https://www.mql5.com/
Strict-Transport-Security: max-age=31536000; includeSubDomains; preload
X-Frame-Options: SAMEORIGIN

Обратите внимание, что данный сайт, как и большинство сегодня, перенаправляет наш запрос на защищенное соединение: это достигается кодом статуса "301 Moved Permanently" и новым адресом "Location: https://www.mql5.com/" (здесь важен протокол "https"). Чтобы повторить запрос с поддержкой TLS, следует использовать несколько других функций, о которых и пойдет далее речь.