你不知道的mq用法

背景

随着业务逻辑越来越复杂,前端需要的手段不再是自己所处那个圈子。本文主要讲解基于mq(队列)去解决一些常见的并发控制, 可靠性等业务难题。

问题场景

有一个多文件管理页面,有个按钮点击后,可以一次性下载多文件,然后合并成zip下载到用户本地。纯前端实现

方案实现

这里主要讲两种方案,用做对比

最初的文件下载写法

js 复制代码
import JSZip from 'jszip'

function downloadFilesToZip(fileList) {
  const zip = new JSZip()
  const reqList = []
  fileList.forEach((file) => {
    reqList.push(getFile(file.url))
  })
  return Promise.all(reqList).then((dataList) => {
    dataList.forEach((data) => {
      zip.file(file.name, data, zip)
    })
    // 合并成了一个zip文件
    zip.generateAsync({ type: 'blob' }).then((content) => {
      // 写一个a标签来触发下载
      const link = document.createElement('a')
      link.setAttribute('download', Date.now() + '.zip')
      const href = URL.createObjectURL(content)
      link.href = href
      link.setAttribute('target', '_blank')
      link.click()
      return 'success'
    })
  })
}
// 获取文件对象blob
function getFile(url) {
  return fetch(url).then((res) => res.blob())
}

很简单,promise.all一次性给你并发完事。当我们一次性下载几十个文件时,就发生了老生常谈的浏览器给你请求给限制了,现象就是请求正常发起,然后你就等吧,它就是不动,等个一分钟或者几分钟控制台就会出现报错信息,请求失败。所以我们需要控制下并发

方案一

使用双数组不断的取值执行实现。先实现一个RequsetQueue类,用来控制异步处理的并发数。实现的原理就是把所有的任务都存入taskList中,然后通过定时轮询去扫描runTaskList中是不少于并发限制的数量,如果少于就从taskList中取出补上

js 复制代码
class RequsetQueue {
  timer = null // 定时器

  constructor(options = {}) {
    this.options = options
    this.runTaskList = [] // 正在执行的任务数组
    this.taskList = [] // 没有在执行的任务数组
    this.errMsgs = [] // 错误消息
    this.runFlag = false // 是否正在执行
    this.runCount = 0 // 依旧执行完的总数量
    this.limit = 6 // 请求限制并发数
  }

  addTask(task) {
    this.taskList.push({
      ...task,
      uid: getUuid() // 任务唯一标识
    })
  }

 // 检查是不是执行结束 
  checkOver(task, errMsg = '') {
    this.runCount++
    this.runTaskList = this.runTaskList.filter((item) => item.uid !== task.uid)
    if (this.runCount === this.runTotal) {
      // 执行完所有任务后,触发promise回调
      if (this.errMsgs.length) {
        this.error(new Error(this.errMsgs.join('\n')))
      } else {
        this.success(`执行成功了${this.runCount}个`)
      }
    }
  }

  runTask(task) {
    task
      .run()
      .then(() => {
        this.checkOver(task)
      })
      .catch((err) => {
        this.errMsgs.push(err.message)
        this.checkOver(task, err.message)
      })
  }

  run() {
    if (!this.runFlag) {
      return
    }
    this.timer && clearTimeout(this.timer)
    this.timer = setTimeout(
      () => {
        if (this.runTaskList.length < this.limit && this.taskList.length) {
          const addCount = this.limit - this.runTaskList.length // 获取要新增执行的任务数
          const addTasks = this.taskList.slice(0, addCount) // 获取要新增执行的任务
          this.runTaskList = this.runTaskList.concat(addTasks) // 更新正在执行中的任务

          // 更新未执行的任务,像队列一样先进先出
          this.taskList = this.taskList.slice(
            addTasks.length,
            this.taskList.length
          )

          // 只有未执行的任务才能执行,执行中的,不需要重复执行了
          addTasks.forEach((task) => {
            this.runTask(task)
          })
        }
        if (!this.runTaskList.length) {
          // 不存在执行的任务,则停止轮询
          this.runFlag = false
        }
        this.run()
      },
      this.timer ? 1000 : 0
    )
  }

  start() {
    this.errMsgs = []
    this.runCount = 0
    this.runTotal = this.taskList.length

    return new Promise((resolve, reject) => {
      this.success = resolve
      this.error = reject
      this.runFlag = true
      this.run()
    })
  }
}

然后改造下载文件函数

js 复制代码
function downloadFilesToZip(fileList) {
  const zip = new JSZip()
  const reqMq = new RequsetQueue()

  fileList.forEach((file) => {
    reqMq.addTask(getFile(file.url).then(data => zip.file(file.name, data, zip)))
  })
  return reqMq.start().then(() => {
    // 合并成了一个zip文件
    zip.generateAsync({ type: 'blob' }).then((content) => {
      // 写一个a标签来触发下载
      const link = document.createElement('a')
      link.setAttribute('download', Date.now() + '.zip')
      const href = URL.createObjectURL(content)
      link.href = href
      link.setAttribute('target', '_blank')
      link.click()
      return 'success'
    })
  })
}

方案二

我们都知道后端服务很多, 服务在发布期间,或者机器突然挂了, 那么在这期间前端的请求是不是就直接没法处理了,为了保证上下游和来自前端的请求能够都被处理,后端引入了kafka,rabbitMq等mq中间件,把请求先存下来,然后等待后端服务去一一消费,消费成功的会回调,告诉mq中间件可以把这个消息删除了。这样就可以保证消息有序消费和可靠性。那么我们可以借鉴一下这种处理思路。

由图可知,我们使用生产消费模式,通过MQ去解偶生产者和消费者之间的绑定关系。这里我们来实现四个类MQ, Producer和Consumer和Event事件中心。

Event类

event主要处理多个mq对接多个producer和多个consumer之间的绑定关系。这里可以直接参考mitt这个库实现

js 复制代码
class Event {
  handlers = new Map()

  on(type, handler) {
    const handlers = this.handlers.get(type)
    if (handlers) {
      handlers.push(handler)
    } else {
      this.handlers.set(type, [handler])
    }
  }

  off(type, handler) {
    const handlers = this.handlers.get(type)
    if (handlers) {
      if (handler) {
        handlers.splice(handlers.indexOf(handler) >>> 0, 1)
      } else {
        this.handlers.set(type, [])
      }

      if (!this.handlers.get(type).length) {
        this.handlers.delete(type)
      }
    }
  }

  emit(type, evt) {
    let handlers = this.handlers.get(type)
    if (handlers) {
      handlers.slice().map((handler) => {
        handler(evt)
      })
    }

    handlers = this.handlers.get('*')
    if (handlers) {
      handlers.slice().map((handler) => {
        handler(type, evt)
      })
    }
  }

  once(type, handler) {
    const onceHandler = (eventData) => {
      handler(eventData)
      this.off(type, onceHandler)
    }

    return this.on(type, onceHandler)
  }

  listenerCount(type) {
    return this.handlers.get(type)?.length || 0
  }

  removeAll(type) {
    if (type === undefined || type === '*') {
      this.handlers.clear()
    } else {
      this.handlers.delete(type)
    }
    return this
  }
}
export const mqEvents = new Event()
export default Event

MQ

js 复制代码
export class Mq {
  constructor(options) {
    this.id = getUuid()
    this.name = options.name || ''
    this.queue = [] // 消息主队列,目前不限制
    this.limit = options.lLimit || 1e5 // 消息主队列长度限制
    this.consumerLimit = options.consumerLimit || 1e3 // 消息者的个数限制
    this.consumerExpireTime = options.consumerExpireTime || 259200000 // 消息者为活跃期限,默认三天
    this.consumerMap = new Map() // 绑定的消费者
    this.msgConsumerMap = new Map() // 消费中消息和消费者绑定关系
    this.listenerMap = new Map()
    this.initEvents()
  }

  initEvents() {
    const enqueueFn = this.enqueue.bind(this)
    const dequeueFn = this.dequeue.bind(this)
    const bindConsumerFn = this.bindConsumer.bind(this)
    const unbindConsumerFn = this.unbindConsumer.bind(this)
    const getMsgFn = this.getMsg.bind(this)

    this.listenerMap.set(`${this.name}_enqueue`, enqueueFn)
    this.listenerMap.set(`${this.name}_dequeue`, dequeueFn)
    this.listenerMap.set(`${this.name}_bindConsumer`, bindConsumerFn)
    this.listenerMap.set(`${this.name}_unbindConsumer`, unbindConsumerFn)
    this.listenerMap.set(`${this.name}_getMsg`, getMsgFn)

    const listeners = [...this.listenerMap]
    listeners.forEach(([evName, listener]) => {
      mqEvents.on(evName, listener)
    })
  }

  // 推送一条消息进队
  enqueue(task) {
    if (this.isFull()) {
      throw new Error(`mq ${this.name} is full`)
    }
    this.queue.push({
      id: getUuid(),
      priority: task.priority || 0,
      ctime: Date.now(), // 创建时间,用于定期清理长时间未触发消费的消息
      ...task
    })
    this.notifyAll()
  }

  // 出队
  dequeue(msg) {
    if (!this.msgConsumerMap.has(msg.id)) {
      throw new Error('can not find msgId=' + msg.id)
    }
    const consumerId = this.msgConsumerMap.get(msg.id)
    this.msgConsumerMap.delete(msg.id)
    if (this.consumerMap.has(consumerId)) {
      const consumer = this.consumerMap.get(consumerId)
      this.consumerMap.set(consumerId, {
        ...consumer,
        queue: consumer.queue.filter((id) => id !== msg.id)
      })
    }
  }

  // 通知所有消费者
  notifyAll() {
    mqEvents.emit(`${this.name}_notify`)
  }

  // 绑定消费者
  bindConsumer(data) {
    const now = Date.now()

    // 清理过期未活跃的消费者
    const consumerList = [...this.consumerMap]
    if (consumerList.length > this.consumerLimit) {
      // 消费者满了,那就报错
      throw new Error('consumer is full ' + this.consumerLimit)
    }

    this.consumerMap.set(data.id, {
      id: data.id,
      queue: [],
      limit: data.limit,
      updateTime: now // 更新时间,用于定期清理长时间未活动的消费者,节约内存
    })

    if (!this.isEmpty()) {
      // 如果还有消息积累,需要触发下消费
      this.notifyAll()
    }
  }

  // 解绑消费者
  unbindConsumer(data) {
    // 将所有关于该消费者的子队列消息全部删除
    const msgs = this.consumerMap.get(data.id).queue
    msgs.forEach((msg) => {
      this.msgConsumerMap.delete(msg.id)
    })
    this.consumerMap.delete(data.id)
    // 重新推送未消费的消息
    msgs.forEach((msg) => {
      this.enqueue(msg)
    })
  }

  // 给某个消费者获取一个消息进行消费
  getMsg({ id: consumerId }) {
    if (!this.consumerMap.has(consumerId)) {
      throw new Error(`consumer(${consumerId})并不存在`)
    }

    const consumer = this.consumerMap.get(consumerId)
    const oldMsgs = consumer.queue
    // 先进的先出
    if (oldMsgs.length >= consumer.limit || this.isEmpty()) {
      return
    }

    // 每次取一个消息
    const msg = this.queue.shift()
    // 将获取的消息放进和消费者绑定的子队列中
    this.consumerMap.set(consumerId, {
      ...consumer,
      queue: [...oldMsgs, msg.id]
    })
    this.msgConsumerMap.set(msg.id, consumerId)

    mqEvents.emit(`${this.name}_consumeMsg`, {
      consumerId: consumer.id,
      msg
    })
  }

  isEmpty() {
    return !this.queue.length
  }

  isFull() {
    return this.queue.length === this.limit
  }

  clearMsg() {
    this.queue = []
    this.msgConsumerMap = new Map()
  }

  destroy() {
    const listeners = [...this.listenerMap]
    listeners.forEach(([evName, listener]) => {
      mqEvents.off(evName, listener)
    })
    this.clearMsg()
    this.consumerMap = new Map()
  }
}

Producer

js 复制代码
import { mqEvents } from './event'
// 生产者
export class Producer {
  constructor({ topic }) {
    this.topic = topic
  }

  create(msg, topic) {
    this.id = getUuid()
    mqEvents.emit(`${topic || this.topic}_enqueue`, msg)
  }
}

topic即mq的名称,一个生产者可以生成多个消息放入不同的队列(topic来区分)中

Consumer

js 复制代码
export class Consumer {
  constructor({ topic, limit = 2 }) {
    this.id = getUuid()
    this.topic = topic
    this.limit = limit
    this.listenerMap = new Map()
    this.init()
  }

  init() {
    mqEvents.emit(`${this.topic}_bindConsumer`, {
      id: this.id,
      limit: this.limit
    })

    const notifyFn = this.getMsg.bind(this)
    const consumeMsgFn = (data) => {
      if (data.consumerId === this.id) {
        this.run(data.msg)
      }
    }
    this.listenerMap.set(`${this.topic}_notify`, notifyFn)
    this.listenerMap.set(`${this.topic}_consumeMsg`, consumeMsgFn)

    const listeners = [...this.listenerMap]
    listeners.forEach(([evName, listener]) => {
      mqEvents.on(evName, listener)
    })
  }

  // 消费消息
  consume(msg) {
    if (!msg.run) {
      throw new Error('exec consume() that msg.run is not a function')
    }
    return Promise.resolve().then(() => msg.run())
  }

  // 执行获取消息逻辑进行消费
  run(msg) {
    this.consume(msg).then((time) => {
      this.next(msg)
    })
  }

  getMsg() {
    mqEvents.emit(`${this.topic}_getMsg`, {
      id: this.id
    })
  }

  // 消费结束后告知mq
  next(msg) {
    mqEvents.emit(`${this.topic}_dequeue`, msg)
    this.getMsg()
  }

  destroy() {
    mqEvents.emit(`${this.topic}_unbindConsumer`, {
      id: this.id
    })
    const listeners = [...this.listenerMap]
    listeners.forEach(([evName, listener]) => {
      mqEvents.off(evName, listener)
    })
  }
}

这里的Consumer类我们封装的比较通用,所以在实际使用的时候需要自定义业务类去继承它然后重写run方法。 我们来理顺一下逻辑

  • 当producer生产一条消息时候,会告知mq,然后进队。
  • mq在去notify所有的绑定它的消费者
  • 消费者接收到_notify事件后会调用getMsg方法去mq中获取一条消息进行消费
  • 消费者消费结束后会调用next方法告知mq要把这个成功消费的消息dequeue,然后继续getMsg直到清空mq

嗯嗯,理清MQ后,我们来继续完成我们的下载逻辑优化,我们这里场景不复杂只需要一个mq和一个producer就可以实现

js 复制代码
import { Producer, Consumer, Mq } from './mq'
// 下载请求队列
class RequsetQueue extends Consumer {
  constructor() {
    const dwMq = new Mq({ name: 'downloadFiles' })
    // 限制最多同时消费6条消息
    super({ topic: dwMq.name, limit: 6 })
    this.taskList = []
    this.mq = dwMq
  }

  addTask(task) {
    this.taskList.push(task)
  }

  checkOver(msg) {
    this.next(msg)
    this.runCount++
    if (this.runCount === this.runTotal) {
      if (this.errMsgs.length) {
        this.error(new Error(this.errMsgs.join('\n')))
      } else {
        this.success(`执行成功了${this.runCount}个`)
      }
      this.taskList = []
    }
  }

  run(msg) {
    const task = this.taskList[msg.taskIndex]
    task
      .run()
      .then(() => {
        this.checkOver(msg)
      })
      .catch((err) => {
        this.errMsgs.push(err.message)
        this.checkOver(msg)
      })
  }

  start() {
    this.runTotal = this.taskList.length
    this.errMsgs = []
    this.runCount = 0
    const producer = new Producer({ topic: this.topic })

    return new Promise((resolve, reject) => {
      this.success = resolve
      this.error = reject

      this.taskList.forEach((task, taskIndex) => {
        producer.create({ ...task, run: null, taskIndex })
      })
    })
  }

  destroyAll() {
    this.mq.destroy()
    this.destroy()  // 调用父类的destroy
    // 销毁队列后,还要终止正在执行的请求
    this.taskList.forEach((task) => {
      task?.destroy()
    })
  }
}

那么下载函数可以改成这样

js 复制代码
function downloadFilesToZip(fileList) {
  const zip = new JSZip()
  const reqMq = new RequsetQueue()
  fileList.forEach((file) => {
    reqQueue.addTask({
      name: file.name,
      run: getFile(file.url).then((data) => zip.file(file.name, data, zip)),
      destroy() { // 自己实现请求取消 }
    })
  })
  return reqMq.start().then(() => {
    // 合并成了一个zip文件
    zip.generateAsync({ type: 'blob' }).then((content) => {
      // 写一个a标签来触发下载
      const link = document.createElement('a')
      link.setAttribute('download', Date.now() + '.zip')
      const href = URL.createObjectURL(content)
      link.href = href
      link.setAttribute('target', '_blank')
      link.click()
      return 'success'
    })
  })
}

其实,我们还可以优化优化,比如消息持久化,存入本地,这样就可以保证多文件下载时间比较久,中间断网了再次恢复后可以继续下载等等,本文只是打开大家的思路,就不细说了。

结语

代码很多,大家看的也比较累,总体来说对比吧。方案一相对简单,方案二比较抽象,但是能解决的场景更多。比如我们多个webWorker如何同时有序的处理不同的事情,这就涉及到了使用MQ去调度;比如BI大盘场景,每一个报表会有多个请求,不同请求之间会有优先级(文件-》配置-》数据-》筛选项-》调度状态等),多个报表同时展示给用户看,请求数目就会很多,这时候就可以让MQ支持优先级队列去解决。 最后,前端的发展日新月异,我们不能再以一个前端开发者角度去思考问题,而是跳出去,把自己定位成解决软件问题的工程师,就会看到不一样的世界。

相关推荐
CiL#2 小时前
css动态边框
前端·css
风清云淡_A2 小时前
uniapp中检测应用更新的两种方式-升级中心之uni-upgrade-center-app
前端·uni-app
Citrus_732 小时前
css的选择器及优先级
前端·css
ZL_5672 小时前
uniapp中实现评分组件,多用于购买商品后,对商品进行评价等场景
前端·javascript·uni-app
剑亦未配妥3 小时前
前端vue相关常见面试题,包含MVVM、双向绑定原理、性能优化、vue2和vue3性能对比等
前端
想被带飞的鱼3 小时前
vue3中< keep-alive >页面实现缓存及遇到的问题
开发语言·前端·javascript·vue.js
小凡子空白在线学习4 小时前
8 非静态数据成员默认初始化
开发语言·前端·javascript
服装学院的IT男4 小时前
【Android 14源码分析】WMS-窗口显示-流程概览与应用端流程分析
android·前端
霸王蟹4 小时前
uniapp中uni.request的统一封装 (ts版)
前端·javascript·vue.js·笔记·uni-app
liuy52774 小时前
ceph rgw 桶分片之reshard
前端·ceph