为 Metatrader 5 开发 MQTT 客户端:TDD 方法 - 第 6 部分(基础篇)
「用测试驱动逼近 MT5 的 MQTT 客户端」
在 MT5 里写 MQTT 客户端,最怕一上来就闷头堆连接逻辑。Kent Beck 那句“乐观是编程的职业危害;反馈才是解决办法”放到 EA 开发里同样成立:你以为 broker 的 socket 永远稳,实则断线重连能拖垮整个 OnTick。 把 TDD 引进来,意味着先写失败用例再补实现。对 MQL5 来说,就是先用脚本验证“发布/订阅报文能否被本地 broker 回显”,而不是先调 CURLOPT。 这套思路在 2024 年 12 月 12 日的一篇 MT5 开发笔记里被点名:作者用 616 行量级的示例工程演示了从零搭客户端的反馈闭环。你开 MT5 新建脚本,照着先写断言再写网络层,能少走很多裸连导致的坑。
测试驱动里的技术债与持久层取舍
TDD 能把单元边界和变量命名逼得很清楚,测试覆盖率也容易拉高,但它有个绕不开的副作用:人会自动戴上眼罩,每次只啃最小的一块,故意不碰系统整体复杂度。复杂度不会因为你不看就消失,反而会在你盯着树叶时悄悄变成技术债——多余函数、重复成员、不可读代码全攒下来,慢慢啃掉后续生产力。 所以重构在 TDD 里不是可选项,是必需环节。我们之前写的类要反复打磨,PUBLISH 数据包结构理清后,第一个落地的就是 PUBACK 类;它是 QoS 1 下 PUBLISH 的应答端,逼着客户端必须管会话状态。 会话状态持久层不在 OASIS 标准强制范围内,实现方式很自由:本地文件、云上分布式库都行。MQL5 原生集成了 SQLite,这种单文件无服务器 RDBMS 几乎是不假思索的选择——轻、可扩、免运维,还能开纯内存库方便调试。 不过这阶段先不写持久层。我们打算先把协议编解码的不同数据类型用单元测试打透,再对着本地 Eclipse mosquitto 开源代理做小规模功能测试。PUBLISH/PUBACK 交互验证会先用假数据库加受控数据装置顶上,等编码解码稳了再碰持久层。外汇与贵金属相关的自动化系统若接这类模块,须留意实盘链路的高风险与断连代价。
◍ 重构后的 PUBLISH 包构建逻辑与标志位处理
重写 CPublish 类时,我们砍掉了冗余成员,把固定报头和可变报头的构建合并进同一个分步构造器。现在 SetTopicName()、SetRetain()、SetQos1() 可以任意顺序调用,只要最后跑一次 Build() 就能产出合法包。测试覆盖了 RETAIN+QoS1 等标志组合,确认乱序设置不影响结果。 PUBLISH 固定报头和其他 MQTT 5.0 控制包不同,RETAIN、QoS、DUP 三个标志位直接占用首字节,不保留给未来扩展。重构后切换某个发布标志不再各自调 SetFixedHeader(),而是先转成布尔量传参:true 就与 uchar 成员做按位或赋值置位,false 就做按位与赋值清位,统一攒在 m_pubflags 里,Build() 时再和字节 0 做一次或运算。 可变报头里主题名是必填项且禁通配符,设置时有两条防护:命中通配符或零长度立即报错返回。数据包标识符不用手填,Build() 时按位与 0x06 探 m_pubflags,非零即 QoS>0,自动补 ID;测试态下置 TEST 布尔可强锁 ID=1。 属性段支持八类 PUBLISH 属性,数据类型跨度从单字节到可变字节整数(上限 268,435,455)。Payload Format Indicator 只用 0/1 表原始或 UTF-8,Message Expiry Interval 恒占四字节大端,Topic Alias 走双字节大端,User Property 靠三块辅助缓冲拼 UTF-8 键值对。Build() 末段把属性长和剩余长都编成可变字节整数塞进包尾。 QoS1 的 PUBLISH 必带非零包 ID,对方回 PUBACK 时原样带回该 ID 作为送达凭据。PUBACK 原因码有九种,仅 0x00 成功可隐式省略;若代理回 NO MATCHING SUBSCRIBERS 说明没人订阅但消息已发。客户端在收到对应 PUBACK 前必须把 PUBLISH 挂为未确认态。
「PUBLISH 报文头的类封装落点」
MQTT 里 PUBLISH 控制报文的变长头按固定次序排:主题名、包标识符、属性。想在 MT5 里发自定义行情,得先把这个顺序焊死在代码里,否则 broker 端的 MQTT 桥接大概率直接丢包。 下面这段是 CPublish 类的骨架,继承自 IControlPacket,把发布标志、剩余长度、主题名、属性、负载全拆成受保护成员。注意 m_topname[] 和 m_props[] 都是 uchar 动态数组,说明主题名与属性在序列化前都按字节流处理,不是直接塞 string。 私有里 HasWildcardChar 用来拦掉主题里的通配符,避免订阅端收到预期外的泛匹配。公开方法里 SetQoS_1 / SetQoS_2 分开设,是因为 MQTT 的 QoS 标志位是互斥的两位,不能一次写死。 在 MT5 里把这段 include 进 EA 后,调 SetTopicName("EURUSD/quote") 再填 payload,就可能通过本地 MQTT 代理把报价推到外部看板;外汇与贵金属杠杆高,这类自建通道只做辅助监控,别拿它当下单链路。
class="macro">#include "IControlPacket.mqh" class=class="str">"cmt">/* The Variable Header of the PUBLISH Packet contains the following fields in the order: Topic Name, Packet Identifier, and Properties. */ class CPublish : class="kw">public IControlPacket { class="kw">private: class="type">bool IsControlPacket() {class="kw">return true;} class="type">bool HasWildcardChar(const class="type">class="kw">string str); class="kw">protected: class="type">uchar m_pubflags; class="type">uint m_remlen; class="type">uchar m_topname[]; class="type">uchar m_props[]; class="type">uint m_payload[]; class="kw">public: CPublish(); ~CPublish(); class=class="str">"cmt">//--- methods for setting Publish flags class="type">void SetRetain(const class="type">bool retain); class="type">void SetQoS_1(const class="type">bool QoS_1); class="type">void SetQoS_2(const class="type">bool QoS_2); class="type">void SetDup(const class="type">bool dup); class=class="str">"cmt">//--- method for setting Topic Name class="type">void SetTopicName(const class="type">class="kw">string topic_name); class=class="str">"cmt">//--- methods for setting Properties class="type">void SetPayloadFormatIndicator(PAYLOAD_FORMAT_INDICATOR format); class="type">void SetMessageExpiryInterval(class="type">uint msg_expiry_interval); class="type">void SetTopicAlias(class="type">class="kw">ushort topic_alias); class="type">void SetResponseTopic(const class="type">class="kw">string response_topic);