接口异常状态统一处理方案:优先业务端处理,再按需统一处理。

原文地址:https://monine.github.io/#/ar...vue

最近工做贼忙,这篇文章按说应该两个月以前就产出,但是天天的精力基本都用在工做上,一写文章就犯迷糊,断断续续的每次要从新屡逻辑,之后不再这样了。这篇文章是我司后台项目中遇到的一个基础需求,本身设计了一个实现方案,感受还不错。webpack

3ffb660c6a756466b5e948ee273e3dbfc86417fbc43ee47db5c05248dfeeabe47cbe87ecb72123341968a829eb25b016?pictype=scale&from=30113&version=3.3.3.3&uin=287531381&fname=Best-Practices-for-API-Error-Handling.png&size=750

需求

后端接口响应,根据与后端约定的状态码(非 http 状态码)断定接口是否异常,我司的约定是 status !== 0 则表示接口异常。一旦接口处于异常状态,先让业务端(调用者)处理异常,再由业务端决定是否执行接口异常统一处理(目前我司的统一处理内容就是弹出个 element-ui message 提示消息 😂)。ios

start=>start: 接口响应
isApiError=>condition: 异常?
normalProcess=>operation: 执行正常接口处理流程
isUserHandle=>condition: 业务端处理?
userProcess=>operation: 执行业务端处理
isNeedUniteHandle=>condition: 统一处理?
uniteProcess=>operation: 执行统一处理
end=>end: 结束

start->isApiError
isApiError(no)->normalProcess->end
isApiError(yes)->isUserHandle
isUserHandle(no)->uniteProcess->end
isUserHandle(yes)->userProcess->isNeedUniteHandle
isNeedUniteHandle(yes)->uniteProcess->end
isNeedUniteHandle(no)->end

这个流程有一个难点,当接口响应后处于异常状态,先交由业务端处理,再由业务端决定是否执行统一处理?git

API 层我司使用的是第三方库 axios,接口响应后会先走响应拦截器,再走业务端代码。
正常的接口异常统一处理流程,是在响应拦截器内断定,与后端约定的响应状态码是否为异常状态码。若是是,则先执行统一处理逻辑,再交由到业务端处理。那如今的需求是将接口异常处理的流程逆转,接口响应状态异常以后,先交由业务端执行异常处理,再由业务端决定是否执行接口异常状态统一处理。github

如上所说,若是接口处于异常状态,须要断定是否要执行统一处理,分两种状况:web

  1. 业务端没有处理异常,必然要执行统一处理。
  2. 业务端已经处理异常,而且主动声明是否继续执行统一处理。(_主动声明该如何设计?_)

问题来了,接口异常统一处理的代码应该写在哪里?如何保证它在状态异常状况下,先交由业务端处理,再根据业务端的声明断定是否执行统一处理。vue-cli

历史解决方案之 mixin

我司以前已经有过处理方案,不过是只针对 vue 框架下的处理,经过 mixin 将 methods 内全部方法进行覆写,补丁函数内对源函数执行完成以后获取的返回结果进行断定,若是返回结果为 Promise 类型,则继续进行相关异常处理操做。这样确实可以达到实现效果,但总以为很不优雅:element-ui

  1. API 层的处理与框架深度绑定,这自己就不合理。
  2. methods 内函数所有被覆写,大量无用开销;若是进行函数名称约定,加入覆写筛选,这又增长约定成本。
  3. 只能应用于 vue 框架,没法再其它项目下直接使用,很局限。

当时我了解到上述的处理方案后第一反应是 API 层的任何操做都不该该与框架自己进行任何关联绑定,如同当年 vue 从全家桶中移除 vue-resource 同样。axios

个人解决方案

通过一些思考,大体肯定了一个思路:利用 Promise 状态的稳定,以接口名称做为惟一标识,表示当前接口是否还须要执行统一处理。后端

我是这样设计的,接口调用时使用 url 做为惟一标识,以状态的形式保存在数组内 const unhandleAPI = []

接口返回后进入响应拦截器,在此对接口响应状态进行判断,若是属于异常状态,则使用 setTimeout 将接口异常统一处理函数设置为 macro task,做为一个异步任务推迟到下一轮 Event Loop 再执行,并返回 Promise.reject。而后会进入业务端接口调用代码的 catch 回调函数内,执行完业务异常处理后,若是没有返回值,则表示无需再执行统一处理。相反,返回非 undefined 值,则表示还须要执行统一处理。

执行接口异常统一处理以前,先断定 url 标识是否存在于 unhandleAPI 内,如存在,则执行统一处理。

以上是一个大体的设计思路,具体到实现还须要解决一些实际问题:

  1. 如何肯定接口的惟一性?由于同一个接口可能毫秒内被屡次调用。
  2. 接口的异常状态以什么样的形式进行保存?
  3. 如何在适当的时候移除接口异常状态?好比业务端处理了异常不想再执行统一处理。

具体实现

  • 接口异常处理状态存储

    使用一个数组对象 const unhandleAPI = [] 保存全部已经调用但暂未响应的接口惟一标识,接口异常统一处理函以此断定断定是否须要执行。另外对外暴露一些操做 unhandleAPI 的接口。

    const unhandleAPI = [];
    
    if (process.env.NODE_ENV !== 'production') {
      window.unhandleAPI = unhandleAPI;
    }
    
    export function matchUnhandleAPI(id) {
      return unhandleAPI.find(apiUid => apiUid === id);
    }
    
    export function addUnhandleAPI(id) {
      unhandleAPI.push(id);
    }
    
    export function removeUnhandleAPI(id) {
      const index = unhandleAPI.findIndex(apiUid => apiUid === id);
    
      if (process.env.NODE_ENV === 'production') {
        unhandleAPI.splice(index, 1);
      } else {
        // 方便非 production 环境查看接口处理状况
        unhandleAPI[index] += '#removed';
      }
    }
  • 发送接口请求

    经过查看 axios 源码,知道 axios 真正调用接口的方法是 axios.Axios.prototype.request,因此须要对其进行覆写。将当前调用接口的惟一标识添加到 unhandleAPI 数组对象内,同时也要添加到 axios.Axios.prototype.request 方法所返回的 Promise 实例对象当中(接口响应后的处理会使用到)。

    let uid = 0;
    const axiosRequest = axios.Axios.prototype.request;
    axios.Axios.prototype.request = function(config) {
      uid += 1;
    
      const apiUid = `${config.url}?uid=${uid}`; // 接口调用的惟一标识
      config.apiUid = apiUid; // 响应拦截器内须要使用到 apiUid,因此添加为 config 属性
      addUnhandleAPI(apiUid); // 添加到接口异常处理状态存储的数组对象
    
      const p = axiosRequest.call(this, config); // 触发 axios 接口调用
      p.apiUid = apiUid; // 在当前接口调用所返回的 Promise 实例中添加惟一标识属性
      return p;
    };
  • 接口响应进入响应拦截器

    在响应拦截器内断定接口状态,若是正常,则从接口状态存储的数组对象中移除当前响应接口的惟一标识。若是异常,则 setTimeout 延迟执行接口状态异常统一处理函数,并返回 Promise.reject() 给到业务端。

    service.interceptors.response.use(
      ({ data, config }) => {
        const { status, msg, data: result } = data;
    
        // 判断接口状态是否异常
        if (status !== 0) {
          const pr = Promise.reject(data);
          pr.apiUid = config.apiUid; // Promise 实例中添加当前接口的惟一标识属性
          setTimeout(handleAPIStatusError, 0, pr, msg); // 异常先交由业务端处理,延迟执行统一处理函数
          return pr;
        }
    
        // 接口状态正常
        removeUnhandleAPI(config.apiUid); // 从接口异常处理状态存储的数组对象中移除当前响应接口的惟一标识
        return result;
      },
      error => {
        Message.error(error.message);
        return Promise.reject(error);
      }
    );
  • 业务端处理

    如今假设接口状态属于异常状况,通过响应拦截器以后,代码执行到业务端,先看看业务端接口调用代码:

    callAPIMethod().catch(error => {
      // 业务端处理异常
    });

    以上是 Promise catch 的常规语法,此时若是 callAPIMethod 返回的 Promise 状态为 rejected,则会执行 catch 函数的回调函数。

    还记得上文提到的流程上的难点吗?

    业务端决定是否执行接口异常统一处理函数,所以须要在此进行设计,catch 函数的回调函数如何进行声明?其实上文已经提到 声明 的设计方案,利用 catch 函数的回调函数的返回值。

    设计方案 OK,落实到具体实现该如何进行代码编写?无疑,须要针对 catch 函数进行覆写:

    Promise.prototype.catch = function(onRejected) {
      function $onRejected(...args) {
        const catchResult = onRejected(...args);
        if (catchResult === undefined && this.apiUid) {
          removeUnhandleAPI(this.apiUid);
        }
      }
      return this.then(null, $onRejected.bind(this));
    };

    catch 方法自己其实只是语法糖,将 catch 函数的回调函数进行包装,在包装后的函数内,先执行业务端 catch 的回调函数,获取到函数执行结果。接着,若是当前 promise 对象上有 apiUid 属性,则表示当前 promise 是 API 层的 promise。若是 catch 的回调函数执行完毕以后的返回结果是 undefined,则表示再也不须要执行接口异常状态统一处理函数,相应的,须要从以前定义的 unhandleAPI 数组内移除当前接口的惟一标识。

  • then 方法返回新 promise

    以上业务端处理看似正常,然而大多数状况下,业务端代码在接口调用以后不会直接链式调用 catch 方法,而是先调用 then 方法,再调用 catch 方法,以下:

    callAPIMethod()
      .then(response => {
        // ...
      })
      .catch(error => {
        // ...
      });

    callAPIMethod() 的执行结果返回的是个 promise 对象,而且这个 promise 对象上会有 apiUid 属性,表示当前 promise 是 API 层接口。而后链式调用 then 方法和 catch 方法,就由于中间插入了 then 方法的调用,致使 catch 的覆写函数内 this 对象的属性上没有了 apiUid 属性,也就没法断定当前 promise 是 API 层接口的返回对象。缘由是 then 方法执行完后返回了新的 Promise 实例,因此一样须要对 then 方法进行覆写。

    const promiseThen = Promise.prototype.then;
    Promise.prototype.then = function(onFulfilled, onRejected) {
      // 获取 then 方法返回的新 Promise 实例对象
      const p = promiseThen.call(this, onFulfilled, onRejected);
      // 在 promise 对象上有 apiUid 的状况下,表示是接口层的 Promise
      // 则给 then 方法返回的 Promise 实例对象也加上 apiUid
      if (this.apiUid) p.apiUid = this.apiUid;
      return p;
    };

    then 方法的覆写函数内,先执行原生的 then 方法,获取返回结果,再判断当前调用者 promise 对象是否有 apiUid 属性。若是有,则表示是 API 层的 Promise,从而须要给当前 then 方法返回的 Promise 实例也添加上 apiUid 属性。

  • 执行接口异常状态统一处理函数

    接口异常状态状况下,若是业务端主动声明须要执行接口异常状态统一处理(业务端 catch 回调函数返回非 undefined 值),则在执行响应拦截器内 setTimeout 延迟执行的函数 handleAPIStatusError

    只要接口响应状态为异常,都会执行接口异常状态统一处理函数,内部会进行断定

    function handleAPIStatusError(pr, msg) {
      const index = unhandleAPI.findIndex(apiUid => apiUid === pr.apiUid);
      if (index >= 0) {
        pr.catch(() => {
          Message.error({ message: msg, duration: 5e3 });
        });
      }
    }

    若是 unhandleAPI 数组对象内可以找到 pr.apiUid,则表示须要执行接口异常状态统一处理。

可能存在的问题

若是项目是由 vue-cli 搭建的 webpack 模板项目,在没有修改 .babelrc 文件配置的状况下,此方案在 Firefox 浏览器下是无效的。接口状态异常的状况下,老是会执行统一处理,不会先交由业务端处理异常,再断定是否执行统一处理。

Firefox 下无效的缘由和解决方案我会在下一篇文章讲解。

自我评价

我的认为这样的设计仍是很优雅的,认知成本很是小,对小伙伴的常规开发没有任何污染;对框架没有任何依赖,可移植到任何框架项目下。

另外

能力有限,哪位小伙伴有更加优雅合适的方案还望不吝赐教。

相关文章
相关标签/搜索