基于 WebSocket 协议的 Web 服务的服务器组件

为了组织所有项目的公共服务器组件,我们将在 MQL5/Experts/MQL5Book/p7/ 内部创建一个单独的 Web 文件夹。理想情况下,将 Web 作为子文件夹放在 Shared Projects 共享项目中会很方便。事实是,MetaTrader 5 的标准发行版中提供了 MQL5/Shared Projects,并被保留用于云存储项目。因此,以后,通过使用共享项目的功能,将有可能把我们项目的所有文件上传到服务器(不仅仅是 Web 文件,还有 MQL 程序)。

稍后,当我们使用 MQL5 客户端程序创建 mqproj 文件时,会将该文件夹中的所有文件添加到项目的 Settings and Files 部分,因为所有这些文件构成了项目的组成部分,即服务器部分。

由于已经为项目服务器分配了一个单独的目录,因此必须确保可以从该目录中的 nodejs 导入模块。默认情况下,nodejs 在当前目录的 /node_modules 子文件夹中查找模块,我们将从项目中运行服务器。因此,在我们将放置该项目的 Web 文件的文件夹中,运行命令:

mklink /j node_modules {drive:/path/to/folder/nodejs}/node_modules

其结果是,将出现一个名为 node_modules 的“符号”目录链接,指向已安装的 nodejs 中同名的原始文件夹。

要检查 WebSockets 功能,最简单方法是使用回显服务。它的操作模型是将任何接收到的消息返回给发送者。让我们考虑如何以最小的配置来组织这样的服务。wsintro.js 文件中包含了一个例子。

首先,我们连接 ws 包(模块),它为 nodejs 提供 WebSocket 功能,这个包是我们随 Web 服务器一起安装的。

// JavaScript
const WebSocket = require('ws');

require 函数的工作方式类似于 MQL5 中的 #include 指令,但会另外返回一个模块对象,其中包含 ws 包中所有文件的 API。得益于此,我们可以调用 WebSocket 对象的方法和特性。在这种情况下,我们需要在端口 9000 上创建一个 WebSocket 服务器。

// JavaScript
const port = 9000;
const wss = new WebSocket.Server({ portport });

这里我们看到 new 运算符调用了常见的 MQL5 构造函数,但是一个未命名的对象(结构)被作为参数传递,在这个参数中,像在映射中一样,可以存储一组命名特性及它们值。在这种情况下,只使用一个 port 特性,它的值被设置为等于上述 port 变量(更准确地说,是一个常量)。基本上,我们可以在运行脚本时在命令行中传递端口号(以及其他设置)。

服务器对象进入 wss 变量。成功后,我们向命令行窗口发出信号,表明服务器正在运行(等待连接)。

// JavaScript
console.log('listening on port: ' + port);

console.log 调用类似于 MQL5 中通常 Print。还要注意,JavaScript 中的字符串不仅可以用双引号,还可以用单引号括起来,甚至可以用反斜杠 `this is a ${template}text`,这增加了一些有用的功能。

随后,对于 wss 对象,我们分配一个“连接”事件处理程序,它指向一个新的客户端连接。显然,支持对象事件列表是由包的开发者定义的,在这种情况下,就是我们使用 ws 包。所有这些都反映在文档中。

处理程序由 on 方法绑定,该方法指定事件的名称和处理程序。

// JavaScript
wss.on('connection', function(channel)
{
   ...
});

处理程序是一个未命名(匿名)函数,直接定义在回调代码在新连接上执行时需要引用参数的地方。这个函数是匿名的,因为它只在这里使用,JavaScript 允许这种语法简化。该函数只有一个参数,即新连接的对象。我们可以自己选择参数的名称,在本例中是 channel

在处理程序内部,应该为与特定通道中新消息的到达相关的“消息”事件设置另一个处理程序。

// JavaScript
   channel.on('message', function(message)
   {
      console.log('message: ' + message);
      channel.send('echo: ' + message);
   });
   ...

它还使用一个匿名函数,该函数只有一个参数,即接收消息的对象。我们将它打印到控制台日志中进行调试。但是最重要的事情发生在第二行:通过调用 channel.send,我们向客户端发送一条响应消息。

为了进一步完善,让我们向“连接”处理程序添加我们自己的欢迎消息。完成后,如下所示:

// 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!');
});

重要的是要理解,虽然在代码中绑定“消息”处理程序比发送“hello”更重要,但是消息处理程序将在以后调用,并且只有当客户端发送消息时才调用。

我们已经查看了组织回显服务的脚本大纲。不过,最好是测试一下。这可以通过使用常规浏览器以最有效的方式完成,但这需要将脚本稍微复杂化一些:将它变成尽可能小的 Web 服务器,用尽可能小的 WebSocket 客户端返回网页。

回显服务和测试网页

我们现在要查看的回显服务器脚本位于 wsecho.js 文件中。要点之一是,不仅需要支持 http/ws 服务器上的开放协议,还需要支持受保护协议 https/wss。这种可能性将在我们所有的示例中提供(包括基于MQL5的客户机),但你需要在服务器上执行一些操作。

你应该从包含加密密钥和证书的几个文件开始。这些文件通常从授权来源(即认证中心)获得,但出于提供信息的目的,你可以自己生成这些文件。当然,你自己生成的这些文件不能在公共服务器上使用,带有此类证书的页面会在任何浏览器中触发警告(地址栏左侧的页面图标以红色突出显示)。

证书设备的描述以及证书自身的生成过程超出了本书的范围,但是本书包含了两个现成的文件: 持续时间有限的 MQL5Book.crtMQL5Book.key (还有其他扩展文件)。这些文件必须传递给 Web 服务器对象的构造函数,以便服务器能够通过 HTTPS 协议工作。

我们将在脚本启动命令行中传递证书文件的名称。例如,像这样:

node wsecho.js MQL5Book

如果运行脚本时没有附加参数,服务器将使用 HTTP 协议工作。

node wsecho.js

在脚本内部,命令行自变量可通过内置对象 process.argv 获得,前两个参数始终包含 node.exe 服务器的名称和要运行的脚本的名称(在本例中为 wsecho.js),因此我们通过 splice 方法将其丢弃。

// JavaScript
const args = process.argv.slice(2);
const secure = args.length > 0 ? 'https' : 'http';

根据现存的证书名称,secure 变量将获取创建服务器后应该加载的包的名称: httpshttp。代码中总共有 3 个依赖项:

// JavaScript
const fs = require('fs');
const http1 = require(secure);
const WebSocket = require('ws');

我们已经了解了 ws 包的所有内容;httpshttp 包提供了一个 Web 服务器实现,内置的 fs 包提供了文件系统的使用。

Web 服务器设置被格式化为 options 对象。此处,我们可以看到命令行中的证书名称是如何使用 ${args[0]} 表达式在字符串中用斜线引号替换的。然后通过 fs.readFileSync 方法读取相应的文件对。

// JavaScript
const options = args.length > 0 ?
{
   key : fs.readFileSync(`${args[0]}.key`),
   cert : fs.readFileSync(`${args[0]}.crt`)
} : null;

Web 服务器是通过调用 createServer 方法创建的,我们将选项对象和一个匿名函数(HTTP 请求处理程序)传递给该方法。该处理程序有两个参数:带有 HTTP 请求的 req 对象和我们用来发送响应的 res 对象(HTTP 头文件和 Web 页面)。

// JavaScript
http1.createServer(optionsfunction (reqres)
{
   console.log(req.methodreq.url);
   console.log(req.headers);
   
   if(req.url == '/') req.url = "index.htm";
   
   fs.readFile('./' + req.url, (errdata) =>
   {
      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 文件,后面我们会用到这些文件。根据是否启用了保护模式,通过在 443 或 80 标准端口上调用 listen 方法来启动服务器(如果你的计算机上已经安装了这些端口,请更改为其他端口)。

要接受 Web 套接字端口 9000 上的连接,我们需要部署另一个采用了相同选项的 Web 服务器实例。但在这种情况下,服务器存在的唯一目的是处理 HTTP 请求,将连接“升级”到 Web Sockets 协议。

// JavaScript
const server = new http1.createServer(options).listen(9000);
server.on('upgrade', function(reqsockethead)
{
   console.log(req.headers); // TODO: we can add authorization!
});

这里,在“upgrade”事件处理程序中,我们接受任何已经通过握手的连接,并将头打印到日志中,但是如果我们正在做一个封闭的(付费的)服务,我们可能会请求用户授权。

最后,我们创建一个 WebSocket 服务器对象,和前面的介绍性例子一样,唯一的区别是一个现成的 Web 服务器被传递给了构造函数。所有连接的客户端都按序列号进行计数和欢迎。

// 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#Hellouser' + count);
   
   client.on('message', function(message)
   {
      console.log('%d : %s', client.idmessage);
      client.send('user' + client.id + '#' + message);
   });
   
   client.on('close', function()
   {
      console.log('User disconnected:', client.id);
   });
});

对于所有事件(包括连接、断开和消息),调试信息都显示在控制台中。

好了,有 Web Socket 服务器支持的 Web 服务器准备好了。现在我们需要为它创建一个客户端网页 index.htm

<!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 上的回显服务网页

WebSocket 上的回显服务网页

该页面使用 wsecho_client.js 脚本,该脚本提供 WebSocket 客户端响应。在浏览器中,WebSocket 内置为“本地”JavaScript 对象,因此你不需要连接任何外部设备:只需使用所需的协议和端口号调用 web socket 构造函数。

// 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 对象允许你对事件做出反应并发送消息。在浏览器中,打开的连接事件被称为“打开”;它通过 onopen 特性连接。相同语法(与服务器实现略有不同)也用于新消息到达事件 - 它的处理程序被分配给 onmessage 特性。

// 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 方法为每个 button 标记对象分配的。这里我们看到了JavaScript 中说明匿名函数的另一种方式:自变量列表的圆括号可以是空的,箭头后的函数体可以是 (arguments) => { ... }

// JavaScript
const button = document.querySelectorAll('button'); // request all buttons
// button "Submit"  
button[0].addEventListener('click', (event) =>
{
   const x = document.getElementById('message').value;
   if(xws.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
http1.createServer(optionsfunction (reqres)
{
   if(req.url == '/') req.url = "wschat.htm";
   ...
});

为了存储连接到聊天的用户相关信息,我们将说明 clients 映射数组。 Map 是一个标准的 JavaScript 关联容器,可以使用任意类型的键将任意值写入其中(包括对象)。

// JavaScript
const clients = new Map();                    // added this line
var count = 0;

在新的用户连接事件处理程序中,我们将把作为函数参数接收的 client 对象添加到当前客户端序列号下的映射中。

// JavaScript
wsServer.on('connection', function onConnect(client)
{
   console.log('New user:', ++count);
   client.id = count
   client.send('server#Hellouser' + count);
   clients.set(countclient);                // added this line
   ...

onConnect 函数中,我们为特定客户端的新消息到达事件设置了一个处理程序,而我们就是在这个嵌套的处理程序中发送消息的。然而,这一次我们遍历了映射的所有元素(也就是说,遍历了所有客户端),并将文本发送给每个客户端。该循环是通过 forEach 方法从映射调用数组来组织的,将对每个元素 (elem) 执行的下一个匿名函数被传递给相应的方法。循环结构的例子清楚地展示了 JavaScript 中流行的 functional-declarative 编程范式(与 MQL5 中的 imperative 方法形成对比)。

// JavaScript
   client.on('message', function(message)
   {
      console.log('%d : %s', client.idmessage);
      Array.from(clients.values()).forEach(function(elem// added a loop
      {
         elem.send('user' + client.id + '#' + message);
      });
   });

需要注意的是,我们会将邮件的副本发送给所有客户,包括原作者。它可能会被过滤掉,但是出于调试的目的,最好确认消息已经发送。

与之前的回显服务的最后一个区别是,当客户端断开连接时,需要将其从映射中删除。

// JavaScript
   client.on('close', function()
   {
      console.log('User disconnected:', client.id);
      clients.delete(client.id);                   // added this line
   });

关于用 wschat.htm 替换 index.htm 页面,我们在这里添加了一个“字段”来显示消息的作者 (origin),并连接了一个新的浏览器脚本 wschat_client.js。该脚本可解析消息(我们使用' # '符号将作者与文本分开),并用收到的信息填充表单字段。因为从 WebSocket 协议的角度来看没有什么变化,所以我们不会提供源代码。

基于 WebSocket 的聊天服务网页

基于 WebSocket 的聊天服务网页

你可以用 wschat.js 聊天服务器启动 nodejs,然后从几个浏览器选项卡连接到它。每个连接都会在头文件中显示一个唯一编号。单击 Submit 后,Message 字段中的文本将发送给所有客户端。然后,客户端表单显示消息的作者(左下方的标签)和文本本身(底部中间的字段)。

因此,我们已经确保支持 WebSocket 的 Web 服务器已经准备好了。让我们转向用 MQL5 编写协议的客户端部分。