SpringBoot 规范接口开发流程

笔记内容以及代码是我阅读了知乎作者RudeCrab的《【项目实践】SpringBoot三招组合拳,手把手教你打出优雅的后端接口》这篇文章后学习和总结的

原文链接: https://zhuanlan.zhihu.com/p/340620501

阶段一:Validator + BindResult进行校验

1. 引入validation依赖

Validation Starter no longer included in web starters
As of #19550, Web and WebFlux starters do not* depend on the validation starter by default anymore. If your application is using validation features, for example you find that javax.validation. imports are not being resolved, you'll need to add the starter yourself. For Maven builds, you can do that with the following:


  org.springframework.boot
  spring-boot-starter-validation

2. User实体类用Validator添加校验规则

  • Validator就是springboot的validation中的一些用于限制属性的注解
/**
 * 用户实体类
 */
@Data
public class User {
    @NotNull(message = "用户id不能为空")
    private Long id;

    @NotNull(message = "用户账号不能为空")
    @Size(min = 6, max = 11, message = "账号长度必须是6-11个字符")
    private String account;

    @NotNull(message = "用户密码不能为空")
    @Size(min = 6, max = 16, message = "账号长度必须是6-16个字符")
    private String password;

    @NotNull(message = "用户邮箱不能为空")
    @Email(message = "邮箱格式不正确")
    private String email;
}

3. 给需要校验的参数加上@Valid注解

  • 在Controller中的入参中,有要校验的实体类就给该参数加上@Valid注解即可使实体类中用@NotNull等validator相关注解生效
  • 如果有参数校验失败,会将错误信息封装成对象组装在BindingResult里,即实体类中和validation相关的注解中的message属性会封装在这里
@RestController
@RequestMapping("user")
public class UserController {

    private UserService userService;

    @Autowired
    public void setUserService(UserService userService) {
        this.userService = userService;
    }

    @PostMapping("/addUser")
    public String addUser(@RequestBody @Valid User user, BindingResult bindingResult) {
        // 如果有参数校验失败,会将错误信息封装成对象组装在BindingResult里
        for (ObjectError error : bindingResult.getAllErrors()) {
            return error.getDefaultMessage();
        }

        return userService.addUser(user);
    }
}
  • 这样子的话校验规则就不用在service层去做了,将校验和业务逻辑处理分离,service只用单纯地处理业务,不需要担心字段的校验

UserService

@Service
public class UserServiceImpl implements UserService {
    @Override
    public String addUser(User user) {
        return "success";
    }
}

这种写法每次都要在controller层传入BindingResult,很不方便,接下来用自动抛出异常的方式去进一步优化

阶段二:Validator + 自动抛出异常

1. 把BindingResult去除

@PostMapping("/add-user")
public String addUser(@RequestBody @Valid User user) {
    return userService.addUser(user);
}

这时候后端已经引发了MethodArgumentNotValidException异常,并且前端收到的数据如下

{
    "timestamp": "2021-09-03T13:35:31.068+00:00",
    "status": 400,
    "error": "Bad Request",
    "trace": "...",
    "message": "Validation failed for object='user'. Error count: 3",
    "errors": [
        {
            "codes": [
                "Email.user.email",
                "Email.email",
                "Email.java.lang.String",
                "Email"
            ],
            "arguments": [
                {
                    "codes": [
                        "user.email",
                        "email"
                    ],
                    "arguments": null,
                    "defaultMessage": "email",
                    "code": "email"
                },
                [],
                {
                    "defaultMessage": ".*",
                    "codes": [
                        ".*"
                    ],
                    "arguments": null
                }
            ],
            "defaultMessage": "邮箱格式不正确",
            "objectName": "user",
            "field": "email",
            "rejectedValue": "975036719qq.com",
            "bindingFailure": false,
            "code": "Email"
        },
        {
            "codes": [
                "NotNull.user.password",
                "NotNull.password",
                "NotNull.java.lang.String",
                "NotNull"
            ],
            "arguments": [
                {
                    "codes": [
                        "user.password",
                        "password"
                    ],
                    "arguments": null,
                    "defaultMessage": "password",
                    "code": "password"
                }
            ],
            "defaultMessage": "用户密码不能为空",
            "objectName": "user",
            "field": "password",
            "rejectedValue": null,
            "bindingFailure": false,
            "code": "NotNull"
        },
        {
            "codes": [
                "NotNull.user.account",
                "NotNull.account",
                "NotNull.java.lang.String",
                "NotNull"
            ],
            "arguments": [
                {
                    "codes": [
                        "user.account",
                        "account"
                    ],
                    "arguments": null,
                    "defaultMessage": "account",
                    "code": "account"
                }
            ],
            "defaultMessage": "用户账号不能为空",
            "objectName": "user",
            "field": "account",
            "rejectedValue": null,
            "bindingFailure": false,
            "code": "NotNull"
        }
    ],
    "path": "/user/add-user"
}

后端直接将整个错误对象相关信息都响应给前端了,这是因为虽然引发了异常,但是我们没有去对其进行处理,所以走了SpringBoot默认的异常处理流程,现在开始进行全局异常处理

2. 全局异常处理

在类上加上@ControllerAdvice或@RestControllerAdvice注解,这个类就配置成全局处理类了

在类中新建方法,在方法上加上@ExceptionHandler注解并指定你想处理的异常类型,接着在方法内编写对该异常的操作逻辑,就完成了对该异常的全局处理

package com.plasticine.demo.config;

@RestControllerAdvice
public class ExceptionControllerAdvice {

    @ExceptionHandler(MethodArgumentNotValidException.class)
    public String MethodArgumentNotValidExceptionHandler(MethodArgumentNotValidException e) {
        // 从异常中拿到 ObjectError 对象
        ObjectError error = e.getBindingResult().getAllErrors().get(0);
        // 提取错误信息返回
        return error.getDefaultMessage();
    }
}

3. 自定义异常

自定义异常的好处

  1. 自定义异常可以携带更多的信息,不像这样只能携带一个字符串。
  2. 项目开发中经常是很多人负责不同的模块,使用自定义异常可以统一了对外异常展示的方式。
  3. 自定义异常语义更加清晰明了,一看就知道是项目中手动抛出的异常。
  • 自定义异常 -- APIException
package com.plasticine.demo.exception;

@Getter // 不需要 setter,需要抛出异常的时候都是直接 new 一个调用构造方法即可
public class APIException extends RuntimeException {
    private final int errcode;
    private final String errmsg;

    public APIException() {
        this(1001, "API Error");
    }

    public APIException(String errmsg) {
        this(1001, errmsg);
    }

    public APIException(int errcode, String errmsg) {
        this.errcode = errcode;
        this.errmsg = errmsg;
    }
}

还能在全局异常处理中处理Exception异常,这样无论遇到什么Exception都能够统一返回给前端,不过这种一般建议是在项目上线之前才这样做,开发的时候为了方便调试还是不太建议这样做

4. 数据统一响应

  • 前面的处理异常的结果都是返回的错误信息,而没有返回别的比如errcode
  • 为了更加统一,应当让异常和正常api数据请求的结果格式相同,都应该包括errcodeerrmsgdata,因此需要自定义一个统一的响应体
  1. 自定义统一响应体(VO,即View Object,是后端向模板引擎渲染数据时传输的对象,也可以是返回JSON数据的统一对象)

    package com.plasticine.demo.vo;
    
    @Getter
    public class ResultVO {
        private final int errcode;    // api 响应状态码
        private final String errmsg;  // api 请求信息
        private final T data;         // api 返回的数据
    
        public ResultVO(T data) {
            this(ResultCode.SUCCESS, data);
        }
    
        public ResultVO(int errcode, String errmsg, T data) {
            this.errcode = errcode;
            this.errmsg = errmsg;
            this.data = data;
        }
    
        public ResultVO(ResultCode resultCode, T data) {
            this.errcode = resultCode.getErrcode();
            this.errmsg = resultCode.getErrmsg();
            this.data = data;
        }
    }
    
  2. 修改全局异常处理的返回值类型为ResultVO

    package com.plasticine.demo.config;
    
    @RestControllerAdvice
    public class ExceptionControllerAdvice {
    
        @ExceptionHandler(MethodArgumentNotValidException.class)
        public ResultVO MethodArgumentNotValidExceptionHandler(MethodArgumentNotValidException e) {
            // 从异常中拿到 ObjectError 对象
            ObjectError error = e.getBindingResult().getAllErrors().get(0);
            // 提取错误信息返回
            return new ResultVO(1001, "参数校验失败", error.getDefaultMessage());
        }
    
        /**
         * 统一全局处理自定义异常 APIException
         *
         * @param e APIException
         * @return 处理结果
         */
        @ExceptionHandler(APIException.class)
        public ResultVO APIExceptionHandler(APIException e) {
            return new ResultVO<>(e.getErrcode(), "api请求失败", e.getErrmsg());
        }
    }
    

    现在遇到异常时返回的就会是JSON数据格式的字符串了

    {
        "errcode": 1001,
        "errmsg": "参数校验失败",
        "data": "邮箱格式不正确"
    }
    
  3. 修改Controller和Service返回值类型

    • service

      public interface UserService {
      
          /**
           * 添加 user
           *
           * @param user User 实体类对象
           * @return 成功返回 success,失败返回错误信息
           */
          ResultVO addUser(User user);
      }
      
      @Service
      public class UserServiceImpl implements UserService {
          @Override
          public ResultVO addUser(User user) {
              return new ResultVO(user);
          }
      }
      
    • controller

      @RestController
      @RequestMapping("user")
      public class UserController {
      
          private UserService userService;
      
          @Autowired
          public void setUserService(UserService userService) {
              this.userService = userService;
          }
      
          @PostMapping("/add-user")
          public ResultVO addUser(@RequestBody @Valid User user) {
              return userService.addUser(user);
          }
      }
      

    现在请求成功的api返回的数据也可以是自定义的了

    • 请求体

      {
          "id": 666,
          "account": "plasticine",
          "password": "abc123",
          "email": "[email protected]"
      }
      
    • 响应体

      {
          "errcode": 1000,
          "errmsg": "success",
          "data": {
              "id": 666,
              "account": "plasticine",
              "password": "abc123",
              "email": "[email protected]"
          }
      }
      
  4. 响应码枚举

    响应码都是纯数字,不好理解errcode的意思,使用枚举可以解决这一问题

    /**
     * errcode 枚举
     */
    @Getter
    public enum ResultCode {
    
        SUCCESS(1000, "操作成功"),
        FAILED(1001, "操作失败"),
        VALIDATE_FAILED(1002, "参数校验失败"),
        ERROR(5000, "未知错误");
    
        private int errcode;
        private String errmsg;
    
        ResultCode(int errcode, String errmsg) {
            this.errcode = errcode;
            this.errmsg = errmsg;
        }
    }
    

    修改全局异常处理的返回值

    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ResultVO MethodArgumentNotValidExceptionHandler(MethodArgumentNotValidException e) {
        // 从异常中拿到 ObjectError 对象
        ObjectError error = e.getBindingResult().getAllErrors().get(0);
        // 提取错误信息返回
        return new ResultVO<>(ResultCode.VALIDATE_FAILED, error.getDefaultMessage());
    }
    
    @ExceptionHandler(APIException.class)
    public ResultVO APIExceptionHandler(APIException e) {
        return new ResultVO<>(ResultCode.FAILED, e.getErrmsg());
    }
    

5. 全局处理响应数据

  1. 先创建一个类加上注解使其成为全局处理类
  2. 继承ResponseBodyAdvice接口重写其中的方法
package com.plasticine.demo.config;

@RestControllerAdvice(basePackages = {"com.plasticine.demo.controller"})
public class ResponseControllerAdvice implements ResponseBodyAdvice {

    @Override
    public boolean supports(MethodParameter returnType, Class> converterType) {
        /*
            返回 true 代表要走 beforeBodyWrite 方法去对返回结果进行额外处理
            反之,false 代表不走 beforeBodyWrite 方法,直接返回 returnType
            如果返回的类型是 ResultVO 则不需要进行额外操作,直接返回即可
         */
        return !returnType.getParameterType().equals(ResultVO.class);
    }

    @Override
    public Object beforeBodyWrite(Object body, MethodParameter returnType, MediaType selectedContentType, Class> selectedConverterType, ServerHttpRequest request, ServerHttpResponse response) {

        // String 不能直接由 ResultVO 封装,要经过一些处理先
        if (returnType.getGenericParameterType().equals(String.class)) {
            ObjectMapper objectMapper = new ObjectMapper();
            try {
                return objectMapper.writeValueAsString(new ResultVO<>(body));
            } catch (JsonProcessingException e) {
                throw new APIException("返回 String 类型时封装出错");
            }
        }

        // 将原本的数据经 ResultVO 统一响应体封装后再返回
        return new ResultVO<>(body);
    }
}
 
 

写一个新的api,返回的类型不再是ResultVo,而是直接返回实体类对象,这样就会走beforeBodyWrite方法去包装,然后真正返回给前端的还是ResultVO,这样做的目的就是可以省去我们自己手动封装数据到ResultVO的过程

  • 对比返回ResultVO和直接返回User实体类对象

    /**
     * 返回的类型是 ResultVO 类型,不会经过 ResponseControllerAdvice 的 beforeBodyWrite 处理
     *
     * @param user 要添加的用户
     * @return 经过 ResultVO 封装的统一响应体
     */
    @PostMapping("/add-user")
    public ResultVO addUser(@RequestBody @Valid User user) {
        return userService.addUser(user);
    }
    
    /**
     * 返回的类型是 User 不是 ResultVO 类型,会经过 ResponseControllerAdvice 的 beforeBodyWrite 处理
     * 将 user 封装进 ResultVO 的 data 属性中返回
     *
     * @return user 封装到 ResultVO 后的统一响应体
     */
    @GetMapping("/get-user")
    public User getUser() {
        return new User(666L, "plasticine", "abc123", "[email protected]");
    }
    
  • 如果beforeBodyWrite()中不处理返回类型是String的情况会怎样呢?

    @Override
    public Object beforeBodyWrite(Object body, MethodParameter returnType, MediaType selectedContentType, Class> selectedConverterType, ServerHttpRequest request, ServerHttpResponse response) {
        return new ResultVO<>(body);
    }
    

    会遇到ClassCastException异常

    java.lang.ClassCastException: com.plasticine.demo.vo.ResultVO cannot be cast to java.lang.String
    

阶段三:自定义注解实现绕过数据统一响应

前面的方案中,正常数据返回的状态码都是统一的,封装在enum状态码枚举中,这没什么问题

可是在异常的处理中,字段校验错误的状态码统一都是1002,如果想给每个字段自定义状态码以及错误信息时,就做不到了,这时就可以在要校验的字段上加上我们自定义的注解,然后通过反射来获取注解从而对每个字段的校验错误实现自定义错误码的功能

1. 自定义注解

package com.plasticine.demo.annotation;

/**
 * 自定义参数校验错误码以及错误信息注解
 */
@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.FIELD})        // 作用在类的属性上
public @interface ExceptionCode {
    // 响应码 errcode
    int errcode() default 100000;
    // 响应信息 errmsg
    String errmsg() default "参数校验错误";
}

2. 给实体类中要自定义校验响应体的字段加上注解

package com.plasticine.demo.pojo;

/**
 * 用户实体类
 */
@Data
@AllArgsConstructor
@NoArgsConstructor
public class User {
    @NotNull(message = "用户id不能为空")
    private Long id;

    @NotNull(message = "用户账号不能为空")
    @Size(min = 6, max = 11, message = "账号长度必须是6-11个字符")
    @ExceptionCode(errcode = 100001, errmsg = "账号不符合校验规则")
    private String account;

    @NotNull(message = "用户密码不能为空")
    @Size(min = 6, max = 16, message = "账号长度必须是6-16个字符")
    @ExceptionCode(errcode = 100002, errmsg = "密码不符合校验规则")
    private String password;

    @NotNull(message = "用户邮箱不能为空")
    @Email(message = "邮箱格式不正确")
    @ExceptionCode(errcode = 100003, errmsg = "邮箱不符合校验规则")
    private String email;
}

3. 修改全局异常处理MethodArgumentNotValidException的代码

package com.plasticine.demo.config;

@RestControllerAdvice
public class ExceptionControllerAdvice {

    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ResultVO MethodArgumentNotValidExceptionHandler(MethodArgumentNotValidException e) throws NoSuchFieldException {
        // 从异常对象中拿到错误信息
        String defaultMessage = e.getBindingResult().getAllErrors().get(0).getDefaultMessage();
        // 获取参数的 Class 对象,从而获取到 Field 对象,便可以拿到自定义的注解内容了
        Class parameterType = e.getParameter().getParameterType();
        // 拿到错误字段名称,然后根据错误字段名获取到 Field 对象
        String fieldName = e.getBindingResult().getFieldError().getField();
        Field field = parameterType.getDeclaredField(fieldName);
        // 获取 Field 的自定义注解
        ExceptionCode annotation = field.getAnnotation(ExceptionCode.class);
        // 有注解则用注解中自定义的 errcode 和 errmsg 作为统一响应体返回
        if (annotation != null) {
            return new ResultVO<>(annotation.errcode(), annotation.errmsg(), defaultMessage);
        }

        // 没有使用自定义注解的字段则用枚举中的 VALIDATE_FAILED 进行统一返回
        return new ResultVO<>(ResultCode.VALIDATE_FAILED, defaultMessage);
    }
}

效果

上面实体类中,id字段没有加上自定义注解,所以会走统一的VALIDATE_FAILED封装的响应体,而其他字段都加上了自定义注解,所以遇到字段校验出错时就会用自定义注解中的errcode和errmsg去封装返回

  • id为空

    // 请求体
    {
        "account": "plasticine",
        "password": "abc123",
        "email": "[email protected]"
    }
    
    // 响应体
    {
        "errcode": 1002,
        "errmsg": "参数校验失败",
        "data": "用户id不能为空"
    }
    
  • account为空

    // 请求体
    {
        "id": 666,
        "password": "abc123",
        "email": "[email protected]"
    }
    
    // 响应体
    {
        "errcode": 100001,
        "errmsg": "账号不符合校验规则",
        "data": "用户账号不能为空"
    }
    

4. 绕过数据统一响应

上面返回的响应体都是经过ResultVO封装过的,如果不想用ResultVO封装,而是自定义返回数据的格式,也就是要绕过数据统一响应,那可以给要绕过统一响应的方法加上一个自定义注解,然后在统一处理的部分对有该注解的方法不进行封装处理

  • 自定义绕过统一响应注解

    package com.plasticine.demo.annotation;
    
    /**
     * 绕过统一数据响应
     */
    @Retention(RetentionPolicy.RUNTIME)
    @Target(ElementType.METHOD)
    public @interface BypassResultVO {
    }
    
  • 修改ResponseControllerAdvice

    package com.plasticine.demo.config;
    
    @RestControllerAdvice(basePackages = {"com.plasticine.demo.controller"})
    public class ResponseControllerAdvice implements ResponseBodyAdvice {
    
        @Override
        public boolean supports(MethodParameter returnType, Class> converterType) {
            /*
                返回 true 代表要走 beforeBodyWrite 方法去对返回结果进行额外处理
                反之,false 代表不走 beforeBodyWrite 方法,直接返回 returnType
                如果返回的类型是 ResultVO 则不需要进行额外操作,直接返回即可
             */
            if (returnType.getParameterType().equals(ResultVO.class)) return false;     // 返回类型已经是 ResultVO,不需要经过额外处理
            if (returnType.hasMethodAnnotation(BypassResultVO.class)) return false;     // 加上了 @BypassResultVO 注解,不需要经过额外处理
    
            return true;
        }
    }
     

    即在supports()中加上returnType.hasMethodAnnotation(BypassResultVO.class);即可,也就是说有BypassResultVO这个注解的方法就不走beforeBodyWrite()方法处理

  • 测试:给getUser()加上BypassResultVO

    @GetMapping("/get-user")
    @BypassResultVO
    public User getUser() {
        return new User(666L, "plasticine", "abc123", "[email protected]");
    }
    

    未加上@BypassResultVO注解之前的响应体:

    {
        "errcode": 1000,
        "errmsg": "操作成功",
        "data": {
            "id": 666,
            "account": "plasticine",
            "password": "abc123",
            "email": "[email protected]"
        }
    }
    

    加上注解后:

    {
        "id": 666,
        "account": "plasticine",
        "password": "abc123",
        "email": "[email protected]"
    }
    
  • 你可能感兴趣的:(SpringBoot 规范接口开发流程)