English Русский Español Deutsch 日本語 Português
preview
为 Metatrader 5 开发 MQTT 客户端:TDD 方法 - 第 5 部分

为 Metatrader 5 开发 MQTT 客户端:TDD 方法 - 第 5 部分

MetaTrader 5积分 | 12 八月 2024, 13:34
19 0
Jocimar Lopes
Jocimar Lopes

"过早优化是万恶之源"。(Donald Knuth)

概述

MQTT 是一种 pub/sub(发布/订阅) 消息共享协议。因此,我们可以预计其核心是 PUBLISH(发布) 和 SUBSCRIBE (订阅)数据包。所有其他类型的数据包都是为了获取它们而存在的。

除了能写 PUBLISH 数据包外,我们还必须能读取它们,因为我们的客户端从其他客户端收到的消息也是 PUBLISH 数据包。这是因为传输协议是对称的。

"PUBLISH 数据包从客户端发送到服务器,或从服务器发送到客户端,以传输应用信息"。

PUBLISH 数据包有一个不同的固定报头(含发布标志)和一个可变报头(含以 UFT-8 字符串编码的所需主题名称和所需数据包标识符(如果 QoS > 0))。除此之外,它最终还能使用 MQTT 5.0 中引入的几乎所有属性和用户属性,包括与请求/响应(Request/Response)交互模式相关的属性。

在本文中,我们将了解其报头的结构,以及我们如何测试和实现发布标志、主题名称和数据包标识符。 

在后面的描述中,我们使用的术语 MUST 和 MAY 是 OASIS 标准使用的,而 OASIS 标准使用的术语 MUST 和 MAY 又是 IETF RFC 2119 中描述的。

此外,除非另有说明,所有引文均来自 OASIS 标准


MQTT 5.0 PUBLISH 数据包固定报头的结构

PUBLISH 数据包固定报头与所有其他控制数据包类型一样,采用两个字节的基本结构。第一个字节专门用于承载数据包类型。第二个字节包含着以可变字节整数形式编码的数据包剩余长度。

但是,其他所有数据包类型的第一个字节的前四位都处于保留(RESERVED)状态,而 PUBLISH 数据包则使用这四位对三个特征进行编码:RETAIN、QoS 级别和 DUP。

MQTT 控制包 固定报头标志 位 3 位 2 位 1 位 0
CONNECT Reserved 0 0 0 0
CONNACK Reserved
0 0 0 0
PUBLISH 在 MQTT v5.0 中使用 DUP Qos 2 Qos 1 RETAIN
PUBACK Reserved
0 0 0 0
PUBREC Reserved
0 0 0 0
PUBREL Reserved
0 0 1 0
PUBCOMP Reserved
0 0 0 0
SUBSCRIBE Reserved
0 0 1 0
SUBACK Reserved 0 0 0 0
UNSUBSCRIBE Reserved
0 0 1 0
UNSUBACK Reserved
0 0 0 0
PINGREQ Reserved
0 0 0 0
PINGRESP Reserved
0 0 0 0
DISCONNECT Reserved
0 0 0 0
AUTH Reserved
0 0 0 0

表 1 - MQTT 5.0 Oasis 标准中表 2-3 标志位的重现

标记为 "Reserved" 的标志位是为将来使用而保留的,必须设置为所列值。

由于 PUBLISH 数据包与所有其他控制数据包的固定报头不同,我们一直使用的生成固定报头的功能在此无法使用。

//+------------------------------------------------------------------+
//|                     SetFixedHeader                               |
//+------------------------------------------------------------------+
void SetFixedHeader(ENUM_PKT_TYPE pkt_type, uchar& buf[], uchar& dest_buf[])
  {
   dest_buf[0] = (uchar)pkt_type << 4;
   dest_buf[1] = EncodeVariableByteInteger(buf);
  }

如您所见,函数参数中只有数据包类型和对两个数组的引用,一个是固定报头数组的源数组,另一个是目标数组。第一行从数据包类型的枚举中获取整数值,然后将整数值左移四位,并将位操作的结果赋予给固定报头数组(dest_buf[0])的第一个字节。这种按位运算可确保前四位不赋值,或按标准要求保持为 "Reserved"。

第二行调用计算数据包剩余长度的函数,将该值赋值给固定报头数组(dest_buf[1])的第二个字节,并将其编码为可变字节整数。

但该函数不提供任何设置发布标志的方法。

图 1 - MQTT 5.0 PUBLISH 数据包固定报头 RETAIN、QoS 级别和 DUP 标志

图 1 - MQTT 5.0 PUBLISH 数据包固定报头 RETAIN、QoS 级别和 DUP 标志

因此,我们添加了一个 switch 语句来处理 PUBLISH 数据包,并添加了最后一个参数来接收发布标志。我们本可以重载接收 "发布标志" 的函数,对其主体稍作修改,以实现 PUBLISH 数据包的特殊性。但对于 switch 来说,这是一个完美的用例,因为我们只有一个例外(PUBLISH),所有其他用例都默认为以前的实现。

最后一个参数的默认值为 0,这意味着在设置所有数据包的固定标头时可以忽略它。只有在设置了任何 "发布标志" 的情况下,才会更改 dest_buf。

//+------------------------------------------------------------------+
//|                     SetFixedHeader                               |
//+------------------------------------------------------------------+
void SetFixedHeader(ENUM_PKT_TYPE pkt_type,
                    uchar& buf[], uchar& dest_buf[], uchar publish_flags = 0)
  {
   switch(pkt_type)
     {
      case PUBLISH:
         dest_buf[0] = (uchar)pkt_type << 4;
         dest_buf[0] |= publish_flags;
         dest_buf[1] = EncodeVariableByteInteger(buf);
         break;
      default:
         dest_buf[0] = (uchar)pkt_type << 4;
         dest_buf[1] = EncodeVariableByteInteger(buf);
         break;
     }
  }

如图所示,通过 OR 位运算和对其第一个字节的赋值,对保存固定报头的目标缓冲区进行了修改。我们已经广泛使用这种模式来切换连接标记,现在我们使用同样的模式来切换发布标记。

例如,RETAIN 标志的设置/取消代码如下。 

//+------------------------------------------------------------------+
//|               CPktPublish::SetRetain                             |
//+------------------------------------------------------------------+
void CPktPublish::SetRetain(const bool retain)
  {
   retain ? m_publish_flags |= RETAIN_FLAG : m_publish_flags &= ~RETAIN_FLAG;
   SetFixedHeader(PUBLISH, m_buf, ByteArray, m_publish_flags);
  }

QoS_1 级别标志(去掉类似函数签名)。

QoS_1 ? m_publish_flags |= QoS_1_FLAG : m_publish_flags &= ~QoS_1_FLAG;   
SetFixedHeader(PUBLISH, m_buf, ByteArray, m_publish_flags);

QoS_2 级别标志。

QoS_2 ? m_publish_flags |= QoS_2_FLAG : m_publish_flags &= ~QoS_2_FLAG;
SetFixedHeader(PUBLISH, m_buf, ByteArray, m_publish_flags);

DUP 标志

dup ? m_publish_flags |= DUP_FLAG : m_publish_flags &= ~DUP_FLAG;
SetFixedHeader(PUBLISH, m_buf, ByteArray, m_publish_flags);

标志(标志掩码)的值是在枚举中定义的常量,是根据被切换字节上相应位的位置定义的二乘幂值。

//+------------------------------------------------------------------+
//|             PUBLISH - FIXED HEADER - PUBLISH FLAGS               |
//+------------------------------------------------------------------+
enum ENUM_PUBLISH_FLAGS
  {
   RETAIN_FLAG  	= 0x01,
   QoS_1_FLAG           = 0x02,
   QoS_2_FLAG           = 0x04,
   DUP_FLAG             = 0x08
  };

因此,标志的二进制值和在字节中的位置如下。

RETAIN

十进制 1 0 0 0 0 0 0 0 1

Qos 1

十进制 2 0 0 0 0 0 0 1 0

Qos 2

十进制 4 0 0 0 0 0 1 0 0

DUP

十进制 8 0 0 0 0 1 0 0 0

PUBLISH 数据包的十进制值为 3。

十进制 3 0 0 0 0 0 0 1 1

我们将数据包类型值左移四位(dest_buf[0] = (uchar)pkt_type <<4)。

十进制 48 0 0 1 1 0 0 0 0

当我们对数据包类型值和标志的二进制表示应用位 OR 运算(dest_buf[0] |= publish_flags;)时,我们实质上是在合并位。因此,设置了 DUP 标志的左移 PUBLISH 数据包值的二进制表示方法如下。

十进制 56 0 0 1 1 1 0 0 0

在设置了 RETAIN 和 QoS 2 标志后,固定报头第一个字节的位将如下所示。

十进制 53 0 0 1 1 0 1 0 1

反之,数据包类型值与标志二进制表示的一元补码(~)之间的 AND 位运算则相反,即取消设置标志(m_publish_flags &=~RETAIN_FLAG)。

因此,如果字节设置为 QoS 1,但不带 DUP 或 RETAIN,就会是这样。

小数 50 0 0 1 1 0 0 1 0

上述 QoS 1 标志的一元补码是其所有位翻转后的值。

QoS_1 标志 0 0 1 0
~QoS_1标志 1 1 0 1

由于任何与零相与的值都是零,因此我们实际上是取消了标志的设置。

请注意,在我们设置标志时,字节的二进制值显然会发生变化。在所有标志都是未设置的情况下,十进制值 3 左移 4 位后的十进制值为 48。当我们设置 RETAIN 标志时,它的十进制值为 49。使用 RETAIN 和 QoS 1 时,该值变为 51。等等。

这些十进制值就是我们在测试中探索设置/取消设置标志的所有可能组合时要寻找的值。

//+------------------------------------------------------------------+
//|              TEST_SetFixedHeader_DUP_QoS2_RETAIN                 |
//+------------------------------------------------------------------+
bool TEST_SetFixedHeader_DUP_QoS2_RETAIN()
  {
   Print(__FUNCTION__);
//--- Arrange
   static uchar expected[] = {61, 0};
   uchar buf[] = {};
//--- Act
   CPktPublish *cut = new CPktPublish(buf);
   cut.SetDup(true);
   cut.SetQoS_2(true);
   cut.SetRetain(true);
   uchar result[];
   ArrayCopy(result, cut.ByteArray);
//--- Assert
   bool isTrue = AssertEqual(expected, result);
//--- cleanup
   delete cut;
   ZeroMemory(result);
   return isTrue;
  }

这种在实现之前编写的有些初级的测试(以及其他一些更复杂的测试)引领着我们的开发,因为它们除了能让我们专注于手头的任务外,还能在我们需要更改或重构代码时很好地起到 "安全网" 的作用。您可以在所附文件中找到很多。 

运行测试后,您应该会看到如下内容。

图 2 - MQTT 5.0 PUBLISH 测试输出固定报头

图 2 - MQTT 5.0 PUBLISH 测试输出固定报头

如果说发布/订阅循环是协议的核心,那么这三个功能(RETAIN、DUP 和 QoS)就是协议运行行为的核心。可以说,它们将对会话状态管理产生重大影响。因此,让我们超越严格的协议规范,试着对它们的语义有一个合理的理解。

RETAIN

正如我们在本系列的第一部分中所看到的,发布/订阅模式与特定的主题名称(Topic Name)相关联:客户端发布带有主题名称的消息或订阅主题名称,所有客户端都会收到以其订阅的主题名称发布的消息。 

发布时,我们可以使用设置为 1(一/true)的 RETAIN 标志来指示服务器存储信息,并将其作为 "保留信息" 发送给新的订阅者。保留的信息始终只有一条,我们将 RETAIN 设置为 1,以存储/替换现有的保留信息。我们会发送一个零字节的有效数据,并将该标志设为 1,以清理被保留的报文。我们将其重设为 0,是为了指示服务器不对该主题名下保留的信息做任何处理,既不存储、替换,也不清理。

订阅主题名称时,我们会收到保留的信息。在共享订阅(Shared Subscription)中,保留的信息将只发送给共享主题过滤器(Topic Filter)的一个客户端。我们将在处理 SUBSCRIBE 数据包时深入研究共享订阅。

该功能与服务器发送的 CONNACK 数据包上的 "Retain Available"(保留可用)和 "Retain Not Supported"(不支持保留)标志配合使用。 

根据 PUBLISH 或 CONNECT 有效载荷的 Will Properties 上设置的 "Message Expiry Interval"(信息过期时间间隔),保留的信息会像其他信息一样过期。

我们必须考虑到,RETAIN 是一个动态的代理功能,这意味着它可能在同一会话中从 "可用" 变为 "不支持",反之亦然。

Qos 级别

在本系列文章的引言中,我们已经谈到了 QoS 级别,当时列举了协议创建者的一些设计选择。

"尽管由于技术栈的限制和昂贵的网络成本,它的设计是稳健、快速和廉价的,但它需要提供具有连续会话感知的服务质量数据交付,这可以应对不可靠甚至间歇性的互联网连接"。

在连接标志(Connect Flag)方面,我们可以看到下表中每个 QoS 级别的定义。

Qos 值 位 2 位 1 描述
0 0 0 最多传递一次
1 0 1 至少传递一次
2 1 0 正好传递一次
- 1 1 保留 - 不得使用

表 2 - MQTT 5.0 Oasis 标准中表 3-9 QoS 定义的再现

在描述 QoS 级别和其他功能的使用时,我们一直使用 "服务器" 和 "代理" 来指定分发信息的服务。但根据标准:

"传输协议是对称的,在下面的描述中,客户端和服务器可以各自扮演发送方或接收方的角色。传送协议只涉及将应用信息从单个发送方传送到单个接收方。当服务器向多个客户端传送应用信息时,每个客户端都会被独立处理。用于将应用消息出站传递到客户端的QoS级别可能与入站应用消息的QoS级别不同。" (着重部分是我们标记的)。

因此,到目前为止,我们一直在使用 "服务器" 和 "代理" 这两个术语,这是有道理的,因为我们是从广义上的客户角度出发的,但请记住,传输协议是对称的。

默认 QoS 级别为 0,也就是说,如果我们不设置这个标志,我们就会告知服务器,0(零)是我们愿意接受的最大 QoS 级别。任何合规的代理都接受这一水平。它是一种 "阅后即焚" 的发布方式,发件人同意在传送时可能会发生遗失和复制。

图 3 - MQTT 5.0 - QoS 0 级客户端-服务器流程图

图 3 - MQTT 5.0 - QoS 0 级客户端-服务器流程图

QoS 1 级接受在交付时可能出现的重复,但不接受丢失。服务器将通过 PUBACK 确认信息。

图 4 - MQTT 5.0 - QoS 1 级客户端-服务器流程图

图 4 - MQTT 5.0 - QoS 1 级客户端-服务器流程图

QoS 2 级要求无丢失或重复。本级别涉及四个数据包,服务器将会识别此传送以 PUBREC 开始。然后,客户端将使用 PUBREL 请求释放该特定的数据包标识符,最后,服务器将使用 PUBCOMP 通知传送完成。

图 5 - MQTT 5.0 - QoS 2 级客户端-服务器流程图

图 5 - MQTT 5.0 - QoS 2 级客户端-服务器流程图

这是我们在上一篇文章中谈到连接标志时打的一个比方:

"可以把这个 [QoS 2] 级别看作寄送挂号包裹。邮政系统会在你把包裹转到他们手中时给你一张收据,确认从现在起,他们负责把包裹送到正确的地址。当包裹送达时,他们会向您发送收件人签名的收据,确认包裹已送达。"

服务质量可以是针对 Will 报文、订阅(包括共享订阅)或特定报文的要求。 

Will 报文 订阅 消息
CONNECT Will QoS SUBSCRIBE 订阅选项 PUBLISH QoS 级别标志

表 3 - 可设置 QoS 级别的 MQTT 5.0 数据包和标记

细心的读者可能已经注意到,QoS 1 和 QoS 2 都涉及某种会话状态。我们将在专门讨论这一广泛主题的文章中讨论会话状态和相应的持久层。

DUP

设置时,DUP 标志表示我们正在重试发送之前失败的 PUBLISH 数据包。对于所有 QoS 0 报文,它必须重新设置为 0(零)。重复指的是数据包本身,而不是信息。


MQTT 5.0 PUBLISH 数据包的可变报头:主题名称、数据包标识符和属性

MQTT 5.0 PUBLISH 数据包的可变报头必须有一个主题名称(Topic Name),如果 QoS 大于 0(零),还必须有一个数据包标识符(Packet Identifier)。这两个字段后通常会有一组属性和一个有效载荷,但没有属性和零长度有效载荷的 PUBLISH 数据包也是有效的数据包。换句话说,最简单并且有效的 PUBLISH 数据包是带有固定标头(QoS 0)、无 DUP 和 RETAIN 标志以及只有主题名称的可变报头的数据包。

主题名称

由于在发布/订阅消息共享协议中,客户端和服务器之间的所有交互(以及用户/设备之间的所有交互)都是围绕着向主题发布和向主题订阅进行的,因此我们可以说,主题名称字段在这里值得特别关注。在许多实时服务中,我们会发现 "频道"(channel) 一词代替了 "主题名称"。这是有道理的,因为主题名称代表客户订阅的信息通道。

主题名称是以分层树形结构组织的 UTF-8 编码字符串。正斜线 ( / U+002F ) 用作主题级分隔符。 

broker1/account12345/EURUSD

它们是区分大小写的,因此,这是两个不同的主题。

  • broker1/account12345/EURUSD
  • broker1/account12345/eurusd

只有当客户端订阅上存在主题过滤器(Topic Filter)通配符(见下文)时,这些级别分隔符才有意义。除了 UTF-8 字符串本身的限制外,对级别数量没有限制。最终,主题名称可能会被主题别名(Topic Alias)取代。

"主题别名是一个整数值,用来标识主题而不是使用主题名称。这可以减小 PUBLISH 数据包的大小,在主题名称较长和网络连接中重复使用相同主题名称时非常有用。"

数据包标识符

数据包标识符是 QoS > 0 的 PUBLISH 数据包所需的双字节整数字段。它用于直接参与会话状态管理的发布/订阅循环的所有数据包。数据包标识符不得在 QoS 0 的 PUBLISH 中使用。

它用于连接 PUBLISH 和相关的 ACK。

请记住,由于传送协议是对称的,因此在使用 QoS 1 时,我们的客户端可能会在收到与我们之前发送的 PUBLISH 相关的 PUBACK 之前,收到来自服务器的具有相同数据包 ID 的 PUBLISH。

"客户机有可能发送一个包标识符为 0x1234 的 PUBLISH 数据包,然后在收到其发送的 PUBLISH 数据包的 PUBACK 之前,又从其服务器收到一个包标识符为 0x1234 的不同 PUBLISH 数据包"。

值得注意的是,数据包标识符还用于连接 SUBSCRIBE 和 UNSUBSCRIBE 数据包中的相关 ACK。


我们如何撰写主题名称

主题名称是可变报头的第一个字段。它被编码为 UTF-8 字符串,其中包含一些不允许使用的 Unicode 代码点,这里有一个问题。请看一下这三个语句,其中包含为 MQTT 5.0 编码 UTF-8 字符串的一些要求。

"[......]字符数据不得包括 U+D800 和 U+DFFF 之间的代码值的编码。如果客户端或服务器收到的 MQTT 控制数据包包含格式不正确的 UTF-8,则属于畸形数据包(Malformed Packet)"。

"UTF-8编码字符串不得包含空字符U+0000的编码。如果接收器(服务器或客户端)收到包含 U+0000 的 MQTT 控制包,则为畸形数据包"。

"数据不应包括下列 Unicode [Unicode] 码位的编码。如果接收器(服务器或客户端)收到包含其中任何一项的 MQTT 控制包,则可能将其视为畸形数据包。这些是禁止使用的 Unicode 代码点。

U+0001...U+001F 控制字符

U+007F...U+009F 控制字符

Unicode 规范[Unicode]中定义为非字符的码位"。

如您所见,上述第一项和第二项声明都是严格声明(MUST NOT),这意味着任何符合要求的实现都会检查是否存在不允许的代码点,而第三项声明则是建议声明(SHOULD NOT),这意味着实现可能不会检查是否存在不允许的代码点,但仍被视为符合要求。

由于 "畸形数据包 "是断开连接(DISCONNECT)的原因之一,如果我们允许客户端使用这些代码点,而我们的代理选择不将其视为 "畸形数据包",那么我们可能会导致执行该建议的其他客户端断开连接。因此,尽管将 Unicode 控制字符和非字符排除在外只是一项建议,但我们在实现过程中不允许使用它们。

现在,我们将字符串编码为 UTF-8 的函数如下所示:

//+------------------------------------------------------------------+
//|                    Encode UTF-8 String                           |
//+------------------------------------------------------------------+
void EncodeUTF8String(string str, ushort& dest_buf[])
  {
   uint str_len = StringLen(str);
// check for disallowed Unicode code points
   uint iter_pos = 0;
   while(iter_pos < str_len)
     {
      Print("Checking disallowed code points");
      ushort code_point = StringGetCharacter(str, iter_pos);
      if(IsDisallowedCodePoint(code_point))
        {
         printf("Found disallowed code point at position %d", iter_pos);
         ZeroMemory(dest_buf);
         return;
        }
      printf("Iter position %d", iter_pos);
      iter_pos++;
     }
   if(str_len == 0)
     {
      Print("Cleaning buffer: string empty");
      ZeroMemory(dest_buf);
      return;
     }
// we have no disallowed code points and the string is not empty: encode it.
   printf("Encoding %d bytes ", str_len);
   ArrayResize(dest_buf, str_len + 2);
   dest_buf[0] = (char)str_len >> 8; // MSB
   dest_buf[1] = (char)str_len % 256; // LSB
   ushort char_array[];
   StringToShortArray(str, char_array, 0, str_len);// to Unicode
   ArrayCopy(dest_buf, char_array, 2);
   ZeroMemory(char_array);
  }

如果传递给此函数的字符串有不允许的码位,我们会记录它在字符串上的位置,将目标缓冲区传递给 ZeroMemory,然后立即返回。由于主题名称的最小长度要求为 1,如果字符串为空,我们就会执行相同的操作:记录日志、清理缓冲区并返回。

顺便指出,我们使用 StringToShortArray 将字符串转换为 Unicode 数组。如果我们要将其转换为 ASCII 数组,则需要使用 StringToCharArray。您可以在最近包含在文档中的书或这篇关于 MQL5 字符串的综合文章中找到详细解释和更多信息。

还要注意的是,在这次对 StringToShortArray 的调用中,我们使用了字符串的长度作为最后一个参数,而不是函数默认值。这是因为我们不希望在数组中出现空字符 (0x00)、 

"默认值为-1,表示复制到数组末尾,或直到 0 值结尾。末尾 0 也将复制到接收方数组"。

而 StringLen 的返回值是

"字符串中不含末尾 0 的符号个数"。

检查不允许的代码点的函数非常简单。

//+------------------------------------------------------------------+
//|              IsDisallowedCodePoint                               |
//|   https://unicode.org/faq/utf_bom.html#utf16-2                   |
//+------------------------------------------------------------------+
bool IsDisallowedCodePoint(ushort code_point)
  {
   if((code_point >= 0xD800 && code_point <= 0xDFFF) // Surrogates
      || (code_point > 0x00 && code_point <= 0x1F) // C0 - Control Characters
      || (code_point >= 0x7F && code_point <= 0x9F) // C0 - Control Characters
      || (code_point == 0xFFF0 || code_point == 0xFFFF)) // Specials - non-characters
     {
      return true;
     }
   return false;
  };

除了不允许的代码点,我们还需要检查订阅主题过滤器中使用但主题名称中禁止使用的两个通配符:加号('+' U+002B)和数字符号('#' U+0023)。

检查不允许的码位的函数一般用于对任何字符串进行编码,因此它位于我们的 MQTT.mqh 标头中,而检查通配符的函数专门用于主题名称,因此它是 CPktPublish 类的一部分。

//+------------------------------------------------------------------+
//|            CPktPublish::HasWildcardChar                          |
//+------------------------------------------------------------------+
bool CPktPublish::HasWildcardChar(const string str)
  {
   if(StringFind(str, "#") > -1 || StringFind(str, "+") > -1)
     {
      printf("Wildcard char not allowed in Topic Names");
      return true;
     }
   return false;
  }

内置函数 StringFind 返回匹配子串的起始位置,如果未找到匹配子串,则返回 -1。因此,我们只需检查任何高于 -1 的值。然后,我们在主函数中调用它。

//+------------------------------------------------------------------+
//|            CPktPublish::SetTopicName                             |
//+------------------------------------------------------------------+
void CPktPublish::SetTopicName(const string topic_name)
  {
   if(HasWildcardChar(topic_name) || StringLen(topic_name) == 0)
     {
      ArrayFree(ByteArray);
      return;
     }
   ushort encoded_string[];
   EncodeUTF8String(topic_name, encoded_string);
   ArrayCopy(ByteArray, encoded_string, 2);
   ByteArray[1] = EncodeVariableByteInteger(encoded_string);
  }

此时,如果发现通配符,我们就会进行与之前相同的 "错误处理":记录信息、清除缓冲区并立即返回。稍后,我们可以通过发出提醒等方式改进这一点。

函数的最后一行使用标准建议的算法,将数据包的剩余长度分配给固定报头的第二个字节。我们在本系列的第一篇文章中对此进行了解释。

我们的测试也采用完全相同的结构。

//+------------------------------------------------------------------+
//|           TEST_SetTopicName_WildcardChar_NumberSign              |
//+------------------------------------------------------------------+
bool TEST_SetTopicName_WildcardChar_NumberSign()
  {
   Print(__FUNCTION__);
//--- Arrange
   static uchar expected[] = {};
   uchar payload[] = {};
//--- Act
   CPktPublish *cut = new CPktPublish(payload);
   cut.SetTopicName("a#");
   uchar result[];
   ArrayCopy(result, cut.ByteArray);
//--- Assert
   bool isTrue = AssertEqual(expected, result);
//--- cleanup
   delete cut;
   ZeroMemory(result);
   return isTrue;
  }

如果运行测试,应该会看到类似这样的结果:

图 6 - MQTT 5.0 - PUBLISH 测试输出主题名称

图 6 - MQTT 5.0 - PUBLISH 测试输出 主题名称


我们如何编写数据包标识符

数据包标识符不是由用户指定的。相反,客户端必须将其分配给 QoS 级别 > 0 的任何 PUBLISH 数据包,其它情况则不得分配。换句话说,每次我们创建一个 QoS 1 或 QoS 2 的 PUBLISH 数据包时,都必须设置其数据包标识符。 

我们现在就可以开始测试。我们只需实例化一个数据包,并将其所需的主题名称并把 QoS 设置为 1 或 2。生成的数据包字节数组应有一个数据包 ID。

//+------------------------------------------------------------------+
//|            TEST_SetPacketID_QoS2_TopicName1Char                  |
//+------------------------------------------------------------------+
bool TEST_SetPacketID_QoS2_TopicName5Char()
  {
   Print(__FUNCTION__);
// Arrange
   uchar payload[] = {};
   uchar result[]; // expected {52, 9, 0, 1, 'a', 'b', 'c', 'd', 'e', pktID MSB, pktID LSB}
// Act
   CPktPublish *cut = new CPktPublish(payload);
// FIX: if we call SetQoS first this test breaks
   cut.SetTopicName("abcde");
   cut.SetQoS_2(true);
   ArrayCopy(result, cut.ByteArray);
// Assert
   ArrayPrint(result);
   bool is_true = result[9] > 0 || result[10] > 0;
// cleanup
   delete cut;
   ZeroMemory(result);
   return is_true;
  }

请注意,我们无法测试生成的数据包 ID 值,因为它是一个(伪)随机生成的数字,实现如下所示。我们正在测试它是否存在。另外,请注意我们还有一项修复工作要做。调用 SetTopicName 和 SetQoS_X 的函数顺序会对生成的字节数组产生意想不到的影响。函数之间存在调用顺序依赖关系并不是一个好主意。这将是一个错误,但俗话说,错误就是因为没有写测试。因此,我们将在下一次迭代中编写一个不依赖调用顺序的测试。目前,我们只关心如何通过测试。

当然,在我们实现设置数据包 ID 的函数之前,测试甚至都无法编译。由于多个控制数据包都需要数据包标识符,因此写入数据包标识符的函数不应是 CPktPublish 类的成员。MQTT.mqh 头文件似乎更适合保存它。

//+------------------------------------------------------------------+
//|            SetPacketID                                           |
//+------------------------------------------------------------------+
void SetPacketID(uchar& buf[], int start_idx)
  {
// MathRand - Before the first call of the function, it's necessary to call
// MathSrand to set the generator of pseudorandom numbers to the initial state.
   MathSrand((int)TimeLocal());
   int packet_id = MathRand();
   if(ArrayResize(buf, buf.Size() + 2) < 0)
     {
      printf("ERROR: failed to resize array at %s", __FUNCTION__);
      return;
     }
   buf[start_idx] = (uchar)packet_id >> 8; // MSB
   buf[start_idx + 1] = (uchar)packet_id % 256; //LSB
  }

我们使用内置函数 MathRand 生成数据包标识符。它要求我们先调用 MathSrand。我们必须向该函数传递随机发生器的 "种子"。我们选择 TimeLocal 作为种子,因为我们在最近添加到文档中的一本书中找到了有关MQL5 中伪随机数生成的明确参考建议。

要设置数据包 ID,我们要调整原始字节数组的大小,为数据包 ID(两个字节的整数)留出空间,并从作为参数传递的位置(start_idx)开始,设置最显著字节和最不显著字节的值。最后一步是调用 CPktPublish 类中 SetQoS_1 和 SetQoS_2 方法的函数。

//+------------------------------------------------------------------+
//|            CPktPublish::SetQoS_2                                 |
//+------------------------------------------------------------------+
void CPktPublish::SetQoS_2(const bool QoS_2)
  {
   QoS_2 ? m_publish_flags |= QoS_2_FLAG : m_publish_flags &= ~QoS_2_FLAG;
   SetFixedHeader(PUBLISH, m_buf, ByteArray, m_publish_flags);
   SetPacketID(ByteArray, ByteArray.Size());
  }

通过运行附件文件中的测试,您应该会看到类似的结果(为简洁起见,此处已删除):

图 7 - MQTT 5.0 - PUBLISH 测试输出数据包标识符

图 7 - MQTT 5.0 - PUBLISH 测试输出数据包标识符

结论

作为协议的核心,PUBLISH 数据包对实现的要求更高:它们有不同的固定报头,需要一个可变报头,其中的主题名称编码为 UTF-8,并防止某些不允许的编码点,如果 QoS > 0,则需要一个数据包标识符,而且它们可以使用 MQTT 5.0 中的几乎所有属性和用户属性。

在本文中,我们介绍了如何使用发布标志、主题名称和数据包标识符构建有效的 PUBLISH 标头。在本系列的下一篇文章中,我们将了解如何编写其属性。

顺便提一下上次的改动:如果您关注本 MQTT 客户端的开发,您可能已经注意到我们更改了多个函数签名、变量名、字段访问级别、测试补丁等。其中一些变化是任何软件开发过程中都会发生的,但大多数变化都是由于我们正在使用 TDD 方法,并努力尽可能忠实于这种方法,以便在这些文章中进行报告。我们可以预期,在我们有第一个可交付成果之前,会有很多变化。

如你所知,没有一个开发人员自己知道为我们的代码库开发这样的客户端所需的一切。TDD 在我们的“大规格,小步骤”之旅中帮助很大,但如果你能帮忙,请在我们的社区聊天或下面的评论中留言。欢迎提供任何帮助。谢谢您。

本文由MetaQuotes Ltd译自英文
原文地址: https://www.mql5.com/en/articles/13998

附加的文件 |
交易策略 交易策略
各种交易策略的分类都是任意的,下面这种分类强调从交易的基本概念上分类。
种群优化算法:进化策略,(μ,λ)-ES 和 (μ+λ)-ES 种群优化算法:进化策略,(μ,λ)-ES 和 (μ+λ)-ES
本文研究一套称为进化策略(ES)的优化算法。它们是最早使用进化原理来寻找最优解的种群算法之一。我们将针对传统的 ES 变体实现变更,并修改算法的测试函数和测试台方法。
新手在交易中的10个基本错误 新手在交易中的10个基本错误
新手在交易中会犯的10个基本错误: 在市场刚开始时交易, 获利时不适当地仓促, 在损失的时候追加投资, 从最好的仓位开始平仓, 翻本心理, 最优越的仓位, 用永远买进的规则进行交易, 在第一天就平掉获利的仓位,当发出建一个相反的仓位警示时平仓, 犹豫。
数据科学和机器学习(第 17 部分):摇钱树?外汇交易中随机森林的艺术与科学 数据科学和机器学习(第 17 部分):摇钱树?外汇交易中随机森林的艺术与科学
探索算法炼金术的秘密,我们将引导您融会贯通如何在解码金融领域时将艺术性和精确性相结合。揭示随机森林如何将数据转化为预测能力,为驾驭股票市场的复杂场景提供独特的视角。加入我们的旅程,进入金融魔法的心脏地带,此处我们会揭开随机森林在塑造市场命运、及解锁赚钱机会之门方面之角色的神秘面纱