通过安全套接字连接读写数据
安全连接自身有一套用于在客户端和服务器之间交换数据的函数。这些函数的名称和操作概念与先前讨论的函数 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相同,唯一的区别在于连接类型。
我们基于先前讨论的 SocketReadWriteHTTP.mq5 创建一个新脚本 SocketReadWriteHTTPS.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_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 标志传递给这些函数,并且它们的实现必须略有不同。
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");
// for large documents, we will save to a file
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 标志,我们使用 SocketTlsSend 或 SocketSend。
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 这样的应用层协议处理。因此,我们需要深入研究规范并自己实现检查。
bool HTTPRecv(int socket, string &result, const uint timeout, const bool TLS)
{
uchar response[]; // accumulate the data as a whole (headers + body of the web document)
uchar block[]; // separate read block
int len; // current block size (signed integer for error flag -1)
int lastLF = -1; // position of the last line feed found LF(Line-Feed)
int body = 0; // offset where document body starts
int size = 0; // document size according to title
result = ""; // set an empty result at the beginning
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:" 标头的分析。这里我们需要三个变量:lastLF、size 和 content_length。不过,这个标头并不一定会存在,我们还需要处理“块”,也就是为此引入了变量 chunk_size、chunk_start、crlf 和 crlf_length 来检测块。
为了演示各种接收数据的技术,我们在此示例中使用了一个“非阻塞”函数 SocketTlsReadAvailable。但是,对于非安全连接,没有类似的函数,因此我们必须自己编写它(稍后会介绍)。算法的总体方案很简单:它是一个循环,尝试接收大小为 1024(或更少)字节的新数据块。如果我们成功读取到一些内容,就将其累积到 response 数组中。如果套接字的输入缓冲区为空,函数将返回 0,我们会暂停一小会儿。最后,如果发生错误或超时,循环将中断。
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); // put all the blocks together
...
// main operation here
}
else
{
if(len == 0) Sleep(10); // wait a bit for the arrival of a portion of data
}
}
while(GetTickCount() - start < timeout && !IsStopped() && !_LastError);
...
|
首先,你需要等待输入数据流中 HTTP 标头的完成。正如我们从前面的示例中已经看到的,标头通过双换行符(即字符序列 \r\n\r\n)与文档主体分隔。通过定位两个相继出现的 '\n' (LF) 符号很容易检测到它。
搜索的结果将是从数据开始到标头结束、文档开始处的字节偏移量。我们将把它存储在 body 变量中。
if(body == 0) // look for the completion of the headers until we find it
{
for(int i = n; i < ArraySize(response); ++i)
{
if(response[i] == '\n') // LF
{
if(lastLF == i - crlf_length) // found sequence "\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; // header/body boundary found
}
lastLF = i;
}
}
}
if(size == ArraySize(response) - body) // entire document
{
Print("* Complete document");
break;
}
...
|
这会立即搜索 "Content-Length:" 标头并从中提取大小。填充的 size 变量使得可以在接收到整个文档时编写一个额外的条件语句来退出数据接收循环。
一些服务器以称为“块”的部分形式提供内容。在这种情况下,HTTP 标头中缺少 "Transfer-Encoding: chunked" 行,而 "Content-Length:" 行。每个块以一个十六进制数开始,指示块的大小,后跟一个换行符和指定数量的数据字节。块以另一个换行符结束。标记文档结束的最后一个块的大小为零。
请注意,这种分段是由服务器自主执行,依据是服务器自身的当前的优化发送“偏好”,与在套接字级别为通过网络传输而将信息划分成的数据块(包)无关。换句话说,块往往会被任意分段,网络包之间的边界甚至可能出现在块大小的数字之间。
具体描述如下(左边是文档的块,右边是来自套接字缓冲区的数据块)。

网页文档在 HTTP 和 TCP 层面传输过程中的分段方式
在我们的算法中,数据包在每次迭代时进入 block 数组,但逐个分析它们没有意义,所有主要工作都是针对共同的 response 数组进行的。
因此,如果 HTTP 标头已完全接收但其中未找到字符串 "Content-Length:",我们会改为采用了 "Transfer-Encoding: chunked" 模式的算法分支。通过 response 数组中 body 的当前位置(紧随 HTTP 标头完成之后),选定字符串片段并假设为十六进制格式转换为数字:这是由辅助函数 HexStringToInteger 完成的(参见随附的源代码)。如果确实是一个数字,我们将其写入 chunk_size,在 chunk_start 中将该位置标记为“块”的开始,并从 response 中移除带有数字和框架换行符的字节。
...
if(lastLF == i - crlf_length) // found sequence "\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; // server did not provide document length
// try to find chunks and the size of the first one
if(StringFind(headers, "Transfer-Encoding: chunked") > 0)
{
// chunk syntax:
// <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; // header/body boundary found
}
lastLF = i;
...
|
现在,要检查文档的完整性,你不仅需要分析 size 变量(正如我们所见,在没有 "Content-Length:" 的情况下,可以通过赋 -1 来实际禁用它),还需要分析用于块的新变量: chunk_start 和 chunk_size。操作方案与 HTTP 标头之后相同:通过 response 数组中的偏移量(前一个块结束的地方),我们分离出下一个“块”的大小。我们继续这个过程,直到找到大小为零的块。
...
if(size == ArraySize(response) - body) // entire document
{
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 // there is '\r\n...\r\n' ?
&& StringFind(preview, crlf, crlf_length) > crlf_length)
{
chunk_size = HexStringToInteger(preview, crlf_length);
if(chunk_size > 0)
{ // twice '\r\n': before and after chunk size
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;
}
} // otherwise wait for more data
}
|
因此,我们通过分析传入流的结果,以两种不同的方式提供了退出循环的条件(此外还有通过超时和错误退出)。在循环正常结束时,我们将数组中从 body 位置开始并包含整个文档的那部分转换到 response 字符串中。否则,我们简单地返回我们设法获取的所有内容,包括标头,以供“分析”。
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)
{
... // main operation here - discussed above
}
else
{
if(len == 0) Sleep(10); // wait a bit for the arrival of a portion of data
}
}
while(GetTickCount() - start < timeout && !IsStopped() && !_LastError);
if(_LastError) PRTF(_LastError);
if(ArraySize(response) > 0)
{
if(body != 0)
{
// TODO: Desirable to check 'Content-Type:' for 'charset=UTF-8'
result = CharArrayToString(response, body, WHOLE_ARRAY, CP_UTF8);
}
else
{
// to analyze wrong cases, return incomplete headers as is
result = CharArrayToString(response);
}
}
return StringLen(result) > 0;
}
|
唯一剩下的函数是 SocketReadAvailable,它类似于 SocketTlsReadAvailable,用于非安全连接。
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 中使用 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>
|
另一个在实践中使用套接字的更大规模示例,我们将在 项目一章中讨论。