前后端的交互逻辑整理

前后端的交互逻辑整理

重构一下前后端逻辑,更清晰了

今天重新整理了一下前后端的交互逻辑,来看一下。

Spring Security + 全局异常状态管理器

1. 业务码

我的业务码只有如下这些。

  • 200 OK:业务成功。

  • 400 Bad Request:前端参数错误(如邮箱格式不对、缺少必填项)。405错误也会包装成400错误返回

  • 401 Unauthorized:未登录、Token 过期或无效。一旦出现这个,前端就会清理登录状态。

  • 403 Forbidden:已登录,但没有权限(如普通用户尝试访问管理员接口)。

  • 404 Not Found:接口路径不存在。

  • 500 Internal Server Error:后端代码抛出未捕获异常(崩溃、数据库连接断开)。

由于配置了Spring Security,通过JWTFilter向认证上下文中写入用户信息,如果不写入,只能访问完全开放的端点,如果是保护端点,进不到控制器中,会先报401错误。

如果访问完全开放的端点,除了403错误,剩下的错误都会从Spring Web中产生,交给全局拦截器。

只有一个特殊的情况就是403响应,因为@PreAuthorize是由Spring Security提供的,如果权限错误,其抛出的错误是org.springframework.security.authorization.AuthorizationDeniedException,这个是一个Runtime Exception,父类是AccessDeniedException

针对403响应,有两个办法:

一是全局处理器里直接拦截,全局处理器只能拦截Spring Web中的东西,而恰好AuthorizationDeniedException 是在Spring Web中产生的,所以也能拦截。

二是拦截这个具体错误之后,可以原样将其抛出,这样这个错误还会被自定义的Spring SecurityAccessDeniedHandler捕获并处理,在这个错误处理器中也可以返回403错误。

2. 异常处理器

根据上边的分析,分别在Spring SecuritySpring Web中配置异常处理器。

Spring Security 异常处理器

先看401错误的处理器,这个只能配置在Spring Security里,因为发生这个错误的时候,请求还没有进到Spring Web里。

package cc.conyli.vegalion.common.config.security;

import cc.conyli.vegalion.common.result.Result;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
import org.jspecify.annotations.NonNull;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.AuthenticationEntryPoint;
import org.springframework.stereotype.Component;
import tools.jackson.databind.ObjectMapper;

@Slf4j
@Component
public class MyAuthenticationEntryPoint implements AuthenticationEntryPoint {

    private final ObjectMapper objectMapper;

    public MyAuthenticationEntryPoint(ObjectMapper objectMapper) {
        this.objectMapper = objectMapper;
    }

    @Override
    public void commence(@NonNull HttpServletRequest request, @NonNull HttpServletResponse response, @NonNull AuthenticationException authException) throws ServletException {
        try {
            response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
            response.setContentType("application/json;charset=UTF-8");
            log.info("Security自定义错误处理器:401错误 JWT未通过认证,变成匿名访问");
            String json = objectMapper.writeValueAsString(Result.result("登录状态已失效,请重新登录"));
            response.getWriter().write(json);
        } catch (Exception e) {
            throw new ServletException(e.getMessage());
        }
    }
}

只要没有通过JWT认证,又尝试访问被保护的端点就会触发这个异常处理,将响应设置为401,然后返回固定的一段信息。

前端发现401响应会清除登录状态,并显示后端设置的消息提示。

然后是403错误的处理器:

package cc.conyli.vegalion.common.config.security;

import cc.conyli.vegalion.common.result.Result;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
import org.jspecify.annotations.NonNull;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.web.access.AccessDeniedHandler;
import org.springframework.stereotype.Component;
import tools.jackson.databind.ObjectMapper;

import java.io.IOException;

@Slf4j
@Component
public class MyAccessDeniedHandler implements AccessDeniedHandler {

    private final ObjectMapper objectMapper;

    public MyAccessDeniedHandler(ObjectMapper objectMapper) {
        this.objectMapper = objectMapper;
    }

    @Override
    public void handle(@NonNull HttpServletRequest request,
                       @NonNull HttpServletResponse response,
                       @NonNull AccessDeniedException accessDeniedException) throws IOException {
        log.info("Security自定义错误处理器:403错误 for {} {}", request.getRequestURI(), accessDeniedException.getMessage());
        response.setStatus(HttpServletResponse.SC_FORBIDDEN);
        response.setContentType("application/json;charset=UTF-8");
        String json = objectMapper.writeValueAsString(Result.result("无访问权限,请联系管理员"));
        response.getWriter().write(json);
    }
}

这个处理器仅仅是写了,但没有实际发挥作用,因为我采用了上述的方法一,在Spring Web的全局异常处理器里拦截了对应异常。

如果原样抛出的话,这个异常处理器就会启动。

Spring Web 全局异常状态处理器

这个拦截器就拦截全部Spring Web中发生的错误,并一一返回对应的响应:

package cc.conyli.vegalion.common.advice;

import cc.conyli.vegalion.common.exception.CustomException;
import cc.conyli.vegalion.common.result.Result;
import jakarta.servlet.http.HttpServletRequest;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.web.HttpRequestMethodNotSupportedException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.servlet.resource.NoResourceFoundException;


/***
 * 全局异常处理器
 * 对于AuthorizationDeniedException错误交给Spring Security的异常处理器进行处理,最后返回401错误
 * 对于业务异常CustomException返回400错误
 * 对于404和不支持的方法都返回404错误
 * 对于其他运行时错误,返回500错误
 * 返回的状态码一共有
 * 400 业务逻辑有误,数据库查询错误等等,都使用该错误,之前的BindingResult出现问题的话,也返回该异常。
 * 401 登录失败,仅仅用于登录的时候,401才清除登录状态,其他的都只展示信息
 * 403 无访问权限
 * 404 找不到页面(这个也不太可能发生)
 * 500 服务器错误
 * 前端 AXIOS 全部拦截即可,然后返回响应,由具体业务逻辑控制
 * 除了Spring Security验证不通过的401错误之外,其他地方一律不返回401错误,仅仅让Axios拦截401错误
 *
 */

@RestControllerAdvice
@Slf4j
public class GlobalExceptionHandler {

    // 自定义异常
    @ExceptionHandler(CustomException.class)
    public ResponseEntity<Result<?>> handle400(CustomException e, HttpServletRequest request) {
        log.warn("请求地址 [{} {}], 业务异常: {}", request.getMethod(), request.getRequestURI(), e.getMessage());
        log.warn(e.getClass().toString());
        return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(Result.result(e.getMessage()));
    }

    //    AuthorizationDeniedException 继承了 AccessDeniedException
    //    AccessDeniedException在自定义Spring security 错误处理器中处理
    @ExceptionHandler(RuntimeException.class)
    public ResponseEntity<Result<?>> handle403and500(RuntimeException e, HttpServletRequest request) {
        if (e instanceof AccessDeniedException) {
            log.warn("请求地址 [{} {}], 发生403权限错误,全局异常处理器截获: ", request.getRequestURI(), e.getMessage());
            log.warn(e.getClass().toString());
            return ResponseEntity.status(HttpStatus.FORBIDDEN).body(Result.result("无访问权限,如需访问请联系管理员"));
        }

        log.error("请求地址 [{} {}], 发生错误:", request.getRequestURI(), e.getMessage());
        log.warn(e.getClass().toString());
        return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(Result.result("服务器发生错误,请联系管理员"));
    }

    // 404错误
    @ExceptionHandler(NoResourceFoundException.class)
    public ResponseEntity<Result<?>> handle404(NoResourceFoundException e, HttpServletRequest request) {
        log.info("请求地址 [{} {}], 发生404错误: ", request.getRequestURI(), e.getMessage());
        log.warn(e.getClass().toString());

        return ResponseEntity.status(HttpStatus.NOT_FOUND).body(Result.result(request.getRequestURI() + " 该资源不可用"));
    }

    // 对于405错误,也返回400响应
    @ExceptionHandler(HttpRequestMethodNotSupportedException.class)
    public ResponseEntity<Result<?>> handle405(HttpRequestMethodNotSupportedException e, HttpServletRequest request) {
        log.info("请求地址 [{} {}], 发生405错误: ", request.getRequestURI(), e.getMessage());
        log.warn(e.getClass().toString());
        return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(Result.result(request.getRequestURI() + " 不支持所用的HTTP请求方法"));
    }

}

可以看到,为了应对业务上出现的异常,专门设置了一个CustomException类,直接在请求处理过程中抛出,就可以触发这个处理器返回400响应,对于最后的405错误,也返回400响应。

404错误其实不太会有,因为前端响应都是自己控制,而后端除了本地回环地址不会对外提供服务,但也给处理上了。

关键是其中的处理RuntimeException的处理器,这其中专门针对AccessDeniedException返回403响应,剩下的普通运行时异常返回500响应。

3. 后端返回的所有响应一览

经过这两层异常处理器的包装之后,正常情况下后端返回的所有响应就符合了最开始的设想:

  1. 200-正常返回

  2. 400-自行在代码中抛出CustomException后由Spring Web全局处理器处理。405错误也被包装成400错误。

  3. 401-由Spring Security异常处理器处理并返回401错误

  4. 403-属于运行时异常的一个分支,由Spring Web全局处理器处理

  5. 404-由Spring Web全局处理器处理

  6. 500-不属于AccessDeniedException的其他运行时错误,由Spring Web全局处理器处理

前端错误处理

前端的思路是这样的:

  1. 200-直接打开包装,返回response.data,对应后端的统一包装类Result

  2. 其他后端返回的意图之内的400-500错误,由于全部都带有预设的响应体,因此进行判断之后,直接展示消息。并返回错误给使用axiosuseQueryuseMutation等钩子,让其在其中捕获错误。

  3. Axios还会发生属于AxiosError类型,但没有带有响应体的错误,比如超时和网络错误,但会有特殊的code属性,根据code属性展示信息,也返回错误

  4. 不属于AxiosError类型的错误,那就是JS运行时候发生的错误,这种也按照错误返回。

根据这个逻辑编写的axios实例如下:

import axios, { AxiosError } from 'axios';
import useAuthStore from '@/store/authStore';
import { enqueueSnackbar } from 'notistack';

// 通用返回响应的外层类型,对应后端的Result类
// 具体类型T对应后端的Result类中的T泛型
export interface GeneralResponse<T = any> {
    message: string;
    content: T;
}

// 基础URL
const BASE_URL = import.meta.env.VITE_MAIN_URL;

// 创建通用的axios实例
const axiosGeneralInstance = axios.create({
  baseURL: BASE_URL,
  timeout: 10000
});

// 请求拦截器:使用Zustand管理的token组装报头
axiosGeneralInstance.interceptors.request.use(
  (config) => {
    console.log('请求拦截器中统一使用token组装报头');
    const token = useAuthStore.getState().token;
    console.log('拦截器中使用的token', token);
    if (token) {
      config.headers.Authorization = token;
    }
    return config;
  },
  (error) => Promise.reject(error)
);

// 响应拦截器
// 纯 200 响应直接返回响应体数据
// 其他响应 - 如果是AxiosError,除了网络或其他错误之外,剩下都应该有响应体,有响应体的就是正常的后端错误响应,弹窗然后返回错误
// 其他响应 - 如果是AxiosError,无响应体,是网络或超时错误,检查一下code,根据更详细的内容进行弹窗然后返回错误
// 其他响应 - 非AxiosError,直接返回错误
// 所有useQuery和useMutation中加上错误检测
axiosGeneralInstance.interceptors.response.use(
    (response) => {
        console.log('响应拦截器成功拦截200响应,直接返回数据');
        console.log(response.data)
        return response.data;
    },
    (error: AxiosError<GeneralResponse> | unknown) => {
        console.log('响应拦截器成功拦截非200响应,错误是:');
        console.log(error);
        // 先判断是否是axios错误
        if (axios.isAxiosError(error)) {
            // 然后判断是否存在响应
            // 存在响应的情况下,说明是后端发来的正常错误响应,如果是401就清除登录状态,所有错误都弹出错误信息之后返回错误
            if (error.response) {
                console.log("是Axios错误,有响应体:")
                console.log(error.response.data)
                if (error.response.status === 401) {
                    useAuthStore.getState().clearAuth();
                }
                enqueueSnackbar(error.response.data.message, { variant: 'error' });
                return Promise.reject(error);
            } else {
                // 不存在响应的情况下,很可能是Axios的网络错误或者其他错误
                if (error.code === 'ECONNABORTED') {
                    enqueueSnackbar('请求超时', { variant: 'error' });
                } else if (error.code === 'ERR_NETWORK') {
                    enqueueSnackbar('网络错误,请检查网络连接', { variant: 'error' });
                } else {
                    console.log('Axios 错误不存在响应体,可能是网络错误或者其他错误,其code是:');
                    console.log(error.code);
                    enqueueSnackbar('AxiosError:无响应体', { variant: 'error' });
                }
                return Promise.reject(error);
            }
        } else {
            // 不是Axios错误,那么是其他错误,返回一个自定义的错误
            console.log('不是AXIOS错误,返回一个自定义错误对象');
            enqueueSnackbar('非Axios错误:无响应体', { variant: 'error' });
            return Promise.reject(error);
        }
    }
);

export default axiosGeneralInstance; // 定义好的泛型响应体

当然,这里的未确认AxiosError和其他JS错误,我故意留了两个特殊的消息用于在开发过程中调试。生产环境里肯定要改成更正式的名称。

这样修改之后,所有的useQuery或者useMutation函数中就简单很多,后端发过来的错误消息直接就在前端弹窗展示。其实我这里还是遵守了HTTP的规则,使用了应该使用的所有HTTP状态码。

还顺便修改全部的访问函数,把具体数据类型都单独抽到一个文件里,axios函数类型统一用GeneralResponse包裹具体数据类型。全部修改完之后,React渲染的代码量就大幅减少了。

LICENSED UNDER CC BY-NC-SA 4.0
Comment