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

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

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,即进行跳转的来源地址(这里没有拼写错误―历史上 "referrer" 这个词在 HTTP 标头中是通过一个 '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 响应代码中(例如,需要授权、无效的数据格式、页面未找到等)。在这种情况下,result 将为空,解决问题的方法通常需要在分析接收到的 response 标头之后才能明确。

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

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

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

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

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

地址的输入方式与浏览器地址栏中的一样:所有被 HTTP 规范禁止直接在地址中使用的字符(包括本地字母数字),在发送前都会被 WebRequest 函数根据 urlencode 算法自动进行“转义”处理(浏览器也会执行完全相同的操作,但我们通常看不到,因为这种转义后的形式是为网络基础设施传输而设计的,并非直接供人阅读)。

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

input bool DumpDataToFiles = true;

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

Address参数中,默认是服务器 API httpbin.orgendpoint 地址。这个动态的“网页”将其请求的 HTTP 标头(以 JSON 格式)返回给客户端。因此,我们能够在程序中看到究竟是什么从终端到达了 Web 服务器。

不要忘记在终端设置中将 "httpbin.org" 域添加到允许列表中。

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

OnStart 处理程序中,我们使用给定的参数调用 WebRequest,并在错误代码为非负时处理结果。服务器响应标头 (response) 总是被记录下来。

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

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

enum URL_PARTS
{
   URL_COMPLETE,   // full address
   URL_SCHEME,     // protocol
   URL_USER,       // username/password (deprecated, not supported)
   URL_HOST,       // server
   URL_PORT,       // port number
   URL_PATH,       // path/directories
   URL_QUERY,      // query string after '?'
   URL_FRAGMENT,   // fragment after '#' (not highlighted)
   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 格式)。

{
  "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..." // <- this is added by the reverse proxy server
  }
}

因此,终端报告它准备好接受任何类型的数据,支持特定方法的压缩以及首选语言列表。此外,它在 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 – 生成条目的站点名称(站点区域)
  • qop – Digest 方法的变体(我们只考虑 "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")。

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 错误代码的处理。如果未提供用户名和密码,程序将无法继续执行。

void OnStart()
{
   string parts[];
   URL::parse(Addressparts);
   uchar data[], result[];
   string response;
   int code = PRTF(WebRequest(MethodAddressHeadersTimeoutdataresultresponse));
   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" 响应标头。

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

对于 Digest 授权,过程则要复杂一些,需遵循前述算法。

      else if(StringFind(auth"Digest ") == 0)
      {
         HttpHeader params(StringSubstr(auth7), ',', '=');
         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) > 0Header += "\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 请求可能会成功,届时我们将获得受保护页面的内容;或者授权被拒绝,服务器则会返回类似“访问被拒绝”的信息。

由于默认参数包含了正确的值("/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 格式的成功授权状态。

{
  "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 代码内部,这些表单本质上是带有若干输入字段的 form 容器标签,用户填写这些字段后,通过 POST 方法发送给服务器。有鉴于此,分析提交表单的示例颇有意义。然而,在深入探讨此问题之前,最好先强调另一种技术。

关键在于,客户端与服务器之间的交互通常伴随着双方状态的改变。以授权为例,这一点最容易理解:授权前,用户对系统而言是未知的;授权后,系统便知晓了用户的登录名,并能应用该用户的偏好设置(例如语言、颜色主题、论坛显示方式等),同时还允许用户访问那些未授权访客无法进入的页面(服务器会通过返回 HTTP 403 Forbidden 状态码来阻止此类尝试)。

分布式 Web 应用程序中客户端与服务器部分状态的一致性支持与同步,是通过 Cookie 机制实现的,该机制指的是 HTTP 标头中的命名变量及其值。“Cookie”这个词源于“幸运饼干”,因为 cookies 也包含着用户不可见的小段信息。

服务器和客户端任何一方都可以向 HTTP 标头添加 cookie。服务器通过类似下面这样的一行来实现:

Set-Cookiename=value; ⌠Domain=domainPath=pathExpires=dateMax-Age=number_of_seconds ...⌡ᵒᵖᵗ

其中,名称和值是必需的,其余属性(如 DomainPathExpiresMax age 等)则是可选的,实际应用中属性可能更多。

收到这样的标头(或多个标头)后,客户端必须记住变量的名称和值,并在后续所有访问该 Domain 及域内对应 Path 的请求中(在过期日期 ExpiresMax-Age 之前)将它们回传给服务器。

在客户端发出的 HTTP 请求中,cookie 以下列字符串形式传递:

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

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

服务器和客户端在每次 HTTP 请求时都会交换所有必要的 Cookie,这种分布式系统的架构风格也因此被称为 REST(Representational State Transfer,表述性状态转移)。例如,用户成功登录服务器后,服务器会(通过 "Set-Cookie:" 标头)设置一个包含用户标识符的特殊 "cookie",之后 Web 浏览器(在我们的例子中是运行 MQL 程序的终端)会在后续请求中发送这个 Cookie(通过向 "Cookie:" 标头添加相应的行)。

WebRequest 函数会自动为我们处理所有这些工作:从传入的标头中收集 Cookie,并向传出的 HTTP 请求中添加相应的 Cookie。

Cookie 会由终端根据其设置在不同会话间进行存储。要验证这一点,只需从一个使用 Cookie 的网站请求两次网页即可。

注意:Cookie 是与特定网站相关联存储的,因此所有使用 WebRequest 访问同一网站的 MQL 程序,其传出标头中的 Cookie 都会被自动替换,这一点用户可能不易察觉。

为了简化连续请求的操作,一种合理做法是将常用行为封装到一个专门的 HTTPRequest 类 (HTTPRequest.mqh)。我们将在这个类中存储所有请求都可能需要的通用 HTTP 标头(例如,支持的语言、代理服务器指令等)。此外,像超时这样的设置也是通用的。这两个设置都通过对象的构造函数传入。

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

默认情况下,超时设置为 5 秒。该类的核心方法,在某种意义上也是通用的方法,是 request

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

我们再介绍几个针对特定类型查询的方法。

GET 请求仅使用标头,而文档的主体(通常称为 payload,即有效负载)为空。

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

POST 请求中通常包含有效负载。

   int POST(const string addressconst uchar &payload[],
      uchar &result[], string &responseconst string custom_headers = NULL)
   {
      return request("POST"addresscustom_headerspayloadresultresponse);
   }

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

   int POST(const string addressconst string payload,
      uchar &result[], string &responseconst string custom_headers = NULL)
   {
      uchar bytes[];
      const int n = StringToCharArray(payloadbytes0, -1CP_UTF8);
      ArrayResize(bytesn - 1); // remove terminal zero
      return request("POST"addresscustom_headersbytesresultresponse);
   }

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

input string Address = "https://www.mql5.com";
input string Headers = "User-Agent: Mozilla/5.0 (Windows NT 10.0) Chrome/103.0.0.0"// Headers (use '|' as separator, if many specified)

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

注意!此测试基于一个假设:即 MQL 程序此前尚未访问过 www.mql5.com 网站,也未从该网站接收过 Cookie。一旦脚本运行过一次,Cookie 将保留在终端的缓存中,届时将无法重现此示例的初次效果:在循环的两次迭代中,我们都将得到相同的日志条目。
 
不要忘记在终端设置中将 "www.mql5.com" 域添加到允许列表中。

void OnStart()
{
   uchar result[];
   string response;
   HTTPRequest http(Headers);
   
   for(int i = 0i < 2; ++i)
   {
      if(http.GET(Addressresultresponse) > -1)
      {
         if(ArraySize(result) > 0)
         {
            PrintFormat("Got data: %d bytes"ArraySize(result));
            if(i == 0// show the beginning of the document only the first time
            {
               const string s = CharArrayToString(result0160CP_UTF8);
               int j = -1k = -1;
               while((j = StringFind(s"\r\n"j + 1)) != -1k = j;
               Print(StringSubstr(s0k));
            }
         }
      }
   }
}

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

>>> 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 函数会自动为我们完成这个转换。

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

测试表单位于 https://httpbin.org/forms/post。这是一个用于订购披萨的对话框。

测试 Web 表单

测试 Web 表单

其内部结构和行为由以下 HTML 代码描述。其中,我们主要关注的是 input 标签,它们设置了服务器期望接收的变量。此外,还应注意 form 标签中的 action 属性,因为它定义了 POST 请求应发送到的地址,在本例中是 "/post",与域名结合后得到 "httpbin.org/post"。这正是我们将在 MQL 程序中使用的地址。

<!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 脚本中,我们准备了类似的输入变量,供用户在发送到服务器之前指定。

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:",并准备了一个包含所有变量的连接字符串。

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(Addressform_fieldsresultresponse) > -1)
   {
      if(ArraySize(result) > 0)
      {
         PrintFormat("Got data: %d bytes"ArraySize(result));
         // NB: UTF-8 is implied for many content-types,
 // but some may be different, analyze the response headers
         Print(CharArrayToString(result0WHOLE_ARRAYCP_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 会在其文档中包含所有必要算法的描述。但也不是绝对。例如,我们将无法使用 WebRequestmql5.com 上登录,因为该站点没有开放的编程接口。

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