Skip to content

SpringMVC教程 - 16 统一异常处理

16.1 为什么需要异常处理

例如,我们现在定义了接口如下:

java
@GetMapping("/")
public String index(HttpServletRequest request) {
    int a = 2 / 0;
    return "index";  // 转发到index视图 index.html
}

那么在访问接口的时候,必然报错,显示如下:

但是这个报错页面也太丑了,而且会泄露日志、服务器信息等,存在安全隐患。而且接口返回 JSON 的,我们也更期望返回 JSON 格式的错误信息,前端好解析。

所以我们就需要进行统一的,更安全的的异常管理。

16.2 传统MVC(返回视图)的异常处理

传统网站不是返回 JSON,而是返回 JSP/HTML。所以我们可以自定义错误页面,显示自定义的错误信息和比较友好的提示。

1 自定义错误页面

首先创建一个错误页面,例如 error.html

html
<!DOCTYPE html>
<html lang="zh">
<head>
    <meta charset="UTF-8">
    <title>出错啦</title>
    <style>
        body {
            font-family: sans-serif;
            padding: 40px;
            text-align: center;
            color: #444;
        }
        h1 {
            color: #d33;
            font-weight: normal;
        }
        a {
            color: #06c;
            text-decoration: none;
        }
        a:hover { text-decoration: underline; }
    </style>
</head>
<body>
  <h1>出错啦</h1>
  <p id="msg" th:text="${error}"></p>
  <p><a href="/">返回首页</a></p>
</body>
</html>
  • 可以根据自己的想象来自定义错误页面;
  • 上面还接收了错误信息,那么后面可以传递错误信息到页面中。

2 使用@ExceptionHandler局部异常处理

看一下下面的代码:

java
package com.foooor.hellospringmvc.controller;

import com.foooor.hellothymeleaf.common.Result;
import jakarta.servlet.http.HttpServletRequest;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.ResponseBody;

@Slf4j
@Controller
public class IndexController {

    @GetMapping("/")
    public String index(HttpServletRequest request) {
        // 算术异常,抛出异常
        int a = 2 / 0;
        return "index";  // 转发到index视图 index.html
    }

    @GetMapping("/hello")
    @ResponseBody
    public Result hello(Model model) {
        // 索引超出范围,抛出异常
        String abc = "abc".substring(10);

        return Result.success("For技术栈");
    }

    /**
     * 处理运行时异常
     */
    @ExceptionHandler(RuntimeException.class)
    public String handleRuntimeException(RuntimeException ex, Model model) {
        // 将错误信息传递到error视图
        model.addAttribute("error", ex.getMessage());
        return "error";  // 转发到error视图 error.html
    }

    /**
     * 处理StringIndexOutOfBoundsException异常
     */
    @ExceptionHandler(StringIndexOutOfBoundsException.class)
    public String handleStringIndexOutOfBoundsException(StringIndexOutOfBoundsException ex, Model model) {
        // 将错误信息传递到error视图
        model.addAttribute("error", "系统异常:" + ex.getMessage());
        return "error";  // 转发到error视图 error.html
    }

}
  • 首先定义了两个接口,运行的时候会报错,抛出了两种类型的异常;
  • 然后使用了 @ExceptionHandler 注解定义了异常的处理,并指定处理什么类型的异常;可以看到异常处理可以定义多个;
  • 在处理异常的时候,会优先匹配范围小的异常,所以如果上面将 handleStringIndexOutOfBoundsException 方法去掉,两个接口的异常都会被 handleRuntimeException 处理,并跳转到错误页面;
  • 这种在 Controller 中定义的处理方式,只对当前 Controller 中的接口抛出异常有效,其他 Controller 抛出的异常是没办法捕获处理的。

所以这种方式,每个 Controller 都要写,用的不多,跳过!

3 报错显示效果

上面添加了错误页面,再次请求接口,显示如下:

4 全局处理器@ControllerAdvice

很明显,我们需要一个全局的处理,对整个应用生效。

@ControllerAdvice 会扫描所有 @Controller,它是 全局控制器增强,不是异常处理专用,但在异常处理中最常用。

下面创建一个类,使用 @ControllerAdvice 注解,并搭配 @ExceptionHandler 进行处理,这样所有接口报错,都可以被捕获跳转到错误页面:

java
package com.foooor.hellospringmvc.handler;

import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.bind.annotation.ExceptionHandler;

@ControllerAdvice
public class GlobalExceptionHandler {

    /**
     * 全局异常处理, 捕获所有异常
     */
    @ExceptionHandler(Exception.class)
    public String handle(Exception e, Model model) {
        model.addAttribute("error", e.getMessage());
        return "error"; // 转发到error视图 error.html
    }
}
  • 上面的异常捕获是可以定义多个的,Exception 会捕获所有的异常,如果想针对指定的异常进行处理,可以再针对指定的异常进行定义。

在传统的项目中,上面的异常处理其实还不够完善,因为只能返回到页面。而哪怕在传统项目中,也难免使用 AJAX 请求,如果 AJAX 请求出错了也返回页面,显示是不合理的。这个待会再完善。

16.3 前后端分离(返回 JSON)异常处理

下面先介绍一下在前后端分离的项目中的异常处理写法,返回 JSON 格式的错误信息。

1 统一返回结果

首先统一一下返回结果,我们之前在 HelloWorld 中已经定义了 Result 类,用来返回数据:

java
package com.foooor.hellospringmvc.common;

import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;

@Data  // Lombok 自动生成 getter/setter 等方法
@NoArgsConstructor
@AllArgsConstructor  // Lombok 自动生成全参构造方法
public class Result {

    private int code;
    private String message;
    private Object data;

    public static Result success() {
        return success(null);
    }

    public static Result success(Object data) {
        return new Result(200, "success", data);
    }

    public static Result error(int code, String message) {
        return new Result(code, message, null);
    }
}

也就是说,不论成功返回结果,还是出错,我们希望都是通过 Result 来返回,这样前端可以根据返回码 code 来判断成功还是失败。

前端得到的 JSON 始终是如下格式:

json
{
  "code": 500,
  "message": "用户不存在",
  "data": {
  	//...
  }
}

2 全局异常处理

和上面一样,定义一个全局处理器,在类上使用 @ControllerAdvice@ResponseBody 注解。

java
package com.foooor.hellospringmvc.handler;

import com.foooor.hellospringmvc.common.Result;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.ResponseBody;

@Slf4j
@ControllerAdvice
@ResponseBody  // 保证返回 JSON
public class GlobalExceptionHandler {

    @ExceptionHandler(Exception.class)
    public Result handleException(Exception e) {
        log.error("统一异常处理:", e);
        return Result.error(HttpStatus.INTERNAL_SERVER_ERROR.value(), "服务器内部错误");
    }

}
  • @ResponseBody 标识将 Result 对象转换为 JSON 返回。

@ControllerAdvice + @ResponseBody 也可以合并,使用 @RestControllerAdvice 注解来代替。

java
@RestControllerAdvice
public class GlobalExceptionHandler {
}

3 报错显示效果

16.4 兼容返回页面+返回 JSON

对于前后端分离的项目而言,使用上面的异常处理方式就可以了,反正也不会返回页面给前端。

而如果是传统的项目,可能也会包含 AJAX 请求的接口,除了可能返回页面,也可能返回 JSON,所以就需要兼容处理一下。

代码如下:

java
package com.foooor.hellospringmvc.handler;

import com.foooor.hellospringmvc.common.Result;
import jakarta.servlet.http.HttpServletRequest;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.ResponseBody;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.method.HandlerMethod;
import org.springframework.web.servlet.ModelAndView;

@Slf4j
@ControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(Exception.class)
    @ResponseBody
    public Object handle(Exception ex,
                         HttpServletRequest request,
                         HandlerMethod handlerMethod) {

        log.error("SpringMVC 异常", ex);

        if (shouldReturnJson(request, handlerMethod)) {
            return Result.error(HttpStatus.INTERNAL_SERVER_ERROR.value(), ex.getMessage());
        }

        // 2.否则返回页面
        ModelAndView mv = new ModelAndView();
        mv.addObject("error", ex.getMessage());
        mv.setViewName("error");   // 视图名称
        return mv;
    }

    private boolean shouldReturnJson(HttpServletRequest request,
                                     HandlerMethod handlerMethod) {
        // 1. 判断Controller / Method 标注,是否有 ResponseBody 和 RestController注解
        if (handlerMethod != null &&
                (handlerMethod.hasMethodAnnotation(ResponseBody.class) ||
                        handlerMethod.getBeanType().isAnnotationPresent(RestController.class))) {
            return true;
        }
        // 2. AJAX 头
        String xhr = request.getHeader("X-Requested-With");
        if ("XMLHttpRequest".equalsIgnoreCase(xhr)) {
            return true;
        }
        // 3. Accept 头
        String accept = request.getHeader("Accept");
        return accept != null && accept.contains("application/json");
    }
}
  • 首先 HandlerMethod handlerMethod 接收到的是 Controller 中的方法,然后判断这个方法是否有 @ResponseBody@RestController 注解然、是否是 Ajax 请求以及是否是 json 的请求格式,如果是就以 JSON 格式返回错误信息。如果不是,就返回视图对象 ModelAndView
  • 需要注意,handleException 是添加了 @ResponseBody 注解的,这样错误信息才能以 JSON 格式返回,但是这个 @ResponseBody 不会影响返回页面,因为 ModelAndView 类型会被自动识别并走视图渲染。
  • 上面只处理了 Exception 异常,如果想针对特定的异常处理,可以定义多个异常处理方法,添加 @ExceptionHandler(异常类.class) 进行处理即可。

16.5 自定义异常

上面我们在返回异常的时候,使用的是 Spring 自带的通用返回码,但是在实际的 Web 开发中,系统异常(例如空指针、除零、数据库连接错误)并不等于业务异常(余额不足、用户名重复、权限不够)。

为了让前端能够准确识别业务问题,并给用户展示友好的提示,我们通常需要 自定义业务异常

1 定义统一的错误码

可以定义一些通用的错误码,也可以针对具体的业务逻辑来定义错误码。

java
package com.foooor.hellospringmvc.common;

public enum ErrorCode {
    SUCCESS(0, "成功"),
    SYSTEM_ERROR(1000, "系统内部错误"),
    PARAM_ERROR(1001, "参数错误"),
    NO_PERMISSION(1002, "无权限访问"),
    USER_NOT_FOUND(1003, "用户不存在");

    public final int code;
    public final String msg;

    ErrorCode(int code, String msg) {
        this.code = code;
        this.msg = msg;
    }
}
  • 使用枚举的方式来定义,规范且易于维护。

然后我们可以修改一下 Result 类,添加几个方法,接收 ErrorCode 作为参数,方便调用:

java
package com.foooor.hellospringmvc.common;

import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;

@Data  // Lombok 自动生成 getter/setter 等方法
@NoArgsConstructor
@AllArgsConstructor  // Lombok 自动生成全参构造方法
public class Result {

    private int code;
    private String message;
    private Object data;

    public static Result success() {
        return success(null);
    }

    public static Result success(Object data) {
        return new Result(200, "success", data);
    }

    public static Result error(ErrorCode errorCode) {
        return new Result(errorCode.code, errorCode.msg, null);
    }

    public static Result error(int code, String message) {
        return new Result(code, message, null);
    }

    public static Result error(int code, String message, Object data) {
        return new Result(code, message, data);
    }
}
  • 根据需要自己定义方法就可以了。

2 定义业务异常类

定义一个业务异常类,如果需要主动抛出异常的时候,可以在代码中抛出异常,例如查找用户查找不到可以抛出异常。

java
package com.foooor.hellospringmvc.handler;

import com.foooor.hellospringmvc.common.ErrorCode;

public class BizException extends RuntimeException {

    private final ErrorCode errorCode;

    public BizException(ErrorCode errorCode) {
        super(errorCode.msg);
        this.errorCode = errorCode;
    }

    public ErrorCode getErrorCode() {
        return errorCode;
    }
}
  • 继承自运行时异常类RuntimeException

3 全局增加业务异常处理

在全局异常处理中,增加对业务异常的处理:

java
package com.foooor.hellospringmvc.handler;

import com.foooor.hellospringmvc.common.ErrorCode;
import com.foooor.hellospringmvc.common.Result;
import jakarta.servlet.http.HttpServletRequest;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.ResponseBody;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.method.HandlerMethod;
import org.springframework.web.servlet.ModelAndView;

@Slf4j
@ControllerAdvice
public class GlobalExceptionHandler {

    /**
     * 全局异常处理兜底
     */
    @ExceptionHandler(Exception.class)
    @ResponseBody
    public Object handle(Exception ex,
                         HttpServletRequest request,
                         HandlerMethod handlerMethod) {
        log.error("全局异常:{}", ex);
        return handleException(ErrorCode.SYSTEM_ERROR, request, handlerMethod);
    }

    /**
     * 业务异常处理
     */
    @ExceptionHandler(BizException.class)
    @ResponseBody
    public Object handleBizException(BizException e,
                                     HttpServletRequest request,
                                     HandlerMethod handlerMethod) {
        log.error("业务异常:{}", e);
        return handleException(e.getErrorCode(), request, handlerMethod);
    }

    /**
     * 统一异常处理
     */
    private Object handleException(ErrorCode errorCode,
                                   HttpServletRequest request,
                                   HandlerMethod handlerMethod) {
        log.error("SpringMVC 异常:{}", errorCode);

        if (shouldReturnJson(request, handlerMethod)) {
            return Result.error(errorCode);
        }

        // 2.否则返回页面
        ModelAndView mv = new ModelAndView();
        mv.addObject("error", errorCode.msg);
        mv.setViewName("error");   // 视图名称
        return mv;
    }

    /**
     * 判断是否返回 JSON 格式异常信息
     */
    private boolean shouldReturnJson(HttpServletRequest request,
                                     HandlerMethod handlerMethod) {

        // 1. Controller / Method 标注
        if (handlerMethod != null &&
                (handlerMethod.hasMethodAnnotation(ResponseBody.class) ||
                        handlerMethod.getBeanType().isAnnotationPresent(RestController.class))) {
            return true;
        }

        // 2. AJAX 头
        String xhr = request.getHeader("X-Requested-With");
        if ("XMLHttpRequest".equalsIgnoreCase(xhr)) {
            return true;
        }

        // 3. Accept 头
        String accept = request.getHeader("Accept");
        return accept != null && accept.contains("application/json");
    }
}
  • 这样可以捕获主动抛出的 BizException 异常,并按照统一的格式返回。
  • 如果要针对不同的异常进行不同的处理,那么可以自定义多个异常类,然后针对性的进行处理。

4 在业务代码中抛出异常

上面的工作都准备好了,下面就可以在代码中抛出异常了。

java
@GetMapping("/user/{id}")
@ResponseBody
public Result getUser(@PathVariable int id) {
    User user = userService.findById(id);
    if (user == null) {
        throw new BizException(ErrorCode.USER_NOT_FOUND);
    }
    return Result.success(user);
}
  • throw new BizException(...) 不需要 try...catch,SpringMVC 会自动走 GlobalExceptionHandler,最终返回统一格式 JSON,方便前端处理。
  • 也可以直接在 Service 类中抛出异常,都可以。

16.6 类型转换异常

在类型转换章节,当时说过,当类型转换失败时,SpringMVC会抛出异常,所以我们只需要在全局异常处理中捕获相关的异常,然后进行处理就可以了:

java
/**
 * 类型转换异常处理
 */
@ExceptionHandler({
        HttpMessageNotReadableException.class,
        MissingRequestValueException.class,
        MethodArgumentTypeMismatchException.class,
        HttpMessageConversionException.class,
        HandlerMethodValidationException.class,
  			ServletRequestBindingException.class,
        BindException.class,
        TypeMismatchException.class
})
@ResponseBody
public Object handleRequestParamException(Exception ex,
                                          HttpServletRequest request,
                                          HandlerMethod handlerMethod) {
    log.info("参数异常:{}, e: {}", ex);
    return handleException(ErrorCode.PARAM_ERROR, request, handlerMethod);
}
  • 异常有多个,有的参数类型不匹配、有的缺失、有的绑定失败,如果有为囊括的,可以再添加一下。

16.7 Filter中的异常处理

拦截器中的异常也是可以被全局异常处理捕获并处理的。但是 Filter 位于 Servlet 层,执行顺序在 SpringMVC 之前。因此,Filter 中发生的异常并不会直接被 @ControllerAdvice 捕捉到,需要手动处理。

我们可以添加一个 Filter,专门将这个过滤器尽量配置到前面,这样就可以尽早被调用,这样如何其他过滤器出现异常也可以捕获。

这里我创建一个 GlobalExceptionFilter.java

java
package com.foooor.hellospringmvc.filter;

import com.fasterxml.jackson.databind.ObjectMapper;
import com.foooor.hellospringmvc.common.ErrorCode;
import com.foooor.hellospringmvc.common.Result;
import jakarta.servlet.*;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;

public class GlobalExceptionFilter implements Filter {

    @Override
    public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain)
            throws IOException {

        HttpServletRequest request = (HttpServletRequest) req;
        HttpServletResponse response = (HttpServletResponse) res;

        try {
            chain.doFilter(req, res);
        } catch (Exception e) {
            if (isAjax(request)) {
                writeJson(response, Result.error(ErrorCode.SYSTEM_ERROR));
            } else {
                // 其他异常,跳转到自定义错误页面
                response.sendRedirect("/error");
            }
        }
    }

    /**
     * 写 JSON 响应
     */
    private void writeJson(HttpServletResponse response, Result result) throws IOException {
        // 1. 一定要先判断 response 是否已经提交
        if (response.isCommitted()) {
            return;
        }
        // 2. 重置响应(避免之前的 HTML / 错误页面内容)
        response.reset();
        // 3. 设置状态码
        response.setStatus(HttpServletResponse.SC_INTERNAL_SERVER_ERROR);
        // 4. 设置返回类型和编码
        response.setContentType("application/json;charset=UTF-8");
        // 5. 写 JSON
        ObjectMapper mapper = new ObjectMapper();
        String json = mapper.writeValueAsString(result);
        response.getWriter().write(json);
        response.getWriter().flush();
    }


    /**
     * 判断是否是 AJAX 请求
     */
    private boolean isAjax(HttpServletRequest request) {

        // 1. X-Requested-With(传统 AJAX)
        String xRequestedWith = request.getHeader("X-Requested-With");
        if ("XMLHttpRequest".equalsIgnoreCase(xRequestedWith)) {
            return true;
        }

        // 2. Accept 包含 application/json(fetch / axios)
        String accept = request.getHeader("Accept");
        if (accept != null && accept.contains("application/json")) {
            return true;
        }

        // 3. Content-Type 是 JSON(POST / PUT)
        String contentType = request.getContentType();
        if (contentType != null && contentType.contains("application/json")) {
            return true;
        }

        return false;
    }
}
  • 在上面的 Filter 中捕获了抛出的异常,根据请求的类型,判断写出 Json 还是跳转到 /error 接口。

那么我们需要写一个 /error 接口,跳转到错误页面:

java
package com.foooor.hellospringmvc.controller;

import jakarta.servlet.http.HttpServletRequest;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;

@Controller
@RequestMapping("/error")
public class ErrorController {

    @RequestMapping("/")
    public String error() {
        return "error";
    }

    @RequestMapping("/{code}")
    public Object handleError(@PathVariable String code,
                              HttpServletRequest request) {

        return "error/" + code;
    }
}
  • 定义跳转到错误页面的接口,可以跳转到错误页面。

通过上面的操作,就可以捕获 Filter 中的异常了。

16.8 容器级别的异常处理

虽然上面对 SpringMVC 中和 Filter 中的异常进行了处理,但是还是有一些异常是无法捕获的,就是容器级别抛出的异常。

我们可以在 web.xml 中配置,针对不同的错误码的异常进行处理:

xml
<error-page>
    <error-code>404</error-code>
    <location>/error</location>
</error-page>

<error-page>
    <exception-type>java.lang.Throwable</exception-type>
    <location>/error</location>
</error-page>

上面针对 404 异常和 Throwable 根异常类进行捕获,并跳转到错误页面。


通过上面的异常处理可以看到,异常处理体系是分层的。

  • SpringMVC 负责处理业务异常,如果 SpringMVC 异常处理没有兜住异常并处理,就会向下传递;
  • Filter 负责请求链的前置兜底;
  • Servlet 容器负责最终失败的展示。
内容未完......