为 MetaTrader 5 开发MQTT客户端:TDD方法第3部分·进阶篇
📘

为 MetaTrader 5 开发MQTT客户端:TDD方法第3部分·进阶篇

第 2/2 篇

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 里复刻验证。

MQL5 / C++
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 开发涉及这类底层解析,行情延迟与断包风险较高,验证结果仅代表本地逻辑正确概率。

MQL5 / C++
 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 地址。

MQL5 / C++
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 位原因码。

MQL5 / C++
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 里的类拖进项目,先编译过再谈别的。

常见问题

0x05 表示未授权,先核对用户名密码与 ACL 配置;若用匿名,确认服务端允许匿名连接再重发 CONNECT。
首字节高 4 位为 2(即 0x20)即为 CONNACK;用位运算 (byte >> 4) == 2 可直接剥离类型,不必解析整包。
可以。小布能读取连接日志中的原因码,对照协议表指出未授权或不支持的 QoS,并提示修改配置项。
不要硬发。收到 CONNACK 回拒后客户端应降级到 QoS 0、Retain 置 false,避免反复被断连。
写一个派生测试类公开继承,用 using 或包装函数把受保护方法暴露给测试,不侵入业务类本身。