Spring REST 错误处理示例

本文旨在展示如何在 Spring Boot REST 应用程序中实现错误处理。我们将探讨默认的错误处理机制、自定义异常处理、验证错误处理以及全局错误属性的定制。

使用的技术栈:

  • Spring Boot 2.1.2.RELEASE
  • Spring 5.1.4.RELEASE
  • Maven 3
  • Java 8

1. 默认错误处理 (/error)

1.1 BasicErrorController

默认情况下,Spring Boot 提供了一个 BasicErrorController,用于映射 /error 路径以处理所有错误。该控制器调用 getErrorAttributes 方法,生成一个包含错误详细信息、HTTP 状态码和异常消息的 JSON 响应。

默认响应示例如下:

{
    "timestamp": "2019-02-27T04:03:52.398+0000",
    "status": 500,
    "error": "Internal Server Error",
    "message": "...",
    "path": "/path"
}

以下是 BasicErrorController 的核心逻辑片段:

package org.springframework.boot.autoconfigure.web.servlet.error;

//...

@Controller
@RequestMapping("${server.error.path:${error.path:/error}}")
public class BasicErrorController extends AbstractErrorController {

    //...

    @RequestMapping
    public ResponseEntity<Map<String, Object>> error(HttpServletRequest request) {
        Map<String, Object> body = getErrorAttributes(request,
                isIncludeStackTrace(request, MediaType.ALL));
        HttpStatus status = getStatus(request);
        return new ResponseEntity<>(body, status);
    }
}
调试技巧:在 IDE 中在此方法处放置断点,您可以直观地了解 Spring Boot 如何生成默认的 JSON 错误响应。

2. 自定义异常处理 (Custom Exception)

在 Spring Boot 中,我们可以使用 @ControllerAdvice 结合 @ExceptionHandler 来处理自定义异常。

2.1 定义自定义异常

首先,定义一个运行时异常,用于表示图书未找到的情况。

BookNotFoundException.java

package com.jsdiff.error;

public class BookNotFoundException extends RuntimeException {

    public BookNotFoundException(Long id) {
        super("Book id not found : " + id);
    }

}

2.2 控制器抛出异常

当未找到图书时,控制器将抛出上述 BookNotFoundException

BookController.java

package com.jsdiff;

//...

@RestController
public class BookController {

    @Autowired
    private BookRepository repository;

    // Find
    @GetMapping("/books/{id}")
    Book findOne(@PathVariable Long id) {
        return repository.findById(id)
                .orElseThrow(() -> new BookNotFoundException(id));
    }

    //...
}

默认情况下,Spring Boot 会将未捕获的运行时异常视为服务器内部错误,生成 HTTP 500 响应:

curl localhost:8080/books/5
{
    "timestamp": "2019-02-27T04:03:52.398+0000",
    "status": 500,
    "error": "Internal Server Error",
    "message": "Book id not found : 5",
    "path": "/books/5"
}

2.3 重写状态码 (返回 404)

如果未找到图书,通常应返回 404 错误而不是 500 错误。我们可以通过 @ExceptionHandler 重写状态码。

CustomGlobalExceptionHandler.java (步骤 1)

package com.jsdiff.error;

import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.bind.annotation.ExceptionHandler;

import javax.servlet.http.HttpServletResponse;
import java.io.IOException;

@ControllerAdvice
public class CustomGlobalExceptionHandler {

    // 让 Spring BasicErrorController 处理异常,我们仅重写状态码
    @ExceptionHandler(BookNotFoundException.class)
    public void springHandleNotFound(HttpServletResponse response) throws IOException {
        response.sendError(HttpStatus.NOT_FOUND.value());
    }

    //...
}

此时再次请求,将返回 404 状态:

curl localhost:8080/books/5
{
    "timestamp": "2019-02-27T04:21:17.740+0000",
    "status": 404,
    "error": "Not Found",
    "message": "Book id not found : 5",
    "path": "/books/5"
}

2.4 自定义 JSON 错误响应结构

除了修改状态码,我们还可以完全自定义返回的 JSON 错误响应结构。

首先定义响应对象:

CustomErrorResponse.java

package com.jsdiff.error;

import com.fasterxml.jackson.annotation.JsonFormat;

import java.time.LocalDateTime;

public class CustomErrorResponse {

    @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd hh:mm:ss")
    private LocalDateTime timestamp;
    private int status;
    private String error;

    //... getters setters
}

然后修改全局异常处理器,返回自定义对象:

CustomGlobalExceptionHandler.java (步骤 2)

package com.jsdiff.error;

import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.context.request.WebRequest;
import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler;

import java.time.LocalDateTime;

@ControllerAdvice
public class CustomGlobalExceptionHandler extends ResponseEntityExceptionHandler {

    @ExceptionHandler(BookNotFoundException.class)
    public ResponseEntity<CustomErrorResponse> customHandleNotFound(Exception ex, WebRequest request) {

        CustomErrorResponse errors = new CustomErrorResponse();
        errors.setTimestamp(LocalDateTime.now());
        errors.setError(ex.getMessage());
        errors.setStatus(HttpStatus.NOT_FOUND.value());

        return new ResponseEntity<>(errors, HttpStatus.NOT_FOUND);
    }

    //...
}

响应结果如下:

curl localhost:8080/books/5
{
    "timestamp": "2019-02-27 12:40:45",
    "status": 404,
    "error": "Book id not found : 5"
}

3. JSR 303 验证错误处理

对于 Spring @Valid 验证错误,框架通常会抛出 MethodArgumentNotValidException。我们可以通过继承 ResponseEntityExceptionHandler 并重写相应方法来处理。

CustomGlobalExceptionHandler.java

package com.jsdiff.error;

import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.context.request.WebRequest;
import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler;

import javax.servlet.http.HttpServletResponse;
import javax.validation.ConstraintViolationException;
import java.io.IOException;
import java.util.Date;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;

@ControllerAdvice
public class CustomGlobalExceptionHandler extends ResponseEntityExceptionHandler {

    //...

    // 用于验证路径变量和请求参数 (@Validate)
    @ExceptionHandler(ConstraintViolationException.class)
    public void constraintViolationException(HttpServletResponse response) throws IOException {
        response.sendError(HttpStatus.BAD_REQUEST.value());
    }

    // 处理 @Valid 注解引发的错误
    @Override
    protected ResponseEntity<Object>
    handleMethodArgumentNotValid(MethodArgumentNotValidException ex,
                                 HttpHeaders headers,
                                 HttpStatus status, WebRequest request) {

        Map<String, Object> body = new LinkedHashMap<>();
        body.put("timestamp", new Date());
        body.put("status", status.value());

        // 获取所有字段错误信息
        List<String> errors = ex.getBindingResult()
                .getFieldErrors()
                .stream()
                .map(x -> x.getDefaultMessage())
                .collect(Collectors.toList());

        body.put("errors", errors);

        return new ResponseEntity<>(body, headers, status);
    }

}

4. ResponseEntityExceptionHandler 调试

如果您不确定 Spring Boot 会抛出何种异常,可以在 ResponseEntityExceptionHandler 的相关方法中放置断点进行调试。该类涵盖了多种常见的 Web 异常。

ResponseEntityExceptionHandler.java

package org.springframework.web.servlet.mvc.method.annotation;

//...
public abstract class ResponseEntityExceptionHandler {

    @ExceptionHandler({
            HttpRequestMethodNotSupportedException.class,
            HttpMediaTypeNotSupportedException.class,
            HttpMediaTypeNotAcceptableException.class,
            MissingPathVariableException.class,
            MissingServletRequestParameterException.class,
            ServletRequestBindingException.class,
            ConversionNotSupportedException.class,
            TypeMismatchException.class,
            HttpMessageNotReadableException.class,
            HttpMessageNotWritableException.class,
            MethodArgumentNotValidException.class,
            MissingServletRequestPartException.class,
            BindException.class,
            NoHandlerFoundException.class,
            AsyncRequestTimeoutException.class
        })
    @Nullable
    public final ResponseEntity<Object> handleException(Exception ex, WebRequest request) throws Exception {
        HttpHeaders headers = new HttpHeaders();

        if (ex instanceof HttpRequestMethodNotSupportedException) {
            HttpStatus status = HttpStatus.METHOD_NOT_ALLOWED;
            return handleHttpRequestMethodNotSupported((HttpRequestMethodNotSupportedException) ex, headers, status, request);
        }
        else if (ex instanceof HttpMediaTypeNotSupportedException) {
            HttpStatus status = HttpStatus.UNSUPPORTED_MEDIA_TYPE;
            return handleHttpMediaTypeNotSupported((HttpMediaTypeNotSupportedException) ex, headers, status, request);
        }
        //...
    }

    //...

}

5. 全局错误属性定制 (DefaultErrorAttributes)

若要覆盖所有异常的默认 JSON 错误响应结构(例如修改时间格式或添加额外字段),可以创建一个 Bean 并扩展 DefaultErrorAttributes

CustomErrorAttributes.java

package com.jsdiff.error;

import org.springframework.boot.web.servlet.error.DefaultErrorAttributes;
import org.springframework.stereotype.Component;
import org.springframework.web.context.request.WebRequest;

import java.text.DateFormat;
import java.text.SimpleDateFormat;
import java.util.Date;
import java.util.Map;

@Component
public class CustomErrorAttributes extends DefaultErrorAttributes {

    private static final DateFormat dateFormat = new SimpleDateFormat("yyyy/MM/dd HH:mm:ss");

    @Override
    public Map<String, Object> getErrorAttributes(WebRequest webRequest, boolean includeStackTrace) {

        // 先让 Spring 处理错误,随后我们进行修改
        Map<String, Object> errorAttributes = super.getErrorAttributes(webRequest, includeStackTrace);

        // 格式化并更新时间戳
        Object timestamp = errorAttributes.get("timestamp");
        if (timestamp == null) {
            errorAttributes.put("timestamp", dateFormat.format(new Date()));
        } else {
            errorAttributes.put("timestamp", dateFormat.format((Date) timestamp));
        }

        // 插入新字段
        errorAttributes.put("version", "1.2");

        return errorAttributes;
    }

}

配置完成后,日期时间将被格式化,并且 JSON 错误响应中会新增 version 字段。

curl localhost:8080/books/5
{
    "timestamp": "2019/02/27 13:34:24",
    "status": 404,
    "error": "Not Found",
    "message": "Book id not found : 5",
    "path": "/books/5",
    "version": "1.2"
}
curl localhost:8080/abc
{
    "timestamp": "2019/02/27 13:35:10",
    "status": 404,
    "error": "Not Found",
    "message": "No message available",
    "path": "/abc",
    "version": "1.2"
}

参考文献


说明: 本文示例基于 Spring Boot 2.1.2 版本编写。Spring Boot 后续版本(如 2.3+ 或 3.x)在错误处理机制、默认属性结构及自动配置类上可能有所调整,请根据实际使用的版本参考官方文档进行适配。