MQTT简介
MQTT是一个基于TCP/IP
的传输层协议,抽象出发布订阅机制,便于应用程序解耦业务和扩展功能。
该协议常见于工业自动化
、物联网
、SCADA系统
等场景。
MQTT无法满足机器人
最近在做巡检机器人
,我当初技术选型时,选择了MQTT,因为:
- 后台能主动向机器人发消息(反例,HTTP只能靠机器人轮询)
- 协议相对轻量,单片机也能跑起来(反例,websocket较为重量)
- 基于话题的pub sub机制,便于消息的解耦和扩展(反例,原始 socket缺少话题机制)
- 能穿透NAT,不用麻烦做端口映射
- 与温湿度计、风速计等固定位置的传感器共用同一套传输协议,减轻后台同事的心智负担
在开发的过程中,发现MQTT本身只提供了发布订阅模型,没有请求响应模型,而机器人跟温湿度记、风速计之类的传感器不同,它是一个复杂系统,能自主的完成很多事情,会主动向后台发请求,也会响应来自后台的请求。
机器人发起登录
后台发起巡检
所以,必须增加一种请求响应模型。
基于MQTT的请求响应模型(RPC版)
请求模型
一开始我的想法是借鉴RPC
机制,上层应用将请求的每个参数封装成一个对象,并将其序列化成字符串,然后传递给通用的request(String topic_request, String request)
方法
request方法流程如下:
- 调用
client.publish(topic_request, request)
,将请求以消息的形式发布给所有潜在的订阅者,包括后台 - 开始监听
client.subscribeWithResponse(topic_response)
- 循环检查
topic_response
是否收到消息 - 收到消息的接收方是不是
client
自己?不是则回到上一步 - 停止监听,返回收到的消息
最后上层应用解析响应内容,完成一次RPC调用。
这种解决方案有2个问题:
- 因为有死等环节,每个请求都要启动一个线程,开销较大。
- 默认不订阅消息,仅在发出请求后才订阅特定主题,效率较低,且无法判断消息类型是不是应用所期待的。
2.1. 效率低是为了通用性而做出的牺牲,因为client默认不收消息,发出请求后才接收,所以收到的一定是上层应用期待的响应,这样就不用对收到的消息做解析。
2.2. 不过网络拥塞严重或丢包时,线程会陷入死循环,导致系统性能严重下降;如果加入超时退出,则可能收到上一个请求的应答,导致消息类型错误,更糟糕😂
响应模型
响应模型比较简单。
初始化阶段:
- 创建一个key为话题字符串,value为
RequestHandler
类的HashMap
- 注册所有跟后台请求对应的话题,及其
RequestHandler
到HashMap
,并开启订阅
public interface RequestHandler {
void handle(byte[] request);
}
运行阶段:
-
client
收到一条MQTT消息,根据消息的话题名,查询HashMap
- 如果查询返回的
RequestHandler
非空,则调用其handle
方法
基于MQTT的请求响应模型(无为版)
在深入开发的过程中,发现了RPC版模型的更多问题:
- 请求模型里包含订阅,响应模型里也包含订阅,虽然订阅的话题不一样(前者是后台的应答,后者是后台的请求),但能否在形式上统一呢?
- 请求模型涉及线程,但响应模型不涉及,形式上不对称,没有美感,能否做到对称呢?
最终,我决定,放弃直接请求响应接口,改为向上层提供一种类似于Javascript
事件回调的范式,这样的性能应该是最好的。
范式的主要特点如下:
- 机器人向后台发送请求、给后台的响应,都通过封装后的
publish
完成,后台根据话题名
区分是请求还是响应 - 机器人接收后台请求、接收后台的响应,都通过封装后的
subscribe回调
完成,机器人根据话题名
区分是请求还是响应
这种范式充分利用了MQTT的话题机制,将请求、响应的判别从框架转移到了应用,也将请求发出后等待响应的时间转移到了应用。
下面给出应用代码样例:
消息回调接口
不再区分收到的消息是请求还是响应
public interface MessageHandler {
void handle(String msg);
}
消息回调的注册,消息的分发
跟笨拙版的响应模型类似,不表。
机器人作为客户端
发送登录请求
LoginRequest loginRequest = new LoginRequest();
loginRequest.setModel("wheel2");
loginRequest.setSn("9527");
String request = JsonUtils.toJson(loginRequest);
mqttAgent.publish("/cloud/register_robot_req", request);
在回调里接收后台响应
mqttAgent.regHandler("/cloud/register_robot_res", (String response) -> {
LoginResponse loginResponse = JsonUtils.fromJson(response, LoginResponse.class);
logger.info("login success with robot_id = "+loginResponse.getRobot_id());
// 解析后台响应,记录session信息等
});
机器人作为服务器
机器人监听巡检任务请求,并返回响应
mqttAgent.regHandler("/robot/patrol_inspect_req", (String request) -> {
PatrolInspectRequest patrolInspectRequest = JsonUtils.fromJson(request, PatrolInspectRequest.class);
String[] spots = patrolInspectRequest.getInspect_spots();
// 检查巡检任务的合法性...
// 返回检查结果
PatrolInspectResponse patrolInspectResponse = new PatrolInspectResponse();
patrolInspectResponse.setRobot_id(patrolInspectRequest.getRobot_id());
patrolInspectResponse.setStatus("success");
HashMap<String, PatrolInspectResponse.Result> hashMap = new HashMap<>();
patrolInspectResponse.setInspect_results(hashMap);
mqttAgent.publish("/robot/patrol_inspect_res", JsonUtils.toJson(patrolInspectResponse));
});
反思
兜兜转转,又回到了原点。
反思自己当初为什么会想要抽象出请求响应模型,应该是以往经验导致的路径依赖。
之前集成云迹
底盘时,因为底盘给上层应用暴露的是原始socket接口,所以需要我自己实现消息的循环接收、分发以及应用层的请求和等待响应。
这是一个高度垂直的集成,所以具体到某一层时,设计的比较粗糙。比如导航到巡检点,我将API设计成底盘实际到达目的地才返回响应,使得上层应用的等待是必不可少的。
但在MQTT网络里,消息的循环接收和一部分 分发(话题等)工作已经由MQTT自身做了,中层只需提供简单的HashMap
完善分发,上层应用只需要关注特定消息的交互,就可以把交互这块做得更加细致和高效。
分层理念的应用
目前手头的机器人项目,底盘提供的接口是WebSocket
,该协议在原始socket的字节流
基础上封装出了消息回调接口,相当于减去话题机制的MQTT,所以很自然的,我想到了将后台发给机器人的所有请求,都翻译成WebSocket格式传给底盘,并将底盘的WebSocket
应答翻译成MQTT格式传给后台。
示例代码,仍以执行巡检任务为例
首先,全局存在2个单例,分别是mqttAgent
和webSocketAgent
,实现协议的翻译和转发
webSocketAgent = AgentFactory.getWebSocketAgent();
mqttAgent = AgentFactory.getMqttAgent("wheel2");
树莓派监听后台的巡检请求,并将请求翻译后转发给底盘
mqttAgent.regHandler("/robot/patrol_inspect_req", (String request) -> {
PatrolInspectRequest patrolInspectRequest = JsonUtils.fromJson(request, PatrolInspectRequest.class);
String[] spots = patrolInspectRequest.getInspect_spots();
// 检查巡检任务的合法性,如果合法,通过WebSocket转发给底盘
PatrolTask patrolTask = new PatrolTask();
webSocketAgent.send(JsonUtils.toJson(patrolTask));
});
树莓派监听底盘的巡检应答,并将应答翻译后转发给后台
webSocketAgent.regHandler("CMD_REPORT", (String message) -> {
PatrolReport patrolReport = JsonUtils.fromJson(message, PatrolReport.class);
// 解析巡检上报的内容,如果无误,通过MQTT转发给后台
PatrolInspectResponse patrolInspectResponse = new PatrolInspectResponse();
mqttAgent.publish("/robot/patrol_inspect_res", JsonUtils.toJson(patrolInspectResponse));
});
这样分层的好处有:
- 不管底盘提供的接口是原始Socket,还是WebSocket,机器人暴露给后台的都是MQTT
- 底盘控制代码跟巡检业务代码完全隔离,如果客户要求机器人内置本地后台,我就可以将云端后台的代码直接部署到树莓派,只改一下IP就行(Java的好处😄)
- 由上层应用决定向客户提供什么调度模型,是同步还是异步,上层应用甚至可以继续延后,由客户决定😉