通过非安全套接字连接读写数据

历史上,套接字默认通过简单连接提供数据传输。以开放形式传输数据支持以技术手段分析所有流量。近年来,安全问题越来越受到重视,因此几乎所有地方都采用了 TLS(传输层安全)技术:TLS 可对发送方和接收方之间的所有数据进行即时加密。特别地,对于互联网连接,区别在于 HTTP(简单连接)和 HTTPS(安全)协议。

MQL5 为处理简单连接和安全连接提供了不同组的 Socket 函数。在本节中,我们将熟悉简单模式,稍后我们将转向受保护的模式。

要从套接字读取数据,可使用 SocketRead 函数。

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

套接字描述符从 SocketCreate 获取,并使用 Socket Connect连接到网络资源。

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 函数。

遗憾的是,函数名 SocketReadSocketSend 并不“对称”:"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 协议来执行工作,即从 Web 服务器请求有关页面的信息。这是 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 请求允许你获取页面的元信息(我们可以用 GET 代替 HEAD 来请求整个页面,但会有一些复杂的问题,我们稍后再讨论)。

HEAD / HTTP/1.1
Host: _server_
User-Agent: MetaTrader 5
                                     // <- two newlines in a row \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;         // signed integer needed for error flag -1
   uint start = GetTickCount();
   result = "";
   
   do 
   {
      ResetLastError();
      if(!(len = (int)SocketIsReadable(socket)))
      {
         Sleep(10); // wait for data or timeout
      }
      else          // read the data in the available volume
      if((len = SocketRead(socketresponselentimeout)) > 0)
      {
         result += CharArrayToString(response0len); // NB: without CP_UTF8 only 'HEAD'
         const int p = StringFind(result"\r\n\r\n");
         if(p > 0)
         {
            // HTTP header ends with a double newline, use this
            // to make sure the entire header is received
            Print("HTTP-header found");
            StringSetLength(resultp); // cut off the body of the document (in case of a GET request)
            return true;
         }
      }
   } 
   while(GetTickCount() - start < timeout && !IsStopped() && !_LastError);
   
   if(_LastErrorPRTF(_LastError);
   
   return StringLen(result) > 0;
}

在这里,我们在循环中检查指定超时内出现的数据,并将其读入 response 缓冲区。发生错误会终止循环。

缓冲区字节立即转换成字符串并连接到 result 变量中的完整响应。务必注意,对于 HTTP 标头,我们只能使用具有默认编码的 CharArrayToString 函数,因为它只允许使用拉丁字母和 ANSI 中的一些特殊字符。

完整的 Web 文档通常采用 UTF-8 编码(但可能采用其他非拉丁编码,这恰好在 HTTP 标头中指明),要接收完整的 Web 文档,将需要更复杂的处理:首先,你需要将所有发送的块收集到一个公共缓冲区中,然后指明 CP_UTF8 将整个内容转换为字符串(否则,任何以双字节编码的字符在发送时都可能被“切割”,并分别到达不同的块中;这就是为什么我们不能期望在单个片段中获得正确的 UTF-8 字节流)。我们将在以下部分改进此示例。

有了 HTTPSendHTTPRecv 函数,我们就完成了 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 的请求,必须使用其他一些函数,我们稍后会讨论它们。