背景
随着业务逻辑越来越复杂,前端需要的手段不再是自己所处那个圈子。本文主要讲解基于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支持优先级队列去解决。 最后,前端的发展日新月异,我们不能再以一个前端开发者角度去思考问题,而是跳出去,把自己定位成解决软件问题的工程师,就会看到不一样的世界。