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

Для защищенного соединения имеется свой набор функций обмена данными между клиентом и сервером. Названия и принцип работы функций почти совпадают с уже рассмотренными функциями SocketRead и SocketSend.

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

Функция SocketTlsRead читает данные из защищенного TLS-соединения, открытого на указанном сокете. Данные попадают в передаваемый по ссылке массив buffer. Если он динамический, его размер будет увеличен под объем данных, но не более чем до INT_MAX (2147483647) байтов.

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

Функция исполняется до тех пор, пока не получит указанное количество данных или не наступит таймаут, заданный с помощью SocketTimeouts.

В случае успеха функция возвращает количество прочитанных байтов, в случае ошибки — -1, при этом в _LastError записывается код 5273 (ERR_NETSOCKET_IO_ERROR). Наличие ошибки говорит о том, что соединение было разорвано.

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

Функция SocketTlsReadAvailable читает все доступные расшифрованные данные из защищенного TLS-соединения, но не более maxlen байтов. В отличие от SocketTlsRead, SocketTlsReadAvailable не ждет обязательного наличия заданного количества данных и сразу возвращает только то, что есть. Таким образом, если внутренний буфер сокета "пуст" (с сервера пока ничего не поступило, уже было прочитано или еще не сформировало готовый для дешифрации блок), функция вернет 0, и в приемный массив buffer ничего не будет записано. Это — штатная ситуация.

Величина maxlen должна быть в пределах от 1 до INT_MAX (2147483647).

int SocketTlsSend(int socket, const uchar &buffer[], uint bufferlen)

Функция SocketTlsSend отправляет данные из массива buffer через защищенное соединение, открытое на указанном сокете. Принцип действия тот же, что у описанной ранее функции SocketSend — разница только в типе соединения.

За основу нового примера скрипта SocketReadWriteHTTPS.mq5 возьмем предыдущий SocketReadWriteHTTP.mq5, но добавим гибкости — выбор HTTP-метода (по умолчанию GET, а не HEAD), настройку таймаута и поддержку защищенных соединений. Стандартным таким портом является 443.

input string Method = "GET"// Method (HEAD,GET)
input string Server = "www.google.com";
input uint Port = 443;
input uint Timeout = 5000;

Сервером по умолчанию указан "www.google.com" — не забудьте добавить его (и любой другой, который вы введете) в список разрешенных в настройках терминала.

Определять, является ли соединение защищенным или нет, будем с помощью функции SocketTlsCertificate: если она завершится успешно, значит сервер предоставил сертификат, и режим TLS активен. Если функция вернет false и взведет код ошибки NETSOCKET_NO_CERTIFICATE(5275) — значит, мы пользуемся обычным соединением, но ошибку можно проигнорировать и сбросить, поскольку нас устраивает и незащищенное соединение.

void OnStart()
{
   PRTF(Server);
   PRTF(Port);
   const int socket = PRTF(SocketCreate());
   if(socket == INVALID_HANDLEreturn;
   SocketTimeouts(socketTimeoutTimeout);
   if(PRTF(SocketConnect(socketServerPortTimeout)))
   {
      string subjectissuerserialthumbprint
      datetime expiration;
      bool TLS = false;
      if(PRTF(SocketTlsCertificate(socketsubjectissuerserialthumbprintexpiration)))
      {
         PRTF(subject);
         PRTF(issuer);
         PRTF(serial);
         PRTF(thumbprint);
         PRTF(expiration);
         TLS = true;
      }
      ...

Остальная часть функции OnStart выполнена по прежнему плану: отправить запрос с помощью функции HTTPSend и принять ответ с помощью HTTPRecv. Но на этот раз мы дополнительно передаем в эти функции признак TLS, и они должны быть реализованы слегка иначе.

      if(PRTF(HTTPSend(socketStringFormat("%s / HTTP/1.1\r\nHost: %s\r\n"
         "User-Agent: MetaTrader 5\r\n\r\n"MethodServer), TLS)))
      {
         string response;
         if(PRTF(HTTPRecv(socketresponseTimeoutTLS)))
         {
            Print("Got "StringLen(response), " bytes");
            // для больших документов предусмотрим сохранение в файл
            if(StringLen(response) > 1000)
            {
               int h = FileOpen(Server + ".htm"FILE_WRITE | FILE_TXT | FILE_ANSI0CP_UTF8);
               FileWriteString(hresponse);
               FileClose(h);
            }
            else
            {
               Print(response);
            }
         }
      }

На примере HTTPSend легко увидеть, что теперь в зависимости от флага TLS, мы используем либо SocketTlsSend, либо SocketSend.

bool HTTPSend(int socketconst string requestconst bool TLS)

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

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

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

bool HTTPRecv(int socketstring &resultconst uint timeoutconst bool TLS)
{
   uchar response[]; // накапливаем данные целиком (заголовки + тело веб-документа)
   uchar block[];    // отдельный блок чтения
   int len;          // размер текущего блока (целое со знаком для признака ошибки -1)
   int lastLF = -1;  // позиция последнего найденного перевода строки LF(Line-Feed)
   int body = 0;     // смещение, где начинается тело документа
   int size = 0;     // размер документа согласно заголовку
   result = "";      // устанавливаем в начале пустой результат
   int chunk_size = 0chunk_start = 0chunk_n = 1;
   const static string content_length = "Content-Length:";
   const static string crlf = "\r\n";
   const static int crlf_length = 2;
   ...

Наиболее простой метод определения размера принимаемых данных основывается на анализе заголовка "Content-Length:". Для него нам потребовались переменные lastLF, size, content_length. Правда, заголовок этот присутствует не всегда, и в дело вступают "чанки" (chunks) — для их обнаружения введены переменные chunk_size, chunk_start, crlf и crlf_length.

Для демонстрации различных техник приема данных воспользуемся в этом примере "неблокирующей" функцией SocketTlsReadAvailable. Однако, аналогичная функция для незащищенного соединения отсутствует, и потому нам предстоит её написать самим (чуть позже). Общая схема алгоритма проста — это цикл с попытками получения новых блоков данных размером 1024 (или меньше) байтов. Если что-то удалось прочитать, мы это аккумулируем в массиве response. Если входной буфер сокета пуст, функции вернут 0, и мы делаем маленькую паузу. Наконец, если возникнет ошибка или таймаут, цикл прервется.

   uint start = GetTickCount();
   do 
   {
      ResetLastError();
      if((len = (TLS ? SocketTlsReadAvailable(socketblock1024) :
         SocketReadAvailable(socketblock1024))) > 0)
      {
         const int n = ArraySize(response);
         ArrayCopy(responseblockn); // собираем все блоки вместе
         ...
         // основная работа здесь
      }
      else
      {
         if(len == 0Sleep(10); // ждем немного прихода порции данных
      }
   } 
   while(GetTickCount() - start < timeout && !IsStopped() && !_LastError);
   ...

Прежде всего, необходимо дождаться во входном потоке данных завершения HTTP-заголовока. Как мы уже видели из предыдущего примера, заголовки отделены от документа двойным переводом строки, то есть последовательностью символов "\r\n\r\n". Её легко обнаружить по двум символам '\n' (LF), расположенным через один.

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

         if(body == 0// ищем завершение заголовков, пока не найдем
         {
            for(int i = ni < ArraySize(response); ++i)
            {
               if(response[i] == '\n') // LF
               {
                  if(lastLF == i - crlf_length// найдена последовательность "\r\n\r\n"
                  {
                     body = i + 1;
                     string headers = CharArrayToString(response0i);
                     Print("* HTTP-header found, header size: "body);
                     Print(headers);
                     const int p = StringFind(headerscontent_length);
                     if(p > -1)
                     {
                        size = (int)StringToInteger(StringSubstr(headers,
                           p + StringLen(content_length)));
                        Print("* "content_lengthsize);
                     }
                     ...
                     break// найдена граница заголовков/тела
                  }
                  lastLF = i;
               }
            }
         }
         
         if(size == ArraySize(response) - body// документ целиком
         {
            Print("* Complete document");
            break;
         }
         ...

При этом сразу выполняется поиск заголовка "Content-Length:" и извлечение из него размера. Заполненная переменная size дает возможность написать дополнительный условный оператор для выхода из цикла приема данных, когда получен весь документ.

Некоторые сервера "отдают" содержимое по частям — именно они называются "chunks". В таких случаях в HTTP-заголовке присутствует строка "Transfer-Encoding: chunked", а строка "Content-Length:" отсутствует. Каждый "чанк" начинается с шестнадцатеричного числа, указывающего размер "чанка", после чего следует перевод строки и указанное количество байтов с данными. Завершается "чанк" еще одним переводом строки. Последний "чанк", обозначающий конец документа, имеет нулевой размер.

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

Схематично это можно изобразить так (слева "чанки" документа, справа блоки данных из сокет-буфера).

Фрагментация веб-документа при передаче на уровнях HTTP и TCP

Фрагментация веб-документа при передаче на уровнях HTTP и TCP

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

Итак, если HTTP-заголовок полностью получен, но в нем не обнаружена строка "Content-Length:", мы переходим в ветвь алгоритма с режимом "Transfer-Encoding: chunked". По текущей позиции body в массиве response (сразу после завершения HTTP-заголовков) выделяется фрагмент строки и преобразуется в число из предположения шестнадцатеричного формата: это делает вспомогательная функция HexStringToInteger (см. прилагаемый исходный код). Если там действительно обнаруживается число, мы записываем его в chunk_size, помечаем позицию, как начало "чанка" в chunk_start и удаляем из response байты с самим числом и обрамляющими переводами строк.

                  ...
                  if(lastLF == i - crlf_length// найдена последовательность "\r\n\r\n"
                  {
                     body = i + 1;
                     ...
                     const int p = StringFind(headerscontent_length);
                     if(p > -1)
                     {
                        size = (int)StringToInteger(StringSubstr(headers,
                           p + StringLen(content_length)));
                        Print("* "content_lengthsize);
                     }
                     else
                     {
                        size = -1// сервер не предоставил длину документа
                        // пытаемся найти чанки и размер первого из них
                        if(StringFind(headers"Transfer-Encoding: chunked") > 0)
                        {
                           // синтаксис чанка:
                           // <hex-size>\r\n<content>\r\n...
                           const string preview = CharArrayToString(responsebody20);
                           chunk_size = HexStringToInteger(preview);
                           if(chunk_size > 0)
                           {
                              const int d = StringFind(previewcrlf) + crlf_length;
                              chunk_start = body;
                              Print("Chunk: "chunk_size" start at "chunk_start" -"d);
                              ArrayRemove(responsebodyd);
                           }
                        }
                     }
                     break// найдена граница заголовков/тела
                  }
                  lastLF = i;
                  ...

Теперь для проверки завершенности документа нужно анализировать не только переменную size (которая, как мы видели, может быть фактически выключена из работы присвоением -1 в отсутствии "Content-Length:"), но и новые переменные для "чанков": chunk_start и chunk_size. Принцип действий тот же самый, что и после HTTP-заголовков: по смещению в массиве response, где завершился предыдущий "чанк", вычленяем размер следующего "чанка". Продолжаем процесс, пока не найдем "чанк" нулевого размера.

         ...
         if(size == ArraySize(response) - body// документ целиком
         {
            Print("* Complete document");
            break;
         }
         else if(chunk_size > 0 && ArraySize(response) - chunk_start >= chunk_size)
         {
            Print("* "chunk_n" chunk done: "chunk_size" total: "ArraySize(response));
            const int p = chunk_start + chunk_size;
            const string preview = CharArrayToString(responsep20);
            if(StringLen(preview) > crlf_length              // есть '\r\n...\r\n' ?
               && StringFind(previewcrlfcrlf_length) > crlf_length)
            {
               chunk_size = HexStringToInteger(previewcrlf_length);
               if(chunk_size > 0)
               {                              // дважды '\r\n': до и после размера чанка
                  int d = StringFind(previewcrlfcrlf_length) + crlf_length;
                  chunk_start = p;
                  Print("Chunk: "chunk_size" start at "chunk_start" -"d);
                  ArrayRemove(responsechunk_startd);
                  ++chunk_n;
               }
               else
               {
                  Print("* Final chunk");
                  ArrayRemove(responsep5); // "\r\n0\r\n"
                  break;
               }
            } // иначе ждем еще данных
         }

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

bool HTTPRecv(int socketstring &resultconst uint timeoutconst bool TLS)
{
   ...
   do 
   {
      ResetLastError();
      if((len = (TLS ? SocketTlsReadAvailable(socketblock1024) :
         SocketReadAvailable(socketblock1024))) > 0)
      {
         ... // основная работа здесь - рассмотрено выше
      }
      else
      {
         if(len == 0Sleep(10); // ждем немного прихода порции данных
      }
   } 
   while(GetTickCount() - start < timeout && !IsStopped() && !_LastError);
      
   if(_LastErrorPRTF(_LastError);
   
   if(ArraySize(response) > 0)
   {
      if(body != 0)
      {
         // TODO: желательно проверить 'Content-Type:' на 'charset=UTF-8'
         result = CharArrayToString(responsebodyWHOLE_ARRAYCP_UTF8);
      }
      else
      {
         // для анализа нештатных ситуаций вернем неполные заголовки как есть
         result = CharArrayToString(response);
      }
   }
   
   return StringLen(result) > 0;
}

Осталось показать функцию SocketReadAvailable — аналог SocketTlsReadAvailable для незащищенных соединений.

int SocketReadAvailable(int socketuchar &block[], const uint maxlen = INT_MAX)
{
   ArrayResize(block0);
   const uint len = SocketIsReadable(socket);
   if(len > 0)
      return SocketRead(socketblockfmin(lenmaxlen), 10);
   return 0;
}

Скрипт готов к работе.

От нас потребовалось довольно много усилий, чтобы реализовать простой запрос веб-страницы с помощью сокетов. Это служит демонстрацией того, какая большая рутинная работа обычно скрывается в поддержке сетевых протоколов на низком уровне. Конечно, в случае HTTP нам проще и правильнее использовать встроенную реализацию WebRequest, но она не включает всех возможностей HTTP (причем мы вскользь затронули HTTP 1.1, а есть еще HTTP/2), да и количество других прикладных протоколов огромно, поэтому для их интеграции в MetaTrader 5 не обойтись без Socket-функций.

Запустим SocketReadWriteHTTPS.mq5 с параметрами по умолчанию.

Server=www.google.com / ok
Port=443 / ok
SocketCreate()=1 / ok
SocketConnect(socket,Server,Port,Timeout)=true / ok
SocketTlsCertificate(socket,subject,issuer,serial,thumbprint,expiration)=true / ok
subject=CN=www.google.com / ok
issuer=C=US, O=Google Trust Services LLC, CN=GTS CA 1C3 / ok
serial=00c9c57583d70aa05d12161cde9ee32578 / ok
thumbprint=1EEE9A574CC92773EF948B50E79703F1B55556BF / ok
expiration=2022.10.03 08:25:10 / ok
HTTPSend(socket,StringFormat(%s / HTTP/1.1
Host: %s
,Method,Server),TLS)=true / ok
* HTTP-header found, header size: 1080
HTTP/1.1 200 OK
Date: Mon, 01 Aug 2022 20:48:35 GMT
Expires: -1
Cache-Control: private, max-age=0
Content-Type: text/html; charset=ISO-8859-1
Server: gws
X-XSS-Protection: 0
X-Frame-Options: SAMEORIGIN
Set-Cookie: 1P_JAR=2022-08-01-20; expires=Wed, 31-Aug-2022 20:48:35 GMT;
   path=/; domain=.google.com; Secure
...
Accept-Ranges: none
Vary: Accept-Encoding
Transfer-Encoding: chunked
Chunk: 22172 start at 1080 -6
* 1 chunk done: 22172 total: 24081
Chunk: 30824 start at 23252 -8
* 2 chunk done: 30824 total: 54083
* Final chunk
HTTPRecv(socket,response,Timeout,TLS)=true / ok
Got 52998 bytes

Как мы видим, документ передается "чанками" и был сохранен во временный файл (вы можете найти его в MQL5/Files/www.mql5.com.htm).

Запустим теперь скрипт для сайта "www.mql5.com" и порта 80. Из предыдущего раздела мы знаем, что сайт в этом случае выдает перенаправление на свою защищенную версию, но этот "редирект" не пустой: у него есть документ-заглушка, и теперь мы можем получить его полностью. Для нас здесь важно, что в данном случае корректно используется заголовок "Content-Length:".

Server=www.mql5.com / ok
Port=80 / ok
SocketCreate()=1 / ok
SocketConnect(socket,Server,Port,Timeout)=true / ok
HTTPSend(socket,StringFormat(%s / HTTP/1.1
Host: %s
,Method,Server),TLS)=true / NETSOCKET_NO_CERTIFICATE(5275)
* HTTP-header found, header size: 291
HTTP/1.1 301 Moved Permanently
Server: nginx
Date: Sun, 31 Jul 2022 19:28:57 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
* Content-Length:162
* Complete document
HTTPRecv(socket,response,Timeout,TLS)=true / ok
<html>
<head><title>301 Moved Permanently</title></head>
<body>
<center><h1>301 Moved Permanently</h1></center>
<hr><center>nginx</center>
</body>
</html>
 

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