初探RabbitMQ数据流:设备实时状态监控和与实时数据获取

929 阅读8分钟

关键词:rabbitmq、STOMP.js、websocket、echarts

背景:最近在做物联网方面的项目,遇到一个需求:

  1. 需要监听设备的连接(设备网络与服务器建立连接)、断开状态,当设备连接断开时,在所有页面需要对系统用户进行 Notification 通知。
  2. 已连接的设备,需要监听设备的实时特征值数据,如最大值、最小值、峰值等,前端使用 echarts 图表可视化展示数据。

涉及到设备的数据获取,就要先了解下mq、rabbitmq、STOMP.js 等概念了。

什么是MQ

消息队列(Message Queue,简称 MQ),从字面意思上看,本质是个队列,FIFO 先入先出,只不过队列中存放的内容是 message 而已。
其主要用途:不同(程序)进程 Process/线程 Thread 之间通信。

什么是RabbitMQ

RabbitMQ 是一个开源的消息代理中间件,可以接受和转发消息。 消息队列:RabbitMQ 最主要的用途是作为一个消息队列,允许生产者发送消息到队列,消费者从队列中取出并处理消息。这种模式非常适合异步处理和解耦系统组件。 其主要用途和特征:
1. 发布/订阅模型:除了点对点的队列模式,RabbitMQ 还支持发布/订阅模式,允许多个消费者订阅同一主题,接收所有发布的消息。
2. 高可用性和集群:RabbitMQ 支持集群配置,可以横向扩展以处理更多的消息和连接,同时也提供了故障转移和数据冗余的能力。
3. 负载均衡:通过将消息均匀地分配给多个消费者,RabbitMQ 可以帮助实现负载均衡。
4. 事务支持:RabbitMQ 支持事务,确保消息的可靠投递。

为什么物联网项目中使用RabbitMQ?

在物联网项目中,设备通常需要与中央系统或其他设备进行通信,这可能涉及大量设备同时发送和接收数据。RabbitMQ 在这样的场景下特别有用,因为:
1.大规模连接:物联网项目可能涉及到成千上万甚至数十万的设备,RabbitMQ 能够处理如此规模的连接。
2.可靠性:RabbitMQ 的高可用性和持久化特性保证了即使在网络不稳定的情况下,消息也能被正确处理。
3. 异步通信:物联网设备可能在不同的时间点产生数据,使用消息队列可以缓冲这些数据,直到后端系统准备好处理它们。
4. 协议支持:RabbitMQ 支持 MQTT 协议,这是一种轻量级的发布/订阅网络协议,非常适合资源受限的设备。
5. 设备管理:RabbitMQ 可以帮助管理设备连接,通过权限设置控制哪些设备可以发送或接收消息。

什么是STOMPJS

STOMPJS 是一个 JavaScript 库,它利用 WebSocket(或长轮询等其他传输方式)来实现 STOMP 协议。STOMP 是一个简单的面向文本的消息传递协议,它允许客户端与消息中间件(如 RabbitMQ)进行通信。STOMPJS 抽象了 STOMP 协议的复杂性,使得开发人员可以更容易地发送和接收消息。 其主要用途:
1. 协议转换:STOMPJS 将 WebSocket 的原始数据转换为 STOMP 消息格式,这样就可以与 RabbitMQ 这样的消息中间件进行交互
2. 简化开发:STOMPJS 提供了高级 API,简化了消息的发送和接收过程,降低了开发复杂度。
3. 错误恢复:STOMPJS 还提供了错误处理和自动重连机制,增加了客户端与 RabbitMQ 通信的稳定性

stompJS和websocket的区别

同HTTP在TCP 套接字上添加请求-响应模型层一样,STOMP在WebSocket 之上提供了一个基于帧的线路格式层,用来定义消息语义。

心跳机制

一种保持连接活跃性的方法,通过定期发送心跳包来确保客户端与服务端之间的连接仍然有效,心跳包通常是个非常小的数据包,用于测试连接状态。
作用

  1. 保持连接的活跃性
  2. 检测连接状态
  3. 及时重连
  4. 资源管理
  5. 提高用户体验

实现方式

  1. 客户端和服务端 双方都会定期发送心跳包
  2. 心跳间隔,几秒 几十秒,取决于网络环境和 需求
  3. 心跳内容可以是空消息或特定标识符,接收方收到会回复一个确认消息

心跳检测为什么要几秒钟后再次调用
在 reconnect 方法中设置 5 秒后调用 close() 和 connect() 方法,主要是为了避免过于频繁地进行重连操作。如果在连接断开后立即重连,可能会由于网络不稳定或服务器尚未准备好等原因导致重连失败,甚至可能给服务器带来不必要的压力。5 秒的延迟可以在一定程度上等待网络状况的恢复或者服务器完成相关处理,提高重连成功的概率。 time:心跳时间间隔 timeout:心跳超时间隔 reconnect:心跳间隔根据服务器的性能及并发数定。心跳超时要小于重连时间~不然会堵塞线程

了解到这些背景知识后,我们开始 STOMPJS 的配置和封装工作,上代码:

// rabbitMq.ts,用于客户端与rabbitmq建立连接
import Stomp, { Frame } from "stompjs";

export default class RabbitMqClient {
  ws: any = null;
  stompClient: Stomp.Client = null!;
  closed = false;
  options = {
    vhost: "/", // rabbitmq的vhost
    incoming: 8000, // 心跳包时间,(须大于0,且小于10000,因为服务器可能默认10秒没心跳就会断开)
    outgoing: 8000, // 心跳包时间,(须大于0,且小于10000,因为服务器可能默认10秒没心跳就会断开)
    account: "", // rabbitmq的账户
    pwd: "", // rabbitmq的密码
    server: `ws://${location.host}/mq`, // ws://rabbitmq的ip:rabbitmq的web-stomp的端口/ws
  };

  connect(cb?: (client: Stomp.Client) => void) {
    this.ws = new WebSocket(this.options.server);
    const client = Stomp.over(this.ws);
    this.stompClient = client;
    this.stompClient.heartbeat.incoming = this.options.incoming;
    this.stompClient.heartbeat.outgoing = this.options.outgoing;

    //开始连接
    client.connect(
      this.options.account, // 用户名
      this.options.pwd, // 密码
      // 连接成功时回调
      () => {
        console.log("stomp 连接成功!");
        cb?.(client);
      },
      // 失败时回调
      (errorMsg: string | Frame) => {
        if (this.closed) return;
        console.error(`stomp 断开连接,正在准备重新连接...`, errorMsg);
        this.connect(cb);
      },
      this.options.vhost
    );
  }

  close() {
    this.closed = true;
    this.ws.close();
  }

  onMessageReceived(message: { body: any }) {
    console.log("返回数据", message.body);
  }
}
// realDataClient.ts 用于订阅、解除订阅、销毁MQ消息
// 导入RabbitMqClient类,用于与RabbitMQ服务器通信
import RabbitMqClient from "@/ws/rabbitMq";
// 导入STOMP库,用于处理STOMP协议通信
import Stomp from "stompjs";
// 导入base64ToDataView函数,用于将Base64字符串转换为DataView对象
import { base64ToDataView } from "@/utils/base64Utils";

// 定义实时数据客户端类,用于管理与RabbitMQ的订阅和取消订阅操作
export default class RealDataClient {
  // 私有成员变量subs,用于存储所有活动的订阅
  private subs: Map<string, Subscription> = new Map();
  // 下一个订阅ID的计数器
  private nextSubId = 0;
  // 创建RabbitMqClient实例,用于实际的STOMP通信
  mqClient = new RabbitMqClient();

  // 构造函数,可选参数cb用于在连接成功时调用
  constructor(cb?: (client: Stomp.Client) => void) {
    // 连接到RabbitMQ服务器
    this.mqClient.connect(cb);
  }

  // 订阅方法,用于向RabbitMQ添加新的订阅
  subscribe(sub: Subscription) {
    // 如果没有提供订阅ID,则自动生成一个新的ID
    sub.id = sub.id || ++this.nextSubId + "";
    // 检查是否已存在相同的订阅ID
    if (this.subs.has(sub.id)) {
      return;
    }
    // 将订阅添加到活动订阅列表中
    this.subs.set(sub.id + "", sub);
    console.log(`已订阅`, sub);

    // 调用辅助函数来添加MQ主题订阅
    addMQTopicSubscription(sub, this);
    // 返回生成的订阅ID
    return sub.id;
  }

  // 取消订阅方法,用于从RabbitMQ移除订阅
  unsubscribe(subId: string) {
    // 根据ID查找订阅
    const sub = this.subs.get(subId);
    if (!sub) {
      return;
    }
    // 如果存在STOMP订阅ID,则取消订阅
    if (sub.stompSubId) {
      this.mqClient.stompClient.unsubscribe(sub.stompSubId);
    }
    // 从活动订阅列表中删除订阅
    this.subs.delete(subId);
    console.log(`已取消订阅`, sub);
  }

  // 取消所有订阅的方法
  unsubscribeAll() {
    // 遍历所有订阅并逐一取消
    this.subs.forEach((sub: Subscription) => {
      this.unsubscribe(sub.id);
    });
  }

  // 取消除特定设备监听外的所有订阅
  unsubscribeExceptDevices() {
    // 遍历所有订阅,只保留与'controlTopicExchange'相关的订阅
    this.subs.forEach((sub: Subscription) => {
      if (sub?.exchangeName != "controlTopicExchange") {
        this.unsubscribe(sub.id);
      }
    });
  }

  // 销毁方法,用于清理所有订阅并关闭RabbitMQ连接
  destroy() {
    // 取消所有订阅
    this.unsubscribeAll();
    // 延迟执行关闭RabbitMQ连接的操作
    setTimeout(() => {
      this.mqClient.close();
    }, 0);
  }
}

// 辅助函数,用于向RabbitMQ添加主题订阅
function addMQTopicSubscription(
  sub: Subscription,
  realDataClient: RealDataClient
) {
  // 根据提供的exchangeName和routingKey订阅RabbitMQ主题
  sub.stompSubId = realDataClient.mqClient.stompClient.subscribe(
    `/exchange/${sub.exchangeName}/${sub.routingKey}`,
    // 接收消息时的回调函数
    (msg: Stomp.Message) => {
      // 分析消息头中的destination字段,提取cardNo和channelNo
      const topicParts = (msg.headers as any)["destination"].split(".");
      const cardNo = +topicParts[topicParts.length - 2];
      const channelNo = +topicParts[topicParts.length - 1];
      // 解码消息体中的数据帧
      const frame = decodeChannelDataFrame(msg.body);
      // 输出消息延迟
      console.log(`延迟 ${Date.now() - frame.ts}ms`);
      // 调用订阅的onData回调函数
      sub.onData(frame, cardNo, channelNo);
    }
  ).id;
}

// 辅助函数,用于解码数据帧
function decodeChannelDataFrame(
  data: string
): ChannelRawValues | ChannelFeatureValues {
  // 如果数据不是字符串,则直接返回
  if (typeof data != "string") {
    return data;
  }
  // 如果数据是JSON格式,解析并返回
  if (data[0] == "{") {
    return JSON.parse(data);
  }
  // 如果数据是Base64编码,转换为DataView并解析
  const dv = base64ToDataView(data);
  let ri = 0;
  // 解析时间戳
  const ts = Number(dv.getBigUint64(ri, true));
  ri += 8;
  // 解析值数组
  const values = [];
  for (let i = 0, len = (dv.byteLength - ri) / 4; i < len; i++) {
    values[i] = dv.getFloat32(ri, true);
    ri += 4;
  }
  // 返回解析后的数据帧
  return { ts, values };
}

// 定义原始数据帧接口
export interface ChannelRawValues {
  ts: number; // 时间戳
  values: number[]; // 数值数组
}

// 定义特征数据帧接口
export interface ChannelFeatureValues {
  ts: number; // 时间戳
  values: Record<string, number>; // 特征值字典
}

// 定义订阅接口
export interface Subscription {
  id: string; // 订阅ID
  exchangeName: string; // 网关名称
  routingKey: string; // 路由键
  analysisType: string; // 分析类型
  stompSubId?: string; // STOMP订阅ID
  onData: (data: any) => void; // 数据接收回调函数
}
// base64Utils.js
/**
 * 将Base64编码的字符串转换为DataView对象。
 *
 * 此函数接收一个Base64编码的字符串,首先将其解码为二进制字符串,
 * 然后创建一个与二进制字符串长度相匹配的ArrayBuffer,并基于此ArrayBuffer
 * 创建一个DataView对象。接着遍历二进制字符串,将每个字符的Unicode值
 * 存储到DataView的Uint8视图中,从而完成从Base64字符串到DataView的转换。
 *
 * @param base64 Base64编码的字符串。
 * @returns DataView对象,其中包含了原Base64字符串的二进制数据。
 */
export function base64ToDataView(base64: string): DataView {
  // 使用atob函数将Base64字符串解码为二进制字符串
  const bin = atob(base64);
  // 创建一个与二进制字符串长度相等的ArrayBuffer
  const dv = new DataView(new ArrayBuffer(bin.length));
  // 遍历二进制字符串,将每个字符的Unicode值存入DataView的Uint8视图中
  for (let i = 0, len = bin.length; i < len; i++) {
    dv.setUint8(i, bin.charCodeAt(i));
  }
  // 返回填充了二进制数据的DataView对象
  return dv;
}
// 全局监听设备状态变动,进行提醒 App.vue
import { onMounted, onUnmounted } from 'vue';
import { ElNotification } from 'element-plus';
import emitter from '@/utils/mybus';
import router from '@/router';
import RealDataClient from '@/ws/realDataClient.ts';
let realDataClient: RealDataClient = null!;
interface IClientData {
    eventType: string;  //时间类型
    sn: string; // 设备序列号
    ip: string;
}
const CONNECT_EVENT_TYPE_Map = {
    connected: '接入',
    disconnected: '离线',
};
onMounted(() => {
  onSubscribeDeviceStatus();
}
// 订阅设备状态
const onSubscribeDeviceStatus = () => {
    realDataClient = new RealDataClient(() => {
        realDataClient.subscribe({
        id: '' + 'feature',
        exchangeName: 'controlTopicExchange', //网关
        routingKey: `*.*.*.*`, // 后端提供的路由键
        analysisType: 'feature',
        onData: (data: IClientData) => {
            // 监听到设备状态变动,eventbus通知页面更新数据(设备通电、设备离线)
            console.log('新设备接入', data);
            if (CONNECT_EVENT_TYPE_LIST.includes(data?.eventType) && data?.sn) {
            let deviceSn = data?.sn || '';
            let warningMessage = CONNECT_EVENT_TYPE_Map[data?.eventType];
            emitter.emit('on_device_status_change', data); // 在设备页面监听eventbus 事件,做相应业务处理
            ElNotification({
                title: '提醒',
                dangerouslyUseHTMLString: true,
                message: `<div>您有新设备${deviceSn}${warningMessage}!可在 <span style="color: #3d5cff; cursor: pointer;">设备管理-设备接入</span> 中查看<div>`,
                onClick() {
                router.push('/deviceAccess');
              },
            });
            }
        },
        });
    });
};
onUnmounted(() => {
//清除订阅和监听
// 取消所有订阅
realDataClient?.destroy();
// 取消eventbus订阅
emitter.off('on_device_status_change');
});

需求二中获取实时数据,绘制图表同理,通过配置exchangeName和routingKey监听到设备实时数据,传递到echarts组件中绘制。