原文地址:
最近工作贼忙,这篇文章按说应该两个月之前就产出,可是每天的精力基本都用在工作上,一写文章就犯迷糊,断断续续的每次要重新屡逻辑,以后再也不这样了。这篇文章是我司后台项目中遇到的一个基础需求,自己设计了一个实现方案,感觉还不错。
需求
后端接口响应,根据与后端约定的状态码(非 http 状态码)判定接口是否异常,我司的约定是 status !== 0
则表示接口异常。一旦接口处于异常状态,先让业务端(调用者)处理异常,再由业务端决定是否执行接口异常统一处理(目前我司的统一处理内容就是弹出个 element-ui message 提示消息 ?)。
start=>start: 接口响应isApiError=>condition: 异常?normalProcess=>operation: 执行正常接口处理流程isUserHandle=>condition: 业务端处理?userProcess=>operation: 执行业务端处理isNeedUniteHandle=>condition: 统一处理?uniteProcess=>operation: 执行统一处理end=>end: 结束start->isApiErrorisApiError(no)->normalProcess->endisApiError(yes)->isUserHandleisUserHandle(no)->uniteProcess->endisUserHandle(yes)->userProcess->isNeedUniteHandleisNeedUniteHandle(yes)->uniteProcess->endisNeedUniteHandle(no)->end
这个流程有一个难点,当接口响应后处于异常状态,先交由业务端处理,再由业务端决定是否执行统一处理?
API 层我司使用的是第三方库 axios,接口响应后会先走响应拦截器,再走业务端代码。
正常的接口异常统一处理流程,是在响应拦截器内判定,与后端约定的响应状态码是否为异常状态码。如果是,则先执行统一处理逻辑,再交由到业务端处理。那现在的需求是将接口异常处理的流程逆转,接口响应状态异常之后,先交由业务端执行异常处理,再由业务端决定是否执行接口异常状态统一处理。如上所说,如果接口处于异常状态,需要判定是否要执行统一处理,分两种情况:
- 业务端没有处理异常,必然要执行统一处理。
- 业务端已经处理异常,并且主动声明是否继续执行统一处理。(_主动声明该如何设计?_)
问题来了,接口异常统一处理的代码应该写在哪里?如何保证它在状态异常情况下,先交由业务端处理,再根据业务端的声明判定是否执行统一处理。
历史解决方案之 mixin
我司之前已经有过处理方案,不过是只针对 vue 框架下的处理,通过 mixin 将 methods 内所有方法进行覆写,补丁函数内对源函数执行完成之后获取的返回结果进行判定,如果返回结果为 Promise 类型,则继续进行相关异常处理操作。这样确实能够达到实现效果,但总觉得很不优雅:
- API 层的处理与框架深度绑定,这本身就不合理。
- methods 内函数全部被覆写,大量无用开销;如果进行函数名称约定,加入覆写筛选,这又增加约定成本。
- 只能应用于 vue 框架,无法再其它项目下直接使用,很局限。
当时我了解到上述的处理方案后第一反应是 API 层的任何操作都不应该与框架本身进行任何关联绑定,如同当年 vue 从全家桶中移除 vue-resource 一样。
我的解决方案
经过一些思考,大致确定了一个思路:利用 Promise 状态的稳定,以接口名称作为唯一标识,表示当前接口是否还需要执行统一处理。。
我是这样设计的,接口调用时使用 url 作为唯一标识,以状态的形式保存在数组内 const unhandleAPI = []
。
接口返回后进入响应拦截器,在此对接口响应状态进行判断,如果属于异常状态,则使用 setTimeout
将接口异常统一处理函数设置为 macro task,作为一个异步任务推迟到下一轮 Event Loop 再执行,并返回 Promise.reject
。然后会进入业务端接口调用代码的 catch 回调函数内,执行完业务异常处理后,如果没有返回值,则表示无需再执行统一处理。相反,返回非 undefined
值,则表示还需要执行统一处理。
执行接口异常统一处理之前,先判定 url 标识是否存在于 unhandleAPI
内,如存在,则执行统一处理。
以上是一个大致的设计思路,具体到实现还需要解决一些实际问题:
- 如何确定接口的唯一性?因为同一个接口可能毫秒内被多次调用。
- 接口的异常状态以什么样的形式进行保存?
- 如何在适当的时候移除接口异常状态?比如业务端处理了异常不想再执行统一处理。
具体实现
-
接口异常处理状态存储
使用一个数组对象
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 下无效的原因和解决方案我会在讲解。
自我评价
个人认为这样的设计还是很优雅的,认知成本非常小,对小伙伴的常规开发没有任何污染;对框架没有任何依赖,可移植到任何框架项目下。
另外
能力有限,哪位小伙伴有更加优雅合适的方案还望不吝赐教。