物联网实战--入门篇之(七)嵌入式-MQTT

目录

一、MQTT简介

二、MQTT使用方法

三、MQTT驱动设计

四、代码解析

五、使用过程

六、总结


一、MQTT简介

        MQTT因为其轻量、高效和稳定的特点,特别适合作为物联网系统的数据传输协议,已经成为物联网事实上的通信标准了。关于协议的具体内容看看这篇文章和官方文档MQTT协议详解(完整版)-CSDN博客,在这里我们主要讲解使用方法。

        作为嵌入式设备,设备资源比较紧张,我们这里选用开源库paho mqtt,开源地址在这儿GitHub - eclipse/paho.mqtt.embedded-c: Paho MQTT C client library for embedded systems. Paho is an Eclipse IoT project (https://iot.eclipse.org/)

        我们项目里已经都整理好了,直接用就行了,具体如下图所示,从映射文件可以看出,mqtt开源库大概占用2KB的 ROM,已经很轻量化了。这个开源库的核心作用就是可以帮我们根据协议要求组合要发送的数据,或者拆解接收到的数据,而应用层不用去太关心协议本身的内容。

二、MQTT使用方法

        MQTT是以服务器为中心,客户端对为对象,话题为关系纽带的一种通讯协议,在这个体系里,净化器设备是客户端,用户手机也是客户端,手机订阅净化器发布的话题,服务器就会把净化器发布的消息推送给手机;同样的道理,手机根据设备订阅的话题来发布消息,就可以对净化器设备进行控制了。

        下图是净化器项目的话题,其中11223344是设备的序列号,对于所有净化器的数据手机都能收的到,手机针对某个净化器的数据也只有某个净化器能接收,其它序列号的设备收不到。这里面的核心逻辑都是服务器根据话题来区分运行的。

三、MQTT驱动设计

        MQTT的驱动应该算是比较难的,首先要确定它的地位和作用,如下图所示,drv_mqtt是作为设备端mqtt的核心,整合了底层的开源库、物理层的收发接口和应用层的参数配置功能,以及自身的连接、收发、订阅/取消订阅等功能。

        下面进入代码进行解析,从头文件开始,MQTTPacket.h主要包含了mqtt开源库的功能文件,这个应该没什么问题,下面的ringbuffer.h需要强调下,它是RT-Thread的功能,叫环形缓冲区,就是数据按顺序环形保存,取出的时候按照先进先出的原则,MQTT开源库需要按顺序取出数据解析,有这个ringbuffer作为缓存媒介在操作上非常便捷,这也是使用RT-Thread的另一个重要原因了。

     接下来是宏定义的内容,没什么特殊情况默认即可,有需要改变的在user_opt.h中重定义即可,具体的内容都有注释,就不赘述了。

   

        订阅话题是个重要组成部分,在这里定义了话题的三个状态,空闲、订阅和取消订阅,取消订阅一般用不到,特殊情况下会有一些临时话题,为了缓解资源,可以取消订阅。结构体里的base_msg_id主要是为了标记 订阅/取消订阅 时返回的话题,这样程序才能区分。

        

        最后是最重要的客户端连接信息了,具体都有注释,其中用户名、密码和客户端ID都是指针,在应用层定义这些信息需要用全局变量或者静态变量,才能保证信息的完整性;同样的,收发函数也是采用回调的方式,在应用程根据不同的物理接口进行注册,这里我们采用的自然是esp8266的收发函数了。

四、代码解析

        先从初始化开始,主要就是对用户名、密码和客户端ID进行赋值。

/*		
================================================================================
描述 : 初始化指定MQTT连接
输入 : 
输出 : 
================================================================================
*/
void drv_mqtt_init(u8 index, char *usr_name, char *passwd, char *client_id)
{if(index<MQTT_CONN_NUM){MqttClientStruct *pClient=&g_sMqttWork.client_list[index];MQTTPacket_connectData connect_init = MQTTPacket_connectData_initializer;if((pClient->rb=rt_ringbuffer_create(MQTT_RING_BUFF_SIZE))!=NULL )   {memcpy(&pClient->condata, &connect_init, sizeof(connect_init));//复制连接初始化信息pClient->condata.keepAliveInterval=MQTT_KEEP_TIME;			pClient->condata.username.cstring=usr_name;//用户名pClient->condata.password.cstring=passwd;//密码pClient->condata.clientID.cstring=client_id;//客户ID      pClient->is_enable=true;}      }    
}

        接下来就是连接和订阅了,在这里就可以很清晰的看到mqtt开源库的作用了,就是组合连接、订阅和取消订阅的报文。MQTT里也有保活功能,这是协议层的,如果指定时间内没有没有收到数据,那么会自己发个ping请求包来保持连接。


/*		
================================================================================
描述 : 连接和订阅
输入 : 
输出 : 
================================================================================
*/
void drv_mqtt_connect(void)
{static u32 last_sec_time=0;static u8 make_buff[80]={0};const int make_size=sizeof(make_buff);int make_len;	u32 now_sec_time=drv_get_sec_counter();if(now_sec_time-last_sec_time>=2){static u8 conn_ptr=0;if(conn_ptr>=MQTT_CONN_NUM)conn_ptr=0;MqttClientStruct *pClient=&g_sMqttWork.client_list[conn_ptr];if(pClient->is_enable){if(pClient->is_connected==false){memset(make_buff, 0, make_size);make_len=MQTTSerialize_connect(make_buff, make_size, &pClient->condata);//组合连接请求包		if(pClient->mqtt_send!=NULL){
//          printf("client=%d, mqtt send connect!  make_len=%d\n",conn_ptr, make_len); pClient->mqtt_send(make_buff, make_len);//发送}		          }else{//订阅话题for(u8 i=0; i<MQTT_SUB_NUM; i++){SubPackStruct *pSub=&pClient->sub_list[i];if(strlen(pSub->sub_topic)>0 && pSub->curr_state!=pSub->dst_state){if(pSub->dst_state==TopicStateSub)//需要订阅{MQTTString topicString = MQTTString_initializer;int req_qos=1;					topicString.cstring=pSub->sub_topic;memset(make_buff, 0, make_size);make_len = MQTTSerialize_subscribe(make_buff, make_size, 0, pSub->base_msg_id, 1, &topicString, &req_qos);//组合订阅报文if(pClient->mqtt_send!=NULL){printf("sub topic=%s\n", pSub->sub_topic);pClient->mqtt_send(make_buff, make_len);//发送}		              }else if(pSub->dst_state==TopicStateUnSub)//需要取消订阅{MQTTString topicString = MQTTString_initializer;			topicString.cstring=pSub->sub_topic;memset(make_buff, 0, make_size);make_len = MQTTSerialize_unsubscribe(make_buff, make_size, 0, pSub->base_msg_id, 1, &topicString);//组合取消订阅报文if(pClient->mqtt_send!=NULL){printf("unsub topic=%s\n", pSub->sub_topic);pClient->mqtt_send(make_buff, make_len);//发送}		              }break;//每次只订阅一个,避免堵塞}}//超时检测u32 det_time=now_sec_time-pClient->keep_time;if(det_time>=MQTT_KEEP_TIME){printf("mqtt sock_id=%d timeout, close!\n", conn_ptr);drv_mqtt_close(pClient);//超时关闭	          }else if(det_time>=MQTT_KEEP_TIME-10){//发送ping请求,保活memset(make_buff, 0, make_size);make_len=MQTTSerialize_pingreq(make_buff, make_size);//组合ping包		if(pClient->mqtt_send!=NULL){
//            printf("sock=%d, mqtt send ping req! make_len=%d\n",conn_ptr,make_len); pClient->mqtt_send(make_buff, make_len);//发送}		          }          }}conn_ptr++;last_sec_time=drv_get_sec_counter();}  
}  

        接收部分的逻辑是MQTTPacket_read函数调用回调函数pClient->mqtt_recv获取环形缓冲区内的数据并按照协议解析,最后根据解析结果执行相应动作,消息类型如下图所示,常用的是连接回复、收到发布数据、订阅回复、取消订阅回复、ping回复和断开连接。


/*		
================================================================================
描述 : 接收检查
输入 : 
输出 : 
================================================================================
*/
void drv_mqtt_recv_check(void)
{static u8 make_buff[MQTT_SUB_BUFF_SIZE];const int make_size=sizeof(make_buff);int rc;u8 dup;int qos;u8 retained;u16 msgid;int payloadlen_in;u8 *payload_in;	MQTTString receivedTopic;	for(u8 i=0; i<MQTT_CONN_NUM; i++){MqttClientStruct *pClient=&g_sMqttWork.client_list[i];if(pClient->is_enable==true)//启用{rc=MQTTPacket_read(make_buff, make_size, pClient->mqtt_recv);switch(rc){case CONNACK://连接回复{printf("mqtt_id=%d CONNACK!\n", i);u8 sessionPresent, connack_rc;if (MQTTDeserialize_connack(&sessionPresent, &connack_rc, make_buff, make_size) != 1 || connack_rc != 0)//解析收到的回复报文{drv_mqtt_close(pClient);printf("mqtt sock_id=%d  Unable to connect, return code %d\n",i, connack_rc); }else{pClient->is_connected=true;pClient->keep_time=drv_get_sec_counter();//更新时间printf("mqtt sock_id=%d connect ok!\n", i);}						break;}		case PUBREC:case PUBACK: //发布回复{
//					debug("sock_id=%d PUBACK!\n", i);break;}			case PUBLISH://收到发布的消息{pClient->keep_time=drv_get_sec_counter();//更新时间printf("sock_id=%d PUBLISH!\n", i);rc = MQTTDeserialize_publish(&dup, &qos, &retained, &msgid, &receivedTopic, &payload_in, &payloadlen_in, make_buff, make_size);	char *pTopic=receivedTopic.lenstring.data;if(g_sMqttWork.mqtt_recv_parse!=NULL){char topic[30]={0};int len=(char*)payload_in-pTopic;//topic 长度if(len>sizeof(topic)){len=sizeof(topic)-1;}memcpy(topic, pTopic, len);g_sMqttWork.mqtt_recv_parse(i, topic, payload_in, payloadlen_in);//应用层数据解析}break;}	case SUBACK://订阅回复{
//					debug("sock_id=%d SUBACK!\n", i);
//					printf_hex("sub buff=", make_buff, 30);int count, requestedQoSs[1];MQTTDeserialize_suback(&msgid, 1, &count, requestedQoSs, make_buff, make_size);
//					debug("$$$ msgid=0x%04X\n", msgid);for(u8 k=0; k<MQTT_SUB_NUM; k++){SubPackStruct *pSub=&pClient->sub_list[k];if(pSub->base_msg_id==msgid){printf("topic=%s sub ok!\n", pSub->sub_topic);pSub->curr_state=TopicStateSub;
//              pSub->subed_time=drv_get_sec_counter();}}break;}	              case UNSUBACK://取消订阅回复{
//					debug("sock_id=%d UNSUBACK!\n", i);MQTTDeserialize_unsuback(&msgid, make_buff, make_size);
//					debug("$$$ msgid=0x%04X\n", msgid); for(u8 k=0; k<MQTT_SUB_NUM; k++){SubPackStruct *pSub=&pClient->sub_list[k];if(pSub->base_msg_id==msgid){printf("topic=%s unsub ok!\n", pSub->sub_topic);pSub->curr_state=TopicStateUnSub;
//              pSub->subed_time=drv_get_sec_counter();}}          break;}case PINGRESP://ping回复{pClient->keep_time=drv_get_sec_counter();//更新时间
//					debug("sock_id=%d PINGRESP!\n", i);break;}	case DISCONNECT://断开连接{printf("mqtt_id=%d DISCONNECT!\n", i);drv_mqtt_close(pClient);					break;}					}}	}
}

        剩下的就是一些简单的功能了,比如设置话题、发布消息,关闭连接等等,较为简单。

/*		
================================================================================
描述 : 设置话题信息
输入 : 
输出 :  
================================================================================
*/
void drv_mqtt_set_topic_info(u8 client_id, u8 sub_id, char *topic, u32 base_msg_id, u8 dst_state)
{if(client_id<MQTT_CONN_NUM) { MqttClientStruct *pClient=&g_sMqttWork.client_list[client_id];if(sub_id<MQTT_SUB_NUM){SubPackStruct *pSub=&pClient->sub_list[sub_id];if(strlen(topic)<sizeof(pSub->sub_topic)){pSub->curr_state=TopicStateIdel;  pSub->dst_state=dst_state;pSub->base_msg_id=base_msg_id;strcpy(pSub->sub_topic, topic);        }}}
}/*		
================================================================================
描述 : 设置话题订阅状态
输入 : 
输出 :  
================================================================================
*/
void drv_mqtt_set_topic_state(u8 client_id, u8 sub_id, u8 dst_state)
{if(client_id<MQTT_CONN_NUM) { MqttClientStruct *pClient=&g_sMqttWork.client_list[client_id];if(sub_id<MQTT_SUB_NUM){SubPackStruct *pSub=&pClient->sub_list[sub_id];pSub->dst_state=dst_state;}}  
}
/*		
================================================================================
描述 : MQTT发布数据
输入 : 
输出 : 
================================================================================
*/
void drv_mqtt_publish(u8 index, u8 *msg_buff, u16 msg_len, char *topic)
{static u8 make_buff[MQTT_PUB_BUFF_SIZE]={0};static const int make_size=sizeof(make_buff);	u16 make_len=0;	  if(index<MQTT_CONN_NUM){MqttClientStruct *pClient=&g_sMqttWork.client_list[index];if(pClient->is_connected==true)//已经连接{ pClient->msg_id++;MQTTString topicString = MQTTString_initializer;topicString.cstring=topic; make_len = MQTTSerialize_publish(make_buff, make_size, 0, 1,0, pClient->msg_id, topicString, msg_buff, msg_len);//组合发布报文if(pClient->mqtt_send!=NULL && make_len>0){int ret=pClient->mqtt_send(make_buff, make_len);//发送}			}		    }
}
/*		
================================================================================
描述 : 关闭连接
输入 : 
输出 : 
================================================================================
*/
void drv_mqtt_close(MqttClientStruct *pClient)
{pClient->is_connected=false;for(u8 i=0; i<MQTT_SUB_NUM; i++){SubPackStruct *pSub=&pClient->sub_list[i];pSub->curr_state=TopicStateIdel;
//    pSub->subed_time=0;}pClient->msg_id=0;pClient->keep_time=0;
}

五、使用过程

        应用层的使用主要就是根据要求配置信息,首先物理通讯接口先设置,这里使用esp8266的连接3作为网络链路,同时注册接收函数把数据缓存进ringbuffer;然后就是MQTT用户名、密码、客户端ID的设置了;接下来有三个回调函数注册,两个是物理层的MQTT收发,还有一个是应用层的数据解析,这里已经来到了最后的净化器项目本身了,由此可以看出,要想代码好维护,写代码之前就要分层设计,这样出问题了才好分级排查,再后期自己阅读时逻辑也更走得通;最后一步就是话题订阅了,这样才能收到用户的控制数据,每个设备订阅话题都不一样,最后都带上了自己序列号,这样用户端才能针对性控制设备。

        下面代码是净化器应用层的数据解析。


/*		
================================================================================
描述 : 设备解析服务器下发的数据
输入 : 
输出 : 
================================================================================
*/
void app_air_recv_parse(u8 *buff, u16 len)
{u8 head[2]={0xAA, 0x55};u8 *pData=memstr(buff, len, head, 2);if(pData!=NULL){u16 total_len=pData[2]<<8 | pData[3];u16 crcValue=pData[total_len]<<8 | pData[total_len+1];if(crcValue==drv_crc16(pData, total_len)){pData+=4;u32 device_sn=pData[0]<<24|pData[1]<<16|pData[2]<<8|pData[3];pData+=4;if(device_sn!=g_sAirWork.device_sn)//识别码确认return;u8 cmd_type=pData[0];pData++;switch(cmd_type){case AIR_CMD_HEART://心跳包{break;}case AIR_CMD_DATA://数据包{break;}case AIR_CMD_SET_SPEED://设置风速{u8 speed=pData[0];pData+=1;app_motor_set_speed(speed);break;}        case AIR_CMD_SET_SWITCH://设置开关{u8 state=pData[0];pData+=1;g_sAirWork.switch_state=state;if(state>0){app_motor_set_speed(100);//启动风扇}else{app_motor_set_speed(0);//停止风扇}app_air_send_status();break;}}}}
}
六、总结

        MQTT协议本身较为繁琐,现在应用阶段暂时不用太深入,先学会使用就行,用熟了再去查阅文档,这样理解起来更透彻。mqtt的驱动设计相较于其他驱动文件更为复杂,因为它所牵涉的内容更广,有开源库、网络链路、应用层参数配置等等,完整的工程在第二篇文章里有的下载,自行查阅。

本项目的交流QQ群:701889554

   写于2024-4-1

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.mzph.cn/news/791600.shtml

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈email:809451989@qq.com,一经查实,立即删除!

相关文章

Java实现两数相除

题意 给你两个整数&#xff0c;被除数 dividend 和除数 divisor。将两数相除&#xff0c;要求不使用乘法、除法和取余运算。 整数除法应该向零截断&#xff0c;也就是截去&#xff08;truncate&#xff09;其小数部分。例如&#xff0c;8.345 将被截断为 8 &#xff0c;-2.7335…

leetcode 热题 100(部分)C/C++

leetcode 热题 100 双指针 盛最多水的容器 【mid】【双指针】 思路&#xff1a; 好久没写代码sb了&#xff0c;加上之前写的双指针并不多&#xff0c;以及有点思维定势了。我对双指针比较刻板的印象一直是两层for循环i&#xff0c;j&#xff0c;初始时i,j都位于左界附近&…

Open CASCADE学习|刚体( TopoDS_Shape)按某种轨迹运动,停在指定位置上

今天实现如下功能&#xff1a;刚体做做螺旋运动&#xff0c;轨迹已知&#xff0c;求刚体在每个位置上的所占据的空间&#xff0c;就是把刚体从初始位置变换到该位置。 这里的刚体是一个砂轮截面&#xff0c;螺旋运动轨迹由B样条曲线拟合&#xff0c;通过Frenet标架确定运动轨迹…

iOS使用CoreML运用小型深度神经网络架构对图像进行解析

查找一个图片选择器 我用的是ImagePicker 项目有点老了&#xff0c;需要做一些改造&#xff0c;下面是新的仓库 platform :ios, 16.0use_frameworks!target learnings dosource https://github.com/CocoaPods/Specs.gitpod ImagePicker, :git > https://github.com/KevinS…

Python之Opencv进阶教程(1):图片模糊

1、Opencv提供了多种模糊图片的方法 加载原始未经模糊处理的图片 import cv2 as cvimg cv.imread(../Resources/Photos/girl.jpg) cv.imshow(girl, img)1.1 平均值 关键代码 # Averaging 平均值 average cv.blur(img, (3, 3)) cv.imshow(Average Blur, average)实现效果 1.2…

STM32F407 FSMC并口读取AD7606

先贴一下最终效果图.这个是AD7606并口读取数据一个周期后的数据结果. 原始波形用示波器看是很平滑的. AD7606不知为何就会出现干扰, 我猜测可能是数字信号干扰导致的. 因为干扰的波形很有规律. 这种现象基本上可以排除是程序问题. 应该是干扰或者数字信号干扰,或者是数字和模拟…

基于Spring Boot的餐厅点餐系统

基于Spring Boot的餐厅点餐系统 开发语言&#xff1a;Java框架&#xff1a;springbootJDK版本&#xff1a;JDK1.8数据库工具&#xff1a;Navicat11开发软件&#xff1a;eclipse/myeclipse/ideaMaven包&#xff1a;Maven3.3.9 部分系统展示 管理员登录界面 用户注册登录界面 …

​如何使用ArcGIS Pro进行洪水淹没分析

洪水淹没分析是一种常见的水文地理信息系统应用&#xff0c;用于模拟和预测洪水事件中可能受到淹没影响的地区&#xff0c;这里为大家介绍一下ArcGIS Pro进行洪水淹没分析的方法&#xff0c;希望能对你有所帮助。 数据来源 教程所使用的数据是从水经微图中下载的DEM数据&…

Python学习笔记-Flask接收post请求数据并存储数据库

1.引包 from flask import Flask, request, jsonify from flask_sqlalchemy import SQLAlchemy 2.配置连接,替换为自己的MySQL 数据库的实际用户名、密码和数据库名 app Flask(__name__) #创建应用实列 app.config[SQLALCHEMY_DATABASE_URI] mysqlpymysql://ro…

鸿蒙OS开发实例:【应用事件打点】

简介 传统的日志系统里汇聚了整个设备上所有程序运行的过程流水日志&#xff0c;难以识别其中的关键信息。因此&#xff0c;应用开发者需要一种数据打点机制&#xff0c;用来评估如访问数、日活、用户操作习惯以及影响用户使用的关键因素等关键信息。 HiAppEvent是在系统层面…

适用于 Linux 的 Windows 子系统安装初体验

1、简述 Windows Subsystem for Linux (WSL) 是 Windows 的一项功能&#xff0c;允许您在 Windows 计算机上运行 Linux 环境&#xff0c;而无需单独的虚拟机或双重启动。 WSL 旨在为想要同时使用 Windows 和 Linux 的开发人员提供无缝且高效的体验。 使用 WSL 安装和运行各种 L…

【javaScript】DOM编程入门

一、什么是DOM编程 概念&#xff1a;DOM(Document Object Model)编程就是使用document对象的API完成对网页HTML文档进行动态修改&#xff0c;以实现网页数据和样式动态变化的编程 为什么要由DOM编程来动态修改呢&#xff1f;我们就得先理解网页的运行原理&#xff1a; 如上图&a…

IO流:字节流、字符流、缓冲流、转换流、数据流、序列化流 --Java学习笔记

目录 IO流 IO流的分类 IO流的体系 字节流&#xff1a; 1、Filelnputstream(文件字节输入流) 2、FileOutputStream(文件字节输出流) 字节流非常适合做一切文件的复制操作 复制案例&#xff1a; try-catch-finally 和 try-with-resource 字符流 1、FileReader(文件字符…

ALPHA开发板上的PHY芯片驱动:LAN8720驱动

一. 简介 前面文章了解到&#xff0c;Linux内核是有提供 PHY通用驱动的。 本文来简单了解一下ALPHA开发板上的 PHY网络芯片LAN8720的驱动。是 LAN8720芯片的公司提供的 PHY驱动。 二. ALPHA开发板上的PHY芯片驱动&#xff1a;LAN8720驱动 我 们 来 看 一 下 LAN8720A 的 …

输入url到页面显示过程的优化

浏览器架构 线程&#xff1a;操作系统能够进行运算调度的最小单位。 进程&#xff1a;操作系统最核心的就是进程&#xff0c;他是操作系统进行资源分配和调度的基本单位。 一个进程就是一个程序的运行实例。启动一个程序的时候&#xff0c;操作系统会为该程序创建一块内存&a…

HDLbits 刷题 --Always nolatches

学习: Your circuit has one 16-bit input, and four outputs. Build this circuit that recognizes these four scancodes and asserts the correct output. To avoid creating latches, all outputs must be assigned a value in all possible conditions (See also always…

【HTML】简单制作一个3D动画效果重叠圆环

目录 前言 开始 HTML部分 CSS部分 效果图 总结 前言 无需多言&#xff0c;本文将详细介绍一段代码&#xff0c;具体内容如下&#xff1a; 开始 首先新建文件夹&#xff0c;创建两个文本文档&#xff0c;其中HTML的文件名改为[index.html]&#xff0c;CSS的…

搞学术研究好用免费的学术版ChatGPT网站-学术AI

学术版ChatGPThttps://chat.uaskgpt.com/mobile/?user_sn88&channelcsdn&scenelogin 推荐一个非常适合中国本科硕士博士等学生老师使用的学术版ChatGPT&#xff0c; 对接了超大型学术模型&#xff0c;利用AI技术实现学术润色、中英文翻译&#xff0c;学术纠错&#…

centOS如何升级python

centOS下升级python版本的详细步骤 1、可利用linux自带下载工具wget下载&#xff0c;如下所示&#xff1a; 笔者安装的是最小centos系统&#xff0c;所以使用编译命令前&#xff0c;必须安装wget服务&#xff0c;读者如果安装的是界面centos系统&#xff0c;或者使用过编译工具…

在 Amazon Timestream 上通过时序数据机器学习进行预测分析

由于不断变化的需求和现代化基础设施的动态性质&#xff0c;为大型应用程序规划容量可能会非常困难。例如&#xff0c;传统的反应式方法依赖于某些 DevOps 指标&#xff08;如 CPU 和内存&#xff09;的静态阈值&#xff0c;而这些指标在这样的环境中并不足以解决问题。在这篇文…