从零开始搭建一个高颜值后台管理系统全栈框架(四)——封装axios,让请求变得丝滑。

往期回顾

从零开始搭建一个高颜值后台管理系统全栈框架(一)——前端框架搭建

从零开始搭建一个高颜值后台管理系统全栈框架(二)——后端框架搭建

从零开始搭建一个高颜值后台管理系统全栈框架(三)——实现登录功能jwt or token+redis?

前言

掘金有很多关于axios封装的文章,但是我没有看到把token自动刷新,自动回放以及限流(后端防止流量攻击做了限流,同一个用户在很短的时间内,只能调几个接口,如果某个页面一进来就掉很多接口,后端就会因为限流而报错,这时候做前端限流了,不过最好的方案还是一个页面不要同时调很多接口,能合并的就合并。)写的很完善的,即使有的文章写了,也写的很粗,很多细节没有写出来。比如刷新完token回放的时候没有用到队列,还有如果在刷新token期间,又来了一个请求怎么办,还有在axios中如何实现限流,说实话没有看到过把这两个写的很清楚的,这篇文章我会把我在公司里面封装的axios分享给大家,欢迎大家在评论区说出你们的意见和建议。

实现刷新token接口

前端把refreshToken传给后端,后端拿到refreshTokenredis中检查当前refreshToken是否失效,如果失效就报错,没失效就颁发一个新的token给前端。这里注意一下,不要生成新的refreshToken返回给前端,不然如果别人拿到一次refreshToken后,可以一直刷新拿新token了。

// src/module/auth/controller/auth.ts
...

@Post('/refresh/token', { description: '刷新token' })
  @NotLogin()
  async refreshToken(@Body(ALL) data: RefreshTokenDTO) {
    return this.authService.refreshToken(data);
  }
...
// src/module/auth/service/auth.ts
 ...
 async refreshToken(refreshToken: RefreshTokenDTO): Promise<TokenVO> {
    const userId = await this.redisService.get(
      `refreshToken:${refreshToken.refreshToken}`
    );



    if (!userId) {
      throw R.error('刷新token失败');
    }



    const { expire } = this.tokenConfig;


    const token = uuid();

    await this.redisService
      .multi()
      .set(`token:${token}`, userId)
      .expire(`token:${token}`, expire)
      .exec();




    const refreshExpire = await this.redisService.ttl(
      `refreshToken:${refreshToken.refreshToken}`
    );



    return {
      expire,
      token,
      refreshExpire,
      refreshToken: refreshToken.refreshToken,
    } as TokenVO;
  }

...

使用中间件校验token,并给上下文注入当前用户信息

说明

Web 中间件是在控制器调用 之前 和 之后(部分)调用的函数。 中间件函数可以访问请求和响应对象。
image.png

创建auth中间件

// src/middleware/auth.ts
import {

  Middleware,
  IMiddleware,
  Inject,
  MidwayWebRouterService,
  RouterInfo,
} from '@midwayjs/core';
import { NextFunction, Context } from '@midwayjs/koa';
import { R } from '../common/base.error.util';
import { RedisService } from '@midwayjs/redis';



@Middleware()
export class AuthMiddleware implements IMiddleware<Context, NextFunction> {
  @Inject()
  redisService: RedisService;
  @Inject()
  webRouterService: MidwayWebRouterService;
  @Inject()
  notLoginRouters: RouterInfo[];




  resolve() {
    return async (ctx: Context, next: NextFunction) => {
      const token = ctx.header.authorization?.replace('Bearer ', '');

      if (!token) {

        throw R.unauthorizedError('未授权');

      }




      const userInfoStr = await this.redisService.get(`token:${token}`);
      if (!userInfoStr) {
        throw R.unauthorizedError('未授权');

      }





      const userInfo = JSON.parse(userInfoStr);


      ctx.userInfo = userInfo;
      ctx.token = token;
      return next();
    };
  }

  static getName(): string {
    return 'auth';
  }

}

src/configuration.ts注册auth中间件

// src/configuration.ts
export class ContainerLifeCycle {
  @App()
  app: koa.Application;


  async onReady() {
    // add middleware
    this.app.useMiddleware([AuthMiddleware]);
    // add filter
    this.app.useFilter([
      ValidateErrorFilter,
      CommonErrorFilter,
      NotFoundFilter,
      UnauthorizedErrorFilter,
    ]);
  }

}

为了让使用上下文的地方,有代码提示,拓展上下文参数

// src/interface.ts
import '@midwayjs/core';

interface UserContext {
  userId: number;
  refreshToken: string;
}


declare module '@midwayjs/core' {
  interface Context {
    userInfo: UserContext;
    token: string;
  }
}

设置部分接口不鉴权

看了上面代码,细心的兄弟可能看到问题了,在auth中间件中对所有接口进行了token验证,登录接口还没登录呢,哪来的token传,所以我们需要实现一下可以对部分接口不鉴权。关于这个有个简单的实现方案,不需要鉴权的接口用统一的前缀,然后在中间件中判断。还有一种方案,在维护一个接口url白名单。这两个方案虽然都能实现,但是我觉得不够优雅,理想做法是在不需要鉴权的接口上加上某个装饰器就行了,所以我自定义了一个免登录的装饰器。

自定义免登录装饰器

实现思路和上面说的白名单思路是一样的,只不过上面说的白名单是手动维护的,这个是通过给接口加装饰器,然后自动把使用该装饰器的接口动态添加到白名单中,最后在中间件中判断当前请求是不是在白名单中,如果在就不校验了。如何自定义装饰器大家看一下官方文档,官方文档写的很详细的。

// src/decorator/not.login.ts
import {

  IMidwayContainer,
  MidwayWebRouterService,
  Singleton,
} from '@midwayjs/core';
import {
  ApplicationContext,
  attachClassMetadata,
  Autoload,
  CONTROLLER_KEY,
  getClassMetadata,
  Init,
  Inject,
  listModule,
} from '@midwayjs/decorator';





// 提供一个唯一 key
export const NOT_LOGIN_KEY = 'decorator:not.login';

export function NotLogin(): MethodDecorator {
  return (target, key, descriptor: PropertyDescriptor) => {
    attachClassMetadata(NOT_LOGIN_KEY, { methodName: key }, target);
    return descriptor;
  };
}

@Autoload()
@Singleton()
export class NotLoginDecorator {
  @Inject()
  webRouterService: MidwayWebRouterService;
  @ApplicationContext()
  applicationContext: IMidwayContainer;


  @Init()
  async init() {
    const controllerModules = listModule(CONTROLLER_KEY);
    const whiteMethods = [];
    for (const module of controllerModules) {
      const methodNames = getClassMetadata(NOT_LOGIN_KEY, module) || [];
      const className = module.name[0].toLowerCase() + module.name.slice(1);
      whiteMethods.push(
        ...methodNames.map(method => `${className}.${method.methodName}`)
      );
    }

    const routerTables = await this.webRouterService.getFlattenRouterTable();
    const whiteRouters = routerTables.filter(router =>
      whiteMethods.includes(router.handlerName)
    );
    this.applicationContext.registerObject('notLoginRouters', whiteRouters);
  }
}

改造auth中间件中的resolve方法,判断如果当前接口在白名单中,就不鉴权了。

resolve() {
    return async (ctx: Context, next: NextFunction) => {
      const routeInfo = await this.webRouterService.getMatchedRouterInfo(
        ctx.path,
        ctx.method
      );



      if (!routeInfo) {
        await next();
        return;
      }



      if (
        this.notLoginRouters.some(
          o =>
            o.requestMethod === routeInfo.requestMethod &&
            o.url === routeInfo.url
        )
      ) {
        await next();
        return;
      }

      const token = ctx.header.authorization?.replace('Bearer ', '');

      if (!token) {

        throw R.unauthorizedError('未授权');

      }




      const userId = await this.redisService.get(`token:${token}`);
      if (!userId) {
        throw R.unauthorizedError('未授权');

      }





      ctx.userId = Number(userId || 0);
      return next();
    };
  }

具体使用,在接口上使用NotLogin装饰器

image.png

实现获取当前用户信息接口

从上下文中拿到userId,然后通过userId去查询用户信息
image.png

...
// src/module/auth/controller/auth.ts
@Get('/current/user')
async getCurrentUser(): Promise<UserVO> {
  const user = await this.userService.getById(this.ctx.userId);
  return user.toVO();
}

封装antd message、notification、modal方法

开始打算在axios异常拦截中统一弹出错误提示,但是antd 5最近的版本使用message的一些方法会报警告。

[antd: message] Static function can not consume context like dynamic theme. Please use ‘App’ component instead.

意思是如果使用这种方式没办法使用ConfigProvider中定义的主题,希望使用antd的App组件代替。
image.png
只能使用上面这种hooks方式调用,但是在axios的拦截器里面没办法用hooks,所以我想了办法,封装一个对象来保存message这些应用,让其他不能使用hooks方法的地方也能使用。

// src/utils/antd.ts
import { MessageInstance } from 'antd/es/message/interface';
import { ModalStaticFunctions } from 'antd/es/modal/confirm';
import { NotificationInstance } from 'antd/es/notification/interface';


type ModalInstance = Omit<ModalStaticFunctions, 'warn'>;



class AntdUtils {
  message: MessageInstance | null = null;
  notification: NotificationInstance | null = null;
  modal: ModalInstance | null = null;



  setMessageInstance(message: MessageInstance) {
    this.message = message;
    this.message.success
  }






  setNotificationInstance(notification: NotificationInstance) {
    this.notification = notification;
  }




  setModalInstance(modal: ModalInstance) {
    this.modal = modal;
  }
}


export const antdUtils = new AntdUtils();

src/layouts/index.tsx文件中注入这些引用
image.png
layouts组件需要被antd的App组件包裹
image.pngimage.png
然后就可以在任何地方使用message方法了
image.png

封装axios

统一返回值

我不喜欢使用try catch捕获接口异常,所以我在axios的响应拦截器中捕获了异常,接口报错统一在响应拦截器中弹出,不用自己在每一处单独处理了。

// src/request/index.ts
import axios, {
  AxiosInstance,
  AxiosRequestConfig,
  AxiosResponse,
  CreateAxiosDefaults,
  InternalAxiosRequestConfig,
} from 'axios';
import { useGlobalStore } from '@/stores/global';
import { antdUtils } from '@/utils/antd';



export type Response<T> = Promise<[boolean, T, AxiosResponse<T>]>;


class Request {
  constructor(config?: CreateAxiosDefaults) {
    this.axiosInstance = axios.create(config);





    this.axiosInstance.interceptors.request.use(
      (axiosConfig: InternalAxiosRequestConfig) => this.requestInterceptor(axiosConfig)
    );
    this.axiosInstance.interceptors.response.use(
      (response: AxiosResponse<unknown, unknown>) => this.responseSuccessInterceptor(response),
      (error: any) => this.responseErrorInterceptor(error)
    );

  }


  private axiosInstance: AxiosInstance;



  private async requestInterceptor(axiosConfig: InternalAxiosRequestConfig): Promise<any> {
    const { token } = useGlobalStore.getState();
    // 为每个接口注入token
    if (token) {
      axiosConfig.headers.Authorization = `Bearer ${token}`;
    }
    return Promise.resolve(axiosConfig);
  }

  private async responseSuccessInterceptor(response: AxiosResponse<any, any>): Promise<any> {
    return Promise.resolve([
      false,
      response.data,
      response,
    ]);
  }


  private async responseErrorInterceptor(error: any): Promise<any> {
    const { status } = error?.response || {};
    if (status === 401) {
      // TODO 刷新token
    } else {
      antdUtils.notification?.error({
        message: '出错了',
        description: error?.response?.data?.message,
      });
      return Promise.resolve([true, error?.response?.data]);
    }
  }

  request<T, D = any>(config: AxiosRequestConfig<D>): Response<T> {
    return this.axiosInstance(config);
  }


  get<T, D = any>(url: string, config?: AxiosRequestConfig<D>): Response<T> {
    return this.axiosInstance.get(url, config);
  }

  post<T, D = any>(url: string, data?: D, config?: AxiosRequestConfig<D>): Response<T> {
    return this.axiosInstance.post(url, data, config);
  }

  put<T, D = any>(url: string, data?: D, config?: AxiosRequestConfig<D>): Response<T> {
    return this.axiosInstance.put(url, data, config);
  }

  delete<T, D = any>(url: string, config?: AxiosRequestConfig<D>): Response<T> {
    return this.axiosInstance.delete(url, config);
  }
}

const request = new Request({ timeout: 30000 });

export default request;

接口响应是一个元组类型,元组第一个值是boolean类型,表示接口是成功还是失败,第二个值是后端响应的数据,第三个值是axios的response对象。

看下面的使用案例,这样就不用写try catch了。
image.png
每次掉接口都要手动维护一个loading参数,这个很麻烦,可以用使用ahooks里面的useRequest方法,把接口包装一下,请求就变得优雅了。但是useRequest这个hooks不支持我上面定义的响应结构,所以我模仿useRequest的api自己实现了一个useRequest,目前只实现了useRequest的部分功能,不过暂时够用了,后面慢慢完善。

// src/hooks/use-request/index.ts
import { useCallback, useEffect, useState } from 'react';
import { Response } from '@/request';


interface RequestOptions {
  manual?: boolean;
  defaultParams?: any[];
}

interface RequestResponse<T> {
  error: boolean | undefined;
  data: T | undefined;
  loading: boolean;
  run(...params: any): void;
  runAsync(...params: any): Response<T>;
}






export function useRequest<T>(
  serviceMethod: (...args: any) => Response<T>,
  options?: RequestOptions
): RequestResponse<T> {
  const [loading, setLoading] = useState<boolean>(false);
  const [data, setData] = useState<T>();
  const [error, setError] = useState<boolean>();


  const resolveData = async () => {
    setLoading(true);
    const [error, requestData] = await serviceMethod(...(options?.defaultParams || []));
    setLoading(false);
    setData(requestData);
    setError(error);
  }





  const runAsync = useCallback(async (...params: any) => {
    setLoading(true);
    const res = await serviceMethod(...params);
    setLoading(false);
    return res;
  }, [serviceMethod]);

  const run = useCallback(async (...params: any) => {
    setLoading(true);
    const [error, requestData] = await serviceMethod(...params);
    setLoading(false);
    setData(requestData);
    setError(error);
  }, [serviceMethod]);

  useEffect(() => {
    if (!options?.manual) {
      resolveData();
    }
  }, [options]);

  return {
    loading,
    error,
    data,
    run,
    runAsync,
  }

}

改造上面代码,组件一渲染,接口会自动调用,不用自己在useEffect里面自己调用了,优雅了很多。
image.png
如果想自己手动掉接口,把manual为true,然后手动调用run方法就行了。
image.png

无感刷新token

背景

为啥要做这个功能,我在上一篇文章中已经解释过了,这里就不做解释了。先说一下实现思路,在axios异常拦截器,发现响应的code是401的,说明token已经过期了,这时候我们需要调刷新token获取新的token,然后使用新的token回放上一次401的接口。如果刷新token的接口报错了,说明刷新token也过期了,这时候我们跳到登录页面让用户重新登录。刷新token的时候有2个需要注意的点。

  1. 如果同时有很多接口401,那我们不能每个都调一下刷新token接口去刷新token,理论上只需要刷新一次就行了。
  2. 在刷新token没返回之前,又有新接口进来,如果正常请求的话,必然也会401,所以我们需要给拦截一下。
    想解决上面问题,我们需要设置一个表示是否正在刷新token的变量和一个请求队列,如果正在刷新token,那我们把新的401接口都插入到一个队列中,然后等刷新接口拿到新接口了,把队列里的请求一起回放。解决第二个问题也很简单了,在请求拦截器中,判断是否正在刷新token,如果正在刷新token,也加入到队列中,等刷新token返回后,也一起回放。

定义变量和队列

image.png

改造响应拦截器

image.png

添加刷新token方法

image.png

改造请求拦截器

image.png

测试验证

测试这个过程中发现一个估计很多人都不知道的google浏览器的特性,大家应该都知道google浏览器最多同时能进行6个请求,但是很多人不知道google浏览器对同一个接口多次请求,是窜行的,必须等上一个接口响应结束后才会请求下一个,开始遇到这个问题,我一度怀疑是后端接口的问题,但是我用其他工具同时发送10个请求,都是一起返回的,后来查到这方面的资料才发现是Google浏览器的特性。
image.png
同时发送10个相同的请求,正常来说应该是一起返回的,但是不是,看下请求瀑布图。
image.png
明显是等上一个请求结束后,才发送下一个请求。其他浏览器针对这种情况都有优化,Safari浏览器更离谱只发送一个请求。
image.png
为了正常测试,我们给接口加一个随机数参数。
image.png
同时发送10个请求,token都是失效的,但是只调了一次刷新token接口,然后重放也是正常的。
image.png
接口响应值也能正常打印出来

测试一下在刷新过程中来了一个新请求场景,首先把刷新token加一个延时1.5s后响应,然后在前端设置一个定时器,每隔1s发送一个请求。
请求.gif转存失败,建议直接上传图片文件

从上面动态图可以看到,在刷新token的时候,新的请求都不会发送,进入了请求队列,等刷新token结束后,一次性发了5个请求。

实现请求限流

背景

有的服务器害怕被攻击,设置了同一个用户同一时间只能请求几个接口,如果超出限制就会报错,这个我们在前端可以控制一下,这种使用场景不多,一般一个页面不会发送很多请求,能合并的还是尽量合并一下。我在做可视化大屏的时候,遇到过这个场景,一个大屏上配了很多小区块,每个区块都是单独调接口去查数据,然后渲染,最多同时能请求10几个接口,我们后端开启了限流后,前端疯狂报错,为了解决这个问题,只好改造请求工具。不过后来我给大屏做成按需加载小模块,屏幕外的不请求接口,这样同时请求的接口少了很多。

实现思路

和实现刷新token思路差不多,设置一个变量表示当前有多少个请求正在请求,如果同时请求的数量超出我们设置的最大值,就进入队列不要请求,在接口响应成功后,检查队列里有没有请求,如果有就取出(最大值-当前请求数量)个请求去执行。这里代码比较多,我就不贴了,大家可以去仓库里看对应的源码,代码在这个src/request/index.ts这个文件里。

效果展示

image.png
我设置的并发数量是3,同时发送了10个请求,从上面图中可以看到同时只执行了三个。

获取当前用户信息设置到全局

创建user store,这里存放公共的用户信息

// src/stores/global/user.ts
import { create } from 'zustand'
import { devtools } from 'zustand/middleware';


interface User {
  id: number;
  userName: string;
  nickName: string;
  phoneNumber: string;
  email: string;
  createDate: string;
  updateDate: string;
}
interface State {
  currentUser: User | null;
}






interface Action {
  setCurrentUser: (currentUser: State['currentUser']) => void;
}




export const useUserStore = create<State & Action>()(
  devtools(
    (set) => {
      return {
        currentUser: null,
        setCurrentUser: (currentUser: State['currentUser']) => set({ currentUser }),
      };
    },
    { name: 'globalUserStore' }
  )
)




请求用户信息,并设置到用户store中。

先使用我们刚封装的useRequest包裹获取用户信息的请求,并设置为手动调用,页面一进来或refreshToken变化的时候,重新请求用户信息。为啥refreshToken变化也需要重新请求,这个后面说。

// src/layouts/index.tsx
...

  const {
    loading,
    data: currentUserDetail,
    run: getCurrentUserDetail,
  } = useRequest(
    userService.getCurrentUserDetail,
    { manual: true }
  );



  useEffect(() => {
    if (!refreshToken) {
      navigate('/user/login');
      return;
    }
    getCurrentUserDetail();
  }, [refreshToken, getCurrentUserDetail, navigate]);

  useEffect(() => {
    setCurrentUser(currentUserDetail || null);
  }, [currentUserDetail, setCurrentUser]);
  ...

当获取正在获取用户信息的时候,加一个全局loading
image.png

image.png

跨浏览器页签共享token

框架使用zustand的persist数据持久化中间件,在设置值的时候,会把当前store里的值同步到localStorage中,store一初始化也会从localStorage中同步数据到store中。因为token和rereshToken会存到localStorage中,我们打开新页签也能取到token,这样我们就很简单的实现了跨浏览器页签共享token,用户打开新页签的时候,不用重新登录了。

跨浏览器页面同步用户信息

背景

上面虽然实现了跨页签共享token,但是有一个问题需要处理一下,假设我们在一个新的浏览器页签中,退出登录或者切换用户了,这时候我们在另外一个页签也需要退出登录或重新获取用户信息。这个借助zustand实现起来就简单了。我们只需要监听localStorage中refreshToken值是否变动,如果有变动说明有退出或重新登录,这里不能监听token变动是因为token过期后重新获取token,这时候并不需要重新获取用户信息。

实现

退出登录调后端接口,把当前token和refreshToken从redis中删除,然后调用前端store里面的方法把前端存的token和refreshToken清除掉。上面说了,当我们设置值的时候,persist中间件会把值自动同步到localStorage中,也就是说我们只要在系统中监听localStorage中全局信息是否有变动,如果有变动我们调用persist里面的方法,重新把localStorage里面的值同步到store中去,然后因为localStorage是共享的,所以所有页签都能监听到变化,然后发现token或refreshToken没了,也退到登录页面。

后端退出接口实现
image.png
前端调退出登录接口,并把token和refreshToken设置为空
image.png
监听localStorage变化,如果有对应key的值变化就重新同步localStorage中值到store中
image.png
监听refreshToken变化,如果为空则表示退出登录,不为空则表示其他页签重新登录了,我们需要重新获取一下用户信息。这里为啥监听refreshToken而不是token,是因为token失效会重新获取新的,这时候并不需要重新获取用户信息,只有refreshToken变化才说明退出登录或重新登录了。
image.png

最后

整了个服务器和域名,把前后端部署了一下,体验地址:fluxyadmin.cn

有些兄弟私信我,问我会不会烂尾,我这里向大家保证不会烂尾的,因为很多功能我都实现了,只是还没有整理成文章。写作不易,如果文章对你有帮助,麻烦兄弟们给个star吧。

前端仓库地址:github.com/dbfu/fluxy-…

后端仓库地址:github.com/dbfu/fluxy-…

© 版权声明
THE END
喜欢就支持一下吧
点赞0

Warning: mysqli_query(): (HY000/3): Error writing file '/tmp/MYTJ5iLS' (Errcode: 28 - No space left on device) in /www/wwwroot/583.cn/wp-includes/class-wpdb.php on line 2345
admin的头像-五八三
评论 抢沙发
头像
欢迎您留下宝贵的见解!
提交
头像

昵称

图形验证码
取消
昵称代码图片