MQTT简介

MQTT是一个基于TCP/IP的传输层协议,抽象出发布订阅机制,便于应用程序解耦业务和扩展功能。
该协议常见于工业自动化物联网SCADA系统等场景。

MQTT无法满足机器人

最近在做巡检机器人,我当初技术选型时,选择了MQTT,因为:

  1. 后台能主动向机器人发消息(反例,HTTP只能靠机器人轮询)
  2. 协议相对轻量,单片机也能跑起来(反例,websocket较为重量)
  3. 基于话题的pub sub机制,便于消息的解耦和扩展(反例,原始 socket缺少话题机制)
  4. 能穿透NAT,不用麻烦做端口映射
  5. 与温湿度计、风速计等固定位置的传感器共用同一套传输协议,减轻后台同事的心智负担

在开发的过程中,发现MQTT本身只提供了发布订阅模型,没有请求响应模型,而机器人跟温湿度记、风速计之类的传感器不同,它是一个复杂系统,能自主的完成很多事情,会主动向后台发请求,也会响应来自后台的请求。

机器人发起登录

mqtt ping request 响应RST_java

后台发起巡检

mqtt ping request 响应RST_框架_02


所以,必须增加一种请求响应模型。

基于MQTT的请求响应模型(RPC版)

请求模型

一开始我的想法是借鉴RPC机制,上层应用将请求的每个参数封装成一个对象,并将其序列化成字符串,然后传递给通用的request(String topic_request, String request)方法

request方法流程如下:

  1. 调用client.publish(topic_request, request),将请求以消息的形式发布给所有潜在的订阅者,包括后台
  2. 开始监听client.subscribeWithResponse(topic_response)
  3. 循环检查topic_response是否收到消息
  4. 收到消息的接收方是不是client自己?不是则回到上一步
  5. 停止监听,返回收到的消息

最后上层应用解析响应内容,完成一次RPC调用。

这种解决方案有2个问题:

  1. 因为有死等环节,每个请求都要启动一个线程,开销较大。
  2. 默认不订阅消息,仅在发出请求后才订阅特定主题,效率较低,且无法判断消息类型是不是应用所期待的。
    2.1. 效率低是为了通用性而做出的牺牲,因为client默认不收消息,发出请求后才接收,所以收到的一定是上层应用期待的响应,这样就不用对收到的消息做解析。
    2.2. 不过网络拥塞严重或丢包时,线程会陷入死循环,导致系统性能严重下降;如果加入超时退出,则可能收到上一个请求的应答,导致消息类型错误,更糟糕😂

响应模型

响应模型比较简单。

初始化阶段:

  1. 创建一个key为话题字符串,value为RequestHandler类的HashMap
  2. 注册所有跟后台请求对应的话题,及其RequestHandlerHashMap,并开启订阅
public interface RequestHandler {
    void handle(byte[] request);
}

运行阶段:

  1. client收到一条MQTT消息,根据消息的话题名,查询HashMap
  2. 如果查询返回的RequestHandler非空,则调用其handle方法

基于MQTT的请求响应模型(无为版)

在深入开发的过程中,发现了RPC版模型的更多问题:

  1. 请求模型里包含订阅,响应模型里也包含订阅,虽然订阅的话题不一样(前者是后台的应答,后者是后台的请求),但能否在形式上统一呢?
  2. 请求模型涉及线程,但响应模型不涉及,形式上不对称,没有美感,能否做到对称呢?

最终,我决定,放弃直接请求响应接口,改为向上层提供一种类似于Javascript事件回调的范式,这样的性能应该是最好的。

范式的主要特点如下:

  1. 机器人向后台发送请求、给后台的响应,都通过封装后的publish完成,后台根据话题名区分是请求还是响应
  2. 机器人接收后台请求、接收后台的响应,都通过封装后的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格式传给后台。

mqtt ping request 响应RST_MQTT_03


示例代码,仍以执行巡检任务为例

首先,全局存在2个单例,分别是mqttAgentwebSocketAgent,实现协议的翻译和转发

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));
});

这样分层的好处有:

  1. 不管底盘提供的接口是原始Socket,还是WebSocket,机器人暴露给后台的都是MQTT
  2. 底盘控制代码跟巡检业务代码完全隔离,如果客户要求机器人内置本地后台,我就可以将云端后台的代码直接部署到树莓派,只改一下IP就行(Java的好处😄)
  3. 由上层应用决定向客户提供什么调度模型,是同步还是异步,上层应用甚至可以继续延后,由客户决定😉