Đọc và ghi dữ liệu qua kết nối socket bảo mật
Kết nối bảo mật có bộ hàm trao đổi dữ liệu riêng giữa máy khách và máy chủ. Tên và khái niệm hoạt động của các hàm này gần giống với các hàm đã được xem xét trước đó là SocketRead
và SocketSend
.
int SocketTlsRead(int socket, uchar &buffer[], uint maxlen)
Hàm SocketTlsRead
đọc dữ liệu từ kết nối TLS bảo mật được mở trên socket được chỉ định. Dữ liệu được đưa vào mảng buffer
được truyền bằng tham chiếu. Nếu mảng là động, kích thước của nó sẽ tăng theo lượng dữ liệu nhưng không vượt quá INT_MAX (2147483647) byte.
Tham số maxlen
chỉ định số byte giải mã cần nhận (số lượng này luôn ít hơn lượng dữ liệu "thô" đã mã hóa đi vào bộ đệm nội bộ của socket). Dữ liệu không vừa trong mảng sẽ vẫn còn trong socket và có thể được nhận bằng lệnh gọi SocketTlsRead
tiếp theo.
Hàm được thực thi cho đến khi nhận được lượng dữ liệu đã chỉ định hoặc cho đến khi xảy ra thời gian chờ được chỉ định trong SocketTimeouts
.
Nếu thành công, hàm trả về số byte đã đọc; nếu có lỗi, trả về -1, trong khi mã 5273 (ERR_NETSOCKET_IO_ERROR) được ghi vào _LastError
. Sự hiện diện của lỗi cho thấy kết nối đã bị chấm dứt.
int SocketTlsReadAvailable(int socket, uchar &buffer[], const uint maxlen)
Hàm SocketTlsReadAvailable
đọc tất cả dữ liệu đã giải mã có sẵn từ kết nối TLS bảo mật nhưng không vượt quá maxlen
byte. Không giống như SocketTlsRead
, SocketTlsReadAvailable
không đợi sự hiện diện bắt buộc của một lượng dữ liệu nhất định và ngay lập tức trả về chỉ những gì có sẵn. Do đó, nếu bộ đệm nội bộ của socket "trống" (chưa nhận được gì từ máy chủ, đã đọc hết hoặc chưa tạo thành khối sẵn sàng để giải mã), hàm sẽ trả về 0 và không có gì được ghi vào mảng nhận buffer
. Đây là tình huống bình thường.
Giá trị của maxlen
phải nằm trong khoảng từ 1 đến INT_MAX (2147483647).
int SocketTlsSend(int socket, const uchar &buffer[], uint bufferlen)
Hàm SocketTlsSend
gửi dữ liệu từ mảng buffer
qua kết nối bảo mật được mở trên socket được chỉ định. Nguyên tắc hoạt động giống với hàm đã mô tả trước đó SocketSend
, trong khi sự khác biệt duy nhất nằm ở loại kết nối.
Hãy tạo một script mới SocketReadWriteHTTPS.mq5
dựa trên SocketReadWriteHTTP.mq5
đã xem xét trước đó và thêm tính linh hoạt về việc chọn phương thức HTTP (mặc định là GET, không phải HEAD), cài đặt thời gian chờ, và hỗ trợ kết nối bảo mật. Cổng mặc định là 443.
input string Method = "GET"; // Phương thức (HEAD, GET)
input string Server = "www.google.com";
input uint Port = 443;
input uint Timeout = 5000;
2
3
4
Máy chủ mặc định là www.google.com. Đừng quên thêm nó (và bất kỳ máy chủ nào khác mà bạn nhập) vào danh sách được phép trong cài đặt terminal.
Để xác định liệu kết nối có bảo mật hay không, chúng ta sẽ sử dụng hàm SocketTlsCertificate
: nếu thành công, thì máy chủ đã cung cấp chứng chỉ và chế độ TLS đang hoạt động. Nếu hàm trả về false
và đưa ra mã lỗi NETSOCKET_NO_CERTIFICATE(5275), điều này có nghĩa là chúng ta đang sử dụng kết nối bình thường nhưng lỗi có thể được bỏ qua và đặt lại vì chúng ta chấp nhận kết nối không bảo mật.
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;
}
...
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
Phần còn lại của hàm OnStart
được triển khai theo kế hoạch trước đó: gửi yêu cầu bằng hàm HTTPSend
và nhận câu trả lời bằng HTTPRecv
. Nhưng lần này, chúng ta bổ sung thêm cờ TLS vào các hàm này, và chúng phải được triển khai hơi khác một chút.
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");
// đối với tài liệu lớn, chúng ta sẽ lưu vào tệp
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);
}
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
Từ ví dụ với HTTPSend
, bạn có thể thấy rằng tùy thuộc vào cờ TLS, chúng ta sử dụng SocketTlsSend
hoặc 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;
}
2
3
4
5
6
7
Mọi thứ phức tạp hơn một chút với HTTPRecv
. Vì chúng ta cung cấp khả năng tải toàn bộ trang (không chỉ tiêu đề), chúng ta cần một cách nào đó để biết liệu chúng ta đã nhận được tất cả dữ liệu chưa. Ngay cả sau khi toàn bộ tài liệu đã được truyền, socket thường được để mở để tối ưu hóa các yêu cầu dự định trong tương lai. Nhưng chương trình của chúng ta sẽ không biết liệu việc truyền đã dừng lại bình thường hay có thể có "tắc nghẽn" tạm thời đâu đó trong hạ tầng mạng (việc tải trang ngắt quãng, thoải mái như vậy đôi khi có thể quan sát được trong trình duyệt). Hoặc ngược lại, trong trường hợp kết nối bị lỗi, chúng ta có thể nhầm lẫn rằng đã nhận được toàn bộ tài liệu.
Sự thật là bản thân socket chỉ hoạt động như một phương tiện giao tiếp giữa các chương trình và làm việc với các khối dữ liệu trừu tượng: chúng không biết loại dữ liệu, ý nghĩa của chúng và kết thúc logic của chúng. Tất cả những vấn đề này được xử lý bởi các giao thức ứng dụng như HTTP. Do đó, chúng ta sẽ cần nghiên cứu sâu vào thông số kỹ thuật và tự triển khai các kiểm tra.
bool HTTPRecv(int socket, string &result, const uint timeout, const bool TLS)
{
uchar response[]; // tích lũy dữ liệu tổng thể (tiêu đề + nội dung tài liệu web)
uchar block[]; // khối đọc riêng biệt
int len; // kích thước khối hiện tại (số nguyên có dấu cho cờ lỗi -1)
int lastLF = -1; // vị trí của ký tự xuống dòng cuối cùng được tìm thấy LF(Line-Feed)
int body = 0; // vị trí bắt đầu nội dung tài liệu
int size = 0; // kích thước tài liệu theo tiêu đề
result = ""; // đặt kết quả trống ở đầu
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;
...
2
3
4
5
6
7
8
9
10
11
12
13
14
Phương pháp đơn giản nhất để xác định kích thước dữ liệu nhận được dựa trên việc phân tích tiêu đề Content-Length:
. Ở đây chúng ta cần ba biến: lastLF
, size
, và content_length
. Tuy nhiên, tiêu đề này không phải lúc nào cũng có mặt, và chúng ta xử lý các "khối" — các biến chunk_size
, chunk_start
, crlf
, và crlf_length
được giới thiệu để phát hiện chúng.
Để thể hiện các kỹ thuật khác nhau trong việc nhận dữ liệu, trong ví dụ này chúng ta sử dụng hàm "không chặn" SocketTlsReadAvailable
. Tuy nhiên, không có hàm tương tự cho kết nối không bảo mật, và do đó chúng ta phải tự viết nó (sẽ đề cập sau). Sơ đồ chung của thuật toán rất đơn giản: đó là một vòng lặp với các nỗ lực nhận các khối dữ liệu mới có kích thước 1024 (hoặc ít hơn) byte. Nếu chúng ta đọc được gì đó, chúng ta tích lũy nó trong mảng response. Nếu bộ đệm đầu vào của socket trống, các hàm sẽ trả về 0 và chúng ta tạm dừng một chút. Cuối cùng, nếu xảy ra lỗi hoặc hết thời gian chờ, vòng lặp sẽ kết thú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); // ghép tất cả các khối lại
...
// thao tác chính ở đây
}
else
{
if(len == 0) Sleep(10); // đợi một chút để dữ liệu đến
}
}
while(GetTickCount() - start < timeout && !IsStopped() && !_LastError);
...
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
Trước tiên, cần đợi hoàn thành tiêu đề HTTP trong luồng dữ liệu đầu vào. Như đã thấy từ ví dụ trước, tiêu đề được tách khỏi tài liệu bằng hai ký tự xuống dòng, tức là bởi chuỗi ký tự "\r\n\r\n". Điều này dễ phát hiện bằng hai ký tự '\n' (LF) nằm liền kề nhau.
Kết quả tìm kiếm sẽ là vị trí tính bằng byte từ đầu dữ liệu, nơi tiêu đề kết thúc và tài liệu bắt đầu. Chúng ta sẽ lưu nó trong biến body
.
if(body == 0) // tìm kiếm sự hoàn thành của tiêu đề cho đến khi tìm thấy
{
for(int i = n; i < ArraySize(response); ++i)
{
if(response[i] == '\n') // LF
{
if(lastLF == i - crlf_length) // tìm thấy chuỗi "\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; // tìm thấy ranh giới tiêu đề/nội dung
}
lastLF = i;
}
}
}
if(size == ArraySize(response) - body) // toàn bộ tài liệu
{
Print("* Complete document");
break;
}
...
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
Điều này ngay lập tức tìm kiếm tiêu đề Content-Length:
và trích xuất kích thước từ đó. Biến size
đã điền đầy cho phép viết thêm một câu lệnh điều kiện để thoát khỏi vòng lặp nhận dữ liệu khi toàn bộ tài liệu đã được nhận.
Một số máy chủ cung cấp nội dung theo từng phần gọi là "khối" (chunks). Trong những trường hợp như vậy, dòng Transfer-Encoding: chunked
xuất hiện trong tiêu đề HTTP, và dòng Content-Length:
không có. Mỗi khối bắt đầu bằng một số thập lục phân chỉ ra kích thước của khối, theo sau là một dòng mới và số byte dữ liệu đã chỉ định. Khối kết thúc bằng một dòng mới khác. Khối cuối cùng đánh dấu sự kết thúc của tài liệu có kích thước bằng 0.
Lưu ý rằng việc chia thành các đoạn như vậy được thực hiện bởi máy chủ, dựa trên "sở thích" hiện tại của nó để tối ưu hóa việc gửi, và không liên quan gì đến các khối (gói) dữ liệu mà thông tin được chia ra ở cấp độ socket để truyền qua mạng. Nói cách khác, các khối thường bị phân mảnh tùy ý và ranh giới giữa các gói mạng thậm chí có thể xảy ra giữa các chữ số trong kích thước khối.
Sơ đồ có thể được mô tả như sau (bên trái là các khối của tài liệu, và bên phải là các khối dữ liệu từ bộ đệm socket).
Phân mảnh của tài liệu web trong quá trình truyền ở cấp độ HTTP và TCP
Trong thuật toán của chúng ta, các gói được đưa vào mảng block
tại mỗi lần lặp, nhưng không có ý nghĩa gì khi phân tích chúng từng cái một, và tất cả công việc chính được thực hiện với mảng response
chung.
Vì vậy, nếu tiêu đề HTTP đã được nhận hoàn toàn nhưng chuỗi Content-Length:
không được tìm thấy trong đó, chúng ta chuyển sang nhánh thuật toán với chế độ Transfer-Encoding: chunked
. Tại vị trí hiện tại của body
trong mảng response
(ngay sau khi hoàn thành tiêu đề HTTP), một đoạn chuỗi được chọn và chuyển đổi thành số với giả định định dạng thập lục phân: điều này được thực hiện bởi hàm trợ giúp HexStringToInteger
(xem mã nguồn đính kèm). Nếu thực sự có một số, chúng ta ghi nó vào chunk_size
, đánh dấu vị trí là điểm bắt đầu của "khối" trong chunk_start
, và loại bỏ các byte với số và các dòng mới phân khung khỏi response
.
if(lastLF == i - crlf_length) // tìm thấy chuỗi "\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; // máy chủ không cung cấp độ dài tài liệu
// cố gắng tìm các khối và kích thước của khối đầu tiên
if(StringFind(headers, "Transfer-Encoding: chunked") > 0)
{
// cú pháp khối:
// <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; // tìm thấy ranh giới tiêu đề/nội dung
}
lastLF = i;
...
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
Bây giờ, để kiểm tra tính hoàn chỉnh của tài liệu, bạn cần phân tích không chỉ biến size
(mà như chúng ta đã thấy, thực tế có thể bị vô hiệu hóa bằng cách gán -1 khi không có Content-Length:
) mà còn các biến mới cho các khối: chunk_start
và chunk_size
. Sơ đồ hành động tương tự như sau các tiêu đề HTTP: bằng cách bù đắp trong mảng response
, nơi khối trước đó kết thúc, chúng ta tách kích thước của "khối" tiếp theo. Chúng ta tiếp tục quá trình này cho đến khi tìm thấy một khối có kích thước bằng 0.
...
if(size == ArraySize(response) - body) // toàn bộ tài liệu
{
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 // có '\r\n...\r\n' không?
&& StringFind(preview, crlf, crlf_length) > crlf_length)
{
chunk_size = HexStringToInteger(preview, crlf_length);
if(chunk_size > 0)
{ // hai lần '\r\n': trước và sau kích thước khối
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;
}
} // ngược lại, đợi thêm dữ liệu
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
Do đó, chúng ta đã cung cấp một cách thoát khỏi vòng lặp dựa trên kết quả phân tích luồng dữ liệu đầu vào theo hai cách khác nhau (ngoài việc thoát bằng thời gian chờ và lỗi). Khi vòng lặp kết thúc bình thường, chúng ta chuyển đổi phần của mảng đó thành chuỗi response
, bắt đầu từ vị trí body
và chứa toàn bộ tài liệu. Nếu không, chúng ta chỉ trả về mọi thứ mà chúng ta đã nhận được, cùng với các tiêu đề, để "phân tích".
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)
{
... // thao tác chính ở đây - đã thảo luận ở trên
}
else
{
if(len == 0) Sleep(10); // đợi một chút để phần dữ liệu đến
}
}
while(GetTickCount() - start < timeout && !IsStopped() && !_LastError);
if(_LastError) PRTF(_LastError);
if(ArraySize(response) > 0)
{
if(body != 0)
{
// TODO: Nên kiểm tra 'Content-Type:' để tìm 'charset=UTF-8'
result = CharArrayToString(response, body, WHOLE_ARRAY, CP_UTF8);
}
else
{
// để phân tích các trường hợp sai, trả về tiêu đề chưa hoàn chỉnh như nguyên bản
result = CharArrayToString(response);
}
}
return StringLen(result) > 0;
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
Hàm còn lại duy nhất là SocketReadAvailable
, tương tự như SocketTlsReadAvailable
cho các kết nối không bảo mật.
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;
}
2
3
4
5
6
7
8
Script đã sẵn sàng để hoạt động.
Chúng ta đã phải bỏ ra khá nhiều công sức để triển khai một yêu cầu trang web đơn giản bằng cách sử dụng socket. Điều này đóng vai trò như một minh chứng cho việc hỗ trợ các giao thức mạng ở cấp thấp thường phức tạp đến mức nào. Tất nhiên, trong trường hợp HTTP, việc sử dụng triển khai tích hợp sẵn của WebRequest sẽ dễ dàng và chính xác hơn, nhưng nó không bao gồm tất cả các tính năng của HTTP (hơn nữa, chúng ta chỉ đề cập sơ qua HTTP 1.1, nhưng còn có HTTP/2), và số lượng các giao thức ứng dụng khác là rất lớn. Do đó, các hàm Socket
là cần thiết để tích hợp chúng trong MetaTrader 5.
Hãy chạy SocketReadWriteHTTPS.mq5
với cài đặt mặc định.
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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
Như chúng ta thấy, tài liệu được truyền theo từng khối và đã được lưu vào một tệp tạm thời (bạn có thể tìm thấy nó trong MQL5/Files/www.mql5.com.htm
).
Bây giờ hãy chạy script cho trang web "www.mql5.com" và cổng 80. Từ phần trước, chúng ta biết rằng trong trường hợp này, trang web sẽ đưa ra một chuyển hướng đến phiên bản bảo mật của nó, nhưng "chuyển hướng" này không trống: nó có một tài liệu giả, và bây giờ chúng ta có thể lấy nó đầy đủ. Điều quan trọng với chúng ta ở đây là tiêu đề Content-Length:
được sử dụng chính xác trong trường hợp này.
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>
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
Một ví dụ lớn khác về việc sử dụng socket trong thực tế, chúng ta sẽ xem xét trong chương Projects.