Appearance
项目
通常,软件产品是在标准的生命周期内进行开发的:
- 需求收集与补充
- 设计
- 开发
- 测试
- 投入使用
由于需要不断改进和扩展功能,通常有必要对源文件、资源和第三方库进行系统化管理(这里所说的库不仅指二进制格式的库,更广义地说,是指任何文件集,例如头文件)。此外,还需要将各个程序集成到一个体现应用理念的通用产品中。
项目的结构和生命周期
例如,在开发交易机器人时,常常需要连接现成的或自定义的指标;使用外部机器学习算法意味着要编写用于导出报价数据的脚本和重新导入训练好的模型的脚本;与通过互联网进行数据交换相关的程序(例如交易信号)可能需要使用 Web 服务器,并且至少在调试和测试阶段(如果不是为了部署公共服务的话),需要使用其他编程语言对其进行设置。
由多个相互关联的产品及其“依赖项”(即使用的资源和库,这些资源和库可以是自行编写的,也可以来自第三方)组成的整体,就构成了一个软件项目。
当程序规模超过一定程度时,如果没有专门的项目管理工具,就很难方便、高效地进行开发。这一点对于基于 MQL5 的程序尤为适用,因为许多交易者都使用复杂的交易系统。
MetaEditor 支持与其他软件包类似的项目概念。目前,该功能尚处于开发初期,到本书出版时,它可能会有所变化。
在使用 MQL5 处理项目时,要注意平台中的“项目”一词用于指代两种不同的实体:
- 以 mqproj 文件形式存在的本地项目
- MQL5 云存储中的文件夹
本地项目可以将构建特定 MQL 程序所需的有关源代码、资源和设置的所有信息进行系统化整理并集中起来。这样的项目仅存在于你的计算机上,并且可以引用不同文件夹中的文件。
扩展名为 mqproj 的文件采用广泛使用的通用 JSON(JavaScript 对象表示法)文本格式。这种格式方便、简单,非常适合描述任何领域的数据:所有信息都被组织成具有命名属性的对象或数组,并支持不同类型的值。这使得 JSON 在概念上与面向对象编程(OOP)语言非常接近;从名称上你可以很容易猜到,它源自面向对象的 JavaScript。
云存储基于名为 SVN(Subversion)的版本控制系统和软件协同工作机制运行。在这里,项目是本地目录 MQL5/Shared Projects
中的顶级文件夹,该文件夹对应着 MQL5 存储服务器上一个同名的文件夹。在项目文件夹中,你可以组织子文件夹的层次结构。顾名思义,网络项目可以与其他开发者共享,并且通常可以公开(任何在 mql5.com 上注册的用户都可以下载其中的内容)。
系统提供了按需同步功能(通过特殊的用户命令),可以实现云文件夹和本地驱动器上文件夹镜像之间的同步,反之亦然。你既可以将其他人对项目所做的更改“拉取”到你的计算机上,也可以将你所做的编辑“推送”到云端。可以同步整个文件夹镜像,也可以选择性地同步文件,当然包括 .mq5
文件、.mqh
头文件、多媒体文件、设置文件(.set
文件)以及 .mqproj
文件。有关云存储的更多信息,请阅读 MetaEditor 和 SVN 系统的文档。
需要注意的是,存在 .mqproj
文件并不意味着要基于它创建任何云项目,同样,创建共享文件夹也不强制要求使用 .mqproj
项目。
在撰写本文时,一个 .mqproj
文件只能描述一个程序的结构,而不能描述多个程序。然而,由于在开发复杂项目时,这种需求很常见,因此 MetaEditor 未来可能会添加此功能。
在本章中,我们将介绍创建和组织 .mqproj
项目的主要功能,并给出一系列示例。
使用本地项目的一般规则
可以通过 MetaEditor 的主菜单,或者在导航器的上下文菜单中使用“新建项目”或“从源文件新建项目”命令来创建一个本地项目(.mqproj 文件)。在后一种情况下,必须首先在导航器中选择文件,或者在“打开”对话框中选择文件。这样一来,指定的.mq5 文件会立即被包含在项目中。前面提到的第一个命令会启动 MQL 向导,在向导中你应该选择程序类型或者“空项目”选项(之后可以向其中添加源文件)。项目的 MQL 程序类型是按照向导的常规步骤来选择的。
项目包含几个逻辑部分,这些部分类似于一棵包含所有组件的树(层次结构)。它们显示在导航器的左侧面板中一个单独的“项目”选项卡下。
导航器和指标项目属性
在创建项目之后,或者之后通过双击树的根节点,会在窗口的右侧打开一个用于设置 MQL 程序属性的面板。属性的集合会根据程序的类型而有所不同。
大多数属性对应于源代码中的 #property
指令。这些项目属性具有优先权:如果在项目和源代码中都指定了这些属性,将使用项目中的值。
一些开发者可能喜欢在对话框中以交互方式设置属性,而不是在源代码中硬编码。此外,你可以在不同的项目中使用同一个.mq5 文件,并构建具有不同设置(无需更改源代码)的 MQL 程序版本。
有些属性仅在项目中可用。例如,启用/禁用编译优化和内置的除零检查。
在项目编译期间,系统会自动分析依赖项,即包含的头文件、资源等等。依赖项出现在项目层次结构的不同分支中。特别是,使用尖括号(<filename>
)在 #include
指令中包含的来自标准 MQL5/Include
文件夹的头文件,会归入“依赖项”分支,而使用双引号(#include "filename"
)包含的自定义头文件会归入“头文件”部分。
此外,用户可以向项目中添加与已完成的软件产品相关的文件,这些文件可能是软件正常运行或演示所必需的(例如,经过训练的神经网络模型文件),但它们并不直接嵌入到源代码中。为此,可以使用“设置和文件”分支。其上下文菜单包含用于向项目中添加单个文件或整个目录的命令。
特别是,我们接下来会考虑一些项目示例,这些项目不仅会包含客户端 MQL 程序,还会包含服务器部分。
“新建文件”和“新建文件夹”命令会向项目文件所在的文件夹中添加一个新元素:这些元素的搜索总是相对于项目本身进行的(在.mqproj 文件中,它们的 relative_to_project
属性被标记为 true
,详情见后文)。
“添加现有文件”和“添加现有文件夹”命令会从 MQL5
文件夹内的现有目录结构中选择一个或多个元素,并且在.mqproj 文件中,这些元素是相对于 MQL5
根目录引用的(relative_to_project
属性等于 false
)。
relative_to_project
属性只是 MetaTrader 5 开发者定义的用于以 JSON 格式表示项目的少数属性之一。请记住,在编辑项目(层次结构和属性)之后,会形成一个 JSON 格式的.mqproj 文件。
这是上面图片中项目的.mqproj 文件的样子:
json
{
"platform" :"mt5",
"program_type":"indicator",
"copyright" :"Copyright (c) 2015-2022, Marketeer",
"link" :"https:\/\/www.mql5.com\/en\/users\/marketeer",
"version" :"1.0",
"description" :"Create 2 trend lines on highs and lows using Hough transform.",
"optimize" :"1",
"fpzerocheck" :"1",
"tester_no_cache":"0",
"tester_everytick_calculate":"0",
"unicode_character_set":"0",
"static_libraries":"0",
"indicator":
{
"window":"0"
},
"files":
[
{
"path":"HoughChannel.mq5",
"compile":true,
"relative_to_project":true
},
{
"path":"MQL5\\Include\\MQL5Book\\HoughTransform.mqh",
"compile":false,
"relative_to_project":false
}
]
}
在接下来的部分中,我们将更详细地讨论 JSON 格式的技术特点,因为我们会在演示项目中应用它。
需要注意的是,项目所引用的所有文件都不会存储在.mqproj 文件内部,因此仅将项目文件复制到新位置或移动到另一台计算机上并不能恢复项目。为了能够迁移项目,需要为其设置一个共享项目,并将项目的所有内容上传到云端。不过,这可能需要重新组织本地文件系统结构,因为所有组件都必须位于共享项目文件夹内,而.mqproj 格式并不要求这一点。
交易和信号复制的网络服务项目计划
作为一个贯穿本章进行开发的端到端演示项目,我们将打造一个简单却在技术上相当先进的产品:一个客户端 - 服务器式的跟单交易系统。客户端部分将是使用套接字技术与中央部分进行通信的MQL程序。鉴于MQL5仅允许使用客户端套接字,因此需要为套接字服务器选择一个替代平台(下面会详细介绍)。这样一来,该项目将需要多种不同技术的融合,并且要运用我们已经学习过的MQL5 API的许多部分,包括在此基础上开发的应用代码。
得益于基于套接字的客户端 - 服务器架构,该系统可应用于不同场景:
- 方便在一台计算机上的多个终端之间复制交易;
- 在不同计算机上的终端之间建立私人(个人)通信通道,不仅包括在局域网内,还可通过互联网进行;
- 组织一个需要注册的公开或封闭的信号服务;
- 监控交易情况;
- 远程管理自己的账户。
在所有这些情况下,客户端程序将扮演两种角色:数据的发布者(发布方、发送方)和订阅者(接收方)。
我们不会自行发明网络协议,而是会使用现有的且广受欢迎的WebSocket标准。所有浏览器都内置了其客户端实现,我们需要在MQL5中(或多或少完整地)重复实现这一功能。当然,大多数流行的网络服务器也支持WebSocket。因此,无论如何,我们的开发成果不仅可以适配其他服务器(如果有更合适的选择),还可以与提供类似网络服务的知名网站集成。这里的关键在于严格遵循基于WebSocket构建的API规范。
在开发比单个独立程序更复杂的软件系统时,制定行动计划很重要,甚至可能需要设计一个技术方案,包括模块结构、模块间的交互以及编码顺序。
因此,我们的计划包括:
- 对WebSocket协议进行理论分析;
- 选择并安装一个实现了WebSocket服务器的网络服务器;
- 创建一个简单的回显服务器(将接收到的消息副本发送回客户端),以便熟悉该技术;
- 创建一个简单的客户端网页,用于从浏览器测试回显服务器的功能;
- 创建一个简单的聊天服务器,将消息发送给所有连接的客户端,并为其创建一个测试网页;
- 创建一个可识别的提供者和订阅者之间的消息服务器,并为其创建一个测试Web客户端;
- 在MQL5中设计并实现WebSocket功能;
- 创建一个简单的脚本作为回显服务器的客户端;
- 创建一个简单的智能交易系统(Expert Advisor)作为聊天服务器的客户端;
- 最后,在MQL5中创建一个交易复制器,根据设置,它既可以充当信息提供者(监控账户变更和状态),也可以充当信息消费者(复制交易)。
但在开始实施该计划之前,我们需要先安装一个网络服务器。
基于Node.js的Web服务器
为了构建我们项目的服务器部分,我们需要一个Web服务器。我们将使用最轻量级且技术最先进的Node.js。为它编写的服务器端脚本可以使用JavaScript语言,而JavaScript也是用于在浏览器中创建交互式网页的语言。从统一编写系统的客户端和服务器端的角度来看,这非常方便;通常来说,任何Web服务的客户端部分迟早都是需要的,例如用于管理、注册,以及展示有关服务使用情况的精美统计数据。
几乎所有了解MQL5的人也都了解JavaScript,所以要对自己有信心。主要的区别会在侧边栏中进行讨论。
MQL5与JavaScript的对比
JavaScript是一种解释型语言,这与编译型的MQL5不同。对于我们开发者而言,这让工作变得更轻松,因为我们不需要单独的编译阶段来获得一个可运行的程序。不用担心JavaScript的效率问题:所有的JavaScript运行时都使用即时编译(JIT),即在需要时(也就是首次访问某个模块时)对JavaScript进行编译。这个过程会自动、隐式地在每个会话中执行一次,之后脚本就会以编译后的形式运行。
MQL5属于静态类型语言,也就是说,在描述变量时,我们必须显式指定它们的类型,并且编译器会检查类型兼容性。相比之下,JavaScript是一种动态类型语言:变量的类型取决于我们赋给它的值,并且在变量的生命周期内类型可以改变。这提供了灵活性,但需要小心谨慎,以免出现意外错误。
在某种意义上,JavaScript比MQL5更具面向对象特性,因为在JavaScript中几乎所有的实体都是对象。例如,函数也是一个对象,而类作为对象属性的描述符,同样也是一个对象(原型对象)。
JavaScript自身会进行 “垃圾回收”,即释放应用程序为对象分配的内存。在MQL5中,我们必须及时调用delete来释放动态对象的内存。
JavaScript语法中有许多方便的 “缩写” 来编写一些结构,而在MQL5中实现这些结构则需要更长的代码。例如,在MQL5中,为了将一个指向另一个函数的参数传递给某个函数,我们需要使用typedef描述这样一个指针的类型,单独定义一个与该原型匹配的函数,然后才能将其标识符作为参数传递。而在JavaScript中,你可以直接在参数列表中定义你所指向的函数(完整地定义!),而无需使用指针参数。
如果你是一名Web开发者,或者已经熟悉Node.js,你可以跳过安装和配置步骤。
你可以从官方网站nodejs.org下载Node.js。安装方式有多种,例如使用安装程序或解压压缩包。安装完成后,你会在指定目录中得到一个可执行文件node.exe以及一些支持文件和文件夹。
如果安装程序没有将Node.js添加到系统路径中,你可以为当前的Windows用户添加,方法是在Node.js的安装文件夹(即node.exe所在的文件夹)中运行以下命令:
setx PATH "%CD%"
或者,你也可以从系统属性对话框(计算机 -> 属性 -> 高级系统设置 -> 环境变量;具体的对话框类型取决于操作系统的版本)中编辑Windows环境变量。无论哪种方式,这样做都能确保我们可以从计算机的任何文件夹中运行Node.js,这在以后会对我们很有用。
你可以通过运行以下命令(在Windows命令行中)来检查Node.js是否安装正常:
node -v
npm version
第一个命令会输Node.js的版本,第二个命令会输出Node.js一个重要的内置服务——npm包管理器的版本。
包是一个可直接使用的模块,它为Node.js添加特定的功能。Node.js本身非常小巧,如果没有包的支持,很多常规功能都需要大量的编码来实现。
最常用的包存储在Web上的一个集中式仓库中,可以下载并安装到特定的Node.js副本中,或者进行全局安装(如果计算机上有多个Node.js副本,则对所有副本都生效)。将包安装到特定副本的命令如下:
npm install <包名>
在Node.js的安装文件夹中运行该命令。此命令会将包安装在本地,不会影响计算机上已有的或以后可能出现的其他Node.js副本,避免意外的修改。
特别地,我们需要ws包,它实现了WebSocket协议。也就是说,你需要运行以下命令:
npm install ws
然后等待安装过程完成。安装完成后,<nodejs_install_path>/node_modules/文件夹中应该会出现一个新的子文件夹ws,其中包含必要的内容(你可以查看README.md文件,了解包的描述,以确认它是一个WebSocket协议库)。
这个包既包含了服务器端的实现,也包含了客户端的实现。不过,我们将使用MQL5编写自己的客户端,而不使用这个包中的客户端实现。
Node.js服务器的所有功能都集中在/node_modules文件夹中。它的作用可以类比于MetaTrader 5中的标准文件夹MQL5/Include。当用JavaScript编写应用程序时,我们将以一种特殊的方式包含或 “导入” 必要的模块,这类似于在MQL5中使用#include指令包含.mqh头文件。
WebSockets协议的理论基础
WebSocket协议构建在TCP/IP网络连接之上,TCP/IP网络连接的特点是具有一个IP地址(或替代它的域名)和一个端口号。我们在网络功能章节中已经实践过的HTTP/HTTPS协议,也是基于相同的原理工作的。在HTTP/HTTPS协议中,标准端口号为80(用于不安全连接)和443(用于安全连接)。WebSocket没有专用的端口号,因此Web服务提供商可以选择任何可用的端口号。我们所有的示例都将使用端口9000。
当指定URL并以WebSocket协议作为前缀时,我们使用ws(用于不安全连接)和wss(用于安全连接)。
就数据传输而言,WebSocket格式比HTTP更高效,因为它使用的控制数据要少得多。
WebSocket服务的初始连接建立过程与HTTP/HTTPS网页请求完全相同:需要发送一个带有专门准备好的头部信息的GET请求。这些头部信息的一个特点是包含以下几行内容:
Connection: Upgrade
Upgrade: websocket
以及一些额外的行,用于报告WebSocket协议的版本和特殊的随机生成字符串。这些是客户端和服务器之间“握手”过程中涉及的密钥。
Sec-WebSocket-Key: ...
Sec-WebSocket-Version: 13
在实际应用中,“握手”意味着服务器会检查客户端请求的那些选项是否可用,并通过标准的HTTP头部信息进行响应,确认切换到WebSocket模式或拒绝切换。最常见的拒绝原因可能是,你试图通过WebSocket连接到一个简单的Web服务器,而该服务器上没有提供WebSocket服务,或者不支持所需的协议版本。
当前版本的WebSocket协议以代号Hybi和版本号13为人所知。一个较早且更简单的版本,称为Hixie,可能对向后兼容很有用。在接下来的内容中,我们将只使用Hybi版本,不过也会包含Hixie版本的实现。
服务器响应中包含以下HTTP头部信息时,表示连接成功建立:
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: ...
这里的Sec-WebSocket-Accept字段是由服务器根据Sec-WebSocket-Key计算并填充的,用于确认协议的合规性。所有这些都由RFC6455规范进行规定,并且在我们的MQL程序中也会得到支持。
为了更清晰地说明,该过程如下图所示:
通过WebSocket协议进行的客户端与服务器之间的交互
建立WebSocket连接后,客户端和服务器可以交换打包在特殊块中的信息:帧(frame)和消息(message)。一条消息可能由一个或多个帧组成。根据规范,帧的大小限制为一个极大的数值,即2的63次方字节(9223372036854775807,约9.22艾字节!),但具体的实现当然可能会有更实际的限制,因为这个理论限制对于单个数据包的发送来说似乎不太现实。
在任何时候,客户端或服务器都可以终止连接,既可以事先“礼貌地告别”(见下文),也可以直接关闭网络套接字。
帧可以有不同的类型,这在每个帧开头的头部信息(长度为4到16字节)中进行了规定。作为参考,我们列出操作码(它们存在于头部的第一个字节中)以及不同类型帧的用途:
- 0 — 延续帧(继承前一个帧的属性);
- 1 — 包含文本信息的帧;
- 2 — 包含二进制信息的帧;
- 8 — 关闭连接的请求帧和关闭连接的确认帧(用于“礼貌告别”时发送);
- 9 — ping帧,任何一方都可以定期发送,以确保连接在物理上仍然有效;
- 10 — pong帧,作为对ping帧的响应发送。
消息中的最后一个帧在头部用一个特殊的位进行标记。当然,当一条消息只由一个帧组成时,它也是最后一个帧。有效载荷的长度也在头部中传递。
基于 WebSocket 协议的 Web 服务服务器组件
为了组织所有项目通用的服务器组件,我们会在 MQL5/Experts/MQL5Book/p7/
目录下创建一个单独的 Web
文件夹。理想情况下,将 Web
作为子文件夹放在 Shared Projects
中会很方便。实际上,MQL5/Shared Projects
在 MetaTrader 5 的标准发行版中就存在,并且是专门用于云存储项目的。所以,之后借助共享项目的功能,就可以把我们项目的所有文件(不仅是 Web 文件,还有 MQL 程序)上传到服务器。
之后,当我们创建包含 MQL5 客户端程序的 .mqproj
文件时,会把这个文件夹中的所有文件添加到项目的“设置和文件”部分,因为这些文件构成了项目不可或缺的一部分——服务器端。
由于已经为项目服务器分配了一个单独的目录,所以有必要确保能在这个目录中导入 Node.js 模块。默认情况下,Node.js 会在当前目录的 /node_modules
子文件夹中查找模块,而我们会从项目中启动服务器。因此,在放置项目 Web 文件的文件夹中运行以下命令:
mklink /j node_modules {drive:/path/to/folder/nodejs}/node_modules
这样会出现一个名为 node_modules
的“符号”目录链接,它指向已安装的 Node.js 中同名的原始文件夹。
验证 WebSocket 功能最简单的方法是使用回显服务。其工作模式是将接收到的任何消息原样返回给发送者。下面来看看如何以最小的配置搭建这样一个服务。wsintro.js
文件中包含了一个示例。
首先,我们引入 ws
包(模块),它为 Node.js 提供了 WebSocket 功能,并且是随 Web 服务器一起安装的。
javascript
// JavaScript
const WebSocket = require('ws');
require
函数的工作方式与 MQL5 中的 #include
指令类似,但它还会返回一个包含 ws
包中所有文件 API 的模块对象。这样,我们就能调用 WebSocket
对象的方法和属性了。在这个例子中,我们需要在 9000 端口上创建一个 WebSocket 服务器。
javascript
// JavaScript
const port = 9000;
const wss = new WebSocket.Server({ port: port });
这里我们看到使用 new
运算符调用了常见的 MQL5 构造函数,但传递的参数是一个未命名的对象(结构体),在这个对象中,就像在映射中一样,可以存储一组命名属性及其值。在这个例子中,只使用了一个 port
属性,并且其值被设置为上面定义的 port
变量(更准确地说是一个常量)。实际上,我们可以在运行脚本时通过命令行传递端口号(以及其他设置)。
服务器对象被赋值给 wss
变量。成功创建后,我们会在命令行窗口中提示服务器正在运行(等待连接)。
javascript
// JavaScript
console.log('listening on port: ' + port);
console.log
调用类似于 MQL5 中的 Print
函数。另外要注意,JavaScript 中的字符串不仅可以用双引号括起来,还可以用单引号,甚至可以用反引号,如 `this is a ${template}text`
,这增加了一些实用的功能。
接下来,为 wss
对象分配一个“connection”事件处理程序,该事件处理程序用于处理新客户端的连接。显然,受支持的对象事件列表是由包的开发者定义的,在这个例子中,是我们使用的 ws
包。所有这些都在文档中有说明。
处理程序通过 on
方法绑定,该方法指定事件名称和处理程序本身。
javascript
// JavaScript
wss.on('connection', function(channel)
{
...
});
这个处理程序是一个未命名(匿名)函数,它直接定义在期望回调代码引用参数的位置,用于在新连接时执行。之所以将该函数设为匿名函数,是因为它仅在这里使用,而且 JavaScript 允许在语法上进行这样的简化。该函数只有一个参数,即新连接的对象。我们可以自行选择参数的名称,在这个例子中是 channel
。
在处理程序内部,需要为与特定通道中收到新消息相关的“message”事件设置另一个处理程序。
javascript
// JavaScript
channel.on('message', function(message)
{
console.log('message: ' + message);
channel.send('echo: ' + message);
});
...
这里同样使用了一个带有单个参数(接收到的消息对象)的匿名函数。我们将其打印到控制台日志中以便调试。但最重要的操作在第二行:通过调用 channel.send
方法,我们向客户端发送一条响应消息。
为了完善这个功能,我们在“connection”处理程序中添加一条自定义的欢迎消息。完成后的代码如下:
javascript
// JavaScript
wss.on('connection', function(channel)
{
channel.on('message', function(message)
{
console.log('message: ' + message);
channel.send('echo: ' + message);
});
console.log('new client connected!');
channel.send('connected!');
});
需要理解的是,虽然“message”处理程序的绑定在代码中位于发送“hello”消息之前,但只有在客户端发送消息时,消息处理程序才会被调用。
我们已经介绍了搭建回显服务的脚本框架。不过,最好对其进行测试。最有效的测试方法是使用普通浏览器,但这需要对脚本进行一些小的修改:将其转换为一个尽可能小的 Web 服务器,该服务器返回一个包含尽可能小的 WebSocket 客户端的网页。
回显服务和测试网页
我们接下来要介绍的回显服务器脚本在 wsecho.js
文件中。其中一个要点是,服务器不仅要支持开放协议 http
/ws
,还要支持受保护的协议 https
/wss
。在我们的所有示例(包括基于 MQL5 的客户端)中都会提供这种支持,但为此需要在服务器上执行一些操作。
首先需要有两个包含加密密钥和证书的文件。这些文件通常从授权机构(即认证中心)获取,但为了学习目的,也可以自行生成。当然,这些文件不能用于公共服务器,带有此类证书的页面在任何浏览器中都会引发警告(地址栏左侧的页面图标会显示为红色)。
证书的原理以及自行生成证书的过程超出了本书的范围,但本书中包含了两个现成的文件:MQL5Book.crt
和 MQL5Book.key
(还有其他扩展名),它们的有效期有限。为了让服务器通过 HTTPS 协议工作,必须将这些文件传递给 Web 服务器对象的构造函数。
我们将在脚本启动命令行中传递证书文件的名称。例如:
node wsecho.js MQL5Book
如果在运行脚本时不添加额外的参数,服务器将使用 HTTP 协议工作。
node wsecho.js
在脚本内部,可以通过内置对象 process.argv
访问命令行参数,并且前两个参数分别始终包含服务器 node.exe
的名称和要运行的脚本名称(在这个例子中是 wsecho.js
),所以我们使用 splice
方法将它们丢弃。
javascript
// JavaScript
const args = process.argv.slice(2);
const secure = args.length > 0 ? 'https' : 'http';
根据是否存在证书名称,secure
变量会获取接下来创建服务器时应加载的包的名称:https
或 http
。代码中总共有 3 个依赖项:
javascript
// JavaScript
const fs = require('fs');
const http1 = require(secure);
const WebSocket = require('ws');
我们已经了解了 ws
包;https
和 http
包提供了 Web 服务器的实现,而内置的 fs
包提供了文件系统操作功能。
Web 服务器的设置被格式化为 options
对象。在这里,我们可以看到如何使用 ${args[0]}
表达式将命令行中的证书名称插入到带斜杠引号的字符串中。然后,使用 fs.readFileSync
方法读取相应的文件对。
javascript
// JavaScript
const options = args.length > 0 ?
{
key : fs.readFileSync(`${args[0]}.key`),
cert : fs.readFileSync(`${args[0]}.crt`)
} : null;
通过调用 createServer
方法创建 Web 服务器,我们将 options
对象和一个匿名函数(HTTP 请求处理程序)传递给该方法。处理程序有两个参数:包含 HTTP 请求的 req
对象和用于发送响应(HTTP 头和网页)的 res
对象。
javascript
// JavaScript
http1.createServer(options, function (req, res)
{
console.log(req.method, req.url);
console.log(req.headers);
if(req.url == '/') req.url = "index.htm";
fs.readFile('./' + req.url, (err, data) =>
{
if(!err)
{
var dotoffset = req.url.lastIndexOf('.');
var mimetype = dotoffset == -1 ? 'text/plain' :
{
'.htm' : 'text/html',
'.html' : 'text/html',
'.css' : 'text/css',
'.js' : 'text/javascript'
}[ req.url.substr(dotoffset) ];
res.setHeader('Content-Type',
mimetype == undefined ? 'text/plain' : mimetype);
res.end(data);
}
else
{
console.log('File not fount: ' + req.url);
res.writeHead(404, "Not Found");
res.end();
}
});
}).listen(secure == 'https' ? 443 : 80);
主索引页面(也是唯一的页面)是 index.htm
(稍后编写)。此外,处理程序还可以发送 js
和 css
文件,这在未来会很有用。根据是否启用了受保护模式,服务器会通过调用 listen
方法在标准端口 443 或 80 上启动(如果这些端口在你的计算机上已被占用,可以更改端口号)。
为了在 9000 端口上接受 WebSocket 连接,我们需要使用相同的选项部署另一个 Web 服务器实例。但在这种情况下,该服务器的唯一目的是处理将连接升级到 WebSocket 协议的 HTTP 请求。
javascript
// JavaScript
const server = new http1.createServer(options).listen(9000);
server.on('upgrade', function(req, socket, head)
{
console.log(req.headers); // TODO: we can add authorization!
});
在这里,在“upgrade”事件处理程序中,我们接受所有已经通过握手的连接,并将请求头打印到日志中,但如果我们要提供封闭(付费)服务,理论上可以要求用户进行授权。
最后,我们像之前的介绍性示例一样创建一个 WebSocket 服务器对象,唯一的区别是将一个现成的 Web 服务器传递给构造函数。所有连接的客户端都会被计数,并按序号受到欢迎。
javascript
// JavaScript
var count = 0;
const wsServer = new WebSocket.Server({ server });
wsServer.on('connection', function onConnect(client)
{
console.log('New user:', ++count);
client.id = count;
client.send('server#Hello, user' + count);
client.on('message', function(message)
{
console.log('%d : %s', client.id, message);
client.send('user' + client.id + '#' + message);
});
client.on('close', function()
{
console.log('User disconnected:', client.id);
});
});
对于所有事件,包括连接、断开连接和消息事件,都会在控制台中显示调试信息。
至此,支持 WebSocket 的 Web 服务器已经准备好了。现在我们需要为它创建一个客户端网页 index.htm
。
html
<!DOCTYPE html>
<html>
<head>
<title>Test Server (HTTP[S]/WS[S])</title>
</head>
<body>
<div>
<h1>Test Server (HTTP[S]/WS[S])</h1>
<p><label>
Message: <input id="message" name="message" placeholder="Enter a text">
</label></p>
<p><button>Submit</button> <button>Close</button></p>
<p><label>
Echo: <input id="echo" name="echo" placeholder="Text from server">
</label></p>
</div>
</body>
<script src="wsecho_client.js"></script>
</html>
这个页面是一个表单,包含一个输入字段和一个用于发送消息的按钮。
WebSocket 回显服务网页
该页面使用 wsecho_client.js
脚本,该脚本提供 WebSocket 客户端响应功能。在浏览器中,WebSocket 作为“原生”JavaScript 对象内置,因此无需引入任何外部资源:只需使用所需的协议和端口号调用 WebSocket 构造函数即可。
javascript
// JavaScript
const proto = window.location.protocol.startsWith('http') ?
window.location.protocol.replace('http', 'ws') : 'ws:';
const ws = new WebSocket(proto + '//' + window.location.hostname + ':9000');
URL 是根据当前网页的地址(window.location.hostname
)形成的,因此 WebSocket 连接会连接到同一台服务器。
接下来,ws
对象允许我们对事件做出响应并发送消息。在浏览器中,打开连接事件称为“open”,它通过 onopen
属性进行绑定。对于新消息到达事件,也使用类似的语法(与服务器实现略有不同),处理程序被分配给 onmessage
属性。
javascript
// JavaScript
ws.onopen = function()
{
console.log('Connected');
};
ws.onmessage = function(message)
{
console.log('Message: %s', message.data);
document.getElementById('echo').value = message.data;
};
接收到的消息文本会显示在 id
为“echo”的表单元素中。注意,消息事件对象(处理程序参数)本身不是消息,消息存储在 data
属性中。这是 JavaScript 中的一个实现特性。
通过为两个按钮标签对象使用 addEventListener
方法来分配对表单按钮的响应。这里我们看到了 JavaScript 中描述匿名函数的另一种方式:带有参数列表(可以为空)的括号,以及箭头后面的函数体 (arguments) => { ... }
。
javascript
// JavaScript
const button = document.querySelectorAll('button'); // request all buttons
// button "Submit"
button[0].addEventListener('click', (event) =>
{
const x = document.getElementById('message').value;
if(x) ws.send(x);
});
// button "close"
button[1].addEventListener('click', (event) =>
{
ws.close();
document.getElementById('echo').value = 'disconnected';
Array.from(document.getElementsByTagName('button')).forEach((e) =>
{
e.disabled = true;
});
});
要发送消息,我们调用 ws.send
方法;要关闭连接,我们调用 ws.close
方法。
至此,用于演示回显服务的客户端 - 服务器脚本的第一个示例开发完成。你可以使用前面展示的命令之一运行 wsecho.js
,然后在浏览器中打开 http://localhost
或 https://localhost
页面(具体取决于服务器设置)。表单出现在屏幕上后,尝试与服务器进行聊天,确保服务正常运行。
通过逐步完善这个示例,我们将为复制交易信号的 Web 服务奠定基础。但下一步将是开发一个聊天服务,其原理与交易信号服务类似:一个用户的消息会被发送给其他用户。
聊天服务和测试网页
新的服务器脚本名为 wschat.js
,它与 wsecho.js
有很多相似之处。下面列出主要的区别。在 Web 服务器的 HTTP 请求处理程序中,将初始页面从 index.htm
更改为 wschat.htm
。
javascript
// JavaScript
http1.createServer(options, function (req, res)
{
if(req.url == '/') req.url = "wschat.htm";
...
});
为了存储连接到聊天的用户信息,我们将定义 clients
映射数组。Map
是 JavaScript 中的标准关联容器,可以使用任意类型的键(包括对象)写入任意值。
javascript
// JavaScript
const clients = new Map(); // added this line
var count = 0;
在新用户连接事件处理程序中,我们将作为函数参数接收的客户端对象添加到映射中,键为当前客户端的序号。
javascript
// JavaScript
wsServer.on('connection', function onConnect(client)
{
console.log('New user:', ++count);
client.id = count;
client.send('server#Hello, user' + count);
clients.set(count, client); // added this line
...
在 onConnect
函数内部,我们为特定客户端收到新消息的事件设置了一个处理程序,并且正是在嵌套的处理程序中发送消息。不过,这次我们会遍历映射中的所有元素(即所有客户端),并将消息文本发送给每个客户端。循环是通过对映射中的数组调用 forEach
方法来组织的,并且将为每个元素(elem
)执行的下一个匿名函数作为参数传递给该方法。这个循环示例清晰地展示了 JavaScript 中占主导地位的函数式 - 声明式编程范式(与 MQL5 中的命令式方法不同)。
javascript
// JavaScript
client.on('message', function(message)
{
console.log('%d : %s', client.id, message);
Array.from(clients.values()).forEach(function(elem) // added a loop
{
elem.send('user' + client.id + '#' + message);
});
});
需要注意的是,我们会将消息的副本发送给所有客户端,包括原始作者。可以对其进行过滤,但为了调试目的,最好确认消息已发送。
与之前的回显服务的最后一个区别是,当客户端断开连接时,需要将其从映射中移除。
javascript
// JavaScript
client.on('close', function()
{
console.log('User disconnected:', client.id);
clients.delete(client.id); // added this line
});
关于将页面 index.htm
替换为 wschat.htm
,我们在这里添加了一个“字段”来显示消息的作者(来源),并引入了一个新的浏览器脚本 wschat_client.js
。该脚本会解析消息(我们使用 #
符号来分隔作者和文本),并将接收到的信息填充到表单字段中。由于从 WebSocket 协议的角度来看没有任何变化,因此我们不再提供源代码。
WebSocket 聊天服务网页
你可以使用 wschat.js
聊天服务器启动 Node.js,然后从多个浏览器标签连接到它。每个连接都会获得一个唯一的编号,并显示在标题中。点击“Submit”按钮后,“Message”字段中的文本会发送给所有客户端。然后,客户端表单会显示消息的作者(左下角的标签)和消息文本(底部中心的字段)。
至此,我们已经确认支持 WebSocket 的 Web 服务器已准备就绪。接下来,让我们开始在 MQL5 中编写协议的客户端部分。
MQL5中的WebSocket协议
我们之前已经了解了WebSocket协议的理论基础。其完整的规范内容相当广泛,若要详细描述其实现方式,会占用大量篇幅和时间。因此,我们将介绍现成类的总体结构及其编程接口。所有文件都位于目录MQL5/Include/MQL5Book/ws/ 中。
wsinterfaces.mqh
— 所有接口、常量和类型的通用抽象描述;wstransport.mqh
—MqlWebSocketTransport
类,它基于MQL5套接字函数实现了IWebSocketTransport
底层网络数据传输接口;wsframe.mqh
—WebSocketFrame
和WebSocketFrameHixie
类,它们实现了IWebSocketFrame
接口,分别隐藏了为Hybi和Hixie协议生成(编码和解码)帧的算法;wsmessage.mqh
—WebSocketMessage
和WebSocketMessageHixie
类,它们实现了IWebSocketMessage
接口,分别为Hybi和Hixie协议形式化了从帧到消息的形成过程;wsprotocol.mqh
—WebSocketConnection
、WebSocketConnectionHybi
、WebSocketConnectionHixie
类,继承自IWebSocketConnection
;正是在这里,根据规范对帧、消息、问候和断开连接的形成进行协调管理,上述接口也会被用到;wsclient.mqh
— WebSocket客户端的现成实现;WebSocketClient
模板类,支持IWebSocketObserver
接口(用于事件处理),并期望WebSocketConnectionHybi
或WebSocketConnectionHixie
作为参数化类型;wstools.mqh
—WsTools
命名空间中有用的实用工具。
这些头文件将作为#include
指令的依赖项自动包含在我们未来的mqporj
项目中。
MQL5中的WebSocket类图
MQL5中的WebSocket类图
底层网络接口IWebSocketTransport
具有以下方法:
c
interface IWebSocketTransport
{
int write(const uchar &data[]); // 将字节数组写入网络
int read(uchar &buffer[]); // 从网络读取数据到字节数组
bool isConnected(void) const; // 检查连接状态
bool isReadable(void) const; // 检查是否可以从网络读取数据
bool isWritable(void) const; // 检查是否可以向网络写入数据
int getHandle(void) const; // 系统套接字描述符
void close(void); // 关闭连接
};
从这些方法的名称不难猜出,构建这些方法会用到哪些MQL5 API套接字函数。但如果有需要,有意愿的人可以通过自己的方式实现这个接口,例如通过DLL。
实现该接口的MqlWebSocketTransport
类在创建实例时,需要指定建立网络连接的协议、主机名和端口号。此外,还可以指定超时值。
帧类型收集在WS_FRAME_OPCODE
枚举中:
c
enum WS_FRAME_OPCODE
{
WS_DEFAULT = 0,
WS_CONTINUATION_FRAME = 0x00,
WS_TEXT_FRAME = 0x01,
WS_BINARY_FRAME = 0x02,
WS_CLOSE_FRAME = 0x08,
WS_PING_FRAME = 0x09,
WS_PONG_FRAME = 0x0A
};
用于处理帧的接口包含与帧实例相关的静态方法和常规方法。静态方法充当工厂,用于由发送方创建所需类型的帧(create
)和处理传入的帧(decode
)。
c
class IWebSocketFrame
{
public:
class StaticCreator
{
public:
virtual IWebSocketFrame *decode(uchar &data[], IWebSocketFrame *head = NULL) = 0;
virtual IWebSocketFrame *create(WS_FRAME_OPCODE type, const string data = NULL,
const bool deflate = false) = 0;
virtual IWebSocketFrame *create(WS_FRAME_OPCODE type, const uchar &data[],
const bool deflate = false) = 0;
};
...
由于存在模板Creator
和返回它的getCreator
方法实例(假设返回“单例”),因此在派生类中强制要求存在工厂方法。
c
protected:
template<typename P>
class Creator: public StaticCreator
{
public:
// 对IWebSocketFrame中接收到的二进制数据进行解码
// (如果是续帧,上一帧在'head'中)
virtual IWebSocketFrame *decode(uchar &data[],
IWebSocketFrame *head = NULL) override
{
return P::decode(data, head);
}
// 创建所需类型(文本/关闭/其他)的帧,并可选择包含文本
virtual IWebSocketFrame *create(WS_FRAME_OPCODE type, const string data = NULL,
const bool deflate = false) override
{
return P::create(type, data, deflate);
};
// 创建所需类型(二进制/文本/关闭/其他)的帧,并包含数据
virtual IWebSocketFrame *create(WS_FRAME_OPCODE type, const uchar &data[],
const bool deflate = false) override
{
return P::create(type, data, deflate);
};
};
public:
// 需要一个Creator实例
virtual IWebSocketFrame::StaticCreator *getCreator() = 0;
...
该接口的其余方法提供了对帧中数据的所有必要操作(编码/解码、获取数据和各种标志)。
c
// 将帧的“纯净”内容编码为可通过网络传输的数据
virtual int encode(uchar &encoded[]) = 0;
// 获取数据作为文本
virtual string getData() = 0;
// 获取数据作为字节,并返回大小
virtual int getData(uchar &buf[]) = 0;
// 返回帧类型(操作码)
virtual WS_FRAME_OPCODE getType() = 0;
// 检查帧是否为控制帧或包含数据的帧:
// 控制帧在类内部进行处理
virtual bool isControlFrame()
{
return (getType() >= WS_CLOSE_FRAME);
}
virtual bool isReady() { return true; }
virtual bool isFinal() { return true; }
virtual bool isMasked() { return false; }
virtual bool isCompressed() { return false; }
};
IWebSocketMessage
接口包含在消息级别执行类似操作的方法。
c
class IWebSocketMessage
{
public:
// 获取构成此消息的帧数组
virtual void getFrames(IWebSocketFrame *&frames[]) = 0;
// 设置文本作为消息内容
virtual bool setString(const string &data) = 0;
// 返回消息内容作为文本
virtual string getString() = 0;
// 设置二进制数据作为消息内容
virtual bool setData(const uchar &data[]) = 0;
// 以“原始”二进制形式返回消息内容
virtual bool getData(uchar &data[]) = 0;
// 消息完整性标志(所有帧都已接收)
virtual bool isFinalised() = 0;
// 向消息中添加一个帧
virtual bool takeFrame(IWebSocketFrame *frame) = 0;
};
考虑到帧和消息的接口,定义了WebSocket连接的通用接口IWebSocketConnection
。
c
interface IWebSocketConnection
{
// 使用指定的URL及其部分内容以及可选的自定义标头打开连接
bool handshake(const string url, const string host, const string origin,
const string custom = NULL);
// 从服务器进行底层读取帧操作
int readFrame(IWebSocketFrame *&frames[]);
// 底层发送帧(例如关闭帧或ping帧)
bool sendFrame(IWebSocketFrame *frame);
// 底层消息发送
bool sendMessage(IWebSocketMessage *msg);
// 自定义检查新消息(生成事件)
int checkMessages();
// 自定义文本发送
bool sendString(const string msg);
// 自定义二进制数据发送
bool sendData(const uchar &data[]);
// 关闭连接
bool disconnect(void);
};
关于断开连接和新消息的通知通过IWebSocketObserver
接口的方法接收。
c
interface IWebSocketObserver
{
void onConnected();
void onDisconnect();
void onMessage(IWebSocketMessage *msg);
};
特别是,WebSocketClient
类是该接口的继承者,默认情况下只是将信息输出到日志中。类构造函数期望一个连接到ws
或wss
协议的地址。
c
template<typename T>
class WebSocketClient: public IWebSocketObserver
{
protected:
IWebSocketMessage *messages[];
string scheme;
string host;
string port;
string origin;
string url;
int timeOut;
...
public:
WebSocketClient(const string address)
{
string parts[];
URL::parse(address, parts);
url = address;
timeOut = 5000;
scheme = parts[URL_SCHEME];
if(scheme != "ws" && scheme != "wss")
{
Print("WebSocket invalid url scheme: ", scheme);
scheme = "ws";
}
host = parts[URL_HOST];
port = parts[URL_PORT];
origin = (scheme == "wss" ? "https://" : "http://") + host;
}
...
void onDisconnect() override
{
Print(" > Disconnected ", url);
}
void onConnected() override
{
Print(" > Connected ", url);
}
void onMessage(IWebSocketMessage *msg) override
{
// 注意:消息可能是二进制的,仅为了通知而打印
Print(" > Message ", url, " " , msg.getString());
WsTools::push(messages, msg);
}
...
};
WebSocketClient
类将所有消息对象收集到一个数组中,如果MQL程序没有进行删除操作,它会负责删除这些对象。
连接在open
方法中建立。
c
template<typename T>
class WebSocketClient: public IWebSocketObserver
{
protected:
IWebSocketTransport *socket;
IWebSocketConnection *connection;
...
public:
...
bool open(const string custom_headers = NULL)
{
uint _port = (uint)StringToInteger(port);
if(_port == 0)
{
if(scheme == "ws") _port = 80;
else _port = 443;
}
socket = MqlWebSocketTransport::create(scheme, host, _port, timeOut);
if(!socket || !socket.isConnected())
{
return false;
}
connection = new T(&this, socket);
return connection.handshake(url, host, origin, custom_headers);
}
...
最方便的数据发送方式由重载的send
方法提供,分别用于发送文本和二进制数据。
c
bool send(const string str)
{
return connection ? connection.sendString(str) : false;
}
bool send(const uchar &data[])
{
return connection ? connection.sendData(data) : false;
}
要检查是否有新的传入消息,可以调用checkMessages
方法。根据其阻塞参数,该方法要么在循环中等待消息直到超时,要么在没有消息时立即返回。消息将进入IWebSocketObserver::onMessage
处理程序。
c
void checkMessages(const bool blocking = true)
{
if(connection == NULL) return;
uint stop = GetTickCount() + (blocking ? timeOut : 1);
while(ArraySize(messages) == 0 && GetTickCount() < stop && isConnected())
{
// 所有帧都被收集到相应的消息中,并且它们通过IWebSocketObserver::onMessage事件通知变得可用
// 然而,控制帧此时已经在内部被处理并移除了
if(!connection.checkMessages()) // 当没有消息时,进行短暂停顿
{
Sleep(100);
}
}
}
接收消息的另一种方式在readMessage
方法中实现:它将消息的指针返回给调用代码(换句话说,不需要应用程序处理程序onMessage
)。之后,MQL程序负责释放该对象。
c
IWebSocketMessage *readMessage(const bool blocking = true)
{
if(ArraySize(messages) == 0) checkMessages(blocking);
if(ArraySize(messages) > 0)
{
IWebSocketMessage *top = messages[0];
ArrayRemove(messages, 0, 1);
return top;
}
return NULL;
}
该类还允许更改超时时间、检查连接状态并关闭连接。
c
void setTimeOut(const int ms)
{
timeOut = fabs(ms);
}
bool isConnected() const
{
return socket && socket.isConnected();
}
void close()
{
if(isConnected())
{
if(connection)
{
connection.disconnect(); // 这将在服务器确认后关闭套接字
delete connection;
connection = NULL;
}
if(socket)
{
delete socket;
socket = NULL;
}
}
}
};
所讨论的类库允许创建用于回显和聊天服务的客户端应用程序。
MQL5中用于回声服务和聊天服务的客户端程序
我们来编写一个简单的脚本,用于连接到回声服务MQL5/Experts/MQL5Book/p7/wsEcho/wsecho.mq5(请注意,这是一个脚本,但我们将它放置在MQL5/Experts/MQL5Book/p7/文件夹内,使其成为与Web相关的MQL程序的单一容器,因为后续所有示例都将是智能交易系统)。由于在本章中,我们考虑的是在项目中创建软件综合体,所以我们将把这个脚本设计为mqproj项目的一部分,该项目还将包含服务器组件。
该脚本的输入参数允许你指定服务地址和消息文本。默认情况下是不安全的连接。如果你打算启动支持TLS的服务器wsecho.js,你需要将协议更改为安全的wss。请记住,建立安全连接比通常的连接要花费更长时间(大约几秒)。
input string Server = "ws://localhost:9000/";
input string Message = "My outbound message";
#include <MQL5Book/AutoPtr.mqh>
#include <MQL5Book/ws/wsclient.mqh>
在OnStart函数中,我们为给定的地址创建一个WebSocket客户端(wss)实例,并调用open方法。如果连接成功,我们通过以阻塞模式调用wss.readMessage(默认等待最长5秒)来等待服务端发送的欢迎消息。我们对生成的对象使用自动指针,这样就无需在最后手动调用delete。
void OnStart()
{
Print("\n");
WebSocketClient<Hybi> wss(Server);
Print("Opening...");
if(wss.open())
{
Print("Waiting for welcome message (if any)");
AutoPtr<IWebSocketMessage> welcome(wss.readMessage());
...
WebSocketClient类包含事件处理程序的存根,其中包括简单的onMessage方法,该方法会将问候消息打印到日志中。
然后我们发送我们的消息,并再次等待服务器的响应。回声消息也会被记录到日志中。
Print("Sending message...");
wss.send(Message);
Print("Receiving echo...");
AutoPtr<IWebSocketMessage> echo(wss.readMessage());
}
...
最后,我们关闭连接。
if(wss.isConnected())
{
Print("Closing...");
wss.close();
}
}
基于这个脚本文件,我们来创建一个项目文件(wsecho.mqproj)。我们在项目属性中填入版本号(1.0)、版权信息和描述。我们将回声服务的服务器文件添加到 “设置和文件” 分支中(这至少会提醒开发者有一个测试服务器)。编译后,依赖项(头文件)将出现在层次结构中。
一切应该如下图所示。
回声服务项目、客户端脚本和服务器
回声服务项目、客户端脚本和服务器
如果该脚本位于“共享项目”文件夹内,例如在MQL5/Shared Projects/MQL5Book/wsEcho/中,那么在成功编译后,其ex5文件将自动移动到MQL5/Scripts/Shared Projects/MQL5Book/wsEcho/文件夹中,并且相应的条目将显示在编译日志中。这是编译共享项目中任何MQL程序的标准行为。
在本章的所有示例中,在测试MQL脚本之前,不要忘记启动服务器。在这种情况下,在web文件夹中运行命令:node.exe wsecho.js。
接下来,我们运行脚本wsecho.ex5。日志将显示正在发生的操作以及消息通知。
Opening...
Connecting to localhost:9000
Buffer: 'HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: mIpas63g5xGMqJcKtreHKpSbY1w=
'
Headers:
[,0] [,1]
[0,] "upgrade" "websocket"
[1,] "connection" "Upgrade"
[2,] "sec-websocket-accept" "mIpas63g5xGMqJcKtreHKpSbY1w="
> Connected ws://localhost:9000/
Waiting for welcome message (if any)
> Message ws://localhost:9000/ server#Hello, user1
Sending message...
Receiving echo...
> Message ws://localhost:9000/ user1#My outbound message
Closing...
Close requested
Waiting...
SocketRead failed: 5273 Available: 1
> Disconnected ws://localhost:9000/
Server close ack
上述HTTP头信息是服务器在握手过程中的响应。如果我们查看服务器运行的控制台窗口,我们会找到服务器从我们的客户端接收到的HTTP头信息。
回声服务服务器日志
回声服务服务器日志
此外,这里还会显示用户的连接、消息发送和断开连接的情况。
我们对聊天服务做类似的工作:在MQL5中创建一个WebSocket客户端、为它创建一个项目并进行测试。这次客户端程序的类型将是一个智能交易系统,因为聊天需要支持图表上来自键盘的交互式事件。该智能交易系统被放置在MQL5/MQL5Book/p7/wsChat/wschat.mq5文件夹中。
为了演示在处理程序方法中接收事件的技术,我们定义自己的类MyWebSocket,它派生自WebSocketClient。
class MyWebSocket: public WebSocketClient<Hybi>
{
public:
MyWebSocket(const string address, const bool compress = false):
WebSocketClient(address, compress) { }
/* void onConnected() override { } */
void onDisconnect() override
{
// 我们可以做一些其他事情,并且调用(或不调用)旧代码
WebSocketClient<Hybi>::onDisconnect();
}
void onMessage(IWebSocketMessage *msg) override
{
// 待办事项:我们可以截断我们自己消息的副本,
// 但为了调试目的将它们保留下来
Alert(msg.getString());
delete msg;
}
};
当接收到消息时,我们不会将其显示在日志中,而是以警报的形式显示,然后应该删除该对象。
在全局上下文中,我们描述wss类的对象以及用于累积来自键盘的用户输入的消息字符串。
MyWebSocket wss(Server);
string message = "";
OnInit函数包含必要的准备工作,特别是启动一个定时器并打开连接。
int OnInit()
{
ChartSetInteger(0, CHART_QUICK_NAVIGATION, false);
EventSetTimer(1);
wss.setTimeOut(1000);
Print("Opening...");
return wss.open() ? INIT_SUCCEEDED : INIT_FAILED;
}
定时器用于检查来自其他用户的新消息。
void OnTimer()
{
wss.checkMessages(false); // 在定时器中使用非阻塞检查
}
在OnChartEvent处理程序中,我们对按键操作做出响应:所有字母数字键都被转换为字符并附加到消息字符串上。如果需要,可以按Backspace键删除最后一个字符。所有输入的文本都会在图表注释中更新。当消息输入完成后,按Enter键将其发送到服务器。
void OnChartEvent(const int id, const long &lparam, const double &dparam,
const string &sparam)
{
if(id == CHARTEVENT_KEYDOWN)
{
if(lparam == VK_RETURN)
{
const static string longmessage = ...
if(message == "long") wss.send(longmessage);
else if(message == "bye") wss.close();
else wss.send(message);
message = "";
}
else if(lparam == VK_BACK)
{
StringSetLength(message, StringLen(message) - 1);
}
else
{
ResetLastError();
const short c = TranslateKey((int)lparam);
if(_LastError == 0)
{
message += ShortToString(c);
}
}
Comment(message);
}
}
如果我们输入文本“long”,程序将发送一个特别准备的相当长的文本。如果消息文本是“bye”,程序将关闭连接。此外,当程序退出时连接也会关闭。
void OnDeinit(const int)
{
if(wss.isConnected())
{
Print("Closing...");
wss.close();
}
}
我们为智能交易系统创建一个项目(文件wschat.mqproj),填写其属性,并将后端添加到“设置和文件”分支中。这次我们将展示项目文件的内部结构。在mqproj文件中,“依赖项”分支存储在“files”属性中,“设置和文件”分支存储在“tester”属性中。
{
"platform" :"mt5",
"program_type":"expert",
"copyright" :"Copyright 2022, MetaQuotes Ltd.",
"version" :"1.0",
"description" :"WebSocket-client for chat-service.\r\nType and send text messages for all connected users.\r\nShow alerts with messages from others.",
"optimize" :"1",
"fpzerocheck" :"1",
"tester_no_cache":"0",
"tester_everytick_calculate":"0",
"unicode_character_set":"0",
"static_libraries":"0",
"files":
[
{
"path":"wschat.mq5",
"compile":true,
"relative_to_project":true
},
{
"path":"MQL5\\Include\\MQL5Book\\ws\\wsclient.mqh",
"compile":false,
"relative_to_project":false
},
{
"path":"MQL5\\Include\\MQL5Book\\URL.mqh",
"compile":false,
"relative_to_project":false
},
{
"path":"MQL5\\Include\\MQL5Book\\ws\\wsframe.mqh",
"compile":false,
"relative_to_project":false
},
{
"path":"MQL5\\Include\\MQL5Book\\ws\\wstools.mqh",
"compile":false,
"relative_to_project":false
},
{
"path":"MQL5\\Include\\MQL5Book\\ws\\wsinterfaces.mqh",
"compile":false,
"relative_to_project":false
},
{
"path":"MQL5\\Include\\MQL5Book\\ws\\wsmessage.mqh",
"compile":false,
"relative_to_project":false
},
{
"path":"MQL5\\Include\\MQL5Book\\ws\\wstransport.mqh",
"compile":false,
"relative_to_project":false
},
{
"path":"MQL5\\Include\\MQL5Book\\ws\\wsprotocol.mqh",
"compile":false,
"relative_to_project":false
},
{
"path":"MQL5\\Include\\VirtualKeys.mqh",
"compile":false,
"relative_to_project":false
}
],
"tester":
[
{
"type":"file",
"path":"..\\Web\\MQL5Book.crt",
"relative_to_project":true
},
{
"type":"file",
"path":"..\\Web\\MQL5Book.key",
"relative_to_project":true
},
{
"type":"file",
"path":"..\\Web\\wschat.htm",
"relative_to_project":true
},
{
"type":"file",
"path":"..\\Web\\wschat.js",
"relative_to_project":true
},
{
"type":"file",
"path":"..\\Web\\wschat_client.js",
"relative_to_project":true
}
]
}
如果智能交易系统位于“共享项目”文件夹内,例如在MQL5/Shared Projects/MQL5Book/wsChat/中,在成功编译后,其ex5文件将自动移动到MQL5/Experts/Shared Projects/MQL5Book/wsChat/文件夹中。
启动服务器node.exe wschat.js。现在你可以在不同的图表上运行几个智能交易系统的副本。基本上,该服务涉及不同终端甚至不同计算机之间的“通信”,但你也可以从一个终端进行测试。
以下是EURUSD和GBPUSD图表之间通信的一个示例。
(EURUSD,H1)
(EURUSD,H1) Opening...
(EURUSD,H1) Connecting to localhost:9000
(EURUSD,H1) Buffer: 'HTTP/1.1 101 Switching Protocols
(EURUSD,H1) Upgrade: websocket
(EURUSD,H1) Connection: Upgrade
(EURUSD,H1) Sec-WebSocket-Accept: Dg+aQdCBwNExE5mEQsfk5w9J+uE=
(EURUSD,H1)
(EURUSD,H1) '
(EURUSD,H1) Headers:
(EURUSD,H1) [,0] [,1]
(EURUSD,H1) [0,] "upgrade" "websocket"
(EURUSD,H1) [1,] "connection" "Upgrade"
(EURUSD,H1) [2,] "sec-websocket-accept" "Dg+aQdCBwNExE5mEQsfk5w9J+uE="
(EURUSD,H1) > Connected ws://localhost:9000/
(EURUSD,H1) Alert: server#Hello, user1
(GBPUSD,H1)
(GBPUSD,H1) Opening...
(GBPUSD,H1) Connecting to localhost:9000
(GBPUSD,H1) Buffer: 'HTTP/1.1 101 Switching Protocols
(GBPUSD,H1) Upgrade: websocket
(GBPUSD,H1) Connection: Upgrade
(GBPUSD,H1) Sec-WebSocket-Accept: NZENnc8p05T4amvngeop/e/+gFw=
(GBPUSD,H1)
(GBPUSD,H1) '
(GBPUSD,H1) Headers:
(GBPUSD,H1) [,0] [,1]
(GBPUSD,H1) [0,] "upgrade" "websocket"
(GBPUSD,H1) [1,] "connection" "Upgrade"
(GBPUSD,H1) [2,] "sec-websocket-accept" "NZENnc8p05T4amvngeop/e/+gFw="
(GBPUSD,H1) > Connected ws://localhost:9000/
(GBPUSD,H1) Alert: server#Hello, user2
(EURUSD,H1) Alert: user1#I'm typing this on EURUSD chart
(GBPUSD,H1) Alert: user1#I'm typing this on EURUSD chart
(GBPUSD,H1) Alert: user2#Got it on GBPUSD chart!
(EURUSD,H1) Alert: user2#Got it on GBPUSD chart!
由于我们的消息会发送给包括发送者在内的所有人,所以它们会在日志中重复显示,但会显示在不同的图表上。
在服务器端也可以看到通信情况。
聊天服务服务器日志
聊天服务服务器日志
现在我们已经具备了组织交易信号服务的所有技术组件。
交易信号服务与测试网页
从技术层面来看,交易信号服务与聊天服务并无二致,不过,其用户(确切地说是客户端连接)必须扮演以下两种角色之一:
- 消息提供者
- 消息消费者
此外,信息并非对所有人开放,而是要依据某种订阅机制来运作。
为达成这一目的,用户在连接服务时,需要提供特定的身份识别信息,这些信息会因角色不同而有所差异。
消息提供者必须指定一个公开信号标识符(PUB_ID),该标识符在所有信号中必须是唯一的。理论上,同一个人可能会生成多个信号,所以应该能够获取多个标识符。鉴于此,我们不会通过引入单独的提供者标识符(作为特定个人)和其信号的标识符来使服务变得复杂。相反,我们仅支持信号标识符。对于真正的信号服务而言,这个问题需要与授权机制一同进行完善,不过在本书中我们暂不涉及授权相关内容。
之所以需要这个标识符,是为了对信号进行宣传,或者将其提供给有兴趣订阅该信号的人。但不能仅仅因为他人知晓公开标识符,就可以随意访问信号。在最简单的情况下,这对于公开账户监控是可行的,但我们将着重展示在信号场景下的访问限制选项。
为此,提供者必须向服务器提供一个只有自己知道、不对外公开的密钥(PUB_KEY)。这个密钥将用于生成特定订阅者的访问密钥。
消费者(订阅者)同样需要有一个唯一的标识符(SUB_ID,这里我们也不考虑授权问题)。若要订阅所需的信号,用户必须将该标识符告知信号提供者(实际上,在这个阶段还需要确认付款,通常服务器会自动完成这些操作)。提供者会生成一个包含提供者标识符、订阅者标识符和密钥的摘要。在我们的服务中,具体做法是计算字符串 PUB_ID:PUB_KEY:SUB_ID
的SHA256哈希值,然后将得到的字节转换为十六进制格式的字符串。这个字符串就是特定订阅者访问特定提供者信号的访问密钥(SUB_KEY 或 ACCESS_KEY)。提供者(在实际系统中,通常由服务器自动完成)会将这个密钥转发给订阅者。
因此,订阅者在连接服务时,需要指定订阅者标识符(SUB_ID)、所需信号的标识符(PUB_ID)以及访问密钥(SUB_KEY)。由于服务器知晓提供者的密钥,它可以针对给定的 PUB_ID
和 SUB_ID
组合重新计算访问密钥,并将其与订阅者提供的 SUB_KEY
进行比较。若两者匹配,则正常的消息传递流程将继续;若不匹配,则会返回错误消息,并断开该伪订阅者与服务的连接。
需要注意的是,在我们的演示中,为了简化操作,并没有对用户和信号进行正式的注册,因此标识符的选择是随意的。我们只需确保标识符的唯一性,以便实时了解信息的发送方和接收方。所以,我们的服务并不能保证像“超级趋势”这样的标识符在昨天、今天和明天都属于同一个用户。标识符的占用遵循“先到先得”的原则。只要提供者持续使用给定的标识符进行连接,信号就会正常发送。若提供者断开连接,该标识符将在后续的任何连接中可供选择。
唯一始终被占用的标识符是“Server”,服务器会使用它来发送连接状态消息。
在服务器文件夹中,有一个简单的JavaScript脚本 access.js
用于生成访问密钥。在命令行中运行该脚本时,需要将上述类型的字符串 PUB_ID:PUB_KEY:SUB_ID
(标识符和密钥之间用 :
符号连接)作为唯一参数传递给它。
如果未指定参数,脚本会为一些演示标识符(PUB_ID_001
、SUB_ID_100
)和密钥(PUB_KEY_FFF
)生成访问密钥。
javascript
// JavaScript
const args = process.argv.slice(2);
const input = args.length > 0 ? args[0] : 'PUB_ID_001:PUB_KEY_FFF:SUB_ID_100';
console.log('Hashing "', input, '"');
const crypto = require('crypto');
console.log(crypto.createHash('sha256').update(input).digest('hex'));
使用以下命令运行脚本:
bash
node access.js PUB_ID_001:PUB_KEY_FFF:SUB_ID_100
我们会得到如下结果:
fd3f7a105eae8c2d9afce0a7a4e11bf267a40f04b7c216dd01cf78c7165a2a5a
顺便提一下,你可以使用MQL5中的 CryptEncode
函数来验证并重复这个算法。
在分析完概念部分后,让我们开始进行实际的实现。
信号服务的服务器脚本将放置在文件 MQL5/Experts/MQL5Book/p7/Web/wspubsub.js
中。其中服务器的设置与我们之前所做的相同。不过,还需要引入在 access.js
中使用过的“crypto”模块。主页将命名为 wspubsub.htm
。
javascript
// JavaScript
const crypto = require('crypto');
...
http1.createServer(options, function (req, res)
{
...
if(req.url == '/')
{
req.url = "wspubsub.htm";
}
...
});
我们将定义两个映射,分别用于信号提供者和消费者,而不是使用一个连接客户端的映射。
javascript
// JavaScript
const publishers = new Map();
const subscribers = new Map();
在这两个映射中,键都是提供者ID,但第一个映射存储的是提供者对象,第二个映射存储的是订阅每个提供者的订阅者对象(对象数组)。
在握手过程中传递标识符和密钥时,我们将使用WebSocket规范允许的一个特殊头部,即 Sec-Websocket-Protocol
。我们约定,标识符和密钥将用 -
符号连接:对于提供者,期望的字符串格式为 X-MQL5-publisher-PUB_ID-PUB_KEY
;对于订阅者,期望的字符串格式为 X-MQL5-subscriber-SUB_ID-PUB_ID-SUB_KEY
。
任何未携带 Sec-Websocket-Protocol: X-MQL5-...
头部的连接尝试,都将被立即终止。
在新的客户端对象(onConnect(client)
事件处理函数的参数中)中,可以很容易地从 client.protocol
属性中提取这个头部信息。
下面我们以简化形式展示信号提供者的注册和消息发送过程,暂不考虑错误处理(完整代码附后)。需要注意的是,消息文本以JSON格式生成(我们将在下一节详细讨论)。具体来说,消息的发送者会在 origin
属性中传递(此外,当消息由服务本身发送时,该字段包含字符串“Server”),提供者的应用数据会放在 msg
属性中,这些数据可能不仅仅是文本,还可能是任意内容的嵌套结构。
javascript
// JavaScript
const wsServer = new WebSocket.Server({ server });
wsServer.on('connection', function onConnect(client)
{
console.log('New user:', ++count, client.protocol);
if(client.protocol.startsWith('X-MQL5-publisher'))
{
const parts = client.protocol.split('-');
client.id = parts[3];
client.key = parts[4];
publishers.set(client.id, client);
client.send('{"origin":"Server", "msg":"Hello, publisher ' + client.id + '"}');
client.on('message', function(message)
{
console.log('%s : %s', client.id, message);
if(subscribers.get(client.id))
subscribers.get(client.id).forEach(function(elem)
{
elem.send('{"origin":"publisher ' + client.id + '", "msg":'
+ message + '}');
});
});
client.on('close', function()
{
console.log('Publisher disconnected:', client.id);
if(subscribers.get(client.id))
subscribers.get(client.id).forEach(function(elem)
{
elem.close();
});
publishers.delete(client.id);
});
}
...
订阅者的算法有一半与之类似,但在此基础上,还需要计算访问密钥并将其与连接客户端传递的密钥进行比较。
javascript
// JavaScript
else if(client.protocol.startsWith('X-MQL5-subscriber'))
{
const parts = client.protocol.split('-');
client.id = parts[3];
client.pub_id = parts[4];
client.access = parts[5];
const id = client.pub_id;
var p = publishers.get(id);
if(p)
{
const check = crypto.createHash('sha256').update(id + ':' + p.key + ':'
+ client.id).digest('hex');
if(check != client.access)
{
console.log(`Bad credentials: '${client.access}' vs '${check}'`);
client.send('{"origin":"Server", "msg":"Bad credentials, subscriber '
+ client.id + '"}');
client.close();
return;
}
var list = subscribers.get(id);
if(list == undefined)
{
list = [];
}
list.push(client);
subscribers.set(id, list);
client.send('{"origin":"Server", "msg":"Hello, subscriber '
+ client.id + '"}');
p.send('{"origin":"Server", "msg":"New subscriber ' + client.id + '"}');
}
client.on('close', function()
{
console.log('Subscriber disconnected:', client.id);
const list = subscribers.get(client.pub_id);
if(list)
{
if(list.length > 1)
{
const filtered = list.filter(function(el) { return el !== client; });
subscribers.set(client.pub_id, filtered);
}
else
{
subscribers.delete(client.pub_id);
}
}
});
}
客户端页面 wspubsub.htm
上的用户界面只是提供了一个链接,引导用户前往提供者表单页面(wspublisher.htm
+ wspublisher_client.js
)或订阅者表单页面(wssubscriber.htm
+ wssubscriber_client.js
)。
信号服务测试客户端的网页
信号服务测试客户端的网页
这些页面的实现继承了之前所讨论的JavaScript客户端的特点,但在 Sec-Websocket-Protocol: X-MQL5-
头部的定制方面还有一个细微差别。
到目前为止,我们交换的都是简单的文本消息。但对于信号服务而言,需要传输大量的结构化信息,而JSON更适合这种需求。因此,客户端可以解析JSON,尽管它们并未将其用于实际的交易操作,因为即使在JSON中发现了以特定数量买卖特定股票代码的指令,浏览器也不知道如何执行这些操作。
我们需要在MQL5的信号服务客户端中添加对JSON的支持。同时,你可以运行服务器脚本 wspubsub.js
,并根据提供者和消费者指定的详细信息测试信号的选择性连接。我们建议你亲自尝试一下,这对你会有帮助。
MQL5中的信号服务客户端程序
根据我们的决定,服务消息中的文本将采用JSON格式。
在最常见的形式中,JSON是对对象的文本描述,类似于MQL5中对结构体的描述方式。对象被包含在花括号内,在花括号中,对象的属性以逗号分隔书写:每个属性都有一个带引号的标识符,后面跟着一个冒号和属性值。这里支持几种基本类型的属性:字符串、整数和实数、布尔值true/false以及空值null。此外,属性值本身可以是一个对象或一个数组。数组使用方括号进行描述,在方括号内元素之间用逗号分隔。例如:
json
{
"string": "this is a text",
"number": 0.1,
"integer": 789735095,
"enabled": true,
"subobject" :
{
"option": null
},
"array":
[
1, 2, 3, 5, 8
]
}
基本上,顶级的数组也是有效的JSON。例如:
json
[
{
"command": "buy",
"volume": 0.1,
"symbol": "EURUSD",
"price": 1.0
},
{
"command": "sell",
"volume": 0.01,
"symbol": "GBPUSD",
"price": 1.5
}
]
为了减少使用JSON的应用协议中的流量,通常会将字段名缩写为几个字母(通常是一个字母)。
属性名和字符串值都要用双引号括起来。如果要在字符串中指定一个引号,则必须用反斜杠进行转义。
JSON的使用使协议具有通用性和可扩展性。例如,对于正在设计的服务(交易信号,更一般地说,账户状态复制),可以假设以下消息结构:
json
{
"origin": "publisher_id", // 消息发送者(技术消息中为“Server”)
"msg" : // 从发送者处收到的消息(文本或JSON)
{
"trade" : // 当前交易指令(如果有信号)
{
"operation": ..., // 买入/卖出/平仓
"symbol": "ticker",
"volume": 0.1,
... // 其他信号参数
},
"account": // 账户状态
{
"positions": // 持仓
{
"n": 10, // 未平仓持仓数量
[ { ... },{ ... } ] // 未平仓持仓属性数组
},
"pending_orders": // 挂单
{
"n": ...
[ { ... } ]
}
"drawdown": 2.56,
"margin_level": 12345,
... // 其他状态参数
},
"hardware": // 对电脑“健康状况”的远程控制
{
"memory": ...,
"ping_to_broker": ...
}
}
}
这些功能中的某些部分,客户端程序的特定实现可能支持,也可能不支持(它们不 “理解” 的所有内容,都会被简单地忽略)。此外,在同一层级的属性名不冲突的条件下,每个信息提供者都可以向JSON添加自己特定的数据。消息服务会简单地转发这些信息。当然,接收端的程序必须能够解释这些特定的数据。
本书附带了一个名为ToyJson的JSON解析器(“简易” JSON,文件toyjson.mqh),它体积小且效率不高,并不支持该格式规范的全部功能(例如,在转义序列的处理方面)。它是专门为这个演示服务编写的,针对预期的、不是非常复杂的交易信号信息结构进行了调整。我们在这里不会详细描述它,其使用原理将从信号服务的MQL客户端的源代码中变得清晰明了。
对于你的项目以及这个项目的进一步开发,你可以选择mql5.com网站代码库中可用的其他JSON解析器。
ToyJson中的每个元素(容器或属性)都由JsValue类对象描述。定义了几种put(key, value)方法的重载形式,可用于像在JSON对象中那样添加命名的内部属性,或者使用put(value)方法,像在JSON数组中那样添加一个值。此外,这个对象还可以表示基本类型的单个值。要读取JSON对象的属性,可以对JsValue应用运算符[]的表示法,后面跟着括号中所需的属性名。显然,对于访问JSON数组内部,支持使用整数索引。
在形成了相关的JsValue对象的所需配置后,可以使用stringify(string&buffer)方法将其序列化为JSON文本。
toyjson.mqh中的第二个类——JsParser——允许执行相反的操作:将带有JSON描述的文本转换为JsValue对象的层次结构。
考虑到用于处理JSON的类,让我们开始编写一个MQL5的智能交易系统(Expert Advisor)/MQL5/Experts/MQL5Book/p7/wsTradeCopier/wstradecopier.mq5,它将能够在交易复制服务中扮演两种角色:账户上已执行交易的信息提供者,或者从服务接收此信息以重现这些交易的接收者。
从策略角度来看,发送信息的数量和内容由信息提供者自行决定,并且可能会根据使用该服务的场景(目的)而有很大差异。特别是,有可能只复制正在进行的交易,或者复制整个账户余额以及挂单和保护止损水平。在我们的示例中,我们将仅指出信息传输的技术实现,然后你可以自行选择特定的对象和属性集合。
在代码中,我们将描述3个从内置结构体继承而来的结构体,它们提供了JSON格式的信息 “打包” 功能:
- MqlTradeRequestWeb — 继承自MqlTradeRequest
- MqlTradeResultWeb — 继承自MqlTradeResult
- DealMonitorWeb — 基于DealMonitor*定义
严格来说,列表中的最后一个结构体不是内置的,而是我们在文件DealMonitor.mqh中定义的,不过它是根据标准的交易属性集来填充的。
每个派生结构体的构造函数都根据传入的原始数据源(交易请求、其结果或交易)来填充字段。每个结构体都实现了asJsValue方法,该方法返回一个指向JsValue对象的指针,该对象反映了结构体的所有属性:使用JsValue::put方法将这些属性添加到JSON对象中。例如,在MqlTradeRequest的情况下是这样实现的:
cpp
struct MqlTradeRequestWeb: public MqlTradeRequest
{
MqlTradeRequestWeb(const MqlTradeRequest &r)
{
ZeroMemory(this);
action = r.action;
magic = r.magic;
order = r.order;
symbol = r.symbol;
volume = r.volume;
price = r.price;
stoplimit = r.stoplimit;
sl = r.sl;
tp = r.tp;
type = r.type;
type_filling = r.type_filling;
type_time = r.type_time;
expiration = r.expiration;
comment = r.comment;
position = r.position;
position_by = r.position_by;
}
JsValue *asJsValue() const
{
JsValue *req = new JsValue();
// 主要部分:操作类型、交易品种、订单类型
req.put("a", VerboseJson ? EnumToString(action) : (string)action);
if(StringLen(symbol) != 0) req.put("s", symbol);
req.put("t", VerboseJson ? EnumToString(type) : (string)type);
// 交易量
if(volume != 0) req.put("v", TU::StringOf(volume));
req.put("f", VerboseJson ? EnumToString(type_filling) : (string)type_filling);
// 价格部分
if(price != 0) req.put("p", TU::StringOf(price));
if(stoplimit != 0) req.put("x", TU::StringOf(stoplimit));
if(sl != 0) req.put("sl", TU::StringOf(sl));
if(tp != 0) req.put("tp", TU::StringOf(tp));
// 挂单部分
if(TU::IsPendingType(type))
{
req.put("t", VerboseJson ? EnumToString(type_time) : (string)type_time);
if(expiration != 0) req.put("d", TimeToString(expiration));
}
// 修改部分
if(order != 0) req.put("o", order);
if(position != 0) req.put("q", position);
if(position_by != 0) req.put("b", position_by);
// 辅助部分
if(magic != 0) req.put("m", magic);
if(StringLen(comment)) req.put("c", comment);
return req;
}
};
我们将所有属性都转换为JSON(这适用于账户监控服务),但你也可以只保留有限的一组属性。
对于枚举类型的属性,我们提供了两种在JSON中表示它们的方式:作为整数,或者作为枚举元素的字符串名称。通过输入参数VerboseJson来选择表示方法(理想情况下,应该在结构体代码中不是直接设置,而是通过构造函数参数来设置)。
cpp
input bool VerboseJson = false;
只传递数字会简化编码,因为在接收端,只需将它们转换为所需的枚举类型,就可以执行 “镜像” 操作。然而,数字会让人难以理解信息,并且在分析情况(消息)时可能会带来困难。因此,支持字符串表示的选项是有意义的,因为它更 “友好”,尽管这需要在接收算法中进行额外的操作。
输入参数还分别为信息提供者和订阅者指定了服务器地址、应用程序角色以及连接详细信息。
cpp
enum TRADE_ROLE
{
TRADE_PUBLISHER, // 交易信息发布者
TRADE_SUBSCRIBER // 交易信息订阅者
};
input string Server = "ws://localhost:9000/";
input TRADE_ROLE Role = TRADE_PUBLISHER;
input bool VerboseJson = false;
input group "Publisher";
input string PublisherID = "PUB_ID_001";
input string PublisherPrivateKey = "PUB_KEY_FFF";
input string SymbolFilter = ""; // 交易品种过滤器(空值 - 当前品种,'*' - 任意品种)
input ulong MagicFilter = 0; // 魔术数字过滤器(0 - 任意)
input group "Subscriber";
input string SubscriberID = "SUB_ID_100";
input string SubscribeToPublisherID = "PUB_ID_001";
input string SubscriberAccessKey = "fd3f7a105eae8c2d9afce0a7a4e11bf267a40f04b7c216dd01cf78c7165a2a5a";
input string SymbolSubstitute = "EURUSD=GBPUSD"; // 交易品种替换(<原品种>=<目标品种>,...)
input ulong SubscriberMagic = 0;
信息提供者组中的参数SymbolFilter和MagicFilter允许将监控的交易活动限制在给定的交易品种和魔术数字范围内。SymbolFilter中的空值表示仅监控图表的当前交易品种,要拦截任何交易,则输入交易品种为'*'。信号提供者将使用FilterMatched函数来实现此目的,该函数接受交易的交易品种和魔术数字作为参数。
cpp
bool FilterMatched(const string s, const ulong m)
{
if(MagicFilter != 0 && MagicFilter != m)
{
return false;
}
if(StringLen(SymbolFilter) == 0)
{
if(s != _Symbol)
{
return false;
}
}
else if(SymbolFilter != s && SymbolFilter != "*")
{
return false;
}
return true;
}
订阅者输入组中的SymbolSubstitute参数允许将消息中收到的交易品种替换为另一个交易品种,该品种将用于跟单交易。如果不同经纪商之间相同金融工具的交易品种名称不同,此功能将非常有用。但此参数也起到了允许重复信号的过滤器的作用:只有在此处指定的交易品种才会进行交易。例如,要允许对EURUSD交易品种进行信号交易(即使不进行交易品种替换),需要在该参数中设置字符串“EURUSD=EURUSD”。信号消息中的交易品种在'='符号的左侧,用于交易的交易品种在'='符号的右侧。
在初始化期间,字符替换列表由FillSubstitutes函数处理,然后由FindSubstitute函数用于替换和确定交易。
cpp
string Substitutes[][2];
void FillSubstitutes()
{
string list[];
const int n = StringSplit(SymbolSubstitute, ',', list);
ArrayResize(Substitutes, n);
for(int i = 0; i < n; ++i)
{
string pair[];
if(StringSplit(list[i], '=', pair) == 2)
{
Substitutes[i][0] = pair[0];
Substitutes[i][1] = pair[1];
}
else
{
Print("Wrong substitute: ", list[i]);
}
}
}
string FindSubstitute(const string s)
{
for(int i = 0; i < ArrayRange(Substitutes, 0); ++i)
{
if(Substitutes[i][0] == s) return Substitutes[i][1];
}
return NULL;
}
为了与服务进行通信,我们定义了一个从WebSocketClient派生的类。首先,在收到消息时,需要在onMessage处理程序中根据信号启动交易。在我们考虑了信息提供者端的信号生成和发送之后,稍后会再回到这个问题。
cpp
class MyWebSocket: public WebSocketClient<Hybi>
{
public:
MyWebSocket(const string address): WebSocketClient(address) { }
void onMessage(IWebSocketMessage *msg) override
{
...
}
};
MyWebSocket wss(Server);
在OnInit函数中的初始化操作会启动定时器(用于定期调用wss.checkMessages(false)),并根据所选角色准备包含用户详细信息的自定义标头。然后,我们使用wss.open(custom)调用打开连接。
cpp
int OnInit()
{
FillSubstitutes();
EventSetTimer(1);
wss.setTimeOut(1000);
Print("Opening...");
string custom;
if(Role == TRADE_PUBLISHER)
{
custom = "Sec-Websocket-Protocol: X-MQL5-publisher-"
+ PublisherID + "-" + PublisherPrivateKey + "\r\n";
}
else
{
custom = "Sec-Websocket-Protocol: X-MQL5-subscriber-"
+ SubscriberID + "-" + SubscribeToPublisherID
+ "-" + SubscriberAccessKey + "\r\n";
}
return wss.open(custom) ? INIT_SUCCEEDED : INIT_FAILED;
}
复制机制,即拦截交易并将有关交易的信息发送到Web服务,在OnTradeTransaction处理程序中启动。如我们所知,这不是唯一的方法,也可以在OnTrade中分析账户状态的 “快照”。
cpp
void OnTradeTransaction(const MqlTradeTransaction &transaction,
const MqlTradeRequest &request,
const MqlTradeResult &result)
{
if(transaction.type == TRADE_TRANSACTION_REQUEST)
{
Print(TU::StringOf(request));
Print(TU::StringOf(result));
if(result.retcode == TRADE_RETCODE_PLACED // 操作成功
|| result.retcode == TRADE_RETCODE_DONE
|| result.retcode == TRADE_RETCODE_DONE_PARTIAL)
{
if(FilterMatched(request.symbol, request.magic))
{
... // 见下一段代码
}
}
}
}
我们跟踪满足指定过滤器条件的成功完成的交易请求事件。接下来,将交易请求结构体、请求结果结构体和交易结构体转换为JSON对象。所有这些对象都被放置在一个名为msg的公共容器中,分别以“req”、“res”和“deal”为键名。请记住,容器本身将作为“msg”属性包含在Web服务消息中。
cpp
// 要附加到服务消息的容器将作为“msg”属性可见:
// {"origin" : "this_publisher_id", "msg" : { 我们的数据在这里 }}
JsValue msg;
MqlTradeRequestWeb req(request);
msg.put("req", req.asJsValue());
MqlTradeResultWeb res(result);
msg.put("res", res.asJsValue());
if(result.deal != 0)
{
DealMonitorWeb deal(result.deal);
msg.put("deal", deal.asJsValue());
}
ulong tickets[];
Positions.select(tickets);
JsValue pos;
pos.put("n", ArraySize(tickets));
msg.put("pos", &pos);
string buffer;
msg.stringify(buffer);
Print(buffer);
wss.send(buffer);
一旦填充完成,容器将作为字符串输出到buffer中,打印到日志中,并发送到服务器。
我们可以向这个容器中添加其他信息:账户状态(回撤、负载)、挂单的数量和属性等等。所以,只是为了展示扩展消息内容的可能性,我们在上面添加了未平仓头寸的数量。为了根据过滤器选择头寸,我们使用了PositionFilter类对象(PositionFilter.mqh):
cpp
PositionFilter Positions;
int OnInit()
{
...
if(MagicFilter) Positions.let(POSITION_MAGIC, MagicFilter);
if(SymbolFilter == "") Positions.let(POSITION_SYMBOL, _Symbol);
else if(SymbolFilter != "*") Positions.let(POSITION_SYMBOL, SymbolFilter);
...
}
基本上,为了提高可靠性,交易复制器有必要分析头寸状态,而不仅仅是拦截交易。
关于智能交易系统作为信号提供者这部分的内容就讲解到这里。
正如我们之前提到的,作为订阅者,智能交易系统在MyWebSocket::onMessage方法中接收消息。在这里,接收到的消息会通过JsParser::jsonify进行解析,并且从obj["msg"]属性中获取由发送端形成的容器。
cpp
class MyWebSocket: public WebSocketClient<Hybi>
{
public:
void onMessage(IWebSocketMessage *msg) override
{
Alert(msg.getString());
JsValue *obj = JsParser::jsonify(msg.getString());
if(obj && obj["msg"])
{
obj["msg"].print();
if(!RemoteTrade(obj["msg"])) { /* 错误处理 */ }
delete obj;
}
delete msg;
}
};
RemoteTrade函数实现了信号分析和交易操作。这里给出的是简化版本,没有处理潜在的错误。该函数支持枚举的两种表示方式:作为整数值或作为字符串形式的元素名称。通过应用运算符[],对传入的JSON对象进行 “检查”,以查找必要的属性(命令和信号属性),包括连续多次应用(以访问嵌套的JSON对象)。
cpp
bool RemoteTrade(JsValue *obj)
{
bool success = false;
if(obj["req"]["a"] == TRADE_ACTION_DEAL
|| obj["req"]["a"] == "TRADE_ACTION_DEAL")
{
const string symbol = FindSubstitute(obj["req"]["s"].s);
if(symbol == NULL)
{
Print("Suitable symbol not found for ", obj["req"]["s"].s);
return false; // 未找到或被禁止
}
JsValue *pType = obj["req"]["t"];
if(pType == ORDER_TYPE_BUY || pType == ORDER_TYPE_SELL
|| pType == "ORDER_TYPE_BUY" || pType == "ORDER_TYPE_SELL")
{
ENUM_ORDER_TYPE type;
if(pType.detect() >= JS_STRING)
{
if(pType == "ORDER_TYPE_BUY") type = ORDER_TYPE_BUY;
else type = ORDER_TYPE_SELL;
}
else
{
type = obj["req"]["t"].get<ENUM_ORDER_TYPE>();
}
MqlTradeRequestSync request;
request.deviation = 10;
request.magic = SubscriberMagic;
request.type = type;
const double lot = obj["req"]["v"].get<double>();
JsValue *pDir = obj["deal"]["entry"];
if(pDir == DEAL_ENTRY_IN || pDir == "DEAL_ENTRY_IN")
{
success = request._market(symbol, lot) && request.completed();
Alert(StringFormat("Trade by subscription: market entry %s %s %s - %s",
EnumToString(type), TU::StringOf(lot), symbol,
success ? "Successful" : "Failed"));
}
else if(pDir == DEAL_ENTRY_OUT || pDir == "DEAL_ENTRY_OUT")
{
// 平仓操作假定存在合适的头寸,查找它
PositionFilter filter;
int props[] = {POSITION_TICKET, POSITION_TYPE, POSITION_VOLUME};
Tuple3<long,long,double> values[];
filter.let(POSITION_SYMBOL, symbol).let(POSITION_MAGIC,
SubscriberMagic).select(props, values);
for(int i = 0; i < ArraySize(values); ++i)
{
// 需要一个与交易方向相反的头寸
if(!TU::IsSameType((ENUM_ORDER_TYPE)values[i]._2, type))
{
// 需要有足够的交易量(这里要求完全相等!)
if(TU::Equal(values[i]._3, lot))
{
success = request.close(values[i]._1, lot) && request.completed();
Alert(StringFormat("Trade by subscription: market exit %s %s %s - %s",
EnumToString(type), TU::StringOf(lot), symbol,
success ? "Successful" : "Failed"));
}
}
}
if(!success)
{
Print("No suitable position to close");
}
}
}
}
return success;
}
这个实现没有分析交易价格、对交易手数可能的限制、止损水平以及其他方面。我们只是在当前本地价格下重复交易。此外,在平仓时,会检查交易量是否完全相等,这适用于对冲账户,但不适用于轧差账户,在轧差账户中,如果交易的交易量小于头寸(或者在反转情况下可能更大,但这里不支持DEAL_ENTRY_INOUT选项),则可能会进行部分平仓。所有这些要点在实际应用中都应该进一步完善。
让我们启动服务器node.exe wspubsub.js,并在同一终端的不同图表上运行两份智能交易系统wstradecopier.mq5。通常的场景是需要在不同的账户上启动智能交易系统,但一种 “矛盾” 的选项也适合用于检查性能:我们将把一个交易品种的信号复制到另一个交易品种上。
在一份智能交易系统中,我们保留默认设置,角色为信息提供者。它应该放置在EURUSD图表上。在运行在GBPUSD图表上的第二份智能交易系统中,我们将角色更改为订阅者。输入参数SymbolSubstitute中的字符串“EURUSD=GBPUSD”允许根据EURUSD的信号对GBPUSD进行交易。
连接数据将被记录到日志中,包括我们已经见过的HTTP标头和问候信息,所以我们将省略这些内容。
让我们买入EURUSD,并确保在GBPUSD上以相同的交易量 “复制” 了该交易。
以下是日志的片段(请记住,由于两个智能交易系统都在同一终端实例中运行,交易消息将被发送到两个图表,因此,为了便于分析日志,你可以交替设置过滤器“EURUSD”和“GBPUSD”):
(EURUSD,H1) TRADE_ACTION_DEAL, EURUSD, ORDER_TYPE_BUY, V=0.01, ORDER_FILLING_FOK, @ 0.99886, #=1461313378
(EURUSD,H1) DONE, D=1439023682, #=1461313378, V=0.01, @ 0.99886, Bid=0.99886, Ask=0.99886, Req=2
(EURUSD,H1) {"req" : {"a" : "TRADE_ACTION_DEAL", "s" : "EURUSD", "t" : "ORDER_TYPE_BUY", "v" : 0.01,
» "f" : "ORDER_FILLING_FOK", "p" : 0.99886, "o" : 1461313378}, "res" : {"code" : 10009, "d" : 1439023682,
» "o" : 1461313378, "v" : 0.01, "p" : 0.99886, "b" : 0.99886, "a" : 0.99886}, "deal" : {"d" : 1439023682,
» "o" : 1461313378, "t" : "2022.09.19 16:45:50", "tmsc" : 1663605950086, "type" : "DEAL_TYPE_BUY",
» "entry" : "DEAL_ENTRY_IN", "pid" : 1461313378, "r" : "DEAL_REASON_CLIENT", "v" : 0.01, "p" : 0.99886,
» "s" : "EURUSD"}, "pos" : {"n" : 1}}
这显示了已执行请求的内容及其结果,以及发送到服务器的带有JSON字符串的缓冲区。
几乎在瞬间,在接收端,即GBPUSD图表上,会显示一个警报,其中包含来自服务器的 “原始” 形式的消息,以及在JsParser中成功解析后的格式化消息。在 “原始” 形式中,存储着“origin”属性,服务器通过该属性让我们知道信号的来源。
(GBPUSD,H1) Alert: {"origin":"publisher PUB_ID_001", "msg":{"req" : {"a" : "TRADE_ACTION_DEAL",
» "s" : "EURUSD", "t" : "ORDER_TYPE_BUY", "v" : 0.01, "f" : "ORDER_FILLING_FOK", "p" : 0.99886,
» "o" : 1461313378}, "res" : {"code" : 10009, "d" : 1439023682, "o" : 1461313378, "v" : 0.01,
» "p" : 0.99886, "b" : 0.99886, "a" : 0.99886}, "deal" : {"d" : 1439023682, "o" : 1461313378,
» "t" : "2022.09.19 16:45:50", "tmsc" : 1663605950086, "type" : "DEAL_TYPE_BUY",
» "entry" : "DEAL_ENTRY_IN", "pid" : 1461313378, "r" : "DEAL_REASON_CLIENT", "v" : 0.01,
» "p" : 0.99886, "s" : "EURUSD"}, "pos" : {"n" : 1}}}
(GBPUSD,H1) {
(GBPUSD,H1) req =
(GBPUSD,H1) {
(GBPUSD,H1) a = TRADE_ACTION_DEAL
(GBPUSD,H1) s = EURUSD
(GBPUSD,H1) t = ORDER_TYPE_BUY
(GBPUSD,H1) v = 0.01
(GBPUSD,H1) f = ORDER_FILLING_FOK
(GBPUSD,H1) p = 0.99886
(GBPUSD,H1) o = 1461313378
(GBPUSD,H1) }
(GBPUSD,H1) res =
(GBPUSD,H1) {
(GBPUSD,H1) code = 10009
(GBPUSD,H1) d = 1439023682
(GBPUSD,H1) o = 1461313378
(GBPUSD,H1) v = 0.01
(GBPUSD,H1) p = 0.99886
(GBPUSD,H1) b = 0.99886
(GBPUSD,H1) a = 0.99886
(GBPUSD,H1) }
(GBPUSD,H1) deal =
(GBPUSD,H1) {
(GBPUSD,H1) d = 1439023682
(GBPUSD,H1) o = 1461313378
(GBPUSD,H1) t = 2022.09.19 16:45:50
(GBPUSD,H1) tmsc = 1663605950086
(GBPUSD,H1) type = DEAL_TYPE_BUY
(GBPUSD,H1) entry = DEAL_ENTRY_IN
(GBPUSD,H1) pid = 1461313378
(GBPUSD,H1) r = DEAL_REASON_CLIENT
(GBPUSD,H1) v = 0.01
(GBPUSD,H1) p = 0.99886
(GBPUSD,H1) s = EURUSD
(GBPUSD,H1) }
(GBPUSD,H1) pos =
(GBPUSD,H1) {
(GBPUSD,H1) n = 1
(GBPUSD,H1) }
(GBPUSD,H1) }
(GBPUSD,H1) Alert: Trade by subscription: market entry ORDER_TYPE_BUY 0.01 GBPUSD - Successful
上述条目中的最后一条表明在GBPUSD上的交易成功。在账户的交易选项卡上,应该会显示2个头寸。
一段时间后,我们平仓EURUSD头寸,GBPUSD头寸应该会自动平仓。
(EURUSD,H1) TRADE_ACTION_DEAL, EURUSD, ORDER_TYPE_SELL, V=0.01, ORDER_FILLING_FOK, @ 0.99881, #=1461315206, P=1461313378
(EURUSD,H1) DONE, D=1439025490, #=1461315206, V=0.01, @ 0.99881, Bid=0.99881, Ask=0.99881, Req=4
(EURUSD,H1) {"req" : {"a" : "TRADE_ACTION_DEAL", "s" : "EURUSD", "t" : "ORDER_TYPE_SELL", "v" : 0.01,
» "f" : "ORDER_FILLING_FOK", "p" : 0.99881, "o" : 1461315206, "q" : 1461313378}, "res" : {"code" : 10009,
» "d" : 1439025490, "o" : 1461315206, "v" : 0.01, "p" : 0.99881, "b" : 0.99881, "a" : 0.99881},
» "deal" : {"d" : 1439025490, "o" : 1461315206, "t" : "2022.09.19 16:46:52", "tmsc" : 1663606012990,
» "type" : "DEAL_TYPE_SELL", "entry" : "DEAL_ENTRY_OUT", "pid" : 1461313378, "r" : "DEAL_REASON_CLIENT",
» "v" : 0.01, "p" : 0.99881, "m" : -0.05, "s" : "EURUSD"}, "pos" : {"n" : 0}}
如果第一次交易的类型是DEAL_ENTRY_IN,现在则是DEAL_ENTRY_OUT。警报确认收到了消息并且成功平仓了复制的头寸。
(GBPUSD,H1) Alert: {"origin":"publisher PUB_ID_001", "msg":{"req" : {"a" : "TRADE_ACTION_DEAL",
» "s" : "EURUSD", "t" : "ORDER_TYPE_SELL", "v" : 0.01, "f" : "ORDER_FILLING_FOK", "p" : 0.99881,
» "o" : 1461315206, "q" : 1461313378}, "res" : {"code" : 10009, "d" : 1439025490, "o" : 1461315206,
» "v" : 0.01, "p" : 0.99881, "b" : 0.99881, "a" : 0.99881}, "deal" : {"d" : 1439025490,
» "o" : 1461315206, "t" : "2022.09.19 16:46:52", "tmsc" : 1663606012990, "type" : "DEAL_TYPE_SELL",
» "entry" : "DEAL_ENTRY_OUT", "pid" : 1461313378, "r" : "DEAL_REASON_CLIENT", "v" : 0.01,
» "p" : 0.99881, "m" : -0.05, "s" : "EURUSD"}, "pos" : {"n" : 0}}}
...
(GBPUSD,H1) Alert: Trade by subscription: market exit ORDER_TYPE_SELL 0.01 GBPUSD - Successful
最后,在智能交易系统wstradecopier.mq5旁边,我们创建一个项目文件wstradecopier.mqproj,以便向其中添加描述和必要的服务器文件(在旧目录MQL5/Experts/p7/MQL5Book/Web/中)。
总结一下:我们已经通过套接字服务器组织了一个技术上可扩展的、多用户的交易信息交换系统。由于 WebSocket 的技术特性(永久打开的连接),这种信号服务的实现更适合短期和高频交易,以及控制报价中的套利情况。
解决这个问题需要将不同平台上的多个程序组合起来,并连接大量的依赖项,这通常是项目层面的特征。开发环境也得到了扩展,超出了编译器和源代码编辑器的范畴。特别是,项目中客户端或服务器部分的存在通常意味着由不同的程序员负责它们的工作。在这种情况下,云中的共享项目和版本控制就变得不可或缺了。
请注意,当通过MetaEditor在MQL5/Shared Projects文件夹中开发项目时,标准目录MQL5/Include中的头文件不会包含在共享存储中。另一方面,在你的项目中创建一个专用的Include文件夹并将必要的标准.mqh文件转移到其中,会导致信息重复以及头文件版本可能出现差异。MetaEditor中的这种情况可能会在未来得到改善。
对于公开项目的另一个要点是需要管理用户并对他们进行授权。在我们的最后一个示例中,只是提出了这个问题,但没有实现。然而,mql5.com网站提供了一个基于著名的OAuth协议的现成解决方案。任何拥有mql5.com账户的人都可以了解OAuth的原理,并为他们的Web服务进行配置:只需在你的个人资料中找到“Applications”(应用程序)部分(链接类似于https://www.mql5.com/en/users/<login> /apps
)。通过在mql5.com应用程序中注册Web服务,你将能够通过mql5.com网站对用户进行授权。