云计算百科
云计算领域专业知识百科平台

【JavaEE】SpringBoot 统一功能处理: 拦截器、统一返回与全局异常

通过本文我们将:

  • 掌握拦截器的使用及其原理
  • 学习统一数据返回格式和统一异常处理的操作
  • 了解一些 Spring 的源码

文章目录

  • 1. 拦截器:把登录校验从每个接口里抽出来
    • 1.1 为什么需要拦截器
    • 1.2 快速入门:实现 HandlerInterceptor
    • 1.3 注册拦截器与路径控制
    • 1.4 实战:用拦截器完成登录校验
  • 2. 统一数据返回格式:让所有接口说同一种“语言”
    • 2.1 手动包装的痛点
    • 2.2 @ControllerAdvice + ResponseBodyAdvice
    • 2.3 为什么返回 String 时会报 ClassCastException
    • 2.4 一个值得知道的权衡
  • 3. 统一异常处理:不让异常“裸奔”到前端
    • 3.1 为什么需要全局异常处理
    • 3.2 快速入门:@ControllerAdvice + @ExceptionHandler
    • 3.3 分层捕获:精确异常优先,Exception 兜底
  • 4. 源码脉络:DispatcherServlet 与 @ControllerAdvice 的协作
  • 5. 前端 Ajax 配合改造
  • 总结

在真实的 Web 项目中,我们很快会遇到三类 “重复且分散” 的问题:

  • 每个接口都要手写登录校验
  • 每个 Controller 都要手动包装返回结果
  • 每个方法里都要 try-catch 处理异常。
  • SpringBoot 提供了一套统一的机制,让我们把这些横切逻辑从业务代码中抽离出来。

    本文按照拦截器 → 统一数据返回 → 统一异常处理 → 源码脉络 → 前端配合的顺序,带你从零理解并落地这三项能力。

    1. 拦截器:把登录校验从每个接口里抽出来

    1.1 为什么需要拦截器

    在没有拦截器之前,实现强制登录最常见的写法是在每个接口方法里都检查一遍 Session:

    @RequestMapping("/getListByPage")
    public Result getListByPage(PageRequest pageRequest, HttpSession session) {
    if (session.getAttribute(SESSION_USER_KEY) == null) {
    return Result.unlogin();
    }
    // 真正的业务逻辑……
    }

    问题很明显:接口有多少个,这段重复代码就要写多少遍;将来登录规则一变,所有接口都得跟着改。拦截器正是为了解决这类 “每个请求都要做同一件事” 的场景而设计的。它在请求进入 Controller 之前、Controller 执行之后、请求完全结束之后,提供了三个可插入自定义逻辑的时机。

    后端正常的调用顺序:

    用户
    ↓ 调用
    Controller控制层
    ↓
    Service服务层
    ↓
    Mapper数据持久层
    ↓ 调用
    DataBase数据库

    加入拦截器之后的调用顺序:

    用户
    ↓ 调用
    Interceptor拦截器 → false:拦截
    ↓ true:放行
    Controller控制层
    ↓
    Service服务层
    ↓
    Mapper数据持久层
    ↓ 调用
    DataBase数据库

    添加拦截器后,在执行 Controller 的方法之前请求会先被拦截器拦截住并执行 preHandle 方法,这个方法需要返回一个布尔类型的值:如果返回 true,就表示放行本次请求,继续访问 Controller 中的方法;如果返回 false,则不会放行,Controller 中的方法也不会执行。

    1.2 快速入门:实现 HandlerInterceptor

    自定义拦截器只需实现 HandlerInterceptor 接口,重写三个方法:

    @Slf4j
    @Component
    public class LoginInterceptor implements HandlerInterceptor {

    @Override
    public boolean preHandle(HttpServletRequest request,
    HttpServletResponse response,
    Object handler) throws Exception {
    log.info("preHandle:目标方法执行前");
    return true;
    }

    @Override
    public void postHandle(HttpServletRequest request,
    HttpServletResponse response,
    Object handler,
    ModelAndView modelAndView) throws Exception {
    log.info("postHandle:目标方法执行后");
    }

    @Override
    public void afterCompletion(HttpServletRequest request,
    HttpServletResponse response,
    Object handler,
    Exception ex) throws Exception {
    log.info("afterCompletion:视图渲染完毕后执行");
    }
    }

    三个方法的职责和返回值含义如下:

    • preHandle :在 Handler 执行前调用,返回值是布尔类型。返回 true 表示放行,继续执行后续拦截器和 Controller;返回 false 则中断整个请求,Controller 不会被执行。
    • postHandle :在 Controller 方法正常执行完毕后、视图渲染之前调用。如果 Controller 抛出了异常,这个方法不会执行。
    • afterCompletion :在整个请求处理完成后调用,主要任务是释放执行 preHandle 时申请的资源。当某个拦截器的 preHandle 返回 false 导致请求中断时,DispatcherServlet 框架会逆序调用此前已经成功执行过 preHandle 并返回 true 的拦截器的 afterCompletion;当前返回 false 的拦截器自身的 afterCompletion 不会执行,它后面的拦截器也不会执行。这是清理资源、记录最终耗时最合适的位置。

    正常请求下三个方法的执行顺序是:

    preHandle
    ↓
    Controller 方法
    ↓
    postHandle
    ↓
    afterCompletion

    如果把 preHandle 的返回值改成 false,日志会停在 preHandle,请求不会再进入 Controller,响应也不会正常渲染。

    • 一个容易被忽略的细节:异步请求。:
      • 当 Controller 返回 Callable 或使用 DeferredResult 时,Servlet 主线程会先退出,此时 postHandle 和 afterCompletion 不会在本次线程中执行。等异步处理完成、请求重新 dispatch 回来时,框架会再次调用 preHandle 和 postHandle。如果你的拦截器依赖 postHandle 做收尾工作,在异步场景下需要改用 AsyncHandlerInterceptor 的 afterConcurrentHandlingStarted,或者确保逻辑放在 afterCompletion 中。

    1.3 注册拦截器与路径控制

    定义好拦截器后,需要通过 WebMvcConfigurer 的 addInterceptors 把它注册进去,并指定拦截范围和排除范围:

    @Configuration
    public class WebConfig implements WebMvcConfigurer {

    @Autowired
    private LoginInterceptor loginInterceptor;

    @Override
    public void addInterceptors(InterceptorRegistry registry) {
    registry.addInterceptor(loginInterceptor)
    .addPathPatterns("/**")
    .excludePathPatterns(
    "/user/login",
    "/**/*.js",
    "/**/*.css",
    "/**/*.png",
    "/**/*.html"
    );
    }
    }

    路径模式中,/* 只匹配一级路径(如 /user、/book),/** 匹配任意层级路径。实际项目中最容易踩的坑是 静态资源没有被正确排除。一旦你在 addPathPatterns("/**") 的同时没有排除 /**/*.js、/**/*.css 等,浏览器加载前端页面时会连 JS 和 CSS 都被拦截器拦住,页面直接白屏。推荐的排除列表中至少应包含 /**/*.js、/**/*.css、/**/*.png、/**/*.html,如果项目有 Swagger,还要排除 /swagger-ui/** 和 /v3/api-docs/**。

    1.4 实战:用拦截器完成登录校验

    把 Session 校验逻辑搬进 preHandle,所有接口就自动拥有了登录保护:

    @Slf4j
    @Component
    public class LoginInterceptor implements HandlerInterceptor {

    @Override
    public boolean preHandle(HttpServletRequest request,
    HttpServletResponse response,
    Object handler) throws Exception {
    HttpSession session = request.getSession(false);
    if (session != null
    && session.getAttribute(Constants.SESSION_USER_KEY) != null) {
    return true;
    }
    response.setStatus(401);
    return false;
    }
    }

    这里设置 HTTP 状态码 401 Unauthorized ,语义是 “请求需要身份认证,且当前未提供或认证失败”。前端 Ajax 的 error 回调可以通过判断 error.status === 401 统一跳转到登录页,而不需要解析响应体中的业务码。这是拦截器与前端配合的一个关键约定。

    2. 统一数据返回格式:让所有接口说同一种“语言”

    2.1 手动包装的痛点

    在拦截器解决登录问题之后,另一个重复劳动浮出水面:每个 Controller 方法都要手动写 return Result.success(data)。一旦某个接口忘记包装,前端拿到的就是裸数据,解析逻辑立刻崩溃。理想的状态是:Controller 只管返回业务数据本身,框架自动套上统一的外壳。

    2.2 @ControllerAdvice + ResponseBodyAdvice

    SpringMVC 提供了 ResponseBodyAdvice 接口,允许在响应体被 HttpMessageConverter 写出之前进行拦截和修改。配合 @ControllerAdvice,就可以对所有 Controller 的返回值统一加工:

    @ControllerAdvice
    public class ResponseAdvice implements ResponseBodyAdvice<Object> {

    private static final ObjectMapper mapper = new ObjectMapper();

    @Override
    public boolean supports(MethodParameter returnType,
    Class converterType) {
    return true;
    }

    @Override
    public Object beforeBodyWrite(Object body,
    MethodParameter returnType,
    MediaType selectedContentType,
    Class selectedConverterType,
    ServerHttpRequest request,
    ServerHttpResponse response) {
    if (body instanceof Result) {
    return body;
    }
    if (body instanceof String) {
    try {
    return mapper.writeValueAsString(Result.success(body));
    } catch (Exception e) {
    throw new RuntimeException(e);
    }
    }
    return Result.success(body);
    }
    }

    supports 返回 true 表示:对于被 @ControllerAdvice 覆盖、且通过 HttpMessageConverter 写出响应体的 Controller 方法,允许调用 beforeBodyWrite 进行包装;返回 false 则跳过该 Advice。返回 true 只是“允许调用”,是否真正包装还由 beforeBodyWrite 内部逻辑决定。

    要让 supports 返回 true 真正起到 “统一包装”的效果,下面三件事得同时成立:

  • 这个 Controller 方法在 @ControllerAdvice 的覆盖范围内。@ControllerAdvice 默认覆盖所有 Controller;一旦配置了 basePackages、assignableTypes、annotations 这些属性,覆盖范围就变小了。
  • @Target(ElementType.TYPE)
    @Retention(RetentionPolicy.RUNTIME)
    @Documented
    @Component
    public @interface ControllerAdvice {

    /**
    * basePackages 属性的别名。
    * 允许更简洁的注释声明——例如,@ControllerAdvice(“org.my.pkg”) 等效于 @ControllerAdvices(basePackages=“org.my.pkg”)
    * @since 4.0
    */

    @AliasFor("basePackages")
    String[] value() default {};

    @AliasFor("value")
    String[] basePackages() default {};

    Class<?>[] assignableTypes() default {};

    Class<? extends Annotation>[] annotations() default {};

    }

  • 这个返回值是通过 HttpMessageConverter 写出响应体的。@ResponseBody、@RestController、ResponseEntity 这类返回值会走这条链路;如果 Controller 返回的是 ModelAndView 或者视图名,压根不经过 ResponseBodyAdvice,自然也不会被包装。
  • /**
    * 用于在HTTP请求和响应之间进行转换的策略接口。
    *
    * @author Arjen Poutsma
    * @author Juergen Hoeller
    * @author Rossen Stoyanchev
    * @since 3.0
    * @param <T> 转换后的对象类型
    */

    public interface HttpMessageConverter<T> {

    /**
    * 指示此转换器是否可以读取给定的类。
    * @param clazz 测试可读性的类
    * @param mediaType 要读取的媒体类型(如果未指定,可以是null);通常是报头中 Content-Type 属性的值。
    * @return 如果可读,则为true;否则为false
    */

    boolean canRead(Class<?> clazz, @Nullable MediaType mediaType);

    /**
    * 指明给定的类是否可以由此转换器编写。
    * @param clazz 测试可写性的类
    * @param mediaType 要写入的媒体类型(如果未指定,则可以为null);通常是报头中 Accept 属性的值。
    * @return 如果可写,则为true;否则为false
    */

    boolean canWrite(Class<?> clazz, @Nullable MediaType mediaType);

    /**
    * 返回此转换器支持的媒体类型列表。该列表可能不适用于所有可能的目标元素类型,因此通常应通过canWrite(clazz, null)方法来保护对此方法的调用。该列表可能还会排除仅特定类支持的MIME类型。或者,使用getSupportedMediaTypes(Class)获取更精确的列表。
    * @return 支持的媒体类型列表
    */

    List<MediaType> getSupportedMediaTypes();

    /**
    * 返回此转换器针对给定类支持的媒体类型列表。如果转换器不支持给定的类,或者仅支持其中的一部分媒体类型,则该列表可能与getSupportedMediaTypes()返回的列表不同。
    * @param clazz 指定的类
    * @return 给定类支持的媒体类型列表
    * @since 5.3.4
    */

    default List<MediaType> getSupportedMediaTypes(Class<?> clazz) {
    return (canRead(clazz, null) || canWrite(clazz, null) ?
    getSupportedMediaTypes() : Collections.emptyList());
    }

    /**
    * 从给定的输入消息中读取给定类型的对象,并返回该对象。
    * @param clazz 要返回的对象类型。此类型必须之前已传递给此接口的canRead方法,且该方法必须返回true。
    * @param inputMessage 要读取的HTTP输入消息
    * @return 转换后的对象
    * @throws IOException 在发生I/O错误的情况下
    * @throws HttpMessageNotReadableException 以防出现转换错误
    */

    T read(Class<? extends T> clazz, HttpInputMessage inputMessage)
    throws IOException, HttpMessageNotReadableException;

    /**
    * 将给定对象写入给定的输出消息中。
    * @param t 要写入输出消息的对象。此对象的类型必须之前已传递给此接口的canWrite方法,且该方法必须返回true。
    * @param contentType 写入时要使用的内容类型。可以为 null,表示必须使用转换器的默认内容类型。如果非 null,则此媒体类型必须之前已传递给此接口的 canWrite 方法,且该方法必须返回 true。
    * @param outputMessage 要写的信息
    * @throws IOException 在发生I/O错误的情况下
    * @throws HttpMessageNotWritableException 以防出现转换错误
    */

    void write(T t, @Nullable MediaType contentType, HttpOutputMessage outputMessage)
    throws IOException, HttpMessageNotWritableException;

    }

  • beforeBodyWrite 内部没有主动跳过这个 body。supports 只决定“允不允许调用”,不决定 “一定会改写”。
  • /**
    * 允许在执行@ResponseBody或 ResponseEntity控制器方法之后,
    * 但在使用HttpMessageConverter写入响应体之前,自定义响应。
    * 实现类可以直接注册到 RequestMappingHandlerAdapter和 ExceptionHandlerExceptionResolver中,
    * 或者更常见的是使用 @ControllerAdvice进行注解,在这种情况下,它们将被两者自动检测到。
    * @author Rossen Stoyanchev
    * @since 4.1
    * @param <T> 响应体类型
    */

    public interface ResponseBodyAdvice<T> {

    /**
    * 此组件是否支持给定的Controller方法返回类型和所选的 HttpMessageConverter类型。
    * @param returnType 返回的类型
    * @param converterType 所选的 HttpMessageConverter类型
    * @return 如果应该调用beforeBodyWrite,则为true;否则为false
    */

    boolean supports(MethodParameter returnType, Class<? extends HttpMessageConverter<?>> converterType);

    /**
    * 在选择了HttpMessageConverter之后,且在其write方法被调用之前调用。
    * @param body 要被写入的响应体
    * @param returnType Controller方法的返回类型
    * @param selectedContentType 通过内容协商选择的内容类型
    * @param selectedConverterType 选择写入响应的转换器类型
    * @param request 当前请求
    * @param response 当前响应
    * @return 被传入的实体或一个修改后的(可能是新的)实例
    */

    @Nullable
    T beforeBodyWrite(@Nullable T body, MethodParameter returnType, MediaType selectedContentType,
    Class<? extends HttpMessageConverter<?>> selectedConverterType,
    ServerHttpRequest request, ServerHttpResponse response);

    }

    2.3 为什么返回 String 时会报 ClassCastException

    如果你直接让 beforeBodyWrite 无差别地 return Result.success(body),访问一个返回 String 的接口时,会遇到:

    java.lang.ClassCastException: Result cannot be cast to java.lang.String

    原因藏在 SpringMVC 的 HttpMessageConverter 链里。默认注册的转换器顺序中,StringHttpMessageConverter 排在 MappingJackson2HttpMessageConverter 之前。当原始返回类型是 String 时,Spring 会选中 StringHttpMessageConverter 来写出响应;而这个转换器在 addDefaultHeaders 中期望收到的是 String 类型。你的 beforeBodyWrite 却把 String 包装成了 Result,于是类型不匹配,抛出异常。

    修复方式就是上面的代码: 在 beforeBodyWrite 中单独判断 body instanceof String,先用 Jackson 手动把 Result 序列化成 JSON 字符串再返回,这样 StringHttpMessageConverter 拿到的仍然是合法的 String。同时增加 body instanceof Result 的判断,避免对已经手动包装过的返回值进行二次包装(否则前端会收到 {"status":"SUCCESS","data":{"status":"SUCCESS","data":…}} 这样的嵌套结构)。

    2.4 一个值得知道的权衡

    ResponseBodyAdvice 自动包装的优点是 “零侵入”:Controller 完全不需要感知 Result 的存在。但它也有代价——Controller 的方法签名中看不出来最终返回结构,对于需要文件下载、流式响应、Swagger 文档等场景,自动包装反而会造成干扰。在中小型项目中,自动包装是高效的;在大型或对外 API 项目中,不少团队会选择手动返回 Result<T>,以获得更强的类型可见性和控制力。

    3. 统一异常处理:不让异常“裸奔”到前端

    3.1 为什么需要全局异常处理

    没有统一异常处理时,Controller 里任何一个未捕获的异常都会让前端收到一个 500 响应,响应体可能是 Spring 默认的错误页或一长串 Java 堆栈。这不仅不友好,还可能把内部类名、SQL 片段等敏感信息暴露给外部。统一异常处理的目标是:所有异常都被转换成与正常返回结构一致的 Result 对象,前端只需按同一套逻辑解析。

    3.2 快速入门:@ControllerAdvice + @ExceptionHandler

    @RestControllerAdvice
    public class GlobalExceptionHandler {

    @ExceptionHandler(Exception.class)
    public Result handleException(Exception e) {
    return Result.fail(e.getMessage());
    }
    }

    @RestControllerAdvice 等价于 @ControllerAdvice + @ResponseBody,这意味着处理方法的返回值会自动被序列化为 JSON,适合前后端分离项目。如果你返回的是视图页面而不是 JSON,才需要使用 @ControllerAdvice 并在方法上单独加 @ResponseBody。

    3.3 分层捕获:精确异常优先,Exception 兜底

    一个健壮的异常处理器不会只有一个 “万能” 方法。@ExceptionHandler 的匹配规则是当前异常类型及其子类向上匹配:如果抛出的异常同时能被多个方法处理,Spring 会选择声明类型与抛出类型最“近”的那个。例如抛出一个 NullPointerException,handler(NullPointerException e) 的匹配深度为 0,handler(Exception e) 的匹配深度为 2,因此精确匹配的方法优先执行。

    基于这个规则,推荐的组织方式是:

    @RestControllerAdvice
    @Slf4j
    public class GlobalExceptionHandler {

    // 业务异常:可预期,返回具体提示
    @ExceptionHandler(BusinessException.class)
    public Result handleBusiness(BusinessException e) {
    log.warn("业务异常: code={}, msg={}", e.getCode(), e.getMessage());
    return Result.fail(e.getCode(), e.getMessage());
    }

    // 参数校验异常
    @ExceptionHandler(MethodArgumentNotValidException.class)
    public Result handleValidation(MethodArgumentNotValidException e) {
    String msg = e.getBindingResult().getFieldError().getDefaultMessage();
    return Result.fail("PARAM_INVALID", msg);
    }

    // 兜底:非预期异常,隐藏细节,记录完整堆栈
    @ExceptionHandler(Exception.class)
    public Result handleUnexpected(Exception e) {
    log.error("未预期异常", e);
    return Result.fail("系统繁忙,请稍后再试");
    }
    }

    业务异常(如“用户不存在”“余额不足”)应该返回给用户可读的提示;非预期的系统异常则只记录日志,对外返回模糊的友好信息,避免信息泄露。这与前端的配合也很关键:前端只需要判断 result.status 是否成功,失败时展示 errorMessage,不需要区分背后是业务异常还是系统异常。

    4. 源码脉络:DispatcherServlet 与 @ControllerAdvice 的协作

    理解上面三项功能共享同一个核心入口——DispatcherServlet,有助于你在遇到 “拦截器没生效”、“异常处理没触发” 时快速定位问题。

    所有请求都会先进入 DispatcherServlet 的 doDispatch 方法。它的主干流程是:

  • 通过 HandlerMapping 找到当前请求对应的 HandlerExecutionChain(包含 Handler 和拦截器链);
  • 通过 HandlerAdapter 适配并准备执行 Handler;
  • 执行 applyPreHandle,依次调用拦截器的 preHandle;
  • 执行 Controller 方法;
  • 执行拦截器的 postHandle;
  • 处理视图或响应,最后触发 afterCompletion。
  • @ControllerAdvice 的两个分支分别挂在这条主干的特定环节上:

  • ResponseBodyAdvice 分支:RequestMappingHandlerAdapter 在启动时会扫描所有 @ControllerAdvice Bean,把实现了 ResponseBodyAdvice 的 Bean 收集到 requestResponseBodyAdvice 列表中。当 Controller 返回值需要写出时,AbstractMessageConverterMethodProcessor.writeWithMessageConverters 会遍历这些 Advice,调用 beforeBodyWrite,这正是自动包装的切入点。
  • ExceptionHandler 分支:DispatcherServlet 初始化时会创建 ExceptionHandlerExceptionResolver,它同样扫描 @ControllerAdvice Bean,并通过 ExceptionHandlerMethodResolver 建立 “异常类型 → 处理方法” 的映射。当 Controller 抛出异常时,Resolver 按匹配深度排序,选出最精确的 @ExceptionHandler 方法执行。
  • 这两个分支的触发时机不同:ResponseBodyAdvice 在 正常返回路径 上生效,@ExceptionHandler 在 异常路径 上生效。如果你的统一异常处理返回的 Result 又被 ResponseBodyAdvice 包装了一层,就会产生嵌套。这也是为什么 beforeBodyWrite 中需要判断 body instanceof Result 并直接返回——正常路径和异常路径的返回值最终汇入同一个写出环节,必须避免重复包装。

    5. 前端 Ajax 配合改造

    后端统一之后,前端也需要做相应调整。核心变化只有两点:响应体结构变了,以及未登录时 HTTP 状态码是 401。

    对于 401,Ajax 的 error 回调统一处理:

    error: function (xhr) {
    if (xhr.status === 401) {
    alert("登录已失效,请重新登录");
    location.href = "login.html";
    }
    }

    对于 成功响应,前端不再直接从顶层字段读取业务数据,而是先判断 result.status,再从 result.data 中取数据:

    success: function (result) {
    if (result.status === "SUCCESS") {
    var bookList = result.data; // 真正的业务数据在 data 里
    // 渲染页面……
    } else {
    alert(result.errorMessage);
    }
    }

    登录接口的返回值也会被统一包装,因此前端判断登录是否成功的条件从原来的 result === true 变为 result.status === "SUCCESS" && result.data === true。这一步看起来微小,却是前后端契约对齐的关键:后端所有接口无论正常还是异常,前端永远面对同一种 JSON 结构。

    总结

    本文覆盖的三项能力,本质上是在解决同一个问题:把与业务无关的横切关注点从 Controller 中抽离,用框架的统一入口集中处理。

  • 拦截器:在 preHandle 中完成登录校验,用 excludePathPatterns 精确控制放行范围;注意异步请求下 postHandle/afterCompletion 的调用时机差异。
  • 统一数据返回 :@ControllerAdvice + ResponseBodyAdvice 在响应写出前自动包装,但必须单独处理 String 类型,否则会因 StringHttpMessageConverter 的类型期望而抛出 ClassCastException。
  • 统一异常处理 :@ControllerAdvice + @ExceptionHandler 分层捕获,精确异常优先、Exception 兜底;生产环境对外隐藏堆栈,对内记录完整日志。
  • 前端配合:401 走 error 回调跳登录,正常响应统一从 result.data 取业务数据。
  • 三项能力串在一起,后端的接口从此有了稳定的 “外形”,前端的解析逻辑也只需要写一次。这就是 SpringBoot 统一功能处理真正的价值所在。


    赞(0)
    未经允许不得转载:网硕互联帮助中心 » 【JavaEE】SpringBoot 统一功能处理: 拦截器、统一返回与全局异常
    分享到: 更多 (0)

    评论 抢沙发

    评论前必须登录!