全局统一响应格式、参数校验、异常处理
问题引入
在前后端分离的开发模式下,我们一般会统一后端的响应格式,比如自定义 Response 结构,但每个开发者可能会封装各自的 Response 结构,造成不一致,因此我们需要将响应格式统一起来,定义一个统一的标准响应格式。
在以前的 JavaWeb 开发中,异常处理通常是通过 try-catch 语句块来实现的。这种方法在应用程序规模较小的情况下还可以,但是在大型应用中,可能存在大量的重复代码和不一致性(处理方式不一致、处理逻辑不一致、处理机制不一致)问题。此外,当抛出未处理的异常时,用户会看到系统生成的默认错误页面,这对于用户体验是非常差的。
因此,在 SpringBoot 项目中,我们通常需要将结果数据和异常信息都封装成某种特定的格式返回给前端,方便前端进行处理。
全局的统一响应
定义响应标准格式
一般至少包含以下三个字段:
- code:响应状态码
- message:响应消息
- data:响应数据
例如:
{
"code": "C0001",
"message": "该用户已存在",
"data": null
}
定义响应状态码
为了方便管理和调用响应状态码,我们可以定义一个名为 ResponseCode
的枚举类,用来统一管理响应状态码。
在这里我们只定义了两种响应状态码,分别是成功和失败,对应的状态码分别为 200 和 300。在实际项目中,我们可以根据实际业务场景定义更多的响应状态码。
/**
* @Description: 定义响应状态码
*/
@Getter
@AllArgsConstructor
public enum ResponseCode {
// 成功
SUCCESS(200, "操作成功"),
FAIL(300,"操作失败"),
// 登录
NEED_LOGIN(401, "需要登录后操作"),
USERNAME_EXIST(501, "用户名已存在");
private Integer code; //响应状态码
private String message; //响应的消息
}
定义统一响应对象
/**
* @Description: 定义统一的响应对象类
*/
@Data
@AllArgsConstructor
@NoArgsConstructor
public class ResultVO<T> implements Serializable {
private static final long serialVersionUID = -2548645345465031121L;
//响应状态码
private Integer code;
//响应的消息
private String message;
//响应返回数据
private T data;
/**
* 功能描述:定义统一返回数据
*/
public static <T> ResultVO<T> success(T data){
return new ResultVO<T>(
ResponseCode.SUCCESS.getCode(),
ResponseCode.SUCCESS.getMessage(),
data);;
}
/**
* 功能描述:定义统一返回数据并自定义一message消息
*/
public static <T> ResultVO<T> success(String message,T data){
return new ResultVO<T>(
ResponseCode.SUCCESS.getCode(),
message,
data);
}
/**
* 功能描述:抛出异常 返回错误信息
*/
public static <T> ResultVO<T> fail(String message) {
return new ResultVO<T>(
ResponseCode.FAIL.getCode(),
message,
null);
}
}
controller 层的使用
@ApiOperation(value="根据id获取学生信息", notes="getStudentInfo")
@GetMapping("/{studentId}")
public ResultVO getStudentInfo(@PathVariable long studentId){
//1、校验传来的参数是不是为空或者异常
return ResultVO.success(studentService.selectById(studentId));
}
最后返回就会是上面带了状态码的数据了。
统一校验
假设有一个添加商品信息的接口,在没有进行统一校验时,我们在 controller 层需要这么做:
@Data
public class ProductInfoVo {
// 商品名称
private String productName;
// 商品价格
private BigDecimal productPrice;
// 上架状态
private Integer productStatus;
}
@PostMapping("/findByVo")
public ProductInfo findByVo(ProductInfoVo vo) {
if (StringUtils.isNotBlank(vo.getProductName())) {
throw new APIException("商品名称不能为空");
}
if (null != vo.getProductPrice() && vo.getProductPrice().compareTo(new BigDecimal(0)) < 0) {
throw new APIException("商品价格不能为负数");
}
...
ProductInfo productInfo = new ProductInfo();
BeanUtils.copyProperties(vo, productInfo);
return new ResultVo(productInfoService.getOne(new QueryWrapper(productInfo)));
}
这繁琐的 if 语句写得人都麻了啊,简直不能忍。
好在有 @Validated
,这是一套帮助我们继续对传输的参数进行数据校验的注解,通过配置 @Validated
可以很轻松的完成对数据的约束。
@Validated 简述
@Validated
是 Spring 框架中的一个注解,用于在方法参数、方法返回值、方法类上开启参数校验功能。它基于 Bean Validation(JSR 380)规范,可以方便地对请求参数进行校验并处理校验结果。
使用 @Validated
注解时,需要配合其他校验注解一起使用,如 @NotNull
、@Size
、@Pattern
等。这些注解用于指定参数的验证规则,例如非空检查、长度检查、正则表达式匹配等。
@Validated
源码:
@Target({ElementType.TYPE, ElementType.METHOD, ElementType.PARAMETER})
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface Validated {
Class<?>[] value() default {};
}
返回的响应码推荐使用 400 bad request
常用验证规则
在 VO 类的属性上,我们可以标注上我们需要的验证规则。
使用方法
@Data
public class ProductInfoVo {
@NotNull(message = "商品名称不允许为空")
private String productName;
@Min(value = 0, message = "商品价格不允许为负数")
private BigDecimal productPrice;
private Integer productStatus;
}
@PostMapping("/findByVo")
public ProductInfo findByVo(@Validated ProductInfoVo vo) {
ProductInfo productInfo = new ProductInfo();
BeanUtils.copyProperties(vo, productInfo);
return new ResultVo(productInfoService.getOne(new QueryWrapper(productInfo)));
}
在上述示例中,@Validated
注解用于标注在方法参数 ProductInfoVo
上,表示开启对 ProductInfoVo
对象的参数校验。根据 ProductInfoVo
类中的字段上的其他校验注解,比如 @NotNull
、@Size
,将对 ProductInfoVo
进行相应的校验。
如果校验失败,将会抛出 BindException
异常,并根据具体的校验规则返回相应的错误信息。
需要注意的是,@Validated
注解只对被标注的对象进行校验,而不会对对象内部嵌套的字段进行递归校验。如果需要递归校验,可以在嵌套的对象上同样添加 @Validated
注解。
如果我们故意传一个价格为 -1
的参数过去,得到的返回结果会是:
Details
{
"timestamp": "2020-04-19T03:06:37.268+0000",
"status": 400,
"error": "Bad Request",
"errors": [
{
"codes": [
"Min.productInfoVo.productPrice",
"Min.productPrice",
"Min.java.math.BigDecimal",
"Min"
],
"arguments": [
{
"codes": [
"productInfoVo.productPrice",
"productPrice"
],
"defaultMessage": "productPrice",
"code": "productPrice"
},
0
],
"defaultMessage": "商品价格不允许为负数",
"objectName": "productInfoVo",
"field": "productPrice",
"rejectedValue": -1,
"bindingFailure": false,
"code": "Min"
}
],
"message": "Validation failed for object\u003d\u0027productInfoVo\u0027. Error count: 1",
"trace": "org.springframework.validation.BindException: org.springframework.validation.BeanPropertyBindingResult: 1 errors\nField error in object \u0027productInfoVo\u0027 on field \u0027productPrice\u0027: rejected value [-1]; codes [Min.productInfoVo.productPrice,Min.productPrice,Min.java.math.BigDecimal,Min]; arguments [org.springframework.context.support.DefaultMessageSourceResolvable: codes [productInfoVo.productPrice,productPrice]; arguments []; default message [productPrice],0]; default message [商品价格不允许为负数]\n\tat org.springframework.web.method.annotation.ModelAttributeMethodProcessor.resolveArgument(ModelAttributeMethodProcessor.java:164)\n\tat org.springframework.web.method.support.HandlerMethodArgumentResolverComposite.resolveArgument(HandlerMethodArgumentResolverComposite.java:121)\n\tat org.springframework.web.method.support.InvocableHandlerMethod.getMethodArgumentValues(InvocableHandlerMethod.java:167)\n\tat org.springframework.web.method.support.InvocableHandlerMethod.invokeForRequest(InvocableHandlerMethod.java:134)\n\tat org.springframework.web.servlet.mvc.method.annotation.ServletInvocableHandlerMethod.invokeAndHandle(ServletInvocableHandlerMethod.java:105)\n\tat org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerAdapter.invokeHandlerMethod(RequestMappingHandlerAdapter.java:879)\n\tat org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerAdapter.handleInternal(RequestMappingHandlerAdapter.java:793)\n\tat org.springframework.web.servlet.mvc.method.AbstractHandlerMethodAdapter.handle(AbstractHandlerMethodAdapter.java:87)\n\tat org.springframework.web.servlet.DispatcherServlet.doDispatch(DispatcherServlet.java:1040)\n\tat org.springframework.web.servlet.DispatcherServlet.doService(DispatcherServlet.java:943)\n\tat org.springframework.web.servlet.FrameworkServlet.processRequest(FrameworkServlet.java:1006)\n\tat org.springframework.web.servlet.FrameworkServlet.doPost(FrameworkServlet.java:909)\n\tat javax.servlet.http.HttpServlet.service(HttpServlet.java:660)\n\tat org.springframework.web.servlet.FrameworkServlet.service(FrameworkServlet.java:883)\n\tat javax.servlet.http.HttpServlet.service(HttpServlet.java:741)\n\tat org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:231)\n\tat org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:166)\n\tat org.apache.tomcat.websocket.server.WsFilter.doFilter(WsFilter.java:53)\n\tat org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:193)\n\tat org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:166)\n\tat com.alibaba.druid.support.http.WebStatFilter.doFilter(WebStatFilter.java:124)\n\tat org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:193)\n\tat org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:166)\n\tat org.springframework.web.filter.RequestContextFilter.doFilterInternal(RequestContextFilter.java:100)\n\tat org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:119)\n\tat org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:193)\n\tat org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:166)\n\tat org.springframework.web.filter.FormContentFilter.doFilterInternal(FormContentFilter.java:93)\n\tat org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:119)\n\tat org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:193)\n\tat org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:166)\n\tat org.springframework.web.filter.CharacterEncodingFilter.doFilterInternal(CharacterEncodingFilter.java:201)\n\tat org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:119)\n\tat org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:193)\n\tat org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:166)\n\tat org.apache.catalina.core.StandardWrapperValve.invoke(StandardWrapperValve.java:202)\n\tat org.apache.catalina.core.StandardContextValve.invoke(StandardContextValve.java:96)\n\tat org.apache.catalina.authenticator.AuthenticatorBase.invoke(AuthenticatorBase.java:541)\n\tat org.apache.catalina.core.StandardHostValve.invoke(StandardHostValve.java:139)\n\tat org.apache.catalina.valves.ErrorReportValve.invoke(ErrorReportValve.java:92)\n\tat org.apache.catalina.core.StandardEngineValve.invoke(StandardEngineValve.java:74)\n\tat org.apache.catalina.connector.CoyoteAdapter.service(CoyoteAdapter.java:343)\n\tat org.apache.coyote.http11.Http11Processor.service(Http11Processor.java:373)\n\tat org.apache.coyote.AbstractProcessorLight.process(AbstractProcessorLight.java:65)\n\tat org.apache.coyote.AbstractProtocol$ConnectionHandler.process(AbstractProtocol.java:868)\n\tat org.apache.tomcat.util.net.NioEndpoint$SocketProcessor.doRun(NioEndpoint.java:1594)\n\tat org.apache.tomcat.util.net.SocketProcessorBase.run(SocketProcessorBase.java:49)\n\tat java.base/java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1128)\n\tat java.base/java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:628)\n\tat org.apache.tomcat.util.threads.TaskThread$WrappingRunnable.run(TaskThread.java:61)\n\tat java.base/java.lang.Thread.run(Thread.java:830)\n",
"path": "/leilema/product/product-info/findByVo"
}
注意:当 @Validated
注解标注在类上时,它的作用是对该类中的方法参数进行校验。它会扫描所有被 @RequestMapping
、@GetMapping
、@PostMapping
等请求处理方法标注的方法,并对这些方法的参数执行校验。
统一的异常处理
在构建大型系统时,通常建议使用自定义异常来替代 RuntimeException。自定义异常可以提供更精细和具有针对性的错误信息,有助于区分系统中的不同类型的错误。使用自定义异常不仅可以提高代码的可读性,因为它们的名称和内容可以直接反映出问题的性质,而且还可以包含更多的信息,比如错误码或其他相关的上下文数据。
为了更有效地处理错误,我们创建三种自定义异常类:ClientException(客户端异常)、BusinessException(业务逻辑异常)和 RemoteException(第三方服务异常)。
这些异常类都继承自 AbstractException,这是一个抽象的基类。
自定义抽象异常的基类
AbstractException
基类包含错误码和错误信息,同时它继承自 RuntimeException
,这意味着它是一个非受检异常(编译器可以不需要去强制去检查异常,这种异常不需要去显示去捕获或者抛出)。
/**
* @Description: 自定义异常的抽象类
*/
@Getter
public abstract class AbstractException extends RuntimeException{
private final Integer code;
private final String message;
public AbstractException(ResponseCode responseCode, String message, Throwable throwable){
super(message,throwable);
this.code = responseCode.getCode();
this.message = Optional.ofNullable(message).orElse(responseCode.getMessage());
}
}
具体的业务处理异常
/**
* @Description: 定义具体的业务异常处理
*/
public class BusinessException extends AbstractException {
public BusinessException(ResponseCode responseCode, String message, Throwable throwable) {
super(responseCode, message, throwable);
}
public BusinessException(ResponseCode responseCode) {
this(responseCode, null, null);
}
public BusinessException(ResponseCode responseCode,String message) {
this(responseCode, message, null);
}
}
全局异常处理
在实际项目中,我们需要对异常情况进行处理,以保证系统的稳定性和可控性。但频繁地使用 try...catch
块可能会使代码变得混乱,因此,我们可以在对外暴露的 API 接口中添加全局异常处理机制,以统一处理系统抛出的异常情况,并防止系统挂掉。
在 SpringBoot 中,我们可以通过使用 @RestControllerAdvice
和 @ExceptionHandler
注解来创建全局异常处理类并且可以定义处理各种类型异常的方法。
这里我们创建一个GlobalExceptionHandler
类,并使用 @RestControllerAdvice
注解。我们主要处理三类异常:
- MethodArgumentNotValidException:处理参数验证异常,并提供清晰的错误信息
- AbstractException:处理之前定义的自定义异常
- Throwable:作为最后的兜底,拦截所有其他异常
/**
* @Description: 全局异常处理类
*/
@RestControllerAdvice
@Slf4j
//@ControllerAdvice
public class GlobalExceptionHandler {
//处理参数验证异常:MethodArgumentNotValidException
@SneakyThrows
@ExceptionHandler(value = MethodArgumentNotValidException.class)
public ResultVO handleValidException(HttpServletRequest request, MethodArgumentNotValidException ex) {
BindingResult bindingResult = ex.getBindingResult();
FieldError firstFieldError = CollectionUtil.getFirst(bindingResult.getFieldErrors());
String exceptionStr = Optional.ofNullable(firstFieldError)
.map(FieldError::getDefaultMessage)
.orElse(StrUtil.EMPTY);
log.error("[{}] {} [ex] {}", request.getMethod(),"URL:", exceptionStr);
return ResultVO.fail(exceptionStr);
}
// 处理自定义异常:AbstractException
@ExceptionHandler(value = {AbstractException.class})
public ResultVO handleAbstractException(HttpServletRequest request, AbstractException ex) {
String requestURL = "URL地址";
log.error("[{}] {} [ex] {}", request.getMethod(), requestURL, ex.toString());
return ResultVO.fail(ex.toString());
}
// 兜底处理:Throwable
@ExceptionHandler(value = Throwable.class)
public ResultVO handleThrowable(HttpServletRequest request, Throwable throwable) {
//String requestURL = getUrl(request);
log.error("[{}] {} ", request.getMethod(), "URL地址", throwable);
return ResultVO.fail(ResponseCode.FAIL.getMessage());
}
}
在上面的例子中,我们使用了 @RestControllerAdvice
注解来定义一个全局的异常处理类,使用 @ExceptionHandler(MethodArgumentNotValidException.class)
和 @ExceptionHandler(AbstractException.class)
注解来分别处理参数验证异常和自定义异常。
当参数验证发生异常时,会被 handleValidException
方法所捕获,输出异常日志,并返回一个参数验证异常的结果。
而当自定义异常发生时,会被 handleAbstractException
方法所捕获,返回该异常对应的状态码、响应消息和响应数据。
在启用全局异常处理功能后,不再需要在接口层手动使用 try...catch
来处理异常。倘若出现其他异常,它们也会被 handleThrowable 拦截,从而确保一致地实施统一的返回格式。
使用结果封装类和模拟异常拦截
经优化后,接口层代码变得更为简洁:
@PostMapping("/api/customer/register")
public Result<UserRegistrationDTO> register(@RequestBody @Valid UserRegistrationDTO customerDTO){
return ResultVO.success(customerService.register(customerDTO));
}
@PostMapping("/api/customer/login")
public Result<UserLoginRespDTO> login(@RequestBody Map<String, String> parameters){
UserLoginDTO loginDTO = LoginDTOFactory.getLoginDTO(parameters);
return ResultVO.success(customerService.login(loginDTO));
}
- 使用
ResultVO<User>
来返回数据给前端 throw new BizException(ResponseCode.FAIL);
抛出异常后会在异常拦截器那里拦截统一进行处理
自动包装类
理论上我们已经实现了想要的统一后端响应格式了,但是有没有发现这里存在着一个缺点:每写一个接口都要调用 ResultVO 来包装返回结果,那是相当的繁琐,并且对代码的侵入性较强,有没有什么更加优雅的方式呢?
答案是肯定的。
在 SpringBoot 中,我们可以利用 ResponseBodyAdvice
来自动包装响应体。
ResponseBodyAdvice 可以拦截控制器(Controller)方法的返回值,允许我们统一处理返回值或响应体。这对于 统一返回格式、加密、签名 等场景非常有用。
ResponseBodyAdvice
源码分析:
public interface ResponseBodyAdvice<T> {
boolean supports(MethodParameter returnType, Class<? extends HttpMessageConverter<?>> converterType);
@Nullable
T beforeBodyWrite(@Nullable T body, MethodParameter returnType, MediaType selectedContentType, Class<? extends HttpMessageConverter<?>> selectedConverterType, ServerHttpRequest request, ServerHttpResponse response);
}
- supports() 用来判断是否支持 advice 功能,返回 true 表示支持,返回 false 则表示不支持
- beforeBodyWrite() 是对返回的数据真正地进行处理
我们要利用此类来增强我们的 ResponseBody,只需要编写一个上述接口的实现类就好了。
但在这里,我们可以先定义一个注解,只有标注了这个注解的类或方法,才会对返回结果进行统一的格式处理。
自定义 ResponseResultVO 注解
/**
* @Description:ResponseResult的注解类
* 只有标注了这个注解的类或方法,才会对返回结果/响应统一格式。
*/
@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.TYPE, ElementType.METHOD})
@Documented
@interface ResponseResultVO {
}
定义自动包装响应体的实现类
@RestControllerAdvice
本身可以配置一些生效范围,但是是在控制器的层面,无法根据方法上的注解来选择性地生效,所以我们需要在 supports() 方法里面对方法上的注解进行一个识别。
/**
* @Description:全局响应数据预处理器,使用RestControllerAdvice和ResponseBodyAdvice
* 拦截Controller方法默认返回参数,统一处理响应体
*/
@RestControllerAdvice
public class RestResponseHandler implements ResponseBodyAdvice<Object> {
@Autowired
private ObjectMapper objectMapper;
/**
* 判断是否需要执行beforeBodyWrite方法,true为执行;false为不执行
* @param returnType
* @param converterType
* @return
*/
@Override
public boolean supports(MethodParameter returnType, Class<? extends HttpMessageConverter<?>> converterType) {
// 判断返回结果是否已经是ResultVO类型的对象,比如全局异常处理之后直接返回了
// 判断该方法是否有ResponseResultVO注解标记
return !(returnType.getParameterType().isAssignableFrom(ResultVO.class))
&& returnType.hasMethodAnnotation(ResponseResultVO.class);
}
/**
* 对返回值包装处理
* @param body
* @param returnType
* @param selectedContentType
* @param selectedConverterType
* @param request
* @param response
* @return
*/
@SneakyThrows
@Override
public Object beforeBodyWrite(Object body, MethodParameter returnType, MediaType selectedContentType,
Class<? extends HttpMessageConverter<?>> selectedConverterType,
ServerHttpRequest request, ServerHttpResponse response) {
if (body instanceof String) { // 如果Controller直接返回String时,需要转换为Json,因为强化的是RestController
// 因为Controller直接返回String时,ContentType会是text/plain,这里统一改为text/json
response.getHeaders().setContentType(MediaType.APPLICATION_JSON);
return objectMapper.writeValueAsString(ResultVO.success(body));
}
return ResultVO.success(body);
}
}
注意:
@RestControllerAdvice
注解,是对@RestController
注解的增强,如果要对@Controller
注解增强,那就改为@ControllerAdvice
注解即可。@RestControllerAdvice
注解主要实现以下三个方面的功能- 全局异常处理
- 全局数据预处理
- 全局数据绑定
测试使用
@RestController
@Api(tags="全局统一异常测试")
public class ExceptionController {
@ApiOperation(value="测试", notes="test01")
@PostMapping("/test01")
@ResponseResultVO
public String test01() {
int i = 10 /1;
return "Ok";
}
}
- 加注解
@ResponseResultVO
- 不加注解
@ResponseResultVO