Appearance
脚本和服务程序
在本章中,我们将总结并呈现关于脚本和服务程序的完整技术信息,在本书前面的部分我们已经开始对它们有所了解。
脚本和服务程序在组织和执行程序代码方面具有相同的原则。如我们所知,它们的主函数 OnStart 也是唯一的函数。脚本和服务程序无法处理其他事件。
然而,它们之间存在一些显著差异。脚本是在图表的上下文中执行的,并且可以通过内置变量(如 _Symbol、_Period、_Point 等)直接访问图表的属性。我们将在“图表属性”部分学习这些变量。另一方面,服务程序独立运行,不与任何窗口绑定,尽管它们能够使用特殊函数分析所有图表(同样的 Chart 函数也可用于其他类型的程序:脚本、指标和智能交易系统)。
另一方面,服务程序创建的实例在终端的下一次会话中会自动恢复。换句话说,服务程序一旦启动,就会一直运行,直到用户停止它。相比之下,当终端关闭或图表关闭时,脚本会被删除。
请注意,服务程序和所有其他类型的 MQL 程序一样在终端中执行,因此关闭终端也会停止服务程序。下次启动终端时,活动的服务程序将恢复运行。只有持续运行的终端(例如在虚拟专用服务器(VPS)上)才能确保 MQL 程序的不间断运行。
在脚本和服务程序中,可以使用 #property 指令设置程序的通用属性。除了这些通用属性外,还有脚本和服务程序特有的属性;我们将在接下来的两个部分中讨论它们。
当前在图表上运行的脚本与正在运行的智能交易系统列在同一个列表中,该列表可通过图表上下文菜单的“智能交易系统列表”命令打开的“智能交易系统”对话框查看。从该对话框中,可以将脚本强制从图表上移除。
服务程序只能通过导航器窗口进行管理。
脚本
脚本是一种 MQL 程序,它只有 OnStart 这一个处理函数,前提是不存在 #property service 指令(否则就会变成一个服务程序,详见下一部分)。
默认情况下,脚本一旦被放置到图表上就会立即开始执行。开发者可以通过在文件开头添加 #property script_show_confirm 指令,要求用户确认是否启动脚本。在这种情况下,终端会显示一条消息,内容为“你确定要在图表‘交易品种,时间框架’上运行‘程序’吗?”,并带有“是”和“否”按钮。
脚本和其他程序一样,可以有输入变量。然而,对于脚本来说,默认情况下不会显示参数输入对话框,即使脚本定义了输入参数也是如此。为了确保在运行脚本之前打开属性对话框,应该应用 #property script_show_inputs 指令。该指令优先于 script_show_confirm 指令,也就是说,打开对话框会取消确认请求(因为对话框本身就起到了类似的作用)。即使没有输入变量,该指令也会调用对话框。它可以用于向用户显示产品描述和版本(它们显示在“常规”选项卡上)。
下表显示了 #property 指令的组合选项及其对程序的影响:
指令 | 效果 | script_show_confirm | script_show_inputs |
---|---|---|---|
立即启动 | 否 | 否 | |
确认请求 | 是 | 否 | |
打开属性对话框 | 不相关 | 是 |
一个带有指令的简单脚本示例在文件 ScriptNoComment.mq5 中。该脚本的用途如下:有时 MQL 程序会在图表的左上角留下不必要的注释。注释与图表一起存储在 chr 文件中,所以即使重启终端,它们也会恢复。这个脚本可以清除注释或将其设置为任意值。如果你使用导航器上下文菜单命令为脚本指定一个热键,就可以一键清除当前图表的注释。
最初,script_show_confirm 和 script_show_inputs 指令是作为内联注释被禁用的。你可以通过一次取消一个或同时取消多个指令的注释,来尝试不同的指令组合。
cpp
//#property script_show_confirm
//#property script_show_inputs
input string Text = "";
void OnStart()
{
Comment(""); // 清除注释
}
服务程序
服务程序是一种 MQL 程序,它具有单个 OnStart 处理函数并且带有 #property service 指令。
回想一下,在服务程序成功编译之后,你需要使用导航器窗口上下文菜单中的“添加服务程序”命令来创建并配置其一个或多个实例。
作为服务程序的一个示例,让我们来解决一个在 MQL 程序开发者中经常出现的小应用问题。许多开发者习惯将他们的程序与用户的账户号码关联起来。这不一定是针对付费产品,也可能是为了在朋友和熟人之间分发程序以收集统计数据或获取成功的设置。同时,用户除了拥有一个有效的真实账户外,还可以注册模拟账户。这类账户的有效期通常是有限的,因此每隔几周就更新一次关联关系会相当不方便。要做到这一点,你需要编辑源代码、重新编译并再次发送程序。
相反,我们可以开发一个服务程序,它会将从给定终端成功连接的账户号码注册到全局变量(或文件)中。
这种关联技术基于对账户号码(旧登录账户和新登录账户)的成对加密(或者也可以使用哈希处理):之前的账户必须是主账户(有条件地“颁发”关联关系的账户),这样账户对的共同签名才能将使用产品的权限扩展到新账户。密钥是一个只有在程序内部才知道的秘密(假定所有程序都是以封闭的、已编译的形式提供的)。操作的结果将是一个 Base64 格式的字符串。实现过程中使用了 MQL5 API 函数,其中一些函数我们还需要进一步学习,特别是通过 AccountInfoInteger 获取账户号码以及 CryptEncode 加密函数。使用 TerminalInfoInteger 函数检查与服务器的连接(详见“检查网络连接”)。
服务程序不需要知道哪些账户是主账户,哪些是附加账户。它只需要以一种特殊的方式对任何连续登录的账户对进行“签名”。但是,特定的应用程序应该补充其“许可证”检查过程:除了将当前账户与主账户进行比较之外,还应该重复服务程序的算法,即创建一个[主账户;当前账户]对,为其计算加密签名,并检查该签名是否在全局变量中。
只有在以交易模式(而非投资者模式)连接到同一个账户时,才有可能通过将许可证转移到另一台计算机来窃取该许可证。当然,不道德的用户可能会为其他人创建模拟账户。因此,有必要加强保护措施。在当前的实现中,全局变量只是被设为临时变量,也就是说,它会在终端会话结束时被删除,但这并不能阻止它可能被复制。
作为额外的措施,例如,可以在签名中加密其创建时间,并规定每天(或按其他频率)权限过期。另一种选择是在服务程序启动时生成一个随机数,并将其与账户号码一起添加到已签名的信息中。这个随机数只有在服务程序内部才知道,但它可以使用 EventChartCustom 函数将其传送给图表上感兴趣的 MQL 程序。这样,在终端的这个实例中,签名将在会话结束之前一直有效。每个会话都会生成并发送一个新的随机数,所以它对其他终端不起作用。最后,最简单和最方便的选择可能是在签名中添加系统启动时间:(TimeLocal() - GetTickCount() / 1000) 或其衍生值。
在各种类型的 MQL 程序中,只有一些程序在账户切换之间会继续运行,并允许实现这种保护方案。由于有必要以统一的方式保护任何类型的 MQL 程序,包括指标和智能交易系统(它们在账户更改时会重新加载),因此将这个任务委托给服务程序是有意义的。这样,从终端加载到关闭期间一直运行的服务程序将控制登录情况并生成授权签名。
服务程序的源代码在文件 MQL5/Services/MQL5Book/p5/ServiceAccount.mq5 中给出。输入参数指定了主账户以及将存储签名的全局变量的前缀。在实际程序中,主账户列表应该硬编码在源代码中,并且最好使用 Common 文件夹中的文件来替代全局变量,以便也能覆盖测试器的情况。
cpp
#property service
input long MasterAccount = 123456789;
input string Prefix = "!A_";
服务程序的主函数按如下方式执行其工作:在一个暂停时间为 1 秒的无限循环中,我们跟踪账户的变化,保存上一个账户号码,为账户对创建签名,并将其写入全局变量。签名由 Cipher 函数创建。
cpp
void OnStart()
{
static long account = 0; // 之前的登录账户
for(; !IsStopped(); )
{
// 需要连接成功、成功登录并且具有完全访问权限(非投资者模式)
const bool c = TerminalInfoInteger(TERMINAL_CONNECTED)
&& AccountInfoInteger(ACCOUNT_TRADE_ALLOWED);
const long a = c ? AccountInfoInteger(ACCOUNT_LOGIN) : 0;
if(account != a) // 账户已更改
{
if(a != 0) // 当前账户
{
if(account != 0) // 之前的账户
{
// 将授权从一个账户转移到另一个账户
const string signature = Cipher(account, a);
PrintFormat("Account %I64d registered by %I64d: %s",
a, account, signature);
// 保存关于账户连接的记录
if(StringLen(signature) > 0)
{
GlobalVariableTemp(Prefix + signature);
GlobalVariableSet(Prefix + signature, account);
}
}
else // 第一个账户已授权,现在等待第二个账户
{
PrintFormat("New account %I64d detected", a);
}
// 记住最后一个活动账户
account = a;
}
}
Sleep(1000);
}
}
Cipher 函数使用特殊的联合体 ByteOverlay2 将一对账户号码(long 类型)表示为字节数组,该字节数组将被传递给 CryptEncode 进行加密(这里选择了 CRYPT_DES 加密方法,但如果不需要从“签名”中恢复信息,也可以将其替换为 CRYPT_AES128、CRYPT_AES256 或仅使用 CRYPT_HASH_SHA256 哈希处理(以秘密作为“盐值”))。
cpp
template<typename T>
union ByteOverlay2
{
T values[2];
uchar bytes[sizeof(T) * 2];
ByteOverlay2(const T v1, const T v2) { values[0] = v1; values[1] = v2; }
};
string Cipher(const long data1, const long data2)
{
// TODO: 将秘密替换为你的密码短语
// TODO: CRYPT_AES128/CRYPT_AES256 方法需要 16/32 字节数组
const static uchar secret[] = {'S', 'E', 'C', 'R', 'E', 'T', '0'};
ByteOverlay2<long> bo(data1, data2);
uchar result[];
if(CryptEncode(CRYPT_DES, bo.bytes, secret, result) > 0)
{
uchar dummy[], text[];
if(CryptEncode(CRYPT_BASE64, result, dummy, text) > 0)
{
return CharArrayToString(text);
}
}
return NULL;
}
然后,终端中的任何程序都可以检查全局变量中是否存在当前账户的“许可证”。这是通过使用 CheckAccounts 和 IsCurrentAccountAuthorizedByMaster 函数来完成的。它们在服务程序中展示仅用于演示目的。 CheckAccounts 函数对硬编码的所有主账户进行检查,以找到与当前账户匹配的账户。
cpp
bool CheckAccounts()
{
const long accounts[] = {MasterAccount}; // TODO: 用常量填充数组
for(int i = 0; i < ArraySize(accounts); ++i)
{
if(IsCurrentAccountAuthorizedByMaster(accounts[i])) return true;
}
return false;
}
IsCurrentAccountAuthorizedByMaster 函数将一个主账户的号码作为参数,为它与当前账户的组合重新创建一个“签名”,并分析是否匹配。
cpp
bool IsCurrentAccountAuthorizedByMaster(const long data)
{
const long a = AccountInfoInteger(ACCOUNT_LOGIN);
if(a == data) return true; // 直接匹配
const string s = Cipher(data, a); // 重新计算“签名”
if(a != 0 && GlobalVariableGet(Prefix + s) == a)
{
Print("Sub-License is active: ", s);
return true;
}
return false;
}
假设允许程序在账户 123456789 上运行,并且该账户当前处于活动状态。服务程序启动时,将在日志中记录以下内容:
New account 123456789 detected
如果我们随后将账户号码更改为,例如 5555555,我们将得到以下签名:
Account 5555555 registered by 123456789: jdVKxUswBiNlZzDAnV3yxw==
如果我们停止并再次启动服务程序,我们将看到对账户 5555555 的验证操作(调用在 OnStart 开头嵌入的用于演示的 CheckAccounts 函数)。
Sub-License is active: jdVKxUswBiNlZzDAnV3yxw==
Account 123456789 registered by 5555555: ZWcwwJ1d8seN1UrFSzAGIw==
许可证对新账户起作用了。如果再切换回原来的账户,将从当前账户生成一个“通行证”到之前的账户(这是因为服务程序不“知道”哪些账户是主账户,哪些是临时账户,并且在程序中很可能不需要这样的“签名”)。
要间接授权一个新账户,你需要再次登录主账户,然后再切换到新账户:这将创建另一个包含加密账户对[主账户;新账户]的全局变量。
这个版本的服务程序没有检查主账户是否为真实账户以及附属账户是否为模拟账户。这些限制中的每一项都可以添加进去。
脚本和服务的限制
在脚本和服务中,禁止使用与指标相关的函数组中的所有函数。这些函数将在相应章节中进行描述:
SetIndexBuffer
IndicatorSetDouble
IndicatorSetInteger
IndicatorSetString
PlotIndexSetDouble
PlotIndexSetInteger
PlotIndexSetString
PlotIndexGetInteger
此外,在脚本和服务中,使用OnTimer
处理程序(与其他任何处理程序一样)和定时器函数是没有意义的:
EventSetMillisecondTimer
EventSetTimer
EventKillTimer
由于脚本和服务不被测试器支持,它们不能使用测试器函数;否则将导致错误ERR_FUNCTION_NOT_ALLOWED
(4014)。