为 Metatrader 5 开发 MQTT 客户端:TDD 方法 - 第 5 部分(基础篇)
◍ 先把过早优化的坑记牢
在给 MT5 写 MQTT 客户端之前,得先认一句老话:过早优化是万恶之源。Donald Knuth 当年这句提醒,放到 EA 和外接消息中间件的工程里同样刺耳。 很多交易者一上手就想把心跳包、重连退避、QoS 队列全塞进 mql5 脚本,结果主图逻辑还没跑通,网络层先把自己绕死。外汇与贵金属杠杆交易本身高风险,先把能连通、能收发的骨架搭起来,比纠结那几毫秒延迟务实得多。 这篇文章系列(2024-08-12 发布,系列第 5 部分)前面读者已看过 552 次基础铺垫,说明社群对 MT5 接 MQTT 的需求是实打实存在的。先让客户端跑通一个 PUBLISH,再谈别的。
「PUBLISH 包为什么是 MQTT 对称通信的核心」
MQTT 走的是 pub/sub 模型,所以 PUBLISH 和 SUBSCRIBE 两类包才是主干,其余包型基本都围着它们转。传输协议是对称的:客户端发出去的 PUBLISH,和服务器转发给其他客户端的 PUBLISH,结构完全一致,因此写客户端时既要能组包也得能解包。 按 OASIS 标准定义,PUBLISH 用于“从客户端到服务器,或从服务器到客户端传递应用消息”。它的固定报头带发布标志(DUP、QoS、RETAIN),可变报头包含 UTF-8 编码的主题名,以及当 QoS>0 时才出现的数据包标识符。MQTT 5.0 起还能挂几乎全部属性,包括请求/响应模式用的配对属性。 文中后续会拆报头结构,并实测发布标志、主题名、包标识符这三处的编解码。标准文档里的 MUST / MAY 措辞沿用 IETF RFC 2119 语义,未另注的引文均出自 OASIS 标准,做底层对接时这几个词决定的是强制项还是可选项,不能混读。
PUBLISH 固定报头里那四个被占用的位
MQTT 5.0 里所有控制包的固头都是两字节起步:首字节高四位是包类型,次字节是变长剩余长度。但 PUBLISH 跟其他十几种包不一样——它的首字节低四位被 RETAIN、QoS1、QoS2、DUP 占用了,而 CONNECT、PUBACK 这些包的低四位全是 Reserved 且必须写 0。
通用写法 dest_buf[0] = (uchar)pkt_type << 4 只能把类型左移四位、低四位留 0,对 PUBLISH 来说等于完全没处理发布标志。原实现里 SetFixedHeader 只接包类型和两个数组引用,第二行 dest_buf[1] = EncodeVariableByteInteger(buf) 负责剩余长度编码,但确实没有任何入口去设那四个标志位。
[CODE]
//+------------------------------------------------------------------+
| // | SetFixedHeader |
|---|
//+------------------------------------------------------------------+ void SetFixedHeader(ENUM_PKT_TYPE pkt_type, uchar& buf[], uchar& dest_buf[]) { dest_buf[0] = (uchar)pkt_type << 4; // 取包类型枚举值,左移4位填入首字节高四位,低四位保持0(Reserved/未设标志) dest_buf[1] = EncodeVariableByteInteger(buf); // 调用变长整数编码函数,把剩余长度写进次字节 } //+------------------------------------------------------------------+
| // | SetFixedHeader |
|---|
作者后来加了个 switch 分支专门接 PUBLISH,并多传一个默认 0 的 publish_flags 参数;只在设了标志时才用 OR 运算改 dest_buf[0]。标志常量按位权定义:RETAIN=1(0b00000001)、QoS1=2(0b00000010)、QoS2=4(0b00000100)、DUP=8(0b00001000)。PUBLISH 类型值本身为 3,左移四位得 48(0b00110000)。
| 位运算能直接验证:48 | 8 = 56(0b00111000) 即带 DUP 的 PUBLISH;48 | 1 | 4 = 53(0b00110101) 即 RETAIN+QoS2。取消标志用 `m_publish_flags &= ~RETAIN_FLAG`,因为和 0 相与必为 0,翻转位后对应位就被清掉。这些十进制数(48/49/51/53/56…)就是单元测试里断言固头首字节的硬指标。 |
|---|
RETAIN=1 让服务器存最后一条该主题消息给新订阅者;发零长度载荷且 RETAIN=1 可清保留;共享订阅里保留消息只投给一个成员。QoS 0 是阅后即焚(可能丢、可能重),QoS 1 靠 PUBACK 保证至少一次(可能重),QoS 2 走 PUBREC/PUBREL/PUBCOMP 四包保证恰好一次——标准明确传输对称,客户端也可当发送方。DUP=1 只说明在重发旧 PUBLISH 包本身,QoS 0 时强制为 0。外汇/贵金属端的 MQTT 遥测接入若用错 QoS,可能倾向出现信号丢失或重复下单,属高风险配置项。
class=class="str">"cmt">//+------------------------------------------------------------------+ class=class="str">"cmt">//| SetFixedHeader | class=class="str">"cmt">//+------------------------------------------------------------------+ class="type">void SetFixedHeader(ENUM_PKT_TYPE pkt_type, class="type">uchar& buf[], class="type">uchar& dest_buf[]) { dest_buf[class="num">0] = (class="type">uchar)pkt_type << class="num">4; dest_buf[class="num">1] = EncodeVariableByteInteger(buf); } class=class="str">"cmt">//+------------------------------------------------------------------+ class=class="str">"cmt">//| SetFixedHeader |
◍ PUBLISH 包标志位怎么塞进固定头
MQTT 风格的 PUBLISH 报文在 MT5 里靠一个字节的固定头承载类型与标志:高 4 位是报文类型,低 4 位留给 retain、QoS、dup 等发布标志。下面这段函数把类型左移 4 位后,或上 publish_flags 写进 dest_buf[0],第二字节放负载长度的可变字节编码。 [CODE]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; } }[/CODE] 逐行看:函数入口拿报文类型、负载 buf、输出缓冲和默认 0 的标志位;switch 进 PUBLISH 分支时,先把类型值左移 4 位占高半字节,再用 |= 把 retain/QoS/dup 标志压进低半字节,随后第二字节写编码后的长度;非 PUBLISH 分支不挂标志位,只写类型和长度。 标志常量定义很直白:RETAIN_FLAG=0x01、QoS_1_FLAG=0x02、QoS_2_FLAG=0x04、DUP_FLAG=0x08,四个位互不重叠,可以直接或运算。CPktPublish::SetRetain 里就是一句三元改 m_publish_flags 再重设头。
| 单测能验证位组合:同时开 dup、QoS2、retain 时,低 4 位 = 0x08 | 0x04 | 0x01 = 0x0D,高 4 位 PUBLISH 类型若编号为 3 则 3<<4=0x30,合计 0x3D = 61。测试里 expected 首字节正是 61,第二字节 0 表示空负载长度编码,开 MT5 跑 TEST_SetFixedHeader_DUP_QoS2_RETAIN 应对得上。 |
|---|
class="type">void SetFixedHeader(ENUM_PKT_TYPE pkt_type, class="type">uchar& buf[], class="type">uchar& dest_buf[], class="type">uchar publish_flags = class="num">0) { class="kw">switch(pkt_type) { case PUBLISH: dest_buf[class="num">0] = (class="type">uchar)pkt_type << class="num">4; dest_buf[class="num">0] |= publish_flags; dest_buf[class="num">1] = EncodeVariableByteInteger(buf); break; class="kw">default: dest_buf[class="num">0] = (class="type">uchar)pkt_type << class="num">4; dest_buf[class="num">1] = EncodeVariableByteInteger(buf); break; } } class=class="str">"cmt">//+------------------------------------------------------------------+ class=class="str">"cmt">//| CPktPublish::SetRetain | class=class="str">"cmt">//+------------------------------------------------------------------+ class="type">void CPktPublish::SetRetain(const class="type">bool retain) { retain ? m_publish_flags |= RETAIN_FLAG : m_publish_flags &= ~RETAIN_FLAG; SetFixedHeader(PUBLISH, m_buf, ByteArray, m_publish_flags); } QoS_1 ? m_publish_flags |= QoS_1_FLAG : m_publish_flags &= ~QoS_1_FLAG; SetFixedHeader(PUBLISH, m_buf, ByteArray, m_publish_flags); QoS_2 ? m_publish_flags |= QoS_2_FLAG : m_publish_flags &= ~QoS_2_FLAG; SetFixedHeader(PUBLISH, m_buf, ByteArray, m_publish_flags); dup ? m_publish_flags |= DUP_FLAG : m_publish_flags &= ~DUP_FLAG; SetFixedHeader(PUBLISH, m_buf, ByteArray, m_publish_flags); class=class="str">"cmt">//+------------------------------------------------------------------+ class=class="str">"cmt">//| PUBLISH - FIXED HEADER - PUBLISH FLAGS | class=class="str">"cmt">//+------------------------------------------------------------------+ enum ENUM_PUBLISH_FLAGS { RETAIN_FLAG = 0x01, QoS_1_FLAG = 0x02, QoS_2_FLAG = 0x04, DUP_FLAG = 0x08 }; class=class="str">"cmt">//+------------------------------------------------------------------+ class=class="str">"cmt">//| TEST_SetFixedHeader_DUP_QoS2_RETAIN | class=class="str">"cmt">//+------------------------------------------------------------------+ class="type">bool TEST_SetFixedHeader_DUP_QoS2_RETAIN() { Print(__FUNCTION__); class=class="str">"cmt">//--- Arrange class="kw">static class="type">uchar expected[] = {class="num">61, class="num">0}; class="type">uchar buf[] = {}; class=class="str">"cmt">//--- Act CPktPublish *cut = new CPktPublish(buf); cut.SetDup(true); cut.SetQoS_2(true); cut.SetRetain(true); class="type">uchar result[]; ArrayCopy(result, cut.ByteArray); class=class="str">"cmt">//--- Assert class="type">bool isTrue = AssertEqual(expected, result); class=class="str">"cmt">//--- cleanup class="kw">delete cut; ZeroMemory(result); class="kw">return isTrue; }