为 MetaTrader 5 开发MQTT客户端:TDD方法第3部分·进阶篇
CONNACK连接原因码怎么读
MQTT v5.0 一共定义了 44 个原因代码(Reason Codes),其中 CONNACK 报文在可变头里单独带一个“连接原因码”,用来告诉客户端这次握手到底成没成。规范里写死了一条:发 CONNACK 的服务器必须从连接原因码取值集合里挑一个返回(对应规范条款 MQTT-3.2.2-8),所以客户端只要解析这一个字节就能判定下一步动作。 实际能拿到的码值不是连续的。0x00 表示 Success,连接直接可用;0x80 到 0x8C 这一段覆盖了协议错误、鉴权失败、服务端不可用等典型异常,比如 0x86 是用户名密码不被接受,0x87 是未授权。再往后 0x90 起会出现 Topic 非法、包过大、限流(0x97 配额超限、0x9F 连接频率超限)以及 0x9C / 0x9D 这种让你换服务器的重定向码。 这些码不只是“成功或失败”的开关。像 0x9A 保留消息不支持、0x9B Will QoS 不支持,都在提前暴露服务端能力边界——你还没发单,就知道哪些发布参数得收敛。外汇或贵金属信号走 MQTT 桥接时,这类握手失败往往意味着行情链路已断,重连前先读码值能少踩很多坑,这类传输层故障叠加杠杆品种本身就是高风险组合。 要拿到原因码,第一步是认报文类型,我们只拦 CONNACK。下面这段就是最轻量的类型识别函数雏形,后面会基于它做测试驱动开发逐步补全解析。
「从首字节剥离 CONNACK 类型识别」
MQTT 控制包的第一个字节高 4 位编码了包类型,服务器回应的首字节直接决定你能否从连接态推进到发布态。规范里写死:CONNACK 的类型枚举值是 0x02,CONNECT 是 0x01,PUBLISH 是 0x03。读不到这个字节,后续连接原因码根本没法定。 别把类型判断塞进 CPktConnect。它只该管 CONNECT 包的构造;读 CONNACK、PUBACK、SUBACK 响应是另一套变动原因,硬揉一起就破了单一责任原则。自然做法是建一个 CSrvResponse 类,实现 IControlPacket 接口,专门承载所有服务器响应控制包。 用 TDD 逼自己只写最小代码:先写失败测试,传一个 wrong_first_byte(比如字符 'X')进去,断言返回类型不等于 CONNACK;再传 right_first_byte 让测试转绿。R. Martin 的三条定律很实在——失败测试前不写生产代码、测试只写到足以失败、生产代码只写到通过当前失败测试。 GetPktType 的实现极薄:把响应缓冲首字节强转 ENUM_PKT_TYPE 返回即可。下面这段 MQL5 是原文核心代码与测试骨架,逐行拆完你就能在 MT5 里复刻验证。
class="type">uchar pkt_type = server_response_buffer[class="num">0]; class=class="str">"cmt">//+------------------------------------------------------------------+ class=class="str">"cmt">//| MQTT - CONTROL PACKET - TYPES | class=class="str">"cmt">//+------------------------------------------------------------------+ class=class="str">"cmt">/* Position: byte class="num">1, bits class="num">7-class="num">4. Represented as a class="num">4-bit unsigned value, the values are shown below. */ enum ENUM_PKT_TYPE { CONNECT = 0x01, class=class="str">"cmt">// Connection request CONNACK = 0x02, class=class="str">"cmt">// Connection Acknowledgment PUBLISH = 0x03, class=class="str">"cmt">// Publish message ... class="type">bool TEST_GetPktType_FAIL() { Print(__FUNCTION__); class=class="str">"cmt">//--- Arrange class="type">uchar expected[] = {(class="type">uchar)CONNACK}; class="type">uchar result[class="num">1] = {}; class="type">uchar wrong_first_byte[] = {&class="macro">#x27;X&class="macro">#x27;}; class=class="str">"cmt">//--- Act CSrvResponse *cut = new CSrvResponse(); ENUM_PKT_TYPE pkt_type = cut.GetPktType(wrong_first_byte); ArrayFill(result,class="num">0,class="num">1,(class="type">uchar)pkt_type); class=class="str">"cmt">//--- Assert class="type">bool isTrue = AssertNotEqual(expected, result); class=class="str">"cmt">//--- cleanup class="kw">delete cut; ZeroMemory(result); class="kw">return isTrue ? true : class="kw">false; } ENUM_PKT_TYPE CSrvResponse::GetPktType(class="type">uchar &resp_buf[]) { class="kw">return (ENUM_PKT_TYPE)resp_buf[class="num">0]; } class="type">bool TEST_GetPktType() { Print(__FUNCTION__); class=class="str">"cmt">//--- Arrange class="type">uchar expected[] = {(class="type">uchar)CONNACK}; class="type">uchar result[class="num">1] = {};
◍ 用单字节数组逼出包类型判定
在 MT5 的 CSrvResponse 单元测试里,可以直接构造一个长度为 1 的 uchar 数组来触发 GetPktType 的解析分支。上面这段把数组初始化成 {2},相当于只喂了报文头第一个字节,绕过了完整包体,专门验证类型识别逻辑。 代码里先 new 出 CSrvResponse 实例,调用 GetPktType(right_first_byte) 拿到 ENUM_PKT_TYPE,再用 ArrayFill 把结果写回长度为 1 的 result 数组。注意 ArrayFill 的起始索引和数量都填 0 和 1,只覆盖首元素,避免越界。 断言阶段用 AssertEqual 比对 expected 与 result,返回布尔值后立刻 delete 对象并 ZeroMemory 清数组。你在 MT5 里复刻时,把 right_first_byte 改成 {0} 或 {1} 就能看到不同包类型的返回差异,外汇与贵金属 EA 开发涉及这类底层解析,行情延迟与断包风险较高,验证结果仅代表本地逻辑正确概率。
class="type">uchar right_first_byte[] = {class="num">2}; class=class="str">"cmt">//--- Act CSrvResponse *cut = new CSrvResponse(); ENUM_PKT_TYPE pkt_type = cut.GetPktType(right_first_byte); ArrayFill(result,class="num">0,class="num">1,(class="type">uchar)pkt_type); 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 ? true : class="kw">false; }
服务器回拒 QoS 与 Retain 时的妥协处理
MQTT v5.0 的 CONNACK 里有两个特殊连接原因码:QoS not supported 与 Retain not supported。它们不是错误,而是服务器在告诉你「连上了,但你要的功能我这边没有」。此时网络层是健康的,数据包格式也没问题,只是需求被降级或忽略。 举例来说,若客户端用 Will QoS 2 发 CONNECT,服务器却回 QoS Maximum 1,开发者面临选择:带着降级标志重发 CONNECT,还是先断线?Retain 不被支持时,是当作无关紧要直接开发,还是重新协商?标准把这些多数留给了应用层,但符合规范的客户端必须实现 AS-IS 的基础响应。 按规范,只有 Will Flag=1 时才允许请求 QoS>0,也就是附带 Will Message 才能用高级 QoS。我们当前不处理 Will,所以只在「够用」和「一步到位」之间取折中:先把服务器不支持的能力记下来,等下次连同一台机子时再用。 具体落点在 CSrvResponse 构造函数。一旦收到 CONNACK 且原因码命中那两个限制码,就把信息写进服务器配置文件,本次先等待断开。这个「稍后」可能是几毫秒,也可能是几周后重连——关键是配置落盘,不丢状态。 下面这段是构造函数里的判定与存储逻辑,逐行看:先判断包类型是否为 CONNACK 且原因码等于 QoS 或 Retain 不支持;命中则新建 CSrvProfile 实例;调用 Update 把服务器 IP(示例填 000.000.00.00)和原始响应缓冲写进去。注意代码里 IP 是占位,实盘要换成真实 broker 地址。
CSrvResponse::CSrvResponse(class="type">uchar &resp_buf[]) { if(GetPktType(resp_buf) == CONNACK && GetConnectReasonCode(resp_buf) == (MQTT_REASON_CODE_QOS_NOT_SUPPORTED || MQTT_REASON_CODE_RETAIN_NOT_SUPPORTED)) { CSrvProfile *serverProfile = new CSrvProfile(); serverProfile.Update("class="num">000.000.class="num">00.00", resp_buf); } }
「用派生类撬开受保护方法做单元测试」
在 MT5 里直接测基类受保护方法会被编译器拦死。务实做法是写一个仅用于测试的派生类,继承被测类(这里就是 CSrvResponse),通过它把受保护方法暴露出来调用,既不改生产代码也不破坏封装边界。 当前阶段服务器配置文件(Server Profile)根本还没落库,测试里只是 Print 一条“正在更新”的占位消息。等后面做持久化,把这处 stub 换成写 SQLite 就行,打印日志甚至可以保留,不影响逻辑。现在的核心目的只是确认:当服务端返回的功能和我们请求的对不上时,客户端能把信息存下来留待重用。 下面这段派生类加两个测试用例,直接拷进 MT5 脚本工程就能跑。TEST_GetConnectReasonCode_FAIL 构造了一个 banned 返回码(第 3 字节填 MQTT_REASON_CODE_BANNED),断言结果不等于 SUCCESS;TEST_GetConnectReasonCode 则用 SUCCESS 码走正常路径,断言相等。两个用例都手工拼了 4 字节报文:0 位包类型 00100000、1 位剩余长度 2、2 位 ACK 标志 0、3 位原因码。
class TestProtectedMethods: class="kw">public CSrvResponse { class="kw">public: TestProtectedMethods() {}; ~TestProtectedMethods() {}; class="type">bool TEST_GetConnectReasonCode_FAIL(); class="type">bool TEST_GetConnectReasonCode(); }; class=class="str">"cmt">//+------------------------------------------------------------------+ class=class="str">"cmt">//| | class=class="str">"cmt">//+------------------------------------------------------------------+ class="type">bool TestProtectedMethods::TEST_GetConnectReasonCode_FAIL() { Print(__FUNCTION__); class=class="str">"cmt">//--- Arrange class="type">uchar expected = MQTT_REASON_CODE_SUCCESS; class="type">uchar reason_code_banned[class="num">4]; reason_code_banned[class="num">0] = B&class="macro">#x27;class="num">00100000&class="macro">#x27;; class=class="str">"cmt">// packet type reason_code_banned[class="num">1] = class="num">2; class=class="str">"cmt">// remaining length reason_code_banned[class="num">2] = class="num">0; class=class="str">"cmt">// connect acknowledge flags reason_code_banned[class="num">3] = MQTT_REASON_CODE_BANNED; class=class="str">"cmt">//--- Act CSrvResponse *cut = new CSrvResponse(); class="type">uchar result = this.GetConnectReasonCode(reason_code_banned); class=class="str">"cmt">//--- Assert class="type">bool isTrue = AssertNotEqual(expected, result); class=class="str">"cmt">//--- cleanup class="kw">delete cut; ZeroMemory(result); class="kw">return isTrue ? true : class="kw">false; } class=class="str">"cmt">//+------------------------------------------------------------------+ class=class="str">"cmt">//| | class=class="str">"cmt">//+------------------------------------------------------------------+ class="type">bool TestProtectedMethods::TEST_GetConnectReasonCode() { Print(__FUNCTION__); class=class="str">"cmt">//--- Arrange class="type">uchar expected = MQTT_REASON_CODE_SUCCESS; class="type">uchar reason_code_success[class="num">4]; reason_code_success[class="num">0] = B&class="macro">#x27;class="num">00100000&class="macro">#x27;; class=class="str">"cmt">// packet type reason_code_success[class="num">1] = class="num">2; class=class="str">"cmt">// remaining length reason_code_success[class="num">2] = class="num">0; class=class="str">"cmt">// connect acknowledge flags reason_code_success[class="num">3] = MQTT_REASON_CODE_SUCCESS; class=class="str">"cmt">//--- Act CSrvResponse *cut = new CSrvResponse(); class="type">uchar result = this.GetConnectReasonCode(reason_code_success); 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 ? true : class="kw">false; }
◍ 把这条线请下神坛
走到这一步,MQTT v5.0 客户端的操作行为骨架已经搭起来了:CSrvResponse 类负责把服务器响应类型和原因代码拆开识别,遇到不支持的服务器功能就直接走降级逻辑,而不是卡死在握手阶段。下一步要落的是 PUBLISH 与 QoS 级别的真实交互,以及会话持久化那点近乎强制的本地落地。 顺手记几个写代码时老被提起的缩写:DRY(别重复自己)、KISS(尽量简单)、YAGNI(现在用不上的先别写)。它们算经验口诀,但落到 MT5 终端里跑不跑得通,终归看你自己环境——YMMV(效果因人而异)。 外汇与贵金属行情靠外部 broker 推送,这类异步通道一旦抖动,客户端容错比指标漂亮更重要;开 MT5 把 headers.zip 里的类拖进项目,先编译过再谈别的。