axios实现双token无感刷新和取消重复请求【后端nestjs】

1,511 阅读3分钟

当系统登录过一次后,如果是你常用的系统,那么就不需要再次登录了,那这是怎么做到的呢?token一定会有失效时间的。大多数系统的解决方案是会生成两个token分别为AccessTokenRefreshToken,当AccessToken过期时使用RefreshToken来刷新AccessToken。这样就可以实现永久登录了。这里要注意我们要将AccessToken的过期时间设置短一点RefreshToken的过期时间设置的长一点。

具体流程如下: image.png

我们先写后端的代码,因为后端代码比较简单,主要还是前端部分,这里后端用nestjs来做。我们大概已经知道了具体流程了,这里我们也就只需要三个接口,简单写一下这三个接口。

我们用@nestjs/jwt来生成token

pnpm add @nestjs/jwt --save

简单配置一下

JwtModule.register({
  global: true,
  signOptions: {
    expiresIn: '7d' // 默认过期时间,我们也可以在生成token时配置时间
  },
  secret: 'zhuirichu' // 密钥
}),

我只写service层的代码,其他层我就不写了。

/login

登录接口简单写一下,把用户名和密码写死就不从数据库中查了。

  async login(user: LoginUserDto) {
    if (user.username!=='zhuirichu' || user.password!=='123@@321') {
      throw new BadRequestException('用户名密码错误');
    }
    
    // 根据userId和username生成accessToken
    const accessToken = this.jwtService.sign({
      userId: 1,
      username: user.username,
    }, {
      expiresIn: '30m' // 30分钟过期
    })
    
    // 根据username生成refreshToken
    const refreshToken = this.jwtService.sign({
      username: user.username,
    }, {
      expiresIn: '7d' // 7天过期
    })

    return {
      access_token: accessToken,
      refresh_token: refreshToken,
    }
  }

/refresh

主要是用来通过RefreshToken来获取新的AccessTokenRefreshToken

async refresh(token) {
    if (!token) {
      throw new BadRequestException('token无效')
    }

    try {
      const data = this.jwtService.verify(token); // 解析token

      const accessToken = this.jwtService.sign({
        userId: data.id,
        username: data.username
      }, {
        expiresIn: '30m'
      })

      const refreshToken = this.jwtService.sign({
        username: data.username
      }, {
        expiresIn: '7d'
      })

      return {
        access_token: accessToken,
        refresh_token: refreshToken,
      }
    } catch (error) {
      this.logger.error(error);
      throw new UnauthorizedException('token 已失效,请重新登录');
    }
  }

/userinfo

主要用来获取用户信息。通常我们会写一个全局守卫来校验一些需要token的接口,通过守卫来解析token。如果token过期则返回false不走接口,如果token解析成功则需要把解析的用户信息放入request中。我们就不写那么麻烦了,我们直接返回token解析后的值就行了。

  async userInfo(token:string) {
     if (!token) { throw new BadRequestException('token无效') }
     try { 
         const data = this.jwtService.verify(token); // 解析token
         return data;
     } catch (error) { 
         this.logger.error(error); 
         throw new UnauthorizedException('token 已失效,请重新登录'); 
     }
  }

后端部分我们就完成了,接下来开始写前端内容了。

需要将token存放在cookie中,使用react-cookie来做。

pnpm add axios react-cookie --save

先来简单配置一下axios

import axios, { AxiosHeaders, AxiosResponse, Canceler, InternalAxiosRequestConfig, AxiosRequestConfig } from "axios";
import { Toast } from "antd-mobile";
import { Cookies } from 'react-cookie';

const cookie = new Cookies();

const instance = axios.create({
  baseURL: 'http://localhost:3000/api',
  timeout: 5000,
})

// 请求拦截器
instance.interceptors.request.use((config: InternalAxiosRequestConfig<any>) => {
    if (cookies.get('token')) {
        config.headers['Authorization'] = `Bearer ${cookies.get('token')}`;
    }
    return config;
}, (error) => {
  return Promise.reject(error);
})

// 响应拦截器
instance.interceptors.response.use((response: AxiosResponse<ResponseDataType<any>, any>) =>{
    return response;
},(error) => {
    const { message, response } = error;
    if (message === "Network Error") {
        Toast.show('网络连接异常');
    } else if (response.status === 401) {
        // token失效
    } else if (response.status === 400) {
        Toast.show(response.data.message);
    }
    return Promise.reject(error);
})


export function post<T>(url: string, data?: any, headers?: AxiosHeaders): Promise<T> {
  return new Promise((resolve) => {
    instance({ url, data, method: 'post', headers }).then(res => {
      resolve(res.data.data);
    })
  })
}

export function get<T>(url: string, data?: any, headers?: AxiosHeaders): Promise<T> {
  return new Promise((resolve) => {
    instance({ url, params: data, method: 'get', headers }).then(res => {
      resolve(res.data.data);
    })
  })
}

简单配置完axios,接下来我们主要就是处理401状态码部分的逻辑了。

重新发送请求使用instance(config)这样的方法。那按照我们的思路来写一下这样的代码,当接口401的时候先调用refreshToken接口,然后再重新发送请求。感觉这样的逻辑是没啥问题的。来运行一下试试。

var isRefreshing = false; // 是否处于刷新token状态

instance.interceptors.response.use((response: AxiosResponse<ResponseDataType<any>, any>) => {
  // 请求成功删除请求
  instanceRequestQueue.removeRequest(response.config.url!);
  return response;
}, async (error) => {
  const { message, response } = error;
  if (response.status===401) {
     if (!isRefreshing) {
      isRefreshing = true;
      const { access_token, refresh_token } = await refreshToken({ refresh_token: cookies.get('refreshToken') });
      cookies.set('token', access_token);
      cookies.set('refreshToken', refresh_token);
      isRefreshing = false;
      return instance(response.config);
    }
  }
})

image.png

这样一看确实是没什么问题的,获取用户信息返回401 -> 刷新token获取新的token -> 再重新获取用户信息。当我们有多个接口401的情况呢?

image.png

在首页多调用几次userInfo用来模拟首页的其他接口。那这样会发生什么情况呢?

image.png

userInfo接口就只调用了一次,原因是因为config指的就是当前的接口。一个拦截器只针对一个接口。这时候我们就需要使用Promise来解决这个问题。

var isRefreshing = false; // 是否处于刷新token状态
var requestQueue: Array<{ resolveCallback: Function }> = []; // 存放请求队列

instance.interceptors.response.use((response: AxiosResponse<ResponseDataType<any>, any>) => {
  // 请求成功删除请求
  instanceRequestQueue.removeRequest(response.config.url!);
  return response;
}, async (error) => {
  const { message, response } = error;
  if (response.status===401) {
         return new Promise(async (resolve) => {
      requestQueue.push({ resolveCallback: () => resolve(instance(response.config)) })
      if (!isRefreshing) {
        isRefreshing = true;
        const { access_token, refresh_token } = await refreshToken({ refresh_token: cookies.get('refreshToken') });
        cookies.set('token', access_token);
        cookies.set('refreshToken', refresh_token);
        isRefreshing = false;
        requestQueue.forEach(request => {
          request.resolveCallback()
        })
      }
    })
  }
})

image.png

这样我们的无感刷新就完成,但是代码写的像屎山,我们来优化下代码,并且添加一个重复请求的处理。

在开发项目中会遇到这样的情况,当用户表单提交的时候点击速度过快会重复提交表单这样会造成很多重复数据,前端一般的解决办法都是给按钮添加loading状态。这里我们就不这样处理,我来使用拦截器和CancelToken来完成重复请求取消的功能。

封装一个requestQueue类,主要实现添加接口队列、删除接口、取消请求、刷新请求、重新发送请求这些功能。

import { Canceler, InternalAxiosRequestConfig } from "axios";
import { refreshToken } from "./api";
import { Cookies } from "react-cookie";

interface MapValueConfig {
  config?: InternalAxiosRequestConfig<any>,
  resolveCallback?: (config: InternalAxiosRequestConfig<any>) => void;
  cancler?: Canceler
}

const cookies = new Cookies();

export default class RequestQueue {
  public requestQueue: Map<string, MapValueConfig> = new Map();
  public isRefreshing: boolean = false;

  // 添加请求
  public addRequest(key: string, value: MapValueConfig) {
    if (this.requestQueue.has(key)) {
      const baseConfig = this.requestQueue.get(key);
      this.requestQueue.set(key, { ...baseConfig, ...value });
      return;
    }
    this.requestQueue.set(key, value);
  }

  // 删除请求
  public removeRequest(key: string) {
    this.requestQueue.delete(key);
  }

  // 取消请求
  public canclerRequest(key?: string) {
    if (key) {
      const value = this.requestQueue.get(key);
      !value?.resolveCallback && value?.cancler?.();
    } else {
      this.requestQueue.forEach((request) => {
        request.cancler?.();
      })
    }
  }

  // 刷新token
  public async refreshToken() {
    if (this.isRefreshing) return;

    this.isRefreshing = true;
    try {
      const { access_token, refresh_token } = await refreshToken({ refresh_token: cookies.get('refreshToken') });
      cookies.set('token', access_token);
      cookies.set('refreshToken', refresh_token);
      this.isRefreshing = false;
      this.resetRequest();
    } catch (error) {
      this.isRefreshing = false;
    }
  }

  // 重新请求
  public resetRequest() {
    this.requestQueue.forEach(request => {
      if (request.config) {
        request.config.headers['Authorization'] = `Bearer ${cookies.get('token')}`;
        request.resolveCallback?.(request.config);
      }
    })
  }
}

axios拦截器中具体使用。

import axios, { AxiosHeaders, AxiosResponse, Canceler, InternalAxiosRequestConfig } from "axios";
import { Toast } from "antd-mobile";
import { ResponseDataType } from "./types";
import { Cookies } from 'react-cookie';
import RequestQueue from "./requestQueue";

export const cookies = new Cookies();

const instance = axios.create({
  baseURL: 'http://localhost:3000/api',
  timeout: 5000,
})

const instanceRequestQueue = new RequestQueue();

instance.interceptors.request.use((config: InternalAxiosRequestConfig<any>) => {
  const key = config.url + (config.data ? JSON.stringify(config.data) : '');
  let caceler!: Canceler;

  const assignConfig = Object.assign({}, config, {
    cancelToken: new axios.CancelToken(function (cancle) {
      caceler = cancle;
    })
  } as InternalAxiosRequestConfig<any>)

  if (cookies.get('token')) {
    config.headers['Authorization'] = `Bearer ${cookies.get('token')}`;
  }

  if (instanceRequestQueue.requestQueue.has(key)) {
    instanceRequestQueue.canclerRequest(key);
  }
  instanceRequestQueue.addRequest(key, {
    cancler: caceler,
    config: { ...assignConfig }
  })
  return assignConfig;
}, (error) => {
  return Promise.reject(error);
})

instance.interceptors.response.use((response: AxiosResponse<ResponseDataType<any>, any>) => {
  // 请求成功删除请求
  instanceRequestQueue.removeRequest(response.config.url!);
  return response;
}, async (error) => {
  const { message, response } = error;

  if (message === "Network Error") {
    Toast.show('网络连接异常');
  } else if (response.status === 401) {
    const originalRequest = response.config;
    const key = originalRequest.url + (originalRequest.data ? JSON.stringify(originalRequest.data) : '');

    return new Promise(async (resolve, _) => {
      const refresh_token = cookies.get('refreshToken')
      if (refresh_token) {
        instanceRequestQueue.addRequest(key, {
          resolveCallback: (config) => resolve(instance(config))
        })
        await instanceRequestQueue.refreshToken();
      } else {
        Toast.show(response.data.message);
        setTimeout(() => {
          window.location.href = '/login'
        }, 300)
      }
    })
  } else if (response.status === 400) {
    Toast.show(response.data.message);
  }
  return Promise.reject(error);
})

export function post<T>(url: string, data?: any, headers?: AxiosHeaders): Promise<T> {
  return new Promise((resolve) => {
    instance({ url, data, method: 'post', headers }).then(res => {
      resolve(res.data.data);
    })
  })
}

export function get<T>(url: string, data?: any, headers?: AxiosHeaders): Promise<T> {
  return new Promise((resolve) => {
    instance({ url, params: data, method: 'get', headers }).then(res => {
      resolve(res.data.data);
    })
  })
}

requestQueue是一个Map类型,需要使用请求的url加上参数来代表key,使用new axios.CancelToken生成取消接口的密钥,并生成新的config返回出去。如果请求栈中有值,我们就取消请求栈中的接口。这样就是实现的重复接口取消的功能。

然后将userInfo接口改成1s后返回结果

image.png

image.png

我们一直点击按钮调用userInfo请求就会看到,重复的请求会被取消。这样我们的功能就完成了。需要源码我后续会上传到github中。