axios重发设计方案
errol发表于2023-06-17 04:46:38 | 分类为 编程 | 标签为axios重发请求刷新令牌jwt

axios是现如今web前端领域中比较流行的一个http客户端,它基于promise封装,不仅体积小巧、简单易用,还具有很强的拓展性,使得它非常灵活多变,可以适应不同的环境场景。因此,axios深得web前端开发者的喜爱,已经成为前端web项目中的"标配"。

通常情况下,"授权认证"是一个项目系统中不可或缺的一部分,在以往传统的web项目中,都是采用基于session+cookie的认证方案,这套体系已经相当成熟,几乎所有语言的web开发框架都内置了这套经典的认证方案,如spring、express等,只需要添加很少的配置依赖即可使用;但在前后端分离的环境中,很少再使用这套认证方式,取而代之的是基于token的认证方案,而token认证常常与ajax一同出现,这正是使用到axios的原因(当然,也还有很多其他的ajax工具库,但个人觉得还是axios好用一些)。

token认证一般指的是,在通过用户认证之后,往请求头中加入由服务器的颁发"令牌"(即token)访问服务器,服务器再对"令牌"进行校验的一个过程,比如判断token是否还有效、判断token的对于用户是否具有某些权限等等,只有校验通过才会放行。

跟session类似的,一般会把token的有效期设置得比较短(如1、2小时不等),以减少数据泄露的风险,从而提升账号的安全性;与sessionId一样,token也会用作存储用户数据的key。

但与session认证不同的是,token默认情况下,不具备"刷新"的机制:基于session的认证会在session快过期的时候,对session进行"刷新",使得访问服务器得以正常继续;而基于token的认证在token过期时,只能重新颁发一组令牌,所引发的结果就是强制用户重新登陆,如果在用户使用系统过程中遇到这种情况,那将会给用户带来非常糟糕的体验,就跟刚看到电影的精彩部分内容,突然跳闸停电了一样。

对于这个问题,有两种处理思路:

  • 在请求前判断token是否过期(或接近过期),如果过期了,就先向服务器请求刷新token后,携带新的token再请求服务器;
  • 在服务器返回token过期后,再向服务器请求刷新token,然后重发因token过期而请求失败的接口。

两种方式都有优缺点,对于第一种方式,因为是在前端判断token是否过期,所以会少发送一次请求,但相对的是,服务器要返回判断token是否过期的依据给前端;第二种刚好相反,由于token过期判断逻辑放在服务器,因此会比第一种方式多请求一次。

综合考虑之下,作者选择了第二种处理思路,一方面作者本人觉得,项目系统中承担主要责任的是后端服务器,不应该把问题抛给前端处理;另一方面,作者还觉得将逻辑放在服务器更有安全感。

顺着这个思路,下面正式开始本文的主题。

axios重发原理

主要依赖了拦截器promise两个点。

当因令牌过期时而导致请求失败时,可以利用拦截器在请求方法源头得到错误结果之前,对响应结果进行拦截,将其替换为一个未决策的promise对象,使得源头方法处于停滞状态,期间重新向服务器刷新令牌,随后带上新令牌重新发起请求,同时把promise决策掉,以恢复源头方法的执行,所达到的效果便是请求方法正常获取到数据。

配置axios

axios实例

此处创建了一个基本的axios实例,后续会将该实例暴露出去供其他地方使用;此外还准备了一些必要的数据。

import { refresh } from '@/api/index';
import { Message, MessageBox } from 'element-ui';
import bus from "@/util/bus.js";

const maxRetryingCount = 3; // 最大重发次数
const queue = []; // 请求队列
let retrying = false;

const service = axios.create({
    baseURL: "http://localhost:8888/",
    timeout: 5000,
    retryingCount: maxRetryingCount
});

function release(ok) {
    if (queue.length) {
        for (const handler of queue) {
            handler(ok);
        }
        // 释放队列
        queue.length = 0;
    }
}

// ...

export default service;

设置请求拦截器

本文的请求拦截器非常简单,主要是检查本地是否存在token,如果存在,则往请求头中写入数据。

// 此处从localStorage中读取token
export const tokenInterceptor = config => {
    const accessToken = window.localStorage.getItem("accessToken");
    if (accessToken) {
        config.headers["Authorization"] = `Bearer ${accessToken}`;
    }
    return config;
}

service.interceptors.request.use(
    tokenInterceptor,
    error => {
        console.log(error);
        return Promise.reject(error);
    }
);

状态码处理器

相对来说,响应拦截器要比请求拦截器更复杂一些,它主要的功能是根据服务器返回的状态码做出下一步动作,如弹出提示信息、正常返回数据等。

由于请求的响应方式有"正常响应"和"错误响应"两种,即后端开发在响应某个请求的时候,可能是以状态码为200的正常响应形式返回,也可能以400、401、405等错误状态码的形式返回,"错误响应"将会被错误处理器或回调处理,"正常响应"则会进入标准处理流程,因此前端作为被动接受的一方,最好是把这两种情况都考虑在内,以防止后续出现奇怪的问题。

为了方便调用,此处把所有的逻辑都封装成了一个函数形式。

该处理器会在服务器返回40101状态码的时候,尝试向服务器刷新令牌,刷新成功再把失败的请求重发一遍,达到了无感登陆的效果。

// 状态码处理器
async function respHandler(resp) {
    const data = resp.data;
    switch (data.code) {
        case 0:
            return data;
        // 未登录或accessToken过期
        case 40101:
            const refreshToken = window.localStorage.getItem("refreshToken");
            // 若请求某个请求返回"未登录"且刷新令牌也为空时,则提示用户重新登陆
            if (!refreshToken) {
                return MessageBox("请先登录", "提示", {
                    confirmButtonText: '确定'
                }).then(() => {});
            }
            if (!retrying) {
                retrying = true;
                try {
                    const refreshResp = await refresh({ refreshToken });
                    if (!refreshResp || refreshResp.code !== 0) {
                        throw new Error('刷新失败');
                    }
                    const { accessToken, refreshToken: _new } = refreshResp.data;
                    window.localStorage.setItem("accessToken", accessToken);
                    if (_new) {
                        window.localStorage.setItem("refreshToken", _new);
                    }
                    bus.$emit('refresh:token', { accessToken, refreshToken: _new });
                    // 刷新成功后,重新发起请求
                    release(true);
                    return service(resp.config);
                } catch (error) {
                    console.error("刷新请求出现错误:", error);
                    window.localStorage.setItem("accessToken", "");
                    bus.$emit('clear:token', { accessToken: true });
                } finally {
                    retrying = false;
                }
            }
            // 在并发请求的情况下,将后续的请求加入队列
            else {
                return new Promise((resolve, reject) => {
                    queue.push((ok) => {
                        // 刷新成功成功后,重新发起请求
                        if (ok) {
                            resolve(service(resp.config));
                        }
                        else {
                            reject('refreshing failure.');
                        }
                    });
                });
            }
            break;
        case 40198:
            window.localStorage.setItem("refreshToken", "");
            bus.$emit('clear:token', { refreshToken: true });
            Message.error(data.message || "请重新登陆");
            break;
        default:
            Message.error(data.message || "系统发生异常");
    }
    return data;
}

设置响应拦截器

正常响应部分直接交由状态码处理器处理即可,但错误响应部分需要做特殊处理,有这样的规则:如果错误对象有请求响应结果response,就将错误交给状态码处理器处理,否则将错误抛给下游,进入重发逻辑或直接返回错误对象。

service.interceptors.response.use(
    response => {
        return respHandler(response);
    },
    error => {
        if (error.response) {
            return respHandler(error.response);
        }

        // 可以拓展更多的需要重发请求的情况
        if (error.code === 'ECONNABORTED' || error.message.includes('timeout of')) {
            if (error.config.retryingCount > 0) {
                error.config.retryingCount--;
                let current = Math.abs(error.config.retryingCount - maxRetryingCount);
                console.error("第" + current + "次重发中...");
                return service(error.config);
            }
            console.error("[" + error.config.url + "] 请求失败,总次数为:" + maxRetryingCount);
            release(false);
        }

        Message.error(error.message || "系统发生异常");
        return Promise.reject(error);
    }
);

使用封装的axios

本文章所用到的demo存放在这里,需要的读者可以自行下载测试。

测试刷新令牌

可以看到,在点击"获取数据"的时候,由于"未登录"导致数据获取失败,接着自动发起刷新令牌请求,最后再携带新的令牌请求服务器,最终成功获取到数据。

测试重发请求

此处最大请求次数为三,如果超过三次没能成功获取数据则不再重试。

1、成功的请求如下

2、失败的请求如下


=== 20230908更新 ===

考虑到有一些场景下并不需要重试请求,因此添加了一个表示"是否启用重试"的参数"enableRetrying",只有当该参数设置为"true"的时候,才会进行重试,以下是代码修改的位置:

service.interceptors.response.use(
    // ...
    error => {
        // ...
        
        // 可以拓展更多的需要重发请求的情况
        if (error.config.enableRetrying && (error.code === 'ECONNABORTED' || error.message.includes('timeout of'))) {
            // ...
        }
    }
);

该参数可以在axios全局配置中添加,也可以在特定的请求中添加(会产生覆盖效果)。

1、全局添加

const service = axios.create({
    baseURL: "http://localhost:8888/",
    timeout: 5000,
    retryingCount: maxRetryingCount,
    enableRetrying: true // 是否启用重试
});

2、局部添加

image

图1 请求数据接口(src/api/index.js)

=== 20231021更新 ===

(事实上,这个不属于重发请求的内容,但由于与之相关,也一并写在这里了)

在某些时候,我们可能会想让某个请求是静默的,也就是说请求不会有任何提示:用户之前登陆过了,但现在凭证已过期,而在某个无需登录的页面下,使用该过期的凭证向服务器发起请求,如果在未改动的情况下,会出现用户登录已过期的弹窗提示。

这样是不合理的,因为当前用户处于非必须登陆的界面,不应该强制用户重新登陆。

比较好的做法应该是对当前请求做静默处理,并删除相关的数据,只有当用户操作必须处于登录状态才能继续的页面或功能时,才强制让用户重新登录。

1、将提示功能封装为一个方法,以更方便调用

/**
 * 通知方法
 * @param {Number} type - 通知类型;1=gotoLoginPage,2=Message.error
 * @param {Object} resp - 响应对象
 * @returns 
 */
 const notice = (type, resp) => {
    if (resp.config && resp.config.silence) {
        return;
    }

    const data = resp.data;
    // 目前只有两种通知类型:1=前往登陆提示,2=默认提示
    if (type == 1) {
        gotoLoginPage(data.message, router.app.$route);
    }
    else if (type == 2) {
        Message.error(data.message || "系统发生异常");
    }
}

2、新增silenceInterceptor拦截器

该拦截器会在请求前检查指定的存储对象,如localStorage、sessionStorage、vuex等,如果其中的silence属性为true,就把silence移到config;

// 静默拦截器;
// 此处使用localStorage、vuex(vue)作为代替也是可以的;
const silenceInterceptor = config => {
    if (!config.silence) {
        const silence = sessionStorage.getItem('silence');
        if (silence) {
            config.silence = true;
        }
    }
    return config;
}

应用拦截器:

// ...
// 设置请求拦截器
service.interceptors.request.use(
    tokenInterceptor,
    // ...
);
service.interceptors.request.use(silenceInterceptor);
// ...

3、修改respHandler中的逻辑

在switch语句中的'case 40101'、'case 40198'、'default'应用一下修改。

1)case 40101

// ...
if (!refreshToken) {
    //MessageBox("请先登录", "提示", { confirmButtonText: '确定' }).then(() => {});
    notice(1, resp);
    // ...
}
// ...

2)case 40198

// ...
bus.$emit('clear:token', { refreshToken: true });
// _Message.error(data.message || "请重新登陆");
notice(1, resp);
// ...

3)default

//  _Message.error(data.message || "系统发生异常");
notice(2, resp);

4、设置silence属性

有两种方式可以为请求设置silence属性,一种是在请求方法中直接添加,另一种只在指定的存储对象中添加。

1)在请求方法中直接添加

如果某个方法总是需要静默处理时,可选用这种添加方式。

export function getTestData2(data) {
    return request({
        url: '/api/test2',
        method: 'get',
        params: data,
        silence: true // 在axios实例对象中直接添加
    });
}

2)在调用请求方法时动态添加

设置的属性会被silenceInterceptor拦截器所拦截,并将silence设置到axios的config中。

async getTestData3() {
    sessionStorage.setItem('silence', true);
    const resp = await getTestData();
    if (resp.code === 0) {
        this.$message.success("操作成功");
        this.testData = JSON.stringify(resp.data);
    }
    sessionStorage.setItem('silence', false);
}

在经过上述的改造后,请求工具已经具备了静默处理请求的能力。

在默认情况下,如果访问令牌和刷新令牌同时过期了的情况下向服务器获取数据时,应该会跳出要求登陆的提示框。

如以下为双令牌过期时,点击'非静默获取数据[查看控制面板]'按钮的情况。

image

图2 登陆提示弹窗

而在设置了silence属性后,请求会被静默处理:点击'静默获取数据[查看控制面板]'按钮,并配合控制台进行查看。

以上就是本次的更新改动,代码已经上传至github

结语

总的来说,重发请求是一个常见且有用的小功能,但由于作者的日常开发中,没有真正遇到过这样的场景(基本上都是直接呼用户重新登陆),因此之前封装的axios总是存在着各种各样的毛病和问题,用起来很别扭,这也是写下本文章的直接原因。

现在一次性把这些问题都解决了,心中的烦闷也随之消散,好不痛快!

中途参考了很多大佬的文章,才得以完成了现在这个自己比较满意的版本,非常感谢他们。

可能还会存在问题或不合理之处,届时还请不吝赐教。


参考文章:

返回