Skip to content

网络函数

MQL 程序能够借助多种协议与分布式网络中的其他计算机或互联网服务器进行通信。这些函数支持与网站和服务(HTTP/HTTPS)、文件传输(FTP)、电子邮件发送(SMTP)以及推送通知等相关的操作。

网络函数可分为三组:

  • SendFTPSendMailSendNotification 是用于发送文件、电子邮件和移动通知的最基础函数。
  • WebRequest 函数旨在与网络资源协同工作,能让你轻松发送 HTTP 请求(包括 GET 和 POST 请求)。
  • 套接字(Socket)函数集允许你通过系统套接字与远程主机建立 TCP 连接(包括安全的 TLS 连接)。

上述分组的排列顺序反映了从高级函数到低级函数的过渡。高级函数为客户端与服务器之间的交互提供了现成的机制,而低级函数则允许根据特定公共服务(例如加密货币交易所或交易信号服务)的需求实现任意应用协议。当然,这样的实现需要付出大量的努力。

为保障终端用户的安全,MQL 程序使用套接字函数和 WebRequest 能够连接的允许网络地址列表,必须在“智能交易系统”选项卡的设置对话框中明确指定。在这里,你可以指定域名、网页的完整路径(不仅包括网站,还包括 URL 的其他部分,如文件夹或端口号)或 IP 地址。以下是本章示例中部分域名的设置截图。

发送推送通知

如你所知,终端允许从MetaQuotes服务、终端本身以及MQL程序向运行iOS或安卓操作系统的移动设备发送推送通知。这项技术使用MetaQuotes ID,即用户的唯一标识符(见注释)。MetaQuotes ID是在用户设备上安装移动版终端时分配的,之后必须在终端设置的“通知”选项卡中指定该ID(可以用逗号分隔指定多个ID)。之后,MQL程序就可以使用发送推送通知的功能了。

实际上,MetaQuotes ID并非用于识别用户,而是用于识别移动终端的特定安装;一个用户可以有多个安装实例。默认情况下,该ID与在mql5.com社区的注册并无关联,不过可以在该网站上进行绑定。不要将用户在社区的注册与MetaQuotes ID混淆。使用通知功能时,终端用户无需登录社区。

bool SendNotification(const string text)

SendNotification函数会将指定文本的推送通知发送到终端设置中包含MetaQuotes ID的所有移动终端。消息长度不得超过255个字符。

若通知从终端成功发出,函数将返回true;若出现错误,则返回false_LastError中可能的错误代码如下:

  • 4515 — ERR_NOTIFICATION_SEND_FAILED — 通信问题
  • 4516 — ERR_NOTIFICATION_WRONG_PARAMETER — 参数无效,例如空字符串
  • 4517 — ERR_NOTIFICATION_WRONG_SETTINGS — MetaQuotes ID配置错误或缺失
  • 4518 — ERR_NOTIFICATION_TOO_FREQUENT — 函数调用过于频繁

若与服务器建立了连接,消息会立即发送。若用户设备处于在线状态,消息应能送达收件人,但一般情况下无法保证送达。程序不会收到关于消息送达的反馈通知。服务器不会保存用于延迟送达的推送消息历史记录。

该函数在使用频率上有限制:每秒最多调用2次,每分钟最多调用10次。

SendNotification函数无法在策略测试器中执行。

本书包含一个简单的脚本NetNotification.mq5,在设置正确时会发送测试通知。

c++
void OnStart()
{
   const string message = MQLInfoString(MQL_PROGRAM_NAME)
      + " runs on " + AccountInfoString(ACCOUNT_SERVER)
      + " " + (string)AccountInfoInteger(ACCOUNT_LOGIN);
   Print("Sending notification: " + message);
   PRTF(SendNotification(NULL));    // INVALID_PARAMETER(4003)
   PRTF(SendNotification(message)); // NOTIFICATION_WRONG_SETTINGS(4517) or 0 (success)
}

终端设置中的网络资源访问权限

你无法通过编程方式编辑此列表。若尝试访问不在此列表中的网络资源,MQL 程序将收到错误信息,且请求会被拒绝。

需要着重注意的是,所有网络函数仅能提供与特定服务器的客户端连接,也就是说,无法使用 MQL5 搭建一个服务器来等待并处理传入的请求。若要实现此目的,就需要将终端与外部程序或互联网服务(例如云服务)进行集成。

发送电子邮件通知

终端允许你向设置对话框中“电子邮件”选项卡指定的电子邮件地址发送邮件。为此,MQL5 提供了 SendMail 函数。

c++
bool SendMail(const string subject, const string text)

该函数的参数用于设置邮件的标题和正文内容。

若邮件已在邮件服务器排队等待发送,函数返回 true;否则返回 false。若在设置中禁用了邮件功能,或者邮件数据(SMTP 服务器、端口、登录名、密码)存在错误或未指定,就可能会出现错误。

SendMail 函数无法在策略测试器中执行。

MQL5 不支持检查和读取收件箱邮件(即不支持 POP、IMAP 协议)。

本书包含了一个名为 NetMail.mq5 的脚本,它会尝试发送一条测试消息。

c++
void OnStart()
{
   const string message = "Hello from "
      + AccountInfoString(ACCOUNT_SERVER)
      + " " + (string)AccountInfoInteger(ACCOUNT_LOGIN);
   Print("Sending email: " + message);
   PRTF(SendMail(MQLInfoString(MQL_PROGRAM_NAME),
      message)); // MAIL_SEND_FAILED(4510) 或 0(成功)
}

向 FTP 服务器发送文件

MetaTrader 5 支持将文件发送到 FTP 服务器。要使此功能正常工作,你必须在设置对话框的 “FTP” 选项卡中输入必要的 FTP 详细信息:FTP 服务器地址、登录名、密码,以及可选的在服务器上放置文件的路径。如果你的计算机处于互联网服务提供商(ISP)的网络中,且该 ISP 未为你分配公共 IP 地址,那么你可能需要开启被动模式。

从 MQL 程序直接发送文件由 SendFTP 函数支持。

cpp
bool SendFTP(const string filename, const string path = NULL)

该函数将具有指定名称的文件从终端设置发送到 FTP 服务器。如有必要,你可以指定一个与预先配置的路径不同的路径。如果未指定 path 参数,则使用设置中描述的目录。

上传的文件必须位于 MQL5/Files 文件夹或其子文件夹中。

该函数返回成功(true)或错误(false)的指示。_LastError 中可能的错误包括:

  • 4514ERR_FTP_SEND_FAILED — 通过 FTP 发送文件失败
  • 4519ERR_FTP_NOSERVER — 未指定 FTP 服务器
  • 4520ERR_FTP_NOLOGIN — 未指定 FTP 登录名
  • 4521ERR_FTP_FILE_ERROR — 在 MQL5/Files 目录中未找到指定的文件
  • 4522ERR_FTP_CONNECT_FAILED — 连接到 FTP 服务器时发生错误
  • 4523ERR_FTP_CHANGEDIR — 在 FTP 服务器上未找到用于上传文件的目录
  • 4524ERR_FTP_CLOSED — 与 FTP 服务器的连接已关闭

该函数会阻止 MQL 程序的执行,直到操作完成。因此,不允许在指标中使用该函数。

此外,SendFTP 函数在策略测试器中不执行。

终端仅支持将单个文件发送到 FTP 服务器。所有其他 FTP 命令在 MQL5 中不可用。

示例脚本 NetFtp.mq5 会截取当前图表的屏幕截图,并尝试通过 FTP 发送它。

cpp
void OnStart()
{
   const string filename = _Symbol + "-" + PeriodToString() + "-"
      + (string)(ulong)TimeTradeServer() + ".png";
   PRTF(ChartScreenShot(0, filename, 300, 200));
   Print("Sending file: " + filename);
   PRTF(SendFTP(filename, "/upload")); // 0 (success) or FTP_CONNECT_FAILED(4522), FTP_CHANGEDIR(4523), etc.
}

通过 HTTP/HTTPS 与 Web 服务器进行数据交换

MQL5 允许将程序与 Web 服务集成,并从互联网请求数据。可以使用 WebRequest 函数通过 HTTP/HTTPS 协议发送和接收数据,该函数有两个版本:用于与 Web 服务器进行简化交互和高级交互的版本。

c
int WebRequest(const string method, const string url, const string cookie, const string referer,
  int timeout, const char &data[], int size, char &result[], string &response)

int WebRequest(const string method, const string url, const string headers, int timeout,
  const char &data[], char &result[], string &response)

这两个函数的主要区别在于,简化版本只允许在请求中指定两种类型的头部信息:cookiereferer,即跳转来源地址(这里没有拼写错误,从历史角度看,在 HTTP 头部中,“referrer” 这个词就是写成一个 “r”)。扩展版本采用一个通用的 headers 参数来发送任意一组头部信息。请求头部信息的格式为 “名称: 值”,如果有多个头部信息,则使用换行符 “\r\n” 连接。

如果假设 cookie 字符串必须包含 “name1=value1; name2=value2”,并且 referer 链接等于 “google.com”,那么要调用第二个版本的函数并达到与第一个版本相同的效果,我们需要在 headers 参数中添加以下内容:“Cookie: name1=value1; name2=value2\r\nReferer: google.com”。

method 参数指定协议方法之一,“HEAD”、“GET” 或 “POST”。请求的资源或服务的地址通过 url 参数传递。根据 HTTP 规范,网络资源标识符的长度限制为 2048 字节,但在撰写本书时,MQL5 的限制是 1024 字节。

请求的最长持续时间由以毫秒为单位的 timeout 确定。

这两个版本的函数都将数据从 data 数组传输到服务器。第一个版本还需要指定这个数组的大小(以字节为单位)(size)。

要发送包含几个变量值的简单请求,可以将它们组合成一个字符串,如 “name1=value1&name2=value2&...”,并将其添加到 GET 请求地址中,放在分隔符字符 “?” 之后,或者使用 “Content-Type: application/x-www-form-urlencoded” 头部信息将其放入 POST 请求的 data 数组中。对于更复杂的情况,例如上传文件,使用 POST 请求和 “Content-Type: multipart/form-data”。

接收结果数组 result 获取服务器响应体(如果有)。服务器响应头部信息放在 response 字符串中。

该函数返回服务器的 HTTP 响应代码,如果发生系统错误(例如,通信问题或参数错误),则返回 -1。_LastError 中可能出现的潜在错误代码包括:

  • 5200 — ERR_WEBREQUEST_INVALID_ADDRESS — 无效的 URL
  • 5201 — ERR_WEBREQUEST_CONNECT_FAILED — 无法连接到指定的 URL
  • 5202 — ERR_WEBREQUEST_TIMEOUT — 从服务器接收响应的超时时间已过
  • 5203 — ERR_WEBREQUEST_REQUEST_FAILED — 请求导致的任何其他错误

请记住,即使在 MQL5 级别请求执行没有错误,服务器的 HTTP 响应代码中也可能包含应用程序错误(例如,需要授权、无效的数据格式、页面未找到等)。在这种情况下,结果将为空,并且通常通过分析接收到的响应头部信息来明确解决问题的说明。

要使用 WebRequest 函数,应将服务器地址添加到终端设置中 “专家顾问” 选项卡的允许 URL 列表中。服务器端口会根据指定的协议自动选择:“http://” 使用 80 端口,“https://” 使用 443 端口。

fWebRequest 函数是同步的,即它在等待服务器响应时会暂停程序执行。因此,不允许从指标中调用该函数,因为它们在每个交易品种的公共流中工作。一个指标的执行延迟将停止该交易品种所有图表的更新。

在策略测试器中工作时,WebRequest 函数不会执行。

让我们从一个简单的脚本 WebRequestTest.mq5 开始,它执行单个请求。在输入参数中,我们将提供方法的选择(默认为 “GET”)、测试网页的地址、附加头部信息(可选)以及超时时间。

c
input string Method = "GET"; // 方法(GET,POST)
input string Address = "https://httpbin.org/headers";
input string Headers;
input int Timeout = 5000;

地址的输入方式与在浏览器地址栏中相同:在发送之前,WebRequest 函数会根据 urlencode 算法自动 “屏蔽” HTTP 规范禁止直接在地址中使用的所有字符(包括本地字母字符)(浏览器也是这样做的,但我们看不到,因为这种形式是为了在网络基础设施中传输,而不是给人看的)。

我们还将添加 DumpDataToFiles 选项:当它的值为 true 时,脚本会将服务器的响应保存到一个单独的文件中,因为响应可能会很大。值为 false 则指示直接将数据输出到日志中。

c
input bool DumpDataToFiles = true;

我们必须马上说明,测试这样的脚本需要一个服务器。有兴趣的人可以安装一个本地 Web 服务器,例如 node.js,但这需要自行准备或安装服务器端脚本(在这种情况下,需要连接 JavaScript 模块)。更简单的方法是使用互联网上可用的公共测试 Web 服务器。例如,你可以使用 httpbin.org、httpbingo.org、webhook 网站、putsreq.com、www.mockable.io 或 reqbin.com。它们提供了不同的功能集。选择或找到适合你的(方便、易懂或尽可能灵活的)服务器。

Address 参数中,默认是服务器 API httpbin.org 的端点地址。这个动态的 “网页” 会将其请求的 HTTP 头部信息(以 JSON 格式)返回给客户端。因此,我们将能够在程序中看到从终端发送到 Web 服务器的具体内容。

不要忘记将 “httpbin.org” 域名添加到终端设置的允许列表中。

JSON 文本格式实际上是 Web 服务的标准格式。可以在 mql5.com 网站上找到用于解析 JSON 的现成类实现,但目前,我们将只按原样显示 JSON。

OnStart 处理程序中,我们使用给定的参数调用 WebRequest,如果错误代码为非负数,则处理结果。服务器响应头部信息(response)总是会记录到日志中。

c
void OnStart()
{
   uchar data[], result[];
   string response;
   
   int code = PRTF(WebRequest(Method, Address, Headers, Timeout, data, result, response));
   if(code > -1)
   {
      Print(response);
      if(ArraySize(result) > 0)
      {
         PrintFormat("Got data: %d bytes", ArraySize(result));
         if(DumpDataToFiles)
         {
            string parts[];
            URL::parse(Address, parts);
            
            const string filename = parts[URL_HOST] +
               (StringLen(parts[URL_PATH]) > 1 ? parts[URL_PATH] : "/_index_.htm");
            Print("Saving ", filename);
            PRTF(FileSave(filename, result));
         }
         else
         {
            Print(CharArrayToString(result, 0, 80, CP_UTF8));
         }
      }
   }
}

为了形成文件名,我们使用头文件 URL.mqh 中的 URL 辅助类(这里不会对其进行完整描述)。URL::parse 方法根据规范将传递的字符串解析为 URL 组件,因为 URL 的一般形式总是 “protocol://domain.com:port/path?query#hash”;请注意,许多片段是可选的。结果将放在接收数组中,数组中的索引对应于 URL 的特定部分,并在 URL_PARTS 枚举中进行了描述:

c
enum URL_PARTS
{
   URL_COMPLETE,   // 完整地址
   URL_SCHEME,     // 协议
   URL_USER,       // 用户名/密码(已弃用,不支持)
   URL_HOST,       // 服务器
   URL_PORT,       // 端口号
   URL_PATH,       // 路径/目录
   URL_QUERY,      // '?' 后的查询字符串
   URL_FRAGMENT,   // '#' 后的片段(不突出显示)
   URL_ENUM_LENGTH
};

因此,当接收到的数据应该写入文件时,脚本会在以服务器命名的文件夹(parts[URL_HOST])中创建该文件,并保留 URL 中的路径层次结构(parts[URL_PATH]):在最简单的情况下,这将只是 “端点” 的名称。当请求网站的主页时(路径只包含一个斜杠 “/”),文件将命名为 “index.htm”。

让我们尝试使用默认参数运行脚本,记住首先要在终端设置中允许这个服务器。在日志中,我们将看到以下行(服务器响应的 HTTP 头部信息和关于文件成功保存的消息):

WebRequest(Method,Address,Headers,Timeout,data,result,response)=200 / ok
Date: Fri, 22 Jul 2022 08:45:03 GMT
Content-Type: application/json
Content-Length: 291
Connection: keep-alive
Server: gunicorn/19.9.0
Access-Control-Allow-Origin: *
Access-Control-Allow-Credentials: true
   
Got data: 291 bytes
Saving httpbin.org/headers
FileSave(filename,result)=true / ok

httpbin.org/headers 文件包含了服务器看到的我们请求的头部信息(服务器在回答我们时自己添加了 JSON 格式)。

json
{
  "headers":
  {
    "Accept": "*/*", 
    "Accept-Encoding": "gzip, deflate", 
    "Accept-Language": "ru,en", 
    "Host": "httpbin.org", 
    "User-Agent": "MetaTrader 5 Terminal/5.3333 (Windows NT 10.0; Win64; x64)", 
    "X-Amzn-Trace-Id": "Root=1-62da638f-2554..." // <- 这是由反向代理服务器添加的
  }
}

因此,终端报告它已准备好接受任何类型的数据,支持通过特定方法进行压缩以及列出了首选语言。此外,它在 User-Agent 字段中显示为 MetaTrader 5。当与某些专门优化为仅与浏览器一起工作的网站交互时,后者可能不太理想。然后,我们可以在 headers 输入参数中指定一个虚构的名称,例如 “User-Agent: Mozilla/5.0 (Windows NT 10.0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/103.0.0.0 Safari/537.36”。

上面列出的一些测试网站允许在服务器上为你的个人实验组织一个带有随机名称的临时测试环境:为此,你需要从浏览器访问该网站并获取一个通常有效期为 24 小时的唯一链接。然后,你将能够使用这个链接作为从 MQL5 进行请求的地址,并直接从浏览器监控请求的行为。在那里,你还可以配置服务器响应,特别是尝试提交表单。

让我们使这个例子稍微复杂一些。服务器可能要求客户端采取额外的行动来满足请求,特别是进行授权、执行 “重定向”(转到不同的地址)、降低请求频率等。所有这些 “信号” 都由 WebRequest 函数返回的特殊 HTTP 代码表示。例如,代码 301 和 302 表示由于不同原因的重定向,并且 WebRequest 会在内部自动执行重定向,在服务器指定的地址重新请求页面(因此,重定向代码永远不会出现在 MQL 程序代码中)。401 代码要求客户端提供用户名和密码,而这完全是我们的责任。有很多方法可以发送这些数据。一个新的脚本 WebRequestAuth.mq5 演示了如何处理服务器使用 HTTP 响应头部信息请求的两种授权选项:“WWW-Authenticate: Basic” 或 “WWW-Authenticate: Digest”。在头部信息中,它可能看起来像这样:

WWW-Authenticate:Basic realm="DemoBasicAuth"

或者像这样:

WWW-Authenticate:Digest realm="DemoDigestAuth",qop="auth", »
»  nonce="cuFAuHbb5UDvtFGkZEb2mNxjqEG/DjDr",opaque="fyNjGC4x8Zgt830PpzbXRvoqExsZeQSDZj"

第一种是最简单且最不安全的,因此几乎不使用:在本书中给出它是因为在第一阶段学习它很容易。其工作原理是通过添加一个特殊的头部信息来生成以下 HTTP 请求以响应服务器请求:

Authorization: Basic dXNlcjpwYXNzd29yZA==

在这里,“Basic” 关键字后面跟着 Base64 编码的字符串 “user:password”,其中包含实际的用户名和密码,并且此后将 “:” 字符按原样插入作为连接块。更清楚地说,交互过程如图片所示。

Web 服务器上的简单授权方案

简单授权方案在 Web 服务器上

授权方案 Digest 被认为更高级。在这种情况下,服务器在其响应中提供一些额外的信息:

  • realms — 进行登录的网站(网站区域)的名称
  • qopDigest 方法的变体(我们只考虑 “auth”)
  • nonce — 一个随机字符串,将用于生成授权数据
  • opaque — 一个随机字符串,我们将在我们的头部信息中原样传递回去
  • algorithm — 哈希算法的可选名称,默认情况下假定为 MD5

为了进行授权,需要执行以下步骤:

  1. 生成你自己的随机字符串 cnonce
  2. 初始化或递增你的请求计数器 nc
  3. 计算 hash1 = MD5(user:realm:password)
  4. 计算 hash2 = MD5(method:uri),这里 uri 是页面的路径和名称
  5. 计算 response = MD5(hash1:nonce:nc:cnonce:qop:hash2)

之后,客户端可以向服务器重复请求,在其头部信息中添加这样一行:

Authorization: Digest username="user",realm="realm",nonce="...", »
»  uri="/path/to/page",qop=auth,nc=00000001,cnonce="...",response="...",opaque="..."

由于服务器拥有与客户端相同的信息,它将能够重复计算并检查哈希值是否匹配。

让我们在脚本参数中添加变量以输入用户名和密码。默认情况下,Address 参数包括 digest-auth 端点的地址,该端点可以使用参数 qop(“auth”)、登录名(“test”)和密码(“pass”)请求授权。在端点路径中,所有这些都是可选的(你可以测试其他方法和用户凭据,例如:“https://httpbin.org/digest-auth/auth-int/mql5client/mql5password”)。

c
const string Method = "GET";
input string Address = "https://httpbin.org/digest-auth/auth/test/pass";
input string Headers = "User-Agent: noname";
input int Timeout = 5000;
input string User = "test";
input string Password = "pass";
input bool DumpDataToFiles = true;

我们在 Headers 参数中指定了一个虚拟的浏览器名称以演示该功能。

OnStart 函数中,我们添加对 HTTP 代码 401 的处理。如果未提供用户名和密码,我们将无法继续。

c
void OnStart()
{
   string parts[];
   URL::parse(Address, parts);
   uchar data[], result[];
   string response;
   int code = PRTF(WebRequest(Method, Address, Headers, Timeout, data, result, response));
   Print(response);
   if(code == 401)
   {
      if(StringLen(User) == 0 || StringLen(Password) == 0)
      {
         Print("Credentials required");
         return;
      }
      ...

下一步是分析从服务器接收的头部信息。为了方便起见,我们编写了 HttpHeader 类(HttpHeader.mqh)。将完整的文本传递给它的构造函数,以及元素分隔符(在这种情况下是换行符 “\n”)和每个元素中名称和值之间使用的字符(在这种情况下是冒号 “:”)。在创建对象时,它会 “解析” 文本,然后通过重载的 [] 运算符可以访问元素,其参数类型为字符串。结果,我们可以通过名称 “WWW-Authenticate” 检查是否需要授权。如果文本中存在这样的元素并且等于 “Basic”,我们将使用 Base64 编码的登录名和密码形成响应头部信息 “Authorization: Basic”。

c
      code = -1;
      HttpHeader header(response, '\n', ':');
      const string auth = header["WWW-Authenticate"];
      if(StringFind(auth, "Basic ") == 0)
      {
         string Header = Headers;
        if(StringLen(Header) > 0) Header += "\r\n";
      Header += "Authorization: Basic ";
      Header += HttpHeader::hash(User + ":" + Password, CRYPT_BASE64);
      PRTF(Header);
      code = PRTF(WebRequest(Method, Address, Header, Timeout, data, result, response));
      Print(response);
   }
   ...

对于摘要(Digest)授权,情况稍微复杂一些,需遵循上述概述的算法。

      else if(StringFind(auth, "Digest ") == 0)
      {
         HttpHeader params(StringSubstr(auth, 7), ',', '=');
         string realm = HttpHeader::unquote(params["realm"]);
         if(realm != NULL)
         {
            string qop = HttpHeader::unquote(params["qop"]);
            if(qop == "auth")
            {
               string h1 = HttpHeader::hash(User + ":" + realm + ":" + Password);
               string h2 = HttpHeader::hash(Method + ":" + parts[URL_PATH]);
               string nonce = HttpHeader::unquote(params["nonce"]);
               string counter = StringFormat("%08x", 1);
               string cnonce = StringFormat("%08x", MathRand());
               string h3 = HttpHeader::hash(h1 + ":" + nonce + ":" + counter + ":" +
                  cnonce + ":" + qop + ":" + h2);
               
               string Header = Headers;
               if(StringLen(Header) > 0) Header += "\r\n";
               Header += "Authorization: Digest ";
               Header += "username=\"" + User + "\",";
               Header += "realm=\"" + realm + "\",";
               Header += "nonce=\"" + nonce + "\",";
               Header += "uri=\"" + parts[URL_PATH] + "\",";
               Header += "qop=" + qop + ",";
               Header += "nc=" + counter + ",";
               Header += "cnonce=\"" + cnonce + "\",";
               Header += "response=\"" + h3 + "\",";
               Header += "opaque=" + params["opaque"] + "";
               PRTF(Header);
               code = PRTF(WebRequest(Method, Address, Header, Timeout, data, result, response));
               Print(response);
            }
         }
      }

静态方法 `HttpHeader::hash` 会为所有需要的复合字符串获取一个十六进制哈希表示形式的字符串(默认是 MD5)。基于这些数据,为下一次 `WebRequest` 调用形成头部信息。静态方法 `HttpHeader::unquote` 会去除包围的引号。

脚本的其余部分保持不变。重复的 HTTP 请求可能会成功,然后我们将获取安全页面的内容,或者授权可能会被拒绝,服务器会写入类似 “Access denied”(访问被拒绝)的内容。

由于默认参数包含正确的值(“/digest-auth/auth/test/pass” 对应于用户 “test” 和密码 “pass”),我们运行脚本应该会得到以下结果(所有主要步骤和数据都会记录到日志中)。

WebRequest(Method,Address,Headers,Timeout,data,result,response)=401 / ok Date: Fri, 22 Jul 2022 10:45:56 GMT Content-Type: text/html; charset=utf-8 Content-Length: 0 Connection: keep-alive Server: gunicorn/19.9.0 WWW-Authenticate: Digest realm="me@kennethreitz.com" » » nonce="87d28b529a7a8797f6c3b81845400370", qop="auth", » opaque="4cb97ad7ea915a6d24cf1ccbf6feeaba", algorithm=MD5, stale=FALSE ...


第一次 `WebRequest` 调用以代码 401 结束,并且在响应头部信息中包含一个带有所需参数的授权请求(“WWW-Authenticate”)。基于这些参数,我们计算出正确的答案并为新的请求准备好头部信息。

Header=User-Agent: noname Authorization: Digest username="test",realm="me@kennethreitz.com" » » nonce="87d28b529a7a8797f6c3b81845400370",uri="/digest-auth/auth/test/pass", » qop=auth,nc=00000001,cnonce="00001c74", » response="c09e52bca9cc90caf9a707d046b567b2",opaque="4cb97ad7ea915a6d24cf1ccbf6feeaba" / ok ...


第二次请求返回 200 以及我们写入文件的有效负载。

WebRequest(Method,Address,Header,Timeout,data,result,response)=200 / ok Date: Fri, 22 Jul 2022 10:45:56 GMT Content-Type: application/json Content-Length: 47 Connection: keep-alive Server: gunicorn/19.9.0 ... Got data: 47 bytes Saving httpbin.org/digest-auth/auth/test/pass FileSave(filename,result)=true / ok


在文件 `MQL5/Files/httpbin.org/digest-auth/auth/test/pass` 中,你可以找到 “网页”,或者更确切地说,以 JSON 格式表示的成功授权状态。

```json
{
  "authenticated": true, 
  "user": "test"
}

如果在运行脚本时指定了错误的密码,我们将从服务器收到一个空响应,并且不会写入文件。

使用 WebRequest,我们自动进入了分布式软件系统领域,在这个领域中,正确的操作不仅取决于我们的客户端 MQL 代码,还取决于服务器(更不用说像代理这样的中间环节了)。因此,你需要为出现他人的错误做好准备。特别是,在撰写本书时,httpbin.org 上的 digest-auth 端点的实现存在一个问题:请求中输入的用户名没有参与授权检查,因此如果指定了正确的密码,任何登录名都将导致成功授权。尽管如此,要检查我们的脚本,可以使用其他服务,例如类似 httpbingo.org/digest-auth/auth/test/pass 的服务。你也可以将脚本配置为访问地址 jigsaw.w3.org/HTTP/Digest/,它期望的登录名/密码是 “guest”/“guest”。

在实践中,大多数网站使用直接嵌入在网页中的表单来实现授权:在 HTML 代码中,它们本质上是表单容器标签以及一组输入字段,用户填写这些字段并使用 POST 方法将其发送到服务器。因此,分析提交表单的示例是有意义的。然而,在详细研究这个问题之前,最好先强调另一种技术。

问题在于,客户端和服务器之间的交互通常伴随着客户端和服务器状态的变化。以授权为例,这一点最容易理解,因为在授权之前,系统不知道用户是谁,而在授权之后,系统已经知道了登录名,并且可以应用网站的首选设置(例如,语言、颜色、论坛显示方式),还可以允许访问那些未经授权的访客无法进入的页面(服务器通过返回 HTTP 状态码 403,Forbidden(禁止访问)来阻止此类尝试)。

分布式 Web 应用程序的客户端和服务器部分的一致状态的支持和同步是通过 cookies 机制提供的,该机制意味着在 HTTP 头部信息中存在命名变量及其值。这个术语可以追溯到 “幸运饼干(fortune cookies)”,因为 cookies 也包含对用户不可见的小消息。

服务器和客户端任何一方都可以向 HTTP 头部添加 cookie。服务器通过如下一行来添加:

Set-Cookie: name=value; ⌠Domain=domain; Path=path; Expires=date; Max-Age=number_of_seconds ...⌡ᵒᵖᵗ

只有名称和值是必需的,其余属性是可选的:这里主要有 Domain(域名)、Path(路径)、Expires(过期时间)和 Max age(最大有效期),但在实际情况中还有更多属性。

客户端收到这样一个(或多个)头部信息后,必须记住变量的名称和值,并在所有针对相应 Domain 以及该域名内的 Path 的请求中,将它们发送给服务器,直到过期日期(ExpiresMax-Age)。

在客户端发出的 HTTP 请求中,cookies 作为一个字符串传递:

Cookie: name⁽№⁾=value⁽№⁾ ⌠; name⁽ⁱ⁾=value⁽ⁱ⁾ ...⌡ᵒᵖᵗ

在这里,所有的 name=value 对用分号和空格分隔列出;它们是由服务器设置且客户端已知的,与当前请求的域名和路径匹配,并且未过期。

服务器和客户端在每个 HTTP 请求中交换所有必要的 cookies,这就是为什么这种分布式系统的架构风格被称为 REST(Representational State Transfer,表述性状态转移)。例如,在用户成功登录到服务器后,服务器会通过 “Set-Cookie:” 头部设置一个特殊的 cookie,其中包含用户的标识符,此后 Web 浏览器(或者在我们的情况中,带有 MQL 程序的终端)将在后续请求中发送它(通过在 “Cookie:” 头部添加相应的行)。

WebRequest 函数会默默地为我们完成所有这些工作:从传入的头部信息中收集 cookies,并在发出的 HTTP 请求中添加相应的 cookies

cookies 由终端存储,并且在会话之间根据它们的设置进行存储。要检查这一点,只需从使用 cookies 的网站两次请求一个网页即可。

注意,cookies 是相对于网站存储的,因此在所有使用 WebRequest 访问同一网站的 MQL 程序发出的头部信息中,它们会不知不觉地被替换。

为了简化顺序请求,将常见的操作形式化为一个特殊的类 HTTPRequestHTTPRequest.mqh)是有意义的。我们将在其中存储所有请求可能都需要的常见 HTTP 头部信息(例如,支持的语言、代理指令等)。此外,像超时时间这样的设置也是常见的。这两个设置都传递给对象的构造函数。

c
class HTTPRequest: public HttpCookie
{
protected:
   string common_headers;
   int timeout;
   
public:
   HTTPRequest(const string h, const int t = 5000):
      common_headers(h), timeout(t) { }
   ...

默认情况下,超时时间设置为 5 秒。从某种意义上说,该类的主要通用方法是 request

c
   int request(const string method, const string address,
      string headers, const uchar &data[], uchar &result[], string &response)
   {
      if(headers == NULL) headers = common_headers;
      
      ArrayResize(result, 0);
      response = NULL;
      Print(">>> Request:\n", method + " " + address + "\n" + headers);
      
      const int code = PRTF(WebRequest(method, address, headers, timeout, data, result, response));
      Print("<<< Response:\n", response);
      return code;
   }
};

让我们描述几个用于特定类型查询的更多方法。

GET 请求只使用头部信息,并且文档主体(通常使用术语 “有效负载(payload)”)为空。

c
   int GET(const string address, uchar &result[], string &response,
      const string custom_headers = NULL)
   {
      uchar nodata[];
      return request("GET", address, custom_headers, nodata, result, response);
   }

在 POST 请求中,通常会有一个有效负载。

c
   int POST(const string address, const uchar &payload[],
      uchar &result[], string &response, const string custom_headers = NULL)
   {
      return request("POST", address, custom_headers, payload, result, response);
   }

表单可以以不同的格式发送。最简单的格式是 “application/x-www-form-urlencoded”。这意味着有效负载将是一个字符串(可能非常长,因为规范没有施加限制,这完全取决于 Web 服务器的设置)。对于这样的表单,我们将提供一个更方便的 POST 方法重载,带有有效负载字符串参数。

c
   int POST(const string address, const string payload,
      uchar &result[], string &response, const string custom_headers = NULL)
   {
      uchar bytes[];
      const int n = StringToCharArray(payload, bytes, 0, -1, CP_UTF8);
      ArrayResize(bytes, n - 1); // 移除终止零
      return request("POST", address, custom_headers, bytes, result, response);
   }

让我们编写一个简单的脚本来测试我们的客户端 Web 引擎 WebRequestCookie.mq5。它的任务是两次请求同一个网页:第一次,服务器很可能会提供设置其 cookies 的机会,然后在第二次请求中它们会自动被替换。在输入参数中,指定测试页面的地址:让它是 mql5.com 网站。我们还将通过修正后的 “User-Agent” 字符串来模拟默认头部信息。

c
input string Address = "https://www.mql5.com";
input string Headers = "User-Agent: Mozilla/5.0 (Windows NT 10.0) Chrome/103.0.0.0"; // 头部信息(如果指定多个,使用 '|' 作为分隔符)

在脚本的主函数中,我们描述 HTTPRequest 对象,并在循环中执行两个 GET 请求。

注意!这个测试是在假设 MQL 程序尚未访问过 www.mql5.com 网站并且尚未从该网站接收过 cookies 的情况下进行的。一旦运行了该脚本,cookies 将保留在终端缓存中,并且将无法重现该示例:在循环的两次迭代中,我们将得到相同的日志条目。

不要忘记将 “www.mql5.com” 域名添加到终端设置的允许列表中。

c
void OnStart()
{
   uchar result[];
   string response;
   HTTPRequest http(Headers);
   
   for(int i = 0; i < 2; ++i)
   {
      if(http.GET(Address, result, response) > -1)
      {
         if(ArraySize(result) > 0)
         {
            PrintFormat("Got data: %d bytes", ArraySize(result));
            if(i == 0) // 仅在第一次显示文档的开头部分
            {
               const string s = CharArrayToString(result, 0, 160, CP_UTF8);
               int j = -1, k = -1;
               while((j = StringFind(s, "\r\n", j + 1)) != -1) k = j;
               Print(StringSubstr(s, 0, k));
            }
         }
      }
   }
}

循环的第一次迭代将生成以下日志条目(带有缩写):

>>> Request:
GET https://www.mql5.com
User-Agent: Mozilla/5.0 (Windows NT 10.0) Chrome/103.0.0.0
WebRequest(method,address,headers,timeout,data,result,response)=200 / ok
<<< Response:
Server: nginx
Date: Sun, 24 Jul 2022 19:04:35 GMT
Content-Type: text/html; charset=utf-8
Transfer-Encoding: chunked
Connection: keep-alive
Cache-Control: no-cache,no-store
Content-Encoding: gzip
Expires: -1
Pragma: no-cache
Set-Cookie: sid=CfDJ8O2AwC...Ne2yP5QXpPKA2; domain=.mql5.com; path=/; samesite=lax; httponly
Vary: Accept-Encoding
Strict-Transport-Security: max-age=31536000; includeSubDomains; preload
Content-Security-Policy: default-src 'self'; script-src 'self' ... 
Generate-Time: 2823
Agent-Type: desktop-ru-en
X-Cache-Status: MISS
Got data: 184396 bytes
   
<!DOCTYPE html>
<html lang="ru">
<head>
  <meta http-equiv="X-UA-Compatible" content="IE=edge" />

我们收到了一个名为 sid 的新 cookie。为了验证其有效性,你可以查看日志的第二部分,即循环的第二次迭代。

>>> Request:
GET https://www.mql5.com
User-Agent: Mozilla/5.0 (Windows NT 10.0) Chrome/103.0.0.0
WebRequest(method,address,headers,timeout,data,result,response)=200 / ok
<<< Response:
Server: nginx
Date: Sun, 24 Jul 2022 19:04:36 GMT
Content-Type: text/html; charset=utf-8
Transfer-Encoding: chunked
Connection: keep-alive
Cache-Control: no-cache, no-store, must-revalidate, no-transform
Content-Encoding: gzip
Expires: -1
Pragma: no-cache
Vary: Accept-Encoding
Strict-Transport-Security: max-age=31536000; includeSubDomains; preload
Content-Security-Policy: default-src 'self'; script-src 'self' ... 
Generate-Time: 2950
Agent-Type: desktop-ru-en
X-Cache-Status: MISS

遗憾的是,在这里我们看不到 WebRequest 内部形成的完整出站标头,但通过“Cookie:”标头将 cookie 实例发送到服务器这一事实,可由服务器在第二次响应中不再要求设置该 cookie 来证明。

从理论上讲,这个 cookie 只是识别访问者(大多数网站都是如此),但并不意味着他们已通过授权。因此,让我们回到以一般方式提交表单的练习,这意味着未来的登录和密码输入这一特定任务。

请记住,要提交表单,我们可以使用带有字符串参数 payload 的 POST 方法。根据 “x-www-form-urlencoded” 标准准备数据的原则是,将命名变量及其值写在一行中(有点类似于 cookie)。

name⁽№⁾=value⁽№⁾[&name⁽ⁱ⁾=value⁽ⁱ⁾...]ᵒᵖᵗ

名称和值用 “=” 符号连接,各对之间使用 “&” 字符连接。值可以为空。例如:

Name=John&Age=33&Education=&Address=

重要的是要注意,从技术角度来看,在发送之前,这个字符串必须根据 urlencode 算法进行转换(这就是这种格式名称的由来),不过,WebRequest 会为我们完成这个转换。

变量名由网页表单(网页中 form 标签的内容)或 Web 应用程序逻辑决定 —— 无论如何,Web 服务器必须能够解释这些名称和值。因此,要了解这项技术,我们需要一个带有表单的测试服务器。

测试表单可在 https://httpbin.org/forms/post 找到。这是一个订购披萨的对话框。

测试网页表单

测试网页表单

其内部结构和行为由以下 HTML 代码描述。在其中,我们主要关注 input 标签,它们设置了服务器期望的变量。此外,应注意 form 标签中的 action 属性,因为它定义了 POST 请求应发送到的地址,在这种情况下是 “/post”,与域名一起构成字符串 “httpbin.org/post”。这就是我们将在 MQL 程序中使用的地址。

html
<!DOCTYPE html>
<html>
  <body>
  <form method="post" action="/post">
    <p><label>Customer name: <input name="custname"></label></p>
    <p><label>Telephone: <input type=tel name="custtel"></label></p>
    <p><label>E-mail address: <input type=email name="custemail"></label></p>
    <fieldset>
      <legend> Pizza Size </legend>
      <p><label> <input type=radio name=size value="small"> Small </label></p>
      <p><label> <input type=radio name=size value="medium"> Medium </label></p>
      <p><label> <input type=radio name=size value="large"> Large </label></p>
    </fieldset>
    <fieldset>
      <legend> Pizza Toppings </legend>
      <p><label> <input type=checkbox name="topping" value="bacon"> Bacon </label></p>
      <p><label> <input type=checkbox name="topping" value="cheese"> Extra Cheese </label></p>
      <p><label> <input type=checkbox name="topping" value="onion"> Onion </label></p>
      <p><label> <input type=checkbox name="topping" value="mushroom"> Mushroom </label></p>
    </fieldset>
    <p><label>Preferred delivery time: <input type=time min="11:00" max="21:00" step="900" name="delivery"></label></p>
    <p><label>Delivery instructions: <textarea name="comments"></textarea></label></p>
    <p><button>Submit order</button></p>
  </form>
  </body>
</html>

WebRequestForm.mq5 脚本中,我们准备了类似的输入变量,供用户在发送到服务器之前进行指定。

c
input string Address = "https://httpbin.org/post";
   
input string Customer = "custname=Vincent Silver";
input string Telephone = "custtel=123-123-123";
input string Email = "custemail=email@address.org";
input string PizzaSize = "size=small"; // PizzaSize (small,medium,large)
input string PizzaTopping = "topping=bacon"; // PizzaTopping (bacon,cheese,onion,mushroom)
input string DeliveryTime = "delivery=";
input string Comments = "comments=";

显示的已设置字符串仅用于一键测试:你可以用自己的字符串替换它们,但请注意,在每个字符串中,只能编辑 “=” 右侧的值,而 “=” 左侧的名称应保持不变(服务器将忽略未知名称)。

OnStart 函数中,我们描述 HTTP 标头 “Content-Type:”,并准备一个包含所有变量的连接字符串。

c
void OnStart()
{
   uchar result[];
   string response;
   string header = "Content-Type: application/x-www-form-urlencoded";
   string form_fields;
   StringConcatenate(form_fields,
      Customer, "&",
      Telephone, "&",
      Email, "&",
      PizzaSize, "&",
      PizzaTopping, "&",
      DeliveryTime, "&",
      Comments);
   HTTPRequest http;
   if(http.POST(Address, form_fields, result, response) > -1)
   {
      if(ArraySize(result) > 0)
      {
         PrintFormat("Got data: %d bytes", ArraySize(result));
         // 注意:许多内容类型默认使用 UTF - 8,但有些可能不同,需分析响应标头
         Print(CharArrayToString(result, 0, WHOLE_ARRAY, CP_UTF8));
      }
   }
}

然后我们执行 POST 方法,并将服务器响应记录到日志中。以下是一个示例结果。

>>> Request:
POST https://httpbin.org/post
Content-Type: application/x-www-form-urlencoded
WebRequest(method,address,headers,timeout,data,result,response)=200 / ok
<<< Response:
Date: Mon, 25 Jul 2022 08:41:41 GMT
Content-Type: application/json
Content-Length: 780
Connection: keep-alive
Server: gunicorn/19.9.0
Access-Control-Allow-Origin: *
Access-Control-Allow-Credentials: true
   
Got data: 721 bytes
{
  "args": {}, 
  "data": "", 
  "files": {}, 
  "form": {
    "comments": "", 
    "custemail": "email@address.org", 
    "custname": "Vincent Silver", 
    "custtel": "123-123-123", 
    "delivery": "", 
    "size": "small", 
    "topping": "bacon"
  }, 
  "headers": {
    "Accept": "*/*", 
    "Accept-Encoding": "gzip, deflate", 
    "Accept-Language": "ru,en", 
    "Content-Length": "127", 
    "Content-Type": "application/x-www-form-urlencoded", 
    "Host": "httpbin.org", 
    "User-Agent": "MetaTrader 5 Terminal/5.3333 (Windows NT 10.0; x64)", 
    "X-Amzn-Trace-Id": "Root=1-62de5745-25bd1d823a9609f01cff04ad"
  }, 
  "json": null, 
  "url": "https://httpbin.org/post"
}

测试服务器以 JSON 副本的形式确认收到了数据。在实践中,服务器当然不会返回数据本身,而只是报告成功状态,并可能重定向到受数据影响的另一个网页(例如,显示订单号)。

借助此类较小的 POST 请求,通常也会执行授权操作。但说实话,大多数 Web 服务出于安全目的故意使这个过程变得复杂,要求你首先根据用户详细信息计算几个哈希值。专门开发的公共 API 通常会在文档中描述所有必要的算法。但并非总是如此。特别是,我们无法使用 WebRequest 在 mql5.com 上登录,因为该网站没有开放的编程接口。

在向 Web 服务发送请求时,始终要遵守不超过请求频率的规则:通常,每个服务都会指定自己的限制,违反这些限制将导致你的客户端程序、账户或 IP 地址被后续阻止。

建立和断开网络套接字连接

在前面的章节中,我们了解了高级的MQL5网络函数:每个函数都支持特定的应用协议。例如,SMTP用于发送电子邮件(SendMail),FTP用于文件传输(SendFTP),而HTTP则可用于接收网页文档(WebRequest)。上述所有标准都基于更低层的传输层协议TCP(传输控制协议)。在协议层次结构中,TCP并非最底层,因为还有更低的层次,但我们在此不做讨论。

应用协议的标准实现将许多技术细节隐藏在内部,使得程序员无需花费大量时间去遵循相关规范。然而,它缺乏灵活性,并且没有考虑到标准中嵌入的高级特性。因此,有时需要在TCP层(即套接字层)对网络通信进行编程。

套接字可以类比为磁盘上的文件:套接字也由一个整数描述符表示,通过该描述符可以读取或写入数据,但这是在分布式网络基础设施中进行的。与文件不同的是,计算机上的套接字数量是有限的,因此在将套接字描述符与网络资源(地址、URL)关联之前,必须预先向系统请求该描述符。另外需要提前说明的是,通过套接字访问信息是流式的,也就是说,无法像处理文件那样将某个“指针”“回退”到开头。

读写线程不会交叉,但可能会影响未来的读或写数据,因为传输的信息通常会被服务器和客户端程序解释为控制命令。协议标准会定义流中包含的是命令还是数据。

SocketCreate函数允许在MQL5中创建一个“空”的套接字描述符。

int SocketCreate(uint flags = 0)

它唯一的参数预留用于将来指定标志的位模式,这些标志决定了套接字的模式,但目前仅支持一个占位标志:SOCKET_DEFAULT对应当前模式,可以省略。在系统层面,这相当于阻塞模式的套接字(这可能会引起网络程序员的兴趣)。

如果函数执行成功,将返回套接字句柄;否则,返回INVALID_HANDLE

一个MQL程序最多可以创建128个套接字。当超过此限制时,错误代码5271(ERR_NETSOCKET_TOO_MANY_OPENED)将记录到_LastError中。

在打开套接字后,需要将其与网络地址关联。

bool SocketConnect(int socket, const string server, uint port, uint timeout)

SocketConnect函数会使套接字连接到指定地址和端口的服务器(例如,Web服务器通常分别在端口80(HTTP)和443(HTTPS)上运行,而SMTP在端口25上运行)。地址可以是域名或IP地址。

timeout参数允许设置等待服务器响应的超时时间,单位为毫秒。

该函数返回连接成功的标志(true)或错误标志(false)。错误代码会写入_LastError中,例如5272(ERR_NETSOCKET_CANNOT_CONNECT)。

请注意,连接地址必须添加到终端设置的允许地址列表中(“服务”->“设置”->“智能交易系统”对话框)。

在完成网络操作后,应该使用SocketClose释放套接字。

bool SocketClose(const int socket)

SocketClose函数通过之前使用SocketCreate函数打开的套接字句柄来关闭套接字。如果套接字之前通过SocketConnect建立了连接,那么该连接将会被断开。

该函数同样返回操作成功的标志(true)或错误标志(false)。特别是当传入无效句柄时,错误代码5270(ERR_NETSOCKET_INVALIDHANDLE)将记录到_LastError中。

提醒一下,本节及后续章节中的所有函数在指标中都是被禁止使用的:在指标中尝试使用套接字相关函数将会导致错误4014(ERR_FUNCTION_NOT_ALLOWED,“不允许调用系统函数”)。

来看一个入门示例,SocketConnect.mq5脚本。在输入参数中,可以指定服务器的地址和端口。我们可以先使用像mql5.com这样的常规Web服务器进行测试。

c++
input string Server = "www.mql5.com";
input uint Port = 443;

OnStart函数中,我们只需创建一个套接字并将其绑定到网络资源。

c++
void OnStart()
{
   PRTF(Server);
   PRTF(Port);
   const int socket = PRTF(SocketCreate());
   if(PRTF(SocketConnect(socket, Server, Port, 5000)))
   {
      PRTF(SocketClose(socket));
   }
}

如果终端中的所有设置都正确,并且已连接到互联网,我们将得到以下“报告”。

Server=www.mql5.com / ok
Port=443 / ok
SocketCreate()=1 / ok
SocketConnect(socket,Server,Port,5000)=true / ok
SocketClose(socket)=true / ok

检查套接字状态

在使用套接字时,检查其状态是很有必要的,因为分布式网络不像文件系统那样可靠。特别是,连接可能会由于各种原因而中断。SocketIsConnected 函数可以让你了解这一情况。

c++
bool SocketIsConnected(const int socket)

该函数检查具有指定句柄(通过 SocketCreate 获得)的套接字是否已连接到其网络资源(在 SocketConnect 中指定),如果成功连接则返回 true

另一个函数 SocketIsReadable 可以让你知道与套接字相关联的系统缓冲区中是否有任何数据可供读取。这意味着我们通过网络地址连接的计算机已经向我们发送了(并且可能会继续发送)数据。

c++
uint SocketIsReadable(const int socket)

该函数返回可以从套接字读取的字节数。如果出现错误,则返回 0

熟悉 Windows/Linux 套接字系统 API 的程序员知道,当套接字的内部缓冲区中没有传入数据时,值 0 也可能是正常状态。然而,在 MQL5 中这个函数的行为有所不同。当系统套接字缓冲区为空时,它会推测性地返回 1,将对数据可用性的实际检查推迟到下一次调用其中一个读取函数时。特别是,当接收内部缓冲区仍然为空时,通常在首次对套接字调用该函数时会出现这种返回 1 字节虚拟结果的情况。

在执行此函数时,可能会发生错误,这意味着通过 SocketConnect 建立的连接已中断(在 _LastError 中我们将得到代码 5273,即 ERR_NETSOCKET_IO_ERROR)。

SocketIsReadable 函数在设计用于使用 SocketRead 进行“非阻塞”数据读取的程序中很有用。关键在于,当接收缓冲区中没有数据时,SocketRead 函数将等待数据到达,从而暂停程序的执行(根据指定的超时值)。

另一方面,从某种意义上说,阻塞式读取更可靠,因为一旦有新数据到达,你的程序就会“唤醒”,但需要根据一些其他事件(通常是在定时器上或在循环中)定期检查数据是否存在,即调用 SocketIsReadable 函数。

在 TLS 安全模式下使用 SocketIsReadable 函数时需要特别小心。该函数返回的是“原始”数据的数量,在 TLS 模式下这是一个加密块。如果“原始”数据尚未累积到解密块的大小,那么随后调用读取函数 SocketTlsRead 将阻塞程序的执行,等待缺失的片段。如果“原始”数据已经包含一个可用于解密的块,读取函数将返回比“原始”字节数更少的已解密字节。因此,在启用 TLS 的情况下,建议始终将 SocketIsReadable 函数与 SocketTlsReadAvailable 函数结合使用。否则,程序的行为将与预期不同。遗憾的是,MQL5 没有提供与 TLS 模式兼容且不带有上述约束的 SocketTlsIsReadable 函数。

类似的 SocketIsWritable 函数用于检查给定的套接字当前是否可以写入数据。

c++
bool SocketIsWritable(const int socket)

该函数返回成功(true)或错误(false)的指示。在后一种情况下,通过 SocketConnect 建立的连接将已中断。

以下是一个用于测试这些函数的简单脚本 SocketIsConnected.mq5。在输入参数中,我们将提供输入地址和端口的机会。

c++
input string Server = "www.mql5.com";
input uint Port = 443;

OnStart 处理程序中,我们创建一个套接字,连接到该网站,并在循环中开始检查套接字的状态。在第二次迭代之后,我们强制关闭套接字,这应该会导致退出循环。

c++
void OnStart()
{
   PRTF(Server);
   PRTF(Port);
   const int socket = PRTF(SocketCreate());
   if(PRTF(SocketConnect(socket, Server, Port, 5000)))
   {
      int i = 0;
      while(PRTF(SocketIsConnected(socket)) && !IsStopped())
      {
         PRTF(SocketIsReadable(socket));
         PRTF(SocketIsWritable(socket));
         Sleep(1000);
         if(++i >= 2)
         {
            PRTF(SocketClose(socket));
         }
      }
   }
}

日志中会显示以下内容:

Server=www.mql5.com / ok
Port=443 / ok
SocketCreate()=1 / ok
SocketConnect(socket,Server,Port,5000)=true / ok
SocketIsConnected(socket)=true / ok
SocketIsReadable(socket)=0 / ok
SocketIsWritable(socket)=true / ok
SocketIsConnected(socket)=true / ok
SocketIsReadable(socket)=0 / ok
SocketIsWritable(socket)=true / ok
SocketClose(socket)=true / ok
SocketIsConnected(socket)=false / NETSOCKET_INVALIDHANDLE(5270)

设置套接字的数据发送和接收超时时间

由于网络连接不可靠,所有使用套接字函数的操作都支持集中式的超时设置。如果在指定的时间内数据读取或发送没有成功完成,函数将停止尝试执行相应的操作。

你可以使用 SocketTimeouts 函数来设置数据接收和发送的超时时间。

cpp
bool SocketTimeouts(int socket, uint timeout_send, uint timeout_receive)

这两个超时时间都以毫秒为单位,并且会在系统级别上影响指定套接字的所有函数。

SocketRead 函数有它自己的超时参数,通过这个参数,你可以在 SocketRead 函数的特定调用期间额外控制超时时间。

如果设置成功,SocketTimeouts 函数返回 true,否则返回 false

默认情况下,不存在超时设置,这意味着会无限期地等待所有数据被接收或发送。

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

从历史上看,套接字默认情况下通过简单连接提供数据传输。以开放形式传输数据使得技术手段能够分析所有流量。近年来,安全问题受到了更严肃的对待,因此几乎在所有地方都实现了 TLS(传输层安全)技术:它为发送方和接收方之间的所有数据提供即时加密。特别是对于互联网连接,区别在于 HTTP(简单连接)和 HTTPS(安全连接)协议。

MQL5 提供了不同的套接字函数集,用于处理简单连接和安全连接。在本节中,我们将了解简单模式,之后再介绍安全模式。

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

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

遗憾的是,函数名 SocketReadSocketSend 并不 “对称”:“read(读取)” 的反向操作是 “write(写入)”,而 “send(发送)” 的反向操作是 “receive(接收)”。这对于有在其他平台上使用网络 API 经验的开发人员来说可能不太习惯。

c
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 参数中,重要的是输入域名而不是特定页面,因为该脚本只能请求主页,即根路径 “/”。

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

在脚本的主函数中,我们将创建一个套接字,并使用指定的参数(超时时间为 5 秒)在该套接字上打开连接。

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

让我们来看看 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:” 是可选的,但它允许程序向服务器 “自我介绍”,没有它,一些服务器可能会拒绝请求。

注意那两个空行:它们标志着头部信息的结束。在我们的脚本中,使用以下表达式形成头部信息很方便:

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

现在我们只需将其发送到服务器。为此,我们编写了一个简单的函数 HTTPSend。它接收一个套接字描述符和一个头部行。

c
bool HTTPSend(int socket, const string request)
{ 
   char req[];
   int len = StringToCharArray(request, req, 0, WHOLE_ARRAY, CP_UTF8) - 1;
   if(len < 0) return false;
   return SocketSend(socket, req, len) == len;
}

在内部,我们将字符串转换为字节数组并调用 SocketSend

接下来,我们需要接收服务器响应,为此我们编写了 HTTPRecv 函数。它也需要一个套接字描述符和一个对用于放置数据的字符串的引用,但更复杂一些。

c
bool HTTPRecv(int socket, string &result, const uint timeout)
{ 
   char response[];
   int len;         // 有符号整数,用于错误标志 -1
   uint start = GetTickCount();
   result = "";
   
   do 
   {
      ResetLastError();
      if(!(len = (int)SocketIsReadable(socket)))
      {
         Sleep(10); // 等待数据或超时
      }
      else          // 读取可用数量的数据
      if((len = SocketRead(socket, response, len, timeout)) > 0)
      {
         result += CharArrayToString(response, 0, len); // 注意:不使用 CP_UTF8 仅适用于 'HEAD'
         const int p = StringFind(result, "\r\n\r\n");
         if(p > 0)
         {
            // HTTP 头部以双换行符结束,利用这一点
            // 确保接收到整个头部
            Print("HTTP-header found");
            StringSetLength(result, p); // 截断文档主体(如果是 GET 请求)
            return true;
         }
      }
   } 
   while(GetTickCount() - start < timeout && !IsStopped() && !_LastError);
   
   if(_LastError) PRTF(_LastError);
   
   return StringLen(result) > 0;
}

在这里,我们在循环中检查在指定的超时时间内是否有数据出现,并将其读取到响应缓冲区中。出现错误会终止循环。

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

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

有了 HTTPSendHTTPRecv 函数,我们完成 OnStart 代码。

c
void OnStart()
{
      ...
      if(PRTF(HTTPSend(socket, StringFormat("HEAD / HTTP/1.1\r\nHost: %s \r\n"
         "User-Agent: MetaTrader 5\r\n\r\n", Server))))
      {
         string response;
         if(PRTF(HTTPRecv(socket, response, 5000)))
         {
            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 的请求,必须使用其他几个函数,我们稍后会讨论它们。

准备安全套接字连接

为了将套接字连接转换为受保护状态并进行检查,MQL6 分别提供了以下函数:SocketTlsHandshakeSocketTlsCertificate。通常情况下,如果连接建立在 443 端口上,我们无需通过调用 SocketTlsHandshake 来 “手动” 启用保护。原因在于,这对于 HTTPS(TLS)来说是标准操作。

保护是基于客户端和服务器之间数据流的加密,为此最初会使用一对非对称密钥:公钥和私钥。我们在 “可用信息转换方法概述” 部分已经涉及到了这个主题。每个正规的网站都会从网络社区信任的认证机构(CA)之一获取数字证书。该证书包含网站的公钥,并且由认证中心进行数字签名。浏览器和其他客户端应用程序存储(或可以导入)认证机构的公钥,因此能够验证特定证书的质量。

建立安全的 TLS 连接

建立安全的 TLS 连接 (图片来自互联网)

此外,在准备安全连接时,浏览器或应用程序会生成某个 “密钥”,使用网站的公钥对其进行加密,然后将其发送给网站,而网站则使用只有它自己知道的私钥对其进行解密。在实践中,这个阶段看起来更为复杂,但最终,客户端和服务器都拥有了当前会话(连接)的加密密钥。通信的双方都会使用这个密钥,在一端对后续的请求和响应进行加密,在另一端进行解密。

SocketTlsHandshake 函数使用 TLS 握手协议与指定的主机发起安全的 TLS 连接。在这种情况下,客户端和服务器会就连接参数达成一致:所使用的协议版本以及数据加密方法。

c
bool SocketTlsHandshake(int socket, const string host)

函数参数中传入套接字句柄以及与之建立连接的服务器地址(实际上,这与在 SocketConnect 中指定的名称是相同的)。

在建立安全连接之前,程序必须首先使用 SocketConnect 与主机建立常规的 TCP 连接。

如果成功,该函数返回 true;否则返回 false。如果出现错误,_LastError 中会写入错误代码 5274(ERR_NETSOCKET_HANDSHAKE_FAILED)。

SocketTlsCertificate 函数获取用于保护网络连接的证书信息。

c
int SocketTlsCertificate(int socket, string &subject, string &issuer, string &serial, string &thumbprint, datetime &expiration)

如果为套接字建立了安全连接(在显式且成功地调用 SocketTlsHandshake 之后,或者在通过 443 端口连接之后),该函数会根据套接字描述符,用相应的信息填充所有其他引用变量:证书所有者名称(subject)、证书颁发者名称(issuer)、序列号(serial)、数字指纹(thumbprint)以及证书有效期(expiration)。

如果成功获取到证书信息,该函数返回 true;如果出现错误则返回 false。错误代码为 5275(ERR_NETSOCKET_NO_CERTIFICATE)。这可用于确定由 SocketConnect 打开的连接是否立即处于受保护模式。我们将在下一节的示例中使用这一点。

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

安全连接在客户端和服务器之间有其自身的一套数据交换函数。这些函数的名称和操作概念几乎与之前讨论的 SocketReadSocketSend 函数一致。

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

SocketTlsRead 函数从在指定套接字上打开的安全 TLS 连接中读取数据。数据会进入通过引用传递的缓冲区数组中。如果该数组是动态的,其大小将根据数据量增加,但不会超过 INT_MAX(2147483647)字节。

maxlen 参数指定要接收的解密字节数(其数量总是小于进入套接字内部缓冲区的“原始”加密数据量)。数组中容纳不下的数据会保留在套接字中,并可通过下一次调用 SocketTlsRead 来接收。

该函数会一直执行,直到接收到指定数量的数据,或者发生在 SocketTimeouts 中指定的超时。

如果成功,函数返回读取的字节数;如果发生错误,返回 -1,同时在 _LastError 中写入代码 5273ERR_NETSOCKET_IO_ERROR)。出现错误表示连接已终止。

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

SocketTlsReadAvailable 函数从安全 TLS 连接中读取所有可用的解密数据,但不超过 maxlen 字节。与 SocketTlsRead 不同,SocketTlsReadAvailable 不会等待必须存在指定数量的数据,而是立即仅返回当前存在的数据。因此,如果套接字的内部缓冲区“为空”(尚未从服务器接收到任何数据,或者数据已被读取,或者尚未形成可解密的块),该函数将返回 0,并且不会向接收数组缓冲区中记录任何内容。这是一种正常情况。

maxlen 的值必须在 1INT_MAX(2147483647)之间。

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

SocketTlsSend 函数通过在指定套接字上打开的安全连接,从缓冲区数组中发送数据。其操作原理与之前描述的 SocketSend 函数相同,唯一的区别在于连接类型。

我们基于之前讨论的 SocketReadWriteHTTP.mq5 创建一个新的脚本 SocketReadWriteHTTPS.mq5,并在选择 HTTP 方法(默认为 GET,非 HEAD)、设置超时以及支持安全连接方面增加灵活性。默认端口为 443

c++
input string Method = "GET"; // 方法(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),这意味着我们使用的是普通连接,但该错误可以忽略并重置,因为我们对不安全的连接也能接受。

c++
void OnStart()
{
   PRTF(Server);
   PRTF(Port);
   const int socket = PRTF(SocketCreate());
   if(socket == INVALID_HANDLE) return;
   SocketTimeouts(socket, Timeout, Timeout);
   if(PRTF(SocketConnect(socket, Server, Port, Timeout)))
   {
      string subject, issuer, serial, thumbprint; 
      datetime expiration;
      bool TLS = false;
      if(PRTF(SocketTlsCertificate(socket, subject, issuer, serial, thumbprint, expiration)))
      {
         PRTF(subject);
         PRTF(issuer);
         PRTF(serial);
         PRTF(thumbprint);
         PRTF(expiration);
         TLS = true;
      }
      ...

OnStart 函数的其余部分按照之前的计划实现:使用 HTTPSend 函数发送请求,并使用 HTTPRecv 函数接收响应。但这次,我们额外将 TLS 标志传递给这些函数,并且它们的实现必须略有不同。

c++
      if(PRTF(HTTPSend(socket, StringFormat("%s / HTTP/1.1\r\nHost: %s\r\n"
         "User-Agent: MetaTrader 5\r\n\r\n", Method, Server), TLS)))
      {
         string response;
         if(PRTF(HTTPRecv(socket, response, Timeout, TLS)))
         {
            Print("Got ", StringLen(response), " bytes");
            // 对于较大的文档,我们将其保存到文件中
            if(StringLen(response) > 1000)
            {
               int h = FileOpen(Server + ".htm", FILE_WRITE | FILE_TXT | FILE_ANSI, 0, CP_UTF8);
               FileWriteString(h, response);
               FileClose(h);
            }
            else
            {
               Print(response);
            }
         }
      }

HTTPSend 的示例中可以看到,根据 TLS 标志,我们使用 SocketTlsSendSocketSend

c++
bool HTTPSend(int socket, const string request, const bool TLS)
{ 
   char req[];
   int len = StringToCharArray(request, req, 0, WHOLE_ARRAY, CP_UTF8) - 1;
   if(len < 0) return false;
   return (TLS ? SocketTlsSend(socket, req, len) : SocketSend(socket, req, len)) == len;
}

HTTPRecv 的情况稍微复杂一些。由于我们提供了下载整个页面(而不仅仅是头部)的功能,我们需要某种方法来知道是否已接收到所有数据。即使在整个文档传输完成后,套接字通常也会保持打开状态,以优化未来预期的请求。但我们的程序无法知道传输是正常停止的,还是网络基础设施的某个地方出现了暂时的“拥堵”(在浏览器中有时会观察到这种轻松、间歇性的页面加载情况)。或者相反,在连接失败的情况下,我们可能会错误地认为已经接收到了整个文档。

事实上,套接字本身仅作为程序之间的通信手段,并且处理的是抽象的数据块:它们不知道数据的类型、含义以及逻辑结束。所有这些问题都由像 HTTP 这样的应用协议来处理。因此,我们需要深入研究规范并自行实现检查。

c++
bool HTTPRecv(int socket, string &result, const uint timeout, const 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 = 0, chunk_start = 0, chunk_n = 1;
   const static string content_length = "Content-Length:";
   const static string crlf = "\r\n";
   const static int crlf_length = 2;
   ...

确定接收到的数据大小的最简单方法是基于对 Content-Length: 头部的分析。这里我们需要三个变量:lastLFsizecontent_length。不过,这个头部并不总是存在,我们要处理“块”——引入变量 chunk_sizechunk_startcrlfcrlf_length 来检测它们。

为了演示接收数据的各种技术,在这个示例中我们使用“非阻塞”函数 SocketTlsReadAvailable。然而,对于不安全连接没有类似的函数,因此我们必须自己编写(稍后会介绍)。算法的总体方案很简单:这是一个循环,尝试接收大小为 1024(或更小)字节的新数据块。如果我们成功读取了一些数据,就将其累积到 response 数组中。如果套接字的输入缓冲区为空,函数将返回 0,我们稍作暂停。最后,如果发生错误或超时,循环将终止。

c++
   uint start = GetTickCount();
   do 
   {
      ResetLastError();
      if((len = (TLS ? SocketTlsReadAvailable(socket, block, 1024) :
         SocketReadAvailable(socket, block, 1024))) > 0)
      {
         const int n = ArraySize(response);
         ArrayCopy(response, block, n); // 将所有块合并在一起
         ...
         // 这里是主要操作
      }
      else
      {
         if(len == 0) Sleep(10); // 稍等一会儿等待一部分数据到达
      }
   } 
   while(GetTickCount() - start < timeout && !IsStopped() && !_LastError);
   ...

首先,需要等待输入数据流中的 HTTP 头部接收完成。正如我们在之前的示例中已经看到的,头部与文档之间由一个双换行符分隔,即字符序列 \r\n\r\n。很容易通过连续的两个 \nLF)符号来检测。

搜索的结果将是从数据开头的偏移量,在该偏移量处头部结束,文档开始。我们将把它存储在 body 变量中。

c++
         if(body == 0) // 寻找头部的结束,直到找到为止
         {
            for(int i = n; i < ArraySize(response); ++i)
            {
               if(response[i] == '\n') // LF
               {
                  if(lastLF == i - crlf_length) // 找到序列 "\r\n\r\n"
                  {
                     body = i + 1;
                     string headers = CharArrayToString(response, 0, i);
                     Print("* HTTP-header found, header size: ", body);
                     Print(headers);
                     const int p = StringFind(headers, content_length);
                     if(p > -1)
                     {
                        size = (int)StringToInteger(StringSubstr(headers,
                           p + StringLen(content_length)));
                        Print("* ", content_length, size);
                     }
                     ...
                     break; // 找到头部/主体边界
                  }
                  lastLF = i;
               }
            }
         }
         
         if(size == ArraySize(response) - body) // 整个文档
         {
            Print("* Complete document");
            break;
         }
         ...

这会立即搜索 Content-Length: 头部并从中提取大小。填充的 size 变量使得在接收到整个文档时可以编写一个额外的条件语句来退出数据接收循环。

一些服务器会以称为“块”的部分来提供内容。在这种情况下,HTTP 头部中会存在 Transfer-Encoding: chunked 这一行,并且 Content-Length: 这一行会缺失。每个块以一个十六进制数字开头,该数字表示块的大小,后面跟着一个换行符和指定数量的数据字节。块以另一个换行符结束。标记文档结束的最后一个块的大小为零。

请注意,这种分段是由服务器根据其自身当前优化发送的“偏好”进行的,与为了在网络上传输而在套接字级别将信息分割成的数据块(数据包)无关。换句话说,块往往是任意碎片化的,并且网络数据包之间的边界甚至可能出现在块大小的数字之间。

从原理上可以这样描述(左边是文档的块,右边是来自套接字缓冲区的数据块)。

网页文档在 HTTP 和 TCP 级别传输时的分段

网页文档在 HTTP 和 TCP 级别传输时的分段

在我们的算法中,每次迭代时数据包都会进入 block 数组,但逐个分析它们没有意义,所有主要工作都在通用的 response 数组上进行。

因此,如果 HTTP 头部已完全接收,但在其中未找到 Content-Length: 字符串,我们进入 Transfer-Encoding: chunked 模式的算法分支。根据 response 数组中 body 的当前位置(就在 HTTP 头部完成之后),选择字符串片段并将其转换为十六进制格式的数字:这由辅助函数 HexStringToInteger 完成(见附带的源代码)。如果确实有一个数字,我们将其写入 chunk_size,将该位置标记为“块”的开始 chunk_start,并从 response 中删除包含该数字和包围的换行符的字节。

c++
                  ...
                  if(lastLF == i - crlf_length) // 找到序列 "\r\n\r\n"
                  {
                     body = i + 1;
                     ...
                     const int p = StringFind(headers, content_length);
                     if(p > -1)
                     {
                        size = (int)StringToInteger(StringSubstr(headers,
                           p + StringLen(content_length)));
                        Print("* ", content_length, size);
                     }
                     else
                     {
                        size = -1; // 服务器未提供文档长度
                        // 尝试找到块以及第一个块的大小
                        if(StringFind(headers, "Transfer-Encoding: chunked") > 0)
                        {
                           // 块的语法:
                           // <hex-size>\r\n<content>\r\n...
                           const string preview = CharArrayToString(response, body, 20);
                           chunk_size = HexStringToInteger(preview);
                           if(chunk_size > 0)
                           {
                              const int d = StringFind(preview, crlf) + crlf_length;
                              chunk_start = body;
                              Print("Chunk: ", chunk_size, " start at ", chunk_start, " -", d);
                              ArrayRemove(response, body, d);
                           }
                        }
                     }
                     break; // 找到头部/主体边界
                  }
                  lastLF = i;
                  ...

现在,为了检查文档的完整性,不仅需要分析 size 变量(如我们所见,在没有 Content-Length: 时实际上可以通过赋值 -1 来禁用它),还需要分析用于块的新变量:chunk_startchunk_size。操作方案与接收 HTTP 头部之后相同:根据 response 数组中的偏移量(前一个块结束的位置),我们分离出下一个“块”的大小。我们继续这个过程,直到找到大小为零的块。

c++
         ...
         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(response, p, 20);
            if(StringLen(preview) > crlf_length              // 存在 '\r\n...\r\n' 吗?
               && StringFind(preview, crlf, crlf_length) > crlf_length)
            {
               chunk_size = HexStringToInteger(preview, crlf_length);
               if(chunk_size > 0)
               {                              // 两次 '\r\n':在块大小之前和之后
                  int d = StringFind(preview, crlf, crlf_length) + crlf_length;
                  chunk_start = p;
                  Print("Chunk: ", chunk_size, " start at ", chunk_start, " -", d);
                  ArrayRemove(response, chunk_start, d);
                  ++chunk_n;
               }
               else
               {
                  Print("* Final chunk");
                  ArrayRemove(response, p, 5); // "\r\n0\r\n"
                  break;
               }
            } // 否则等待更多数据
         }

因此,我们除了通过超时和错误退出循环外,还通过两种不同的方式基于对接收到的数据流的分析提供了退出循环的方法。在循环正常结束时,我们将数组中从 body 位置开始的那部分转换为 response 字符串,它包含整个文档。否则,我们只是返回成功获取到的所有内容,包括头部,以便进行“分析”。

c++
bool HTTPRecv(int socket, string &result, const uint timeout, const bool TLS)
{
   ...
   do 
   {
      ResetLastError();
      if((len = (TLS ? SocketTlsReadAvailable(socket, block, 1024) :
         SocketReadAvailable(socket, block, 1024))) > 0)
      {
         ... // 这里是主要操作 - 前面已讨论
      }
      else
      {
         if(len == 0) Sleep(10); // 稍等一会儿等待一部分数据到达
      }
   } 
   while(GetTickCount() - start < timeout && !IsStopped() && !_LastError);
      
   if(_LastError) PRTF(_LastError);
   
   if(ArraySize(response) > 0)
   {
      if(body != 0)
      {
         // 理想情况下应检查 'Content-Type:' for 'charset=UTF-8'
         result = CharArrayToString(response, body, WHOLE_ARRAY, CP_UTF8);
      }
      else
      {
         // 为了分析错误情况,按原样返回不完整的头部
         result = CharArrayToString(response);
      }
   }
   
   return StringLen(result) > 0;
}

剩下唯一的函数是 `SocketReadAvailable`,它是 `SocketTlsReadAvailable` 对于非安全连接的对应函数。

```c++
int SocketReadAvailable(int socket, uchar &block[], const uint maxlen = INT_MAX)
{
   ArrayResize(block, 0);
   const uint len = SocketIsReadable(socket);
   if(len > 0)
      return SocketRead(socket, block, fmin(len, maxlen), 10);
   return 0;
}

这个脚本已经可以使用了。

我们花费了不少精力才使用套接字实现了一个简单的网页请求。这展示了在底层支持网络协议通常隐藏着多少繁琐的工作。当然,对于 HTTP 协议来说,使用内置的 WebRequest 实现会更简单且更正确,但它并不包含 HTTP 的所有特性(此外,我们只是顺便提到了 HTTP 1.1,而还有 HTTP / 2 等协议),并且其他应用协议的数量非常庞大。因此,在 MetaTrader 5 中集成这些协议需要用到套接字函数。

让我们使用默认设置运行 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>

我们将在“项目”章节中考虑另一个在实际中使用套接字的大型示例。

设置套接字的数据发送和接收超时时间

由于网络连接不可靠,所有使用套接字函数的操作都支持集中式的超时设置。如果在指定的时间内数据读取或发送没有成功完成,函数将停止尝试执行相应的操作。

你可以使用 SocketTimeouts 函数来设置数据接收和发送的超时时间。

cpp
bool SocketTimeouts(int socket, uint timeout_send, uint timeout_receive)

这两个超时时间都以毫秒为单位,并且会在系统级别上影响指定套接字的所有函数。

SocketRead 函数有它自己的超时参数,通过这个参数,你可以在 SocketRead 函数的特定调用期间额外控制超时时间。

如果设置成功,SocketTimeouts 函数返回 true,否则返回 false

默认情况下,不存在超时设置,这意味着会无限期地等待所有数据被接收或发送。