Bendi新闻
>
前端接口防止重复请求实现方案

前端接口防止重复请求实现方案

8月前

推荐关注↓

作者:沽汣 

https://juejin.cn/post/7341840038964363283

前言

前段时间老板心血来潮,要我们前端组对整个的项目都做一下接口防止重复请求的处理(似乎是有用户通过一些快速点击薅到了一些优惠券啥的)。。。听到这个需求,第一反应就是,防止薅羊毛最保险的方案不还是在服务端加限制吗?前端加限制能够拦截的毕竟有限。可老板就是执意要前端搞一下子,行吧,搞就搞吧,you happy jiu ok

虽然大部分的接口处理我们都是加了loading的,但又不能确保真的是每个接口都加了的,可是如果要一个接口一个接口的排查,那这维护了四五年的系统,成百上千的接口肯定要耗费非常多的精力,根本就是不现实的,所以就只能去做全局处理。下面就来总结一下这次的防重复请求的实现方案:

方案一

这个方案是最容易想到也是最朴实无华的一个方案:通过使用axios拦截器,在请求拦截器中开启全屏Loading,然后在响应拦截器中将Loading关闭。


这个方案固然已经可以满足我们目前的需求,但不管三七二十一,直接搞个全屏Loading还是不太美观,何况在目前项目的接口处理逻辑中还有一些局部Loading,就有可能会出现Loading套Loading的情况,两个圈一起转,头皮发麻。

方案二

加Loading的方案不太友好,而对于同一个接口,如果传参都是一样的,一般来说都没有必要连续请求多次吧。那我们可不可以通过代码逻辑直接把完全相同的请求给拦截掉,不让它到达服务端呢?这个思路不错,我们说干就干。

首先,我们要判断什么样的请求属于是相同请求

一个请求包含的内容不外乎就是请求方法地址参数以及请求发出的页面hash。那我们是不是就可以根据这几个数据把这个请求生成一个key来作为这个请求的标识呢?

// 根据请求生成对应的key
function generateReqKey(config, hash{
    const { method, url, params, data } = config;
    return [method, url, JSON.stringify(params), JSON.stringify(data), hash].join("&");
}

有了请求的key,我们就可以在请求拦截器中把每次发起的请求给收集起来,后续如果有相同请求进来,那都去这个集合中去比对,如果已经存在了,说明就是一个重复的请求,我们就给拦截掉。当请求完成响应后,再将这个请求从集合中移除。合理,nice!

具体实现如下:

是不是觉得这种方案还不错,万事大吉?

no,no,no! 这个方案虽然理论上是解决了接口防重复请求这个问题,但是它会引发更多的问题。

比如,我有这样一个接口处理:

那么,当我们触发多次请求时:

这里我连续点击了4次按钮,可以看到,的确是只有一个请求发送出去,可是因为在代码逻辑中,我们对错误进行了一些处理,所以就将报错消息提示了3次,这样是很不友好的,而且,如果在错误捕获中有做更多的逻辑处理,那么很有可能会导致整个程序的异常。

而且,这种方案还会有另外一个比较严重的问题

我们在上面在生成请求key的时候把hash考虑进去了(如果是history路由,可以将pathname加入生成key),这是因为项目中会有一些数据字典型的接口,这些接口可能有不同页面都需要去调用,如果第一个页面请求的字典接口比较慢,第二个页面的接口就被拦截了,最后就会导致第二个页面逻辑错误。那么这么一看,我们生成key的时候加入了hash,讲道理就没问题了呀。

可是倘若我这两个请求是来自同一个页面呢?

比如,一个页面同时加载两个组件,而这两个组件都需要调用某个接口时:

那么此时,后调接口的组件就无法拿到正确数据了。啊这,真是难顶!

方案三

方案二的路子,我们发现确实问题重重,那么接下来我们来看第三种方案,也是我们最终采用的方案。

延续我们方案二的前面思路,仍然是拦截相同请求,但这次我们可不可以不直接把请求挂掉,而是对于相同的请求我们先给它挂起,等到最先发出去的请求拿到结果回来之后,把成功或失败的结果共享给后面到来的相同请求

思路我们已经明确了,但这里有几个需要注意的点:

  • 我们在拿到响应结果后,返回给之前我们挂起的请求时,我们要用到发布订阅模式(日常在面试题中看到,这次终于让我给用上了(^▽^))
  • 对于挂起的请求,我们需要将它拦截,不能让它执行正常的请求逻辑,所以一定要在请求拦截器中通过return Promise.reject()来直接中断请求,并做一些特殊的标记,以便于在响应拦截器中进行特殊处理

最后,直接附上完整代码:

import axios from "axios"

let instance = axios.create({
    baseURL"/api/"
})

// 发布订阅
class EventEmitter {
    constructor() {
        this.event = {}
    }
    on(type, cbres, cbrej) {
        if (!this.event[type]) {
            this.event[type] = [[cbres, cbrej]]
        } else {
            this.event[type].push([cbres, cbrej])
        }
    }

    emit(type, res, ansType) {
        if (!this.event[type]) return
        else {
            this.event[type].forEach(cbArr => {
                if(ansType === 'resolve') {
                    cbArr[0](res)
                }else{
                    cbArr[1](res)
                }
            });
        }
    }
}


// 根据请求生成对应的key
function generateReqKey(config, hash{
    const { method, url, params, data } = config;
    return [method, url, JSON.stringify(params), JSON.stringify(data), hash].join("&");
}

// 存储已发送但未响应的请求
const pendingRequest = new Set();
// 发布订阅容器
const ev = new EventEmitter()

// 添加请求拦截器
instance.interceptors.request.use(async (config) => {
    let hash = location.hash
    // 生成请求Key
    let reqKey = generateReqKey(config, hash)
    
    if(pendingRequest.has(reqKey)) {
        // 如果是相同请求,在这里将请求挂起,通过发布订阅来为该请求返回结果
        // 这里需注意,拿到结果后,无论成功与否,都需要return Promise.reject()来中断这次请求,否则请求会正常发送至服务器
        let res = null
        try {
            // 接口成功响应
          res = await new Promise((resolve, reject) => {
                    ev.on(reqKey, resolve, reject)
                })
          return Promise.reject({
                    type'limiteResSuccess',
                    val: res
                })
        }catch(limitFunErr) {
            // 接口报错
            return Promise.reject({
                        type'limiteResError',
                        val: limitFunErr
                    })
        }
    }else{
        // 将请求的key保存在config
        config.pendKey = reqKey
        pendingRequest.add(reqKey)
    }

    return config;
  }, function (error{
    return Promise.reject(error);
  });

// 添加响应拦截器
instance.interceptors.response.use(function (response{
    // 将拿到的结果发布给其他相同的接口
    handleSuccessResponse_limit(response)
    return response;
  }, function (error{
    return handleErrorResponse_limit(error)
  });

// 接口响应成功
function handleSuccessResponse_limit(response{
      const reqKey = response.config.pendKey
    if(pendingRequest.has(reqKey)) {
      let x = null
      try {
        x = JSON.parse(JSON.stringify(response))
      }catch(e) {
        x = response
      }
      pendingRequest.delete(reqKey)
      ev.emit(reqKey, x, 'resolve')
      delete ev.reqKey
    }
}

// 接口走失败响应
function handleErrorResponse_limit(error{
    if(error.type && error.type === 'limiteResSuccess') {
      return Promise.resolve(error.val)
    }else if(error.type && error.type === 'limiteResError') {
      return Promise.reject(error.val);
    }else{
      const reqKey = error.config.pendKey
      if(pendingRequest.has(reqKey)) {
        let x = null
        try {
          x = JSON.parse(JSON.stringify(error))
        }catch(e) {
          x = error
        }
        pendingRequest.delete(reqKey)
        ev.emit(reqKey, x, 'reject')
        delete ev.reqKey
      }
    }
      return Promise.reject(error);
}

export default instance;

补充

到这里,这么一通操作下来上面的代码讲道理是万无一失了,但不得不说,线上的情况仍然是复杂多样的。而其中一个比较特殊的情况就是文件上传


可以看到,我在这里是上传了两个不同的文件的,但只调用了一次上传接口。按理说是两个不同的请求,可为什么会被我们前面写的逻辑给拦截掉一个呢?

我们打印一下请求的config:


可以看到,请求体data中的数据是FormData类型,而我们在生成请求key的时候,是通过JSON.stringify方法进行操作的,而对于FormData类型的数据执行该函数得到的只有{}。所以,对于文件上传,尽管我们上传了不同的文件,但它们所发出的请求生成的key都是一样的,这么一来就触发了我们前面的拦截机制。

那么我们接下来我们只需要在我们原来的拦截逻辑中判断一下请求体的数据类型即可,如果含有FormData类型的数据,我们就直接放行不再关注这个请求就是了。

function isFileUploadApi(config{
  return Object.prototype.toString.call(config.data) === "[object FormData]"
}

最后

到这里,整个的需求总算是完结啦!不用一个个接口的改代码,又可以愉快的打代码了,nice!

Demo地址:https://github.com/GuJiugc/JueJinDemo


- EOF -

推荐阅读  点击标题可跳转

1、就删了个 printf,代码崩了!

2、Sora 的第一波受害者出现了

3、李彦宏诚不欺我?全球首位 AI 程序员来了

4、谷歌:不建议未成年人接触 C++,太过危险

5、2024年,只有搞颜色的 P 站真正关心网站性能


关注「程序员的那些事」加星标,不错过圈内事

点赞和在看就是最大的支持❤️

微信扫码关注该文公众号作者

来源:程序员的那些事

相关新闻

从 0 到 1 实现一个前端全链路定位方案!| 极客时间11种方案,实现订单到期关闭开源“靓仔”头像生成器:纯前端本地实现、完全随机绘制——保证独一无二!公司来了个大神,三方接口调用方案设计的真优雅~~自研芯片,唯理科技打造非侵入式脑机接口完整解决方案共建产业开放生态一站式解决方案:基于 Arthas 实现服务发现和权限控制吞吐量提升5倍,联合设计后端系统和前端语言的LLM接口来了优雅实现多系统一致性补偿方案优雅的处理 API 接口敏感数据加解密(方案详解)早鸟报|京东支付与微信支付实现互联互通​;辛选官方向快手致歉;月之暗面回应Kimi推出付费方案...嫦娥六号成功实施近月制动;三星3nm移动应用处理器实现首次流片;特斯拉上海储能超级工厂涉河建设方案获批丨智能制造日报连续三年实现50%业务增长,云准医药如何打造单细胞与空间多组学联合分析方案?这样做出来的竞赛方案,也太稳了吧?著名毒品研究专家与华人社区对话:加拿大毒品泛滥及危害现状及解决方案手术中频繁看尿袋的困扰,来看看这个医生的解决方案正在播出丨泌尿系统肿瘤治疗的中国方案(肾癌篇)榜单!前视一体机抢「800万辆」普及增量,谁在领跑纯视觉方案市场?为什么图纸好看的方案,设计费一般都不低?薛定谔获盖茨基金会1000万美元资助,开发计算解决方案,避免早期毒理风险恒大债务重组停滞近一年,公告尚未觅得合适的方案中国恒大突发!继续停牌!清盘人未觅得重组方案;高合汽车申请预重整获法院受理丨大公司动态如何破解“对赌回购”难题?20位大咖分享了7条方案!ICML 2024 | 自动化所提出SpikeLM,首个完全脉冲驱动的通用语言建模方案抄作业 | 甲方集团信息安全管理体系优化咨询方案
logo
联系我们隐私协议©2024 bendi.news
Bendi新闻
Bendi.news刊载任何文章,不代表同意其说法或描述,仅为提供更多信息,也不构成任何建议。文章信息的合法性及真实性由其作者负责,与Bendi.news及其运营公司无关。欢迎投稿,如发现稿件侵权,或作者不愿在本网发表文章,请版权拥有者通知本网处理。