在发送MQTT connect报文之前,必须确保底层提供了有序、可靠、双向连接的网络连接。比如可以建立TCP/TLS连接。
Connect控制报文包括固定报文和可变报文,以及有效载荷三个部分。
有效载荷包含一个或多个编码的字段。包括客户端的唯一标识符,Will主题,Will消息,用户名和密码。除了客户端标识之外,其它的字段都是可选的,基于可变报头中标志位来决定可变报头中是否需要包含这些字段。
长度:2bytes
包括报文类型(1),固定保留位(0000)和剩余长度。
剩余长度等于可变报头的长度(10字节)加上有效载荷的长度。
长度:10bytes。
CONNECT报文的可变报头按下列次序包含四个字段:
我们接下来按照这个顺序依次来介绍这些字段。
l 协议名(Protocol Name)
长度:6bytes
数据包检测工具,例如防火墙,可以使用协议名来识别MQTT流量。
如果协议名不正确服务端可以断开客户端的连接,也可以按照某些其它规范继续处理CONNECT报文。对于后一种情况,按照本规范,服务端不能继续处理CONNECT报文(比如MQTT-3.1.1);
l 协议级别(Protocol Level)
长度:1byte
客户端用8位的无符号值表示协议的修订版本。对于3.1.1版协议,协议级别字段的值是4(0x04)。如果发现不支持的协议级别,服务端必须给发送一个返回码为0x01(不支持的协议级别)的CONNACK报文响应CONNECT报文,然后断开客户端的连接。
l 连接标志(Connect Flags)
连接标志字节包含一些用于指定MQTT连接行为的参数。它还指出有效载荷中的字段是否存在。
服务端必须验证CONNECT控制报文中连接标志的保留标志位(第0位)是否为0,如果不为0必须断开客户端连接。
ü Clean Session:
控制初次连接或是重连时候,服务器对于会话的处理方式。
如果没有与这个客户端标识符关联的会话,服务端必须创建一个新的会话。
如果是重连,服务端必须基于当前会话(使用客户端标识符识别)的状态恢复与客户端的通信。当连接断开后,客户端和服务端必须保存会话信息。
如果在断开连接之前,客户端订阅了QoS1和QoS2级别的消息,那么服务端必须将之后的QoS 1和QoS 2级别的消息保存为会话状态的一部分。服务端也可以保存满足相同条件的QoS 0级别的消息。
客户端和服务端必须丢弃之前的任何会话并开始一个新的会话。会话仅持续和网络连接同样长的时间。与这个会话关联的状态数据不能被任何之后的会话重用。
当清理会话标志被设置为1时,客户端和服务端的状态删除不需要是原子操作。
ü Will Flag:
用于控制遗嘱消息(will Message)的操作行为。
一般来说,客户端连接时总是将清理会话标志设置为0或1,并且不交替使用两种值。这个选择取决于具体的应用。清理会话标志设置为1的客户端不会收到旧的应用消息,而且在每次连接成功后都需要重新订阅任何相关的主题。清理会话标志设置为0的客户端会收到所有在它连接断开期间发布的QoS 1和QoS 2级别的消息。因此,要确保不丢失连接断开期间的消息,需要使用QoS 1或 QoS 2级别,同时将清理会话标志设置为0。
表示如果连接请求被接受了,遗嘱(Will Message)消息必须被存储在服务端并且与这个网络连接关联。之后网络连接关闭(没有收到DISCONNET报文)时,服务端必须发布这个遗嘱消息。如果服务器收到DISCONNET报文,遗嘱消息将会被删除。
连接标志中的Will QoS和Will Retain字段会被服务端用到,同时有效载荷中必须包含Will Topic和Will Message字段。
网络连接断开时,不能发送遗嘱消息
连接标志中的Will QoS和Will Retain字段必须设置为0,并且有效载荷中不能包含Will Topic和Will Message字段
ü Will Qos:
这两位用于指定发布遗嘱消息时使用的服务质量等级,和will flag配合使用。
ü Will Retain:
遗嘱保留,如果遗嘱消息被发布时需要保留,需要指定这一位的值,和will flag位配合使用。
--- 如果遗嘱保留被设置为0,服务端必须将遗嘱消息当作非保留消息发布。
--- 如果遗嘱保留被设置为1,服务端必须将遗嘱消息当作保留消息发布。
ü PassWordFlag:
ü UserName Flag:
l 保持连接
长度:2bytes
保持连接(KeepAlive)是一个以秒为单位的时间间隔,表示为一个16位的字,它是指在客户端传输完成一个控制报文的时刻到发送下一个报文的时刻,两者之间允许空闲的最大时间间隔。客户端负责保证控制报文发送的时间间隔不超过保持连接的值。如果没有任何其它的控制报文可以发送,客户端必须发送一个PINGREQ报文。
不管保持连接的值是多少,客户端任何时候都可以发送PINGREQ报文,并且使用PINGRESP报文判断网络和服务端的活动状态。
如果保持连接的值非零,并且服务端在一点五倍的保持连接时间内没有收到客户端的控制报文,它必须断开客户端的网络连接,认为网络连接已断开。
客户端发送了PINGREQ报文之后,如果在合理的时间内仍没有收到PINGRESP报文,它应该关闭到服务端的网络连接。
保持连接的值为零表示关闭保持连接功能。这意味着,服务端不需要因为客户端不活跃而断开连接。注意:不管保持连接的值是多少,任何时候,只要服务端认为客户端是不活跃或无响应的,可以断开客户端的连接。
保持连接的实际值是由应用指定的,一般是几分钟。允许的最大值是18小时12分15秒,即65535,uint16。
典型的可变报文格式如下:
CONNECT报文的有效载荷(payload)包含一个或多个以长度为前缀的字段,可变报头中的标志决定是否包含这些字段。如果包含的话,必须按这个顺序出现:客户端标识符,遗嘱主题,遗嘱消息,用户名,密码。
l 客户端标识符
服务端使用客户端标识符 (ClientId) 识别客户端。连接服务端的每个客户端都有唯一的客户端标识符(ClientId)。客户端和服务端都必须使用ClientId识别两者之间的MQTT会话相关的状态。
客户端标识符 (ClientId) 必须存在而且必须是CONNECT报文有效载荷的第一个字段。
客户端标识符必须是1.5.3节定义的UTF-8编码字符串。
服务端必须允许1到23个字节长的UTF-8编码的客户端标识符,客户端标识符只能包含这些字符:“0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ”(大写字母,小写字母和数字);
服务端可以允许编码后超过23个字节的客户端标识符(ClientId)。服务端可以允许包含不是上面列表字符的客户端标识符 (ClientId)。
服务端可以允许客户端提供一个零字节的客户端标识符 (ClientId) ,如果这样做了,服务端必须将这看作特殊情况并分配唯一的客户端标识符给那个客户端。然后它必须假设客户端提供了那个唯一的客户端标识符,正常处理这个CONNECT报文。
如果客户端提供了一个零字节的客户端标识符,它必须同时将清理会话标志设置为1。如果客户端提供的ClientId为零字节且清理会话标志为0,服务端必须发送返回码为0x02(表示标识符不合格)的CONNACK报文响应客户端的CONNECT报文,然后关闭网络连接。
如果服务端拒绝了这个ClientId,它必须发送返回码为0x02(表示标识符不合格)的CONNACK报文响应客户端的CONNECT报文,然后关闭网络连接。
客户端实现可以提供一个方便的方法用于生成随机的ClientId。当清理会话标志被设置为0时应该主动放弃使用这种方法。
l 遗嘱主题
如果遗嘱标志被设置为1,有效载荷的下一个字段是遗嘱主题(Will Topic)。遗嘱主题必须是 1.5.3节定义的UTF-8编码字符串。
l 遗嘱消息
如果遗嘱标志被设置为1,有效载荷的下一个字段是遗嘱消息。遗嘱消息定义了将被发布到遗嘱主题的应用消息,见3.1.2.5节的描述。
这个字段由一个两字节的长度(表示为零字节或多个字节序列)和遗嘱消息的有效载荷组成。长度给出了跟在后面的数据的字节数,不包含长度字段本身占用的两个字节。
遗嘱消息被发布到遗嘱主题时,它的有效载荷只包含这个字段的数据部分,不包含开头的两个长度字节。
l 用户名
如果用户名(User Name)标志被设置为1,有效载荷的下一个字段就是它。用户名必须是 1.5.3节定义的UTF-8编码字符串 [MQTT-3.1.3-11]。服务端可以将它用于身份验证和授权。
l 密码
如果密码(Password)标志被设置为1,有效载荷的下一个字段就是它。密码字段包含一个两字节的长度字段,长度表示二进制数据的字节数(不包含长度字段本身占用的两个字节),后面跟着0到65535字节的二进制数据。
服务器可以在同一个TCP端口或其他网络端点上支持多种协议(包括本协议的早期版本)。
如果服务器确定协议是MQTT 3.1.1,那么它按照下面的方法验证连接请求。
如果验证成功,服务端会执行下列步骤。
允许客户端在发送CONNECT报文之后立即发送其它的控制报文;客户端不需要等待服务端的CONNACK报文。如果服务端拒绝了CONNECT,它不能处理客户端在CONNECT报文之后发送的任何数据。
客户端通常会等待一个CONNACK报文。然而客户端有权在收到CONNACK之前发送控制报文,由于不需要维持连接状态,这可以简化客户端的实现。
Ø 重复发送connect报文
在一个网络连接上,客户端只能发送一次CONNECT报文。服务端必须将客户端发送的第二个CONNECT报文当作协议违规处理并断开客户端的连接。
Ø 协议名不正确
如果协议名不正确服务端可以断开客户端的连接,也可以按照某些其它规范继续处理CONNECT报文。对于后一种情况,按照本规范,服务端不能继续处理CONNECT报文(比如MQTT3.1.1);
Ø 不支持的协议级别
如果发现不支持的协议级别,服务端必须给发送一个返回码为0x01(不支持的协议级别)的CONNACK报文响应CONNECT报文,然后断开客户端的连接。
Ø 连接标志字段中的保留位验证失败
服务端必须验证CONNECT控制报文中连接标志的保留标志位(第0位)是否为0,如果不为0必须断开客户端连接。
Ø 客户端发送PINGREQ报文,服务器端超时无反应
如果在合理的时间内仍没有收到PINGRESP报文,它应该关闭到服务端的网络连接。
Ø 服务器端长时间未收到客户端的报文
如果保持连接的值非零,并且服务端在一点五倍的保持连接时间内没有收到客户端的控制报文或是PINGRESP报文,它必须断开客户端的网络连接,认为网络连接已断开。
Ø 服务器端收到ClientID为0但是ClearSession为0的报文
如果客户端提供的ClientId为零字节且清理会话标志为0,服务端必须发送返回码为0x02(表示标识符不合格)的CONNACK报文响应客户端的CONNECT报文,然后关闭网络连接。
Ø 服务器端收到非法的ClientID报文请求
如果服务端拒绝了这个ClientId,它必须发送返回码为0x02(表示标识符不合格)的CONNACK报文响应客户端的CONNECT报文,然后关闭网络连接。
本案例基于Ali 云OS的MQTT通信协议。从他们的开发文档来看,是有一些修改,不过大致过程是一样的。
1)连接配置
所有mqtt连接的配置来着如下的两个结构体:
/* The structure of MQTT initial parameter */ typedef struct { uint16_t port; /* Specify MQTT broker port */ const char *host; /* Specify MQTT broker host */ const char *client_id; /* Specify MQTT connection client id*/ const char *username; /* Specify MQTT user name */ const char *password; /* Specify MQTT password */
/* Specify MQTT transport channel and key. * If the value is NULL, it means that use TCP channel, * If the value is NOT NULL, it means that use SSL/TLS channel and * @pub_key point to the CA certification */ const char *pub_key;
uint8_t clean_session; /* Specify MQTT clean session or not*/ uint32_t request_timeout_ms; /* Specify timeout of a MQTT request in millisecond */ uint32_t keepalive_interval_ms; /* Specify MQTT keep-alive interval in millisecond */
char *pwrite_buf; /* Specify write-buffer */ uint32_t write_buf_size; /* Specify size of write-buffer in byte */ char *pread_buf; /* Specify read-buffer */ uint32_t read_buf_size; /* Specify size of read-buffer in byte */
iotx_mqtt_event_handle_t handle_event; /* Specify MQTT event handle */
} iotx_mqtt_param_t, *iotx_mqtt_param_pt; typedef struct { /** The eyecatcher for this structure. must be MQTC. */ char struct_id[4]; /** The version number of this structure. Must be 0 */ int struct_version; /** Version of MQTT to be used. 3 = 3.1 4 = 3.1.1 */ unsigned char MQTTVersion; MQTTString clientID; unsigned short keepAliveInterval; //鍗曚綅s unsigned char cleansession; unsigned char willFlag; MQTTPacket_willOptions will; MQTTString username; MQTTString password; } MQTTPacket_connectData; #define MQTTPacket_connectData_initializer { {'M', 'Q', 'T', 'C'}, 0, 4, {NULL, {0, NULL}}, KEEP_ALIVE_INTERVAL_DEFAULT_MIN, 1, 0, \ MQTTPacket_willOptions_initializer, {NULL, {0, NULL}}, {NULL, {0, NULL}} } |
其中iotx_mqtt_param_t结构体通过如下的代码完成初始化数据的填充。
/* Device AUTH */ if (0 != IOT_SetupConnInfo(PRODUCT_KEY, DEVICE_NAME, DEVICE_SECRET, (void **)&pconn_info)) { EXAMPLE_TRACE("AUTH request failed!"); rc = -1; goto do_exit; }
/* Initialize MQTT parameter */ memset(&mqtt_params, 0x0, sizeof(mqtt_params));
mqtt_params.port = pconn_info->port; mqtt_params.host = pconn_info->host_name; mqtt_params.client_id = pconn_info->client_id; mqtt_params.username = pconn_info->username; mqtt_params.password = pconn_info->password; mqtt_params.pub_key = pconn_info->pub_key;
mqtt_params.request_timeout_ms = 2000; mqtt_params.clean_session = 0; mqtt_params.keepalive_interval_ms = 60000; mqtt_params.pread_buf = msg_readbuf; mqtt_params.read_buf_size = MSG_LEN_MAX; mqtt_params.pwrite_buf = msg_buf; mqtt_params.write_buf_size = MSG_LEN_MAX;
mqtt_params.handle_event.h_fp = event_handle; mqtt_params.handle_event.pcontext = NULL; |
然后调用iotx_mc_set_connect_params()函数,
MQTTPacket_connectData connectdata = MQTTPacket_connectData_initializer; connectdata.MQTTVersion = IOTX_MC_MQTT_VERSION; connectdata.keepAliveInterval = pInitParams->keepalive_interval_ms / 1000;
connectdata.clientID.cstring = (char *)pInitParams->client_id; connectdata.username.cstring = (char *)pInitParams->username; connectdata.password.cstring = (char *)pInitParams->password; |
2)包空间申请
在Aliyun的MQTT协议的实现中,是通过预先malloc一个1K大小的Buffer用来作为send_buf的,并将指针通过一系列的赋值,传递到第3步。
顺便多提下,Aliyun在实现的过程也同时预先malloc一个1K大小的Buffer用来作为read_buf。
3)组包过程
在网络编程或是寄存器编程中,有个常用的办法就是利用联合体的方式。比如,在组成固定包头中,编程过程中定义了联合体MQTTHeader。
使用的办法参考下面的代码。
而连接标志字段的填充也是利用了联合体的特性。注意是小端格式。
/** * Serializes the connect options into the buffer. * @param buf the buffer into which the packet will be serialized * @param len the length in bytes of the supplied buffer * @param options the options to be used to build the connect packet * @return serialized length, or error if 0 */ int MQTTSerialize_connect(unsigned char* buf, int buflen, MQTTPacket_connectData* options) { unsigned char *ptr = buf; MQTTHeader header = {0}; MQTTConnectFlags flags = {0}; int len = 0; int rc = -1;
if (MQTTPacket_len(len = MQTTSerialize_connectLength(options)) > buflen) { rc = MQTTPACKET_BUFFER_TOO_SHORT; goto exit; } // header是一个联合体。CONNECT定义的值为1。 header.byte = 0; header.bits.type = CONNECT; writeChar(&ptr, header.byte); /* write header */ // 写入固定报头的第二个字节,即剩余长度。 ptr += MQTTPacket_encode(ptr, len); /* write remaining length */
// 写入可变报头中的协议名称和协议等级字段 if (options->MQTTVersion == 4) { writeCString(&ptr, "MQTT"); writeChar(&ptr, (char) 4); } else { writeCString(&ptr, "MQIsdp"); writeChar(&ptr, (char) 3); }
// flags是定义的联合体,按照默认的配置cleansession=1,willFlag= 0 flags.all = 0; flags.bits.cleansession = options->cleansession; flags.bits.will = (options->willFlag) ? 1 : 0; if (flags.bits.will) { flags.bits.willQoS = options->will.qos; flags.bits.willRetain = options->will.retained; }
if (options->username.cstring || options->username.lenstring.data) flags.bits.username = 1; if (options->password.cstring || options->password.lenstring.data) flags.bits.password = 1; writeChar(&ptr, flags.all);
// 组合可变报头中的保持连接(Keep Alive)长度 writeInt(&ptr, options->keepAliveInterval);
// 组合有效载荷中的客户端标识符 writeMQTTString(&ptr, options->clientID);
// 组合有效载荷中的遗嘱主题,遗嘱消息(如果willFlag =1), if (options->willFlag) { writeMQTTString(&ptr, options->will.topicName); writeMQTTString(&ptr, options->will.message); }
// 组合有效载荷中的用户名字, if (flags.bits.username) writeMQTTString(&ptr, options->username);
// 组合有效载荷中的密码, if (flags.bits.password) writeMQTTString(&ptr, options->password);
rc = ptr - buf;
exit: return rc; } |
固定报头中,关于剩余长度的计算过程:
4)发送
直接调用底层的发送函数发送即可。是实际的实现过程中,需要在客户端新建一个定时器,用来监听是否按时收到CONNACK。如果没有及时收到CONNACK,客户端应该断开网络连接,这个超时时间需要根据应用类型和通信环境,我们在本例程中设置的时间是2000ms。
当然,协议运行客户端在收到CONNACK之前发送其他的控制报文。