SpringBoot统一返回格式及参数校验

 

SpringBoot统一返回格式及参数校验

说明:以下内容摘抄自以下博文:

https://www.cnblogs.com/jianzh5/p/15018838.html

https://www.cnblogs.com/jianzh5/p/15131121.html

 

一、SpringBoot统一返回格式

一个标准的返回格式至少包含3部分:当然也可以按需加入其他扩展值,比如我们就在返回对象中添加了接口调用时间

  1. status 状态值:由后端统一定义各种返回结果的状态码
  2. message 描述:本次接口调用的结果描述
  3. data 数据:本次返回的数据。
  4. timestamp: 接口调用时间
{
  "status":"100",
  "message":"操作成功",
  "data":"hello,javadaily"
}

1. 定义返回对象

package com.linwei.jsr.demo.base;

import com.linwei.jsr.demo.enums.ReturnCodeEnum;
import lombok.Data;

/**
 * @author: Linwei
 * @date 2021/8/12
 * @Description:
 */
@Data
public class ResultData<T> {
    private int status;
    private String message;
    private T data;
    private long timestamp ;


    public ResultData (){
        this.timestamp = System.currentTimeMillis();
    }


    public static <T> ResultData<T> success(T data) {
        ResultData<T> resultData = new ResultData<>();
        resultData.setStatus(ReturnCodeEnum.RC100.getCode());
        resultData.setMessage(ReturnCodeEnum.RC100.getMessage());
        resultData.setData(data);
        return resultData;
    }

    public static <T> ResultData<T> fail(int code, String message) {
        ResultData<T> resultData = new ResultData<T>();
        resultData.setStatus(code);
        resultData.setMessage(message);
        return resultData;
    }

}

2. 定义状态码枚举类

package com.linwei.jsr.demo.enums;

/**
 * @author: Linwei
 * @date 2021/8/12
 * @Description:
 */
public enum ReturnCodeEnum {
    /**操作成功**/
    RC100(100,"操作成功"),
    /**操作失败**/
    RC999(999,"操作失败"),
    /**服务限流**/
    RC200(200,"服务开启限流保护,请稍后再试!"),
    /**服务降级**/
    RC201(201,"服务开启降级保护,请稍后再试!"),
    /**热点参数限流**/
    RC202(202,"热点参数限流,请稍后再试!"),
    /**系统规则不满足**/
    RC203(203,"系统规则不满足要求,请稍后再试!"),
    /**授权规则不通过**/
    RC204(204,"授权规则不通过,请稍后再试!"),
    /**access_denied**/
    RC403(403,"无访问权限,请联系管理员授予权限"),
    /**access_denied**/
    RC401(401,"匿名用户访问无权限资源时的异常"),
    /**服务异常**/
    RC500(500,"系统异常,请稍后重试"),

    INVALID_TOKEN(2001,"访问令牌不合法"),
    ACCESS_DENIED(2003,"没有权限访问该资源"),
    CLIENT_AUTHENTICATION_FAILED(1001,"客户端认证失败"),
    USERNAME_OR_PASSWORD_ERROR(1002,"用户名或密码错误"),
    UNSUPPORTED_GRANT_TYPE(1003, "不支持的认证模式");



    /**自定义状态码**/
    private final int code;
    /**自定义描述**/
    private final String message;

    ReturnCodeEnum(int code, String message){
        this.code = code;
        this.message = message;
    }


    public int getCode() {
        return code;
    }

    public String getMessage() {
        return message;
    }
}

 

3. 统一返回格式验证

    @ApiOperation("正常普通请求")
    @GetMapping("/hello")
    public ResultData<String> getInfo(){
        return ResultData.success("hello,java");
    }

此时调用接口获取到的返回值是这样:

{
  "status": 100,
  "message": "操作成功",
  "data": "hello,java",
  "timestamp": 1628764577677
}

这样确实已经实现了我们想要的结果,我在很多项目中看到的都是这种写法,在Controller层通过ResultData.success()对返回结果进行包装后返回给前端。

看到这里我们不妨停下来想想,这样做有什么弊端呢?

最大的弊端就是我们后面每写一个接口都需要调用ResultData.success()这行代码对结果进行包装,重复劳动,浪费体力;而且还很容易被其他老鸟给嘲笑。

 所以呢我们需要对代码进行优化,目标就是不要每个接口都手工制定ResultData返回值。

要优化这段代码很简单,我们只需要借助SpringBoot提供的ResponseBodyAdvice即可。我们只需要编写一个具体实现类即可。

package com.linwei.jsr.demo.base;

import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.SneakyThrows;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.core.MethodParameter;
import org.springframework.http.MediaType;
import org.springframework.http.converter.HttpMessageConverter;
import org.springframework.http.server.ServerHttpRequest;
import org.springframework.http.server.ServerHttpResponse;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.servlet.mvc.method.annotation.ResponseBodyAdvice;

/**
 * @author: Linwei
 * @date 2021/8/12
 * @Description:
 * 避免每个接口都手工制定ResultData返回值
 * 借助SpringBoot提供的ResponseBodyAdvice, controller直接返回数据对象即可,advice自动封装成统一返回对象;
 *  --增加该类后,Knife4j访问报错,通过basePackages可解决,如下:
 *  * swagger相当于是寄宿在应用程序中的一个web服务,统一响应处理器拦截了应用所有的响应,对swagger-ui的响应产生了影响。
 *  * 解决集成Swagger出现404问题,配置统一响应处理器拦截的范围,只拦截本项目的Controller类
 */

@RestControllerAdvice(basePackages = "com.linwei.jsr.demo.controller")
public class ResponseAdvice implements ResponseBodyAdvice<Object> {
    @Autowired
    private ObjectMapper objectMapper;

    // 启用 advice功能 ; 默认false
    @Override
    public boolean supports(MethodParameter methodParameter, Class<? extends HttpMessageConverter<?>> aClass) {
        return true;
    }

    @SneakyThrows
    @Override
    public Object beforeBodyWrite(Object o, MethodParameter methodParameter, MediaType mediaType, Class<? extends HttpMessageConverter<?>> aClass, ServerHttpRequest serverHttpRequest, ServerHttpResponse serverHttpResponse) {
        if(o instanceof String){
            return objectMapper.writeValueAsString(ResultData.success(o));
        }
        if(o instanceof ResultData){
            return o;
        }
        return ResultData.success(o);
    }
}

@RestControllerAdvice@RestController注解的增强,可以实现三个方面的功能:

  1. 全局异常处理
  2. 全局数据绑定
  3. 全局数据预处理
if(o instanceof String){
     return objectMapper.writeValueAsString(ResultData.success(o));
}

这段代码一定要加,如果Controller直接返回String的话,SpringBoot是直接返回,故我们需要手动转换成json。

经过上面的处理我们就再也不需要通过ResultData.success()来进行转换了,直接返回原始数据格式,SpringBoot自动帮我们实现包装类的封装。

    @ApiOperation("正常普通请求")
    @GetMapping("/hello")
    public String getInfo(){
        return "hello,java";
    }
{
"status":100,
"message":"操作成功",
"data":"hello,java",
"timestamp":1628764866582
}

 

是不是感觉很完美,别急,还有个问题在等着你呢。

接口异常问题

此时有个问题,由于我们没对Controller的异常进行处理,当我们调用的方法一旦出现异常,就会出现问题,比如下面这个接口

    @ApiOperation("系统异常测试")
    @GetMapping("/wrong")
    public int error(){
        int i = 9/0;
        return i;
    }
{
  "timestamp": "2021-08-12T10:43:05.251+00:00",
  "status": 500,
  "error": "Internal Server Error",
  "path": "/result/wrong"
}

这显然不是我们想要的结果,没有按照我们统一的格式返回数据,前端看了会打人的。

别急,接下来我们进入第二个议题,如何优雅的处理全局异常。

这个时候,我们还是要用到@RestControllerAdvice 这个注解,上面提到,它也可用于全局异常处理器;

package com.linwei.jsr.demo.base;

import com.linwei.jsr.demo.enums.ReturnCodeEnum;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.validation.BindException;
import org.springframework.validation.ObjectError;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestControllerAdvice;

import javax.validation.ConstraintViolation;
import javax.validation.ConstraintViolationException;
import javax.validation.ValidationException;
import java.util.stream.Collectors;

/**
 * @author: Linwei
 * @date 2021/8/12
 * @Description:
 * 服务层全局响应异常处理器
 */

@Slf4j
@RestControllerAdvice
public class RestExceptionHandler {
    /**
     * 默认全局异常处理。
     * @param e the e
     * @return ResultData
     */
    @ExceptionHandler(Exception.class)
    @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
    public ResultData<String> exception(Exception e) {
        log.error("全局异常信息 ex={}", e.getMessage(), e);
        return ResultData.fail(ReturnCodeEnum.RC500.getCode(),e.getMessage());
    }


    @ExceptionHandler(value = {BindException.class, ValidationException.class, MethodArgumentNotValidException.class})
    public ResponseEntity<ResultData<String>> handleValidatedException(Exception e) {
        ResultData<String> resp = null;

        if (e instanceof MethodArgumentNotValidException) {
            // BeanValidation exception
            MethodArgumentNotValidException ex = (MethodArgumentNotValidException) e;
            resp = ResultData.fail(HttpStatus.BAD_REQUEST.value(),
                    ex.getBindingResult().getAllErrors().stream()
                            .map(ObjectError::getDefaultMessage)
                            .collect(Collectors.joining("; "))
            );
        } else if (e instanceof ConstraintViolationException) {
            // BeanValidation GET simple param
            ConstraintViolationException ex = (ConstraintViolationException) e;
            resp = ResultData.fail(HttpStatus.BAD_REQUEST.value(),
                    ex.getConstraintViolations().stream()
                            .map(ConstraintViolation::getMessage)
                            .collect(Collectors.joining("; "))
            );
        } else if (e instanceof BindException) {
            // BeanValidation GET object param
            BindException ex = (BindException) e;
            resp = ResultData.fail(HttpStatus.BAD_REQUEST.value(),
                    ex.getAllErrors().stream()
                            .map(ObjectError::getDefaultMessage)
                            .collect(Collectors.joining("; "))
            );
        }

        log.error("参数校验异常:{}",resp.getMessage());
        return new ResponseEntity<>(resp,HttpStatus.BAD_REQUEST);
    }
}

 

全局异常接入返回的标准格式

要让全局异常接入标准格式很简单,因为全局异常处理器已经帮我们封装好了标准格式,我们只需要直接返回给客户端即可。关键代码:

ResponseAdvice.java
    @SneakyThrows
    @Override
    public Object beforeBodyWrite(Object o, MethodParameter methodParameter, MediaType mediaType, Class<? extends HttpMessageConverter<?>> aClass, ServerHttpRequest serverHttpRequest, ServerHttpResponse serverHttpResponse) {
        if(o instanceof String){
            return objectMapper.writeValueAsString(ResultData.success(o));
        }
        if(o instanceof ResultData){
            return o;
        }
        return ResultData.success(o);
    }

这时候我们再调用上面的错误方法,返回的结果就符合我们的要求了。

package com.linwei.jsr.demo.controller;

import com.linwei.jsr.demo.base.ResultData;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

/**
 * @author: Linwei
 * @date 2021/8/12
 * @Description:
 */
@Api("统一返回结果测试接口")
@RequestMapping("/result")
@RestController
public class ResultTestController {

    @ApiOperation("正常普通请求")
    @GetMapping("/hello")
    public String getInfo(){
        return "hello,java";
    }

    @ApiOperation("系统异常测试")
    @GetMapping("/wrong")
    public int error(){
        int i = 9/0;
        return i;
    }

    @ApiOperation("自定义异常测试")
    @GetMapping("error1")
    public void empty(){
        throw  new RuntimeException("自定义异常");
    }
}
/result/wrong
{ "status": 500, "message": "/ by zero", "data": null, "timestamp": 1628765343273 }
/result/error1
{ "status": 500, "message": "自定义异常", "data": null, "timestamp": 1628765364205 }

好了,今天的文章就到这里了,希望通过这篇文章你能掌握如何在你项目中友好实现统一标准格式到返回并且可以优雅的处理全局异常。

 

二、SpringBoot中集成参数校验

在日常的接口开发中,为了防止非法参数对业务造成影响,经常需要对接口的参数做校验,例如登录的时候需要校验用户名密码是否为空,创建用户的时候需要校验邮件、手机号码格式是否准确。靠代码对接口参数一个个校验的话就太繁琐了,代码可读性极差。

Validator框架就是为了解决开发人员在开发的时候少写代码,提升开发效率;Validator专门用来进行接口参数校验,例如常见的必填校验,email格式校验,用户名必须位于6到12之间 等等

Validator校验框架遵循了JSR-303验证规范(参数校验规范), JSR是 Java Specification Requests的缩写。

1. 加依赖

<dependency>
  <groupid>org.springframework.boot</groupid>
  <artifactid>spring-boot-starter-web</artifactid>
</dependency>

<dependency>
  <groupid>org.springframework.boot</groupid>
  <artifactid>spring-boot-starter-validation</artifactid>
</dependency>

注:从 springboot-2.3开始,校验包被独立成了一个 starter组件,所以需要引入validation和web,而 springboot-2.3之前的版本只需要引入 web 依赖就可以了。

2. 定义一个用来测试的实体

package com.linwei.jsr.demo.entity;

import io.swagger.annotations.ApiModel;
import lombok.Data;
import org.hibernate.validator.constraints.Length;
import javax.validation.constraints.Email;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.NotEmpty;


/**
 * @author: Linwei
 * @date 2021/8/12
 * @Description:
 */
@Data
@ApiModel("测试实体")
public class TestEntityVO {
    private String id;

    @Length(min = 6,max = 12,message = "appId长度必须位于6到12之间")
    private String appId;

    @NotBlank(message = "名字为必填项")
    private String name;

    @Email(message = "请填写正确的邮箱地址")
    private String email;

    private String sex;

    @NotEmpty(message = "级别不能为空")
    private String level;
}

在实际开发中对于需要校验的字段都需要设置对应的业务提示,即message属性。

常见的约束注解如下:

注解功能
@AssertFalse 可以为null,如果不为null的话必须为false
@AssertTrue 可以为null,如果不为null的话必须为true
@DecimalMax 设置不能超过最大值
@DecimalMin 设置不能超过最小值
@Digits 设置必须是数字且数字整数的位数和小数的位数必须在指定范围内
@Future 日期必须在当前日期的未来
@Past 日期必须在当前日期的过去
@Max 最大不得超过此最大值
@Min 最大不得小于此最小值
@NotNull 不能为null,可以是空
@Null 必须为null
@Pattern 必须满足指定的正则表达式
@Size 集合、数组、map等的size()值必须在指定范围内
@Email 必须是email格式
@Length 长度必须在指定范围内
@NotBlank 字符串不能为null,字符串trim()后也不能等于“”
@NotEmpty 不能为null,集合、数组、map等size()不能为0;字符串trim()后可以等于“”
@Range 值必须在指定范围内
@URL 必须是一个URL

注:此表格只是简单的对注解功能的说明,并没有对每一个注解的属性进行说明;可详见源码。

 

3. 定义一个controller测试

package com.linwei.jsr.demo.controller;

import com.linwei.jsr.demo.entity.TestEntityVO;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import lombok.extern.slf4j.Slf4j;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import javax.validation.constraints.Email;

/**
 * @author: Linwei
 * @date 2021/8/12
 * @Description:
 */

@Slf4j
@Validated
@Api("JSR参数校验测试接口")
@RequestMapping("/valid")
@RestController
public class ValidTestController {
    @ApiOperation("RequestBody校验")
    @PostMapping("/test1")
    public String test1(@Validated @RequestBody TestEntityVO validVO){
        log.info("validEntity is {}", validVO);
        return "test1 valid success";
    }

    @ApiOperation("Form校验")
    @PostMapping(value = "/test2")
    public String test2(@Validated TestEntityVO validVO){
        log.info("validEntity is {}", validVO);
        return "test2 valid success";
    }

    @ApiOperation("单参数校验")
    @PostMapping(value = "/test3")
    public String test3(@Email String email){
        log.info("email is {}", email);
        return "email valid success";
    }
}

虽然我们之前定义了全局异常拦截器,也看到了拦截器确实生效了,但是 Validator校验框架返回的错误提示太臃肿了,不便于阅读,为了方便前端提示,我们需要将其简化一下。

直接修改之前定义的 RestExceptionHandler,单独拦截参数校验的三个异常:javax.validation.ConstraintViolationExceptionorg.springframework.validation.BindExceptionorg.springframework.web.bind.MethodArgumentNotValidException,代码如下:

@ExceptionHandler(value = {BindException.class, ValidationException.class, MethodArgumentNotValidException.class})
public ResponseEntity<resultdata<string>> handleValidatedException(Exception e) {
  ResultData<string> resp = null;

  if (e instanceof MethodArgumentNotValidException) {
    // BeanValidation exception
    MethodArgumentNotValidException ex = (MethodArgumentNotValidException) e;
    resp = ResultData.fail(HttpStatus.BAD_REQUEST.value(),
                           ex.getBindingResult().getAllErrors().stream()
                           .map(ObjectError::getDefaultMessage)
                           .collect(Collectors.joining("; "))
                          );
  } else if (e instanceof ConstraintViolationException) {
    // BeanValidation GET simple param
    ConstraintViolationException ex = (ConstraintViolationException) e;
    resp = ResultData.fail(HttpStatus.BAD_REQUEST.value(),
                           ex.getConstraintViolations().stream()
                           .map(ConstraintViolation::getMessage)
                           .collect(Collectors.joining("; "))
                          );
  } else if (e instanceof BindException) {
    // BeanValidation GET object param
    BindException ex = (BindException) e;
    resp = ResultData.fail(HttpStatus.BAD_REQUEST.value(),
                           ex.getAllErrors().stream()
                           .map(ObjectError::getDefaultMessage)
                           .collect(Collectors.joining("; "))
                          );
  }

  return new ResponseEntity<>(resp,HttpStatus.BAD_REQUEST);
}

4. 自定义参数校验

虽然Spring Validation 提供的注解基本上够用,但是面对复杂的定义,我们还是需要自己定义相关注解来实现自动校验。

比如上面实体类中的sex性别属性,只允许前端传递传 M,F 这2个枚举值,如何实现呢?

 第一步,创建自定义注解

package com.linwei.jsr.demo.vaild;

import javax.validation.Constraint;
import javax.validation.Payload;
import java.lang.annotation.Documented;
import java.lang.annotation.Repeatable;
import java.lang.annotation.Retention;
import java.lang.annotation.Target;

import static java.lang.annotation.ElementType.*;
import static java.lang.annotation.RetentionPolicy.RUNTIME;

/**
 * @author: Linwei
 * @date 2021/8/12
 * @Description:
 */
@Target({METHOD, FIELD, ANNOTATION_TYPE, CONSTRUCTOR, PARAMETER, TYPE_USE})
@Retention(RUNTIME)
@Repeatable(EnumString.List.class)
@Documented
@Constraint(validatedBy = EnumStringValidator.class)//标明由哪个类执行校验逻辑
public @interface EnumString {
    String message() default "value not in enum values.";

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

    Class<? extends Payload>[] payload() default {};

    /**
     * @return date must in this value array
     */
    String[] value();

    /**
     * Defines several {@link EnumString} annotations on the same element.
     *
     * @see EnumString
     */
    @Target({METHOD, FIELD, ANNOTATION_TYPE, CONSTRUCTOR, PARAMETER, TYPE_USE})
    @Retention(RUNTIME)
    @Documented
    @interface List {

        EnumString[] value();
    }
}

第二步,自定义校验逻辑

package com.linwei.jsr.demo.vaild;

import javax.validation.ConstraintValidator;
import javax.validation.ConstraintValidatorContext;
import java.util.Arrays;
import java.util.List;

/**
 * @author: Linwei
 * @date 2021/8/12
 * @Description:
 */
public class EnumStringValidator implements ConstraintValidator<EnumString, String> {
    private List<String> enumStringList;

    @Override
    public void initialize(EnumString constraintAnnotation) {
        enumStringList = Arrays.asList(constraintAnnotation.value());
    }

    @Override
    public boolean isValid(String value, ConstraintValidatorContext context) {
        if(value == null){
            return true;
        }
        return enumStringList.contains(value);
    }
}

第三步,在字段上增加注解

    @ApiModelProperty(value = "性别")
    @EnumString(value = {"F","M"}, message="性别只允许为F或M")
    private String sex;

第四步,体验效果

POST http://localhost:8080/valid/test2
Content-Type: application/x-www-form-urlencoded

id=1&name=javadaily&level=12&email=476938977@qq.com&appId=ab1cdddd&sex=N

 

{
  "status": 400,
  "message": "性别只允许为F或M",
  "data": null,
  "timestamp": 1628767471838
}

 

posted @ 2021-08-12 19:06  穷苦书生  阅读(3803)  评论(0编辑  收藏  举报