恥は/dev/nullへ by 初心者

プログラミング素人がのろのろと学んだことをつづっています♪

[MQL5]MT5からLINEに通知を送信する

昨年MT4用に書いたコードをMT5で試してみたところ、LINEへの通知はできているものの、エキスパートに「6 leaked strings left」と表示されていることに気がつきました。

ネット情報によると、これはメモリリークを示唆する文言のようです。この表示が毎回出るのは気分が良くないので、MT5で問題なく使えるように修正しました(ただ、今はもうMT5からLINEへ通知することが無いのですが)。



参考にしたページ

Wininet.h header - Win32 apps | Microsoft Learn
こちらはMicrosoft社のページです。今回のコード修正をする上で最も重要な情報源でした。MQL5のサイトだけでなく、必ずMicrosoft社のページも読むことをお勧めします。


HINTERNET ハンドル - Win32 apps | Microsoft Learn
こちらのページには、ハンドルと階層処理について書かれています。

インターネットを介して端末間でデータ交換をするためのWinInet.dll利用 - MQL5記事
こちらはMQL5のページです。このページに書かれているコードと、ページ下段からダウンロードできる次のファイルの内容が参考になりました。

wininet.h
internetlib.mqh


しかし、このページの最初の方に登場するimport文(関数の宣言部分)をそのまま使うとマズいです。先述したleaked strings leftの原因がこの部分にありましたし、 Microsoft社のサイトを読みながらこのページにあるコードを見ていくと、「ここ、おかしいな」という疑問がチラホラ浮かんできます。
なお、MQL5のページとしては、次のページも少し参照しました。
MQL5でのWinInet利用パート2:POSTリクエストとファイル - MQL5記事


紛らわしいぞ!LPCTSTR、LPTSTR、LPSTR、LPCSTRは全部意味が違う!(UsefullCode.net)
こちらのページは、wininet.dllに登場するLPCSTR等の意味を知るのに役立ちました。

LINE Notify
こちらはLine Notifyのページです。


事前準備

LINE Notifyを使用するので、LINEのアクセストークンを取得しておく必要があります。取得方法については「LINE アクセストークン 取得方法」等でネット検索すれば出てくると思います。


コード(LineNotify.mqh)

// (1) InternetAttemptConnectでネットに接続しているか調べる
// (2) InternetOpenW でWinInet関数群を使うための初期化処理を行う
// (3) InternetConnectW で接続したいサイトへのHTTPセッションを開く
// (4) HttpOpenRequestW でHTTPリクエストハンドルを作成
// (5) (4)で作成したハンドルを使い、HttpSendRequestWでリクエストを送信する
// (6) ハンドルをcloseする

#define OPEN_TYPE_PRECONFIG    0
#define DEFAULT_HTTPS_PORT     443
#define SERVICE_HTTP           3
#define FLAG_SECURE            0x00800000
#define FLAG_PRAGMA_NOCACHE    0x00000100
#define FLAG_KEEP_CONNECTION   0x00400000
#define FLAG_RELOAD            0x80000000

#import "wininet.dll"
int InternetAttemptConnect(int x);
int InternetOpenW(string sAgent, int lAccessType, string sProxyName, string sProxyBypass, int lFlags);
int InternetConnectW(int hInternet, string szServerName, int nServerPort, string lpszUsername, string lpszPassword, int dwService, int dwFlags, int dwContext);
int HttpOpenRequestW(int hConnect, string Verb, string ObjectName, string Version, string Referer, string AcceptTypes, uint dwFlags, int dwContext);
bool HttpSendRequestW(int hRequest, string &lpszHeaders, int dwHeadersLength, uchar &lpOptional[], int dwOptionalLength);
bool InternetReadFile(int hFile, uchar &sBuffer[], int lNumBytesToRead, int &lNumberOfBytesRead);
bool InternetCloseHandle(int hInet);
#import

//-----------------------------------------------------------------------
// LineNotify関数
//-----------------------------------------------------------------------
bool LineNotify(string lineToken, string messageToLine)
{
    //=============================================================
    // DLLの使用が許可されているか調べる
    //=============================================================
    if(!TerminalInfoInteger(TERMINAL_DLLS_ALLOWED)) {
        Print("[LineNotify Function] Error. DLL is not allowed");
        Print("[LineNotify Function] You need to use wininet.dll.");
        return false;
    }

    //=============================================================
    // ネットに接続しているか調べる
    //=============================================================
    if(InternetAttemptConnect(0) != 0) {
        Print("[LineNotify Function] Error. There is no Internet connection.");
        return false;
    }
    // Microsoftのサイトによると、InternetAttemptConnectは成功した場合に
    // ERROR_SUCCESSを返し、それ以外の場合はシステムエラーコードを返す。
    // ERROR_SUCCESS は 0 とのこと。失敗した場合、戻り値は0以外。
    // [参考] https://learn.microsoft.com/en-us/windows/win32/debug/system-error-codes--0-499-

    //=============================================================
    // WinInet関数群を使うための初期化を行う(InternetOpenW)
    //=============================================================
    string userAgent = "MetaTrader5";  // MT5だと分かれば何でもOK
    string nill = "";
    int hOpen = InternetOpenW(userAgent, OPEN_TYPE_PRECONFIG, nill, nill, 0);

    // OPEN_TYPE_PRECONFIGは「Retrieves the proxy or direct configuration from the registry」
    // [出典] https://learn.microsoft.com/en-us/windows/win32/api/wininet/nf-wininet-internetopenw

    // [InternetOpenWの戻り値]
    // 少ない実験の範囲では、InternetOpenWが返すハンドルは8桁の正の整数だった。
    // よって、MT5のint型(-2,147,483,648 ~ 2,147,483,647)に収まる。
    // 
    // Microsoftのサイトによると、InternetOpenWが失敗した場合、NULLを返す。
    // MT5上でNULLがどのように扱われるのか検証していないが、
    //      int hoge = NULL; Print(hoge);
    // を実行するとエキスパートには 0 と表示される。

    // InternetOpenWが失敗した場合
    if(hOpen <= 0) {
        printf("[LineNotify Function] Error. InternetOpenW() failed.");
        printf("[LineNotify Function] hOpen = %ld", hOpen);
        return false;
    }

    //=============================================================
    // 接続したいサイトへのHTTPセッションを開く(InternetConnectW)
    //=============================================================
    string host = "notify-api.line.me";     // 接続先
    int hConnect = InternetConnectW(hOpen, host, DEFAULT_HTTPS_PORT, nill, nill, SERVICE_HTTP, 0, 0);

    // [arg3] HTTPSのデフォルトポート(port 443)を使う
    // [arg6] HTTPサービスを使う(ここで指定できるのは、HTTP, FTP, GOPHERのいずれか)

    // [InternetConnectWの戻り値]
    // 実験したところ、InternetConnectWが返すハンドルの桁数はInternetOpenWの
    // 戻り値と同じ桁数だったので、MT5のint型に収まる。
    // InternetConnectWが失敗した場合、NULLを返す。

    // InternetConnectWが失敗した場合
    if(hConnect <= 0) {
        printf("[LineNotify Function] Error. InternetConnectW() failed.");
        printf("[LineNotify Function] hConnect = %ld", hConnect);
        CloseHadle(hOpen, "hOpen");    // hOpenをcloseする
        return false;
    }

    //=============================================================
    // HTTPリクエストハンドルを作成する(HttpOpenRequestW)
    //=============================================================
    string version   = "HTTP/1.1";        // 指定できるのは HTTP/1.0 か HTTP/1.1
    string httpVerb  = "POST";            // リクエストメソッド(GET か POST)
    string pathName  = "/api/notify";     // https://notify-bot.line.me/doc/ja/ の「通知系」の記述に従った

    int hRequest = HttpOpenRequestW(hConnect, httpVerb, pathName, version, nill, nill, FLAG_SECURE | FLAG_KEEP_CONNECTION | FLAG_RELOAD | FLAG_PRAGMA_NOCACHE, 0);

    // [arg7] FLAG_SECURE          : HTTPリクエストにおいてSSL/PCTを使用する
    //        FLAG_KEEP_CONNECTION : keep alive接続を利用する
    //        FLAG_RELOAD          : キャッシュではなくオリジナルをダウンロードする
    //        FLAG_PRAGMA_NOCACHE  : proxyにcacheがあってもoriginalサーバーでリクエストをresolveする

    // [HttpOpenRequestWの戻り値]
    // HttpOpenRequestWが返すハンドルの桁数は、InternetOpenW 及び InternetConnectW
    // の戻り値の桁数と同じだったので、MT5のint型に収まる。
    // HttpOpenRequestWが失敗した場合、NULLを返す。

    // HttpOpenRequestWが失敗した場合
    if(hRequest <= 0) {
        printf("[LineNotify Function] Error. HttpOpenRequestW() failed.");
        printf("[LineNotify Function] hRequest = %ld", hRequest);
        CloseHadle(hConnect, "hConnect");   // hConnectをcloseする
        CloseHadle(hOpen   , "hOpen"   );   // hOpenをcloseする
        return false;
    }

    //=============================================================
    // HTTPサーバーにリクエストを送信する(HttpSendRequestW)
    //=============================================================
    //【STEP 1】リクエストで送信する文字列の作成
    uchar contents[];
    ArrayResize(contents, StringToCharArray("message=" + messageToLine, contents, 0, -1, CP_UTF8) - 1);

    // [備考] 送信する文字列は「message=送信したい文字列」という形式

    // 検証用コード (contentsに格納された文字列をエキスパートに表示する)
    //      int array_size = ArraySize(contents);
    //      for(int i = 0; i < array_size; i++) printf("contents[%d] = %d", i, contents[i]);
    //      string letters = CharArrayToString(contents, 0, array_size); 
    //      printf("array_size = %d", array_size);
    //      printf("contentsの中身 : %s", letters);

    //【STEP 2】リクエストに使用するヘッダの作成
    string header = "Authorization: Bearer " + lineToken + "\r\n";
    header += "Content-Type: application/x-www-form-urlencoded";

    // Authorization行の末尾にあるCRLFを忘れないように注意。
    // Content-Type行では、もう改行の必要が無いので行末にCRLFは不要。
    // 以上は https://notify-bot.line.me/doc/ja/ の「通知系」における「リクエスト方法」の記述に従った。

    //【STEP 3】リクエストを送信
    bool result = HttpSendRequestW(hRequest, header, StringLen(header), contents, ArraySize(contents));

    // [HttpSendRequestWの戻り値]
    // HttpSendRequestWはリクエストの送信に成功したらtrue、失敗したらfalseを返す。

    //=============================================================
    // サーバーからレスポンスを受け取る(InternetReadFile)
    //=============================================================
    uchar receivedChar[100];           // サーバーから受信したレスポンスを格納する配列
    ArrayInitialize(receivedChar, ""); // 配列を初期化
    string receivedString = "";        // 文字列に変換したレスポンスを格納する変数
    int byteRead = 0;                  // InternetReadFileが読み込んだバイト数

    while(InternetReadFile(hRequest, receivedChar, 100, byteRead)) {
        if(byteRead <= 0) break;
        receivedString += CharArrayToString(receivedChar, 0, byteRead);
    }

    //[InternetReadFileの役割]
    // InternetReadFileは(HttpSendRequestWではなく)HttpOpenRequestWによって作成された
    // ハンドルを元にサーバーからのレスポンスを読み込む。なお、arg2とarg4は出力先となる。

    // receivedChar[]の中身はサーバーからのレスポンス内容をuchar型(数値)で表現したもの。
    // これをCharArrayToStringで文字列にしてreceivedStringに格納する。
    //    レスポンスの例   {"status":200,"message":"ok"}
    // CharArrayToStringのarg4としてCP_UTF8を指定することもできるが、問題は起きていない
    // ので指定していない(よって、コードページはデフォルトの CP_ACP)。
    // 
    // byteReadが負の数になることは無いと思うので、if条件を byteRead == 0 としても
    // 良いが、イレギュラーを想定するなら byteRead <= 0 の方が無難。


    // リクエスト送信が成功しても失敗しても、他に処理は無いのでハンドルをクローズする
    CloseHadle(hRequest, "hRequest");
    CloseHadle(hConnect, "hConnect");
    CloseHadle(hOpen   , "hOpen"   );

    //=============================================================
    // サーバーからのレスポンスをエキスパートに表示
    //=============================================================
    Print("[Line Notify Function] ", receivedString);
    if(receivedString != "{\"status\":200,\"message\":\"ok\"}")
        Print("Line Notify Function : statusとmessageについては、https://notify-bot.line.me/doc/ja/ の「通知系」を参照");

    // レスポンスの例  {"status":200,"message":"ok"}   ← 200というstatusは成功を表す。
    // レスポンス内容が成功でなかった場合、Line NotifyのURLをPrint文で案内する。

    //=============================================================
    // リクエストの送信に失敗した旨を表示(HttpSendRequestWの失敗)
    //=============================================================
    //   [備考] return falseを含むので、この位置に記述
    if(result == false) {
        Print("[LineNotify Function] : Error. HttpSendRequestW() failed.");
        return false;
    }
    
    return true;  // 全行程が正常に実行されたらtrueを返して終了
}


//-----------------------------------------------------------------------
// ハンドルをcloseする関数
//-----------------------------------------------------------------------
void CloseHadle(int handle, string label)
{
    if(InternetCloseHandle(handle) == false)   // == false の代わりに !InternetCloseHandle(handle)でも可
        printf("[LineNotify Function] %s was not closed.", label);
}


使い方

(1) 上記コードをテキトーな名前でIncludeフォルダ(MQL5\Include)に保存

(例) LineNotify.mqh

(2) インディケーターのコードの中に #include 文を記述

(例) #include <LineNotify.mqh>

(3) インディケーターの中で LineNotify関数を使う

(例)LineNotify("XXXXXXXXX", "たけのこ");

第1引数に、LINEアクセストークンを指定
第2引数に、LINEに送信する文言を指定


MT5用インディケーターのサンプル

#property indicator_chart_window
#property indicator_plots 0
#property strict

#include <LineNotify.mqh>      // ←この行を記述

int OnInit()
{
    string message = "ほげほげ";
    LineNotify("XXXXX", message); // ← XXXXXにLINEのアクセストークンを記述
    
    // この例では OnInit関数内でLineNotify関数を実行して
    // いますが、OnCalculate関数の中でも実行できます。 
    
    return INIT_SUCCEEDED;
}

int OnCalculate(const int rates_total,
                const int prev_calculated,
                const datetime &time[],
                const double &open[],
                const double &high[],
                const double &low[],
                const double &close[],
                const long &tick_volume[],
                const long &volume[],
                const int &spread[])
{
    return rates_total;
}


過去記事に載せたコードとの違い(主な変更点)

昨年書いた記事(←間違いが多かったので現在は削除済み)に載っていたコードと基本構造は同じですが、一部変更したので、それを以下に記述します。

最初の変更点は、関数の引数の一部を「参照渡し」ではなく「値渡し」にしたことです。冒頭で述べた「6 leaked strings left」の原因については、関数の引数のうち「参照」形式になっているものの一部が関係していることが分かりました。 そこで、以下の引数から参照を表す「&」を除去しました。

InternetOpenW関数の sAgent, sProxyName, sProxyBypass
InternetConnectW関数の szServerName, lpszUsername, lpszPassword
HttpOpenRequestW関数の Verb, ObjectName, Version, Referer, AcceptTypes


なお、HttpSendRequestW関数の引数にも参照を使っている箇所がありますが、これらの引数が参照になっていても「 〇 leaked strings left」が発生しないので、HttpSendRequestW関数には手を加えていません。

ついでに書くと、InternetReadFile関数の第2引数と第4引数は出力先となるので、これらは参照でなければなりません。


次の変更点は、以下の関数(の戻り値)をbool型として宣言したことです。

HttpSendRequestW
InternetReadFile
InternetCloseHandle

参考にしたMQL5のページでは、これらの関数の戻り値がint型と宣言されていました。 しかし、Microsoft社のサイトによると、これらの関数はTRUEかFALSEを返してきます。

もちろんTRUEやFALSEをint型で受けることは可能ですが、bool型として宣言した方が、戻り値が分かりやすくて良いと考えた次第です。


疑問点

1つ目の疑問は「InternetOpenW関数が返したハンドルだけを閉じればよいのではないか?」(3つのハンドル全てを閉じる必要は無いのではないか?)というものです。

Microsoftのサイトによると、ハンドルは階層構造になっています。今回のコードに当てはめて言うと、次のようになります。

InternetOpenW関数が返すハンドルは、最上位の階層(ルートノード)
InternetConnectW関数が返すハンドルは、その1つ下の階層(第2階層)
HttpOpenRequestW関数が返すハンドルは、更に1つ下の階層(第3階層)

これより下の階層は無い。

[memo]
HttpSendRequestW関数はHttpOpenRequestW関数が作成したハンドルを利用する。
InternetReadFile関数もHttpOpenRequestW関数が作成したハンドルを利用する。

このような階層構造になっているのであれば、ルートノードであるInternetOpenW関数が返したハンドルだけを閉じれば、その下位階層を担うハンドルは自動的に閉じられそうな気がします。

すなわち、今回アップしたコードでは

CloseHadle(hRequest, "hRequest");
CloseHadle(hConnect, "hConnect");
CloseHadle(hOpen   , "hOpen"   );

としていますが、

CloseHadle(hOpen, "hOpen");

だけで足りるのではないかと思うのです。

しかし、ネット検索をしてみたところ丁寧に1つずつハンドルを閉じているコードがあります。そんなわけで、現状ではそれにならってhRequest、hConnect、hOpenを閉じています。

2つ目の疑問は「3つの関数(InternetOpenW、InternetConnectW、HttpOpenRequestW)が失敗した場合に返すNULLがMT5上でどのように扱われるのか分からない」というものです。

コード内のコメントに書いたように

int hoge = NULL;
Print(hoge);

を実行するとエキスパートに 0 と表示されますけれど、これはMQL上の話に過ぎません。すなわち、3つの関数が返してくるNULLが(MQLの)int型で0として扱われるのか分からないのです。

この点を確かめるために、引数の中身を不正なものにして関数を実行してみました。しかし、NULLは返ってこなくて、ハンドル(正の整数値)が返ってきました。もっと極端なことをするなら、LANケーブルを抜いて関数を実行すれば、HTTPセッションが開かれないのでNULLを返してくるでしょうけれど、今のところそこまでの探求心が無いので放置してあります。

というわけで、上記3つの関数が失敗した場合の処理に関しては、MQL5のサイトにあったコードを真似して

if(hOpen <= 0)

などと記述してありますが、もしも3つの関数が返してくるNULLがMQL上のNULLと同じならば、次のように書いても良いのかもしれません。

if(hOpen == 0)


3つ目の疑問は「while文でループする必要があるのかビミョー」というものです。

while(InternetReadFile(hRequest, receivedChar, 100, byteRead)) {
    if(byteRead <= 0) break;
    receivedString += CharArrayToString(receivedChar, 0, byteRead);
}
// if文は  if(byteRead == 0) break;  でも可

InternetReadFile関数は第3引数で指定したバイト数だけデータを読み込みに行きます。 読み込んだデータを第2引数で指定した配列(receivedChar)に出力し、同時に、読み込んだバイト数 を第4引数(byteRead)に出力します。

よって、上記while文の意味は「読み込んだバイト数が0にならない限り、繰り返し、100バイトずつデータを読み込む」ということになります。 しかし、これまでの所、それほど長いレスポンスが返ってきていないんですよね。 Line Notifyのサイトに

{"status":200,"message":"ok"}
{"status":401,"message":"Invalid access token"}

というレスポンス例が載っていますが、1行目の例で29文字、2行目の例で47文字です。 この2つの例の他に status 400(リクエストが不正)もありますが、なんとなーく、100文字も無さそうな気がします。

仮にそうならば、while文でループする必要があるのかな、、、と少し疑問に思うのです。とはいえ、今まで遭遇したことのないレスポンス(100文字以上のレスポンス)が返ってくる可能性もあるので、while文のままにしてあります。

WebRequest関数はインディケーターで使えない

既に忘れていましたが、昨年このブログに書いた記事によると、WebRequest関数はEAやスクリプトでは使えますが、インディケーターでは使えないようです。というわけで、今のところ、インディケーターからLINEに通知を送るには、本記事のように自作関数を用意するしかなさそうです。