参数校验、通用返回和统一异常处理

摘要:Web 框架,全局异常、API 日志等。整合 Hibernate Validator 加强了参数验证,用 @Validated 等注解实现参数验证,极大简化了代码,验证更简洁方便。通用返回和统一异常处理。

目录

[TOC]

分为 config、core 两部分。

统一配置

  • configurePathMatch:设置 API 前缀,仅仅匹配 controller 包下的
    • 配置API 前缀,实现所有 Controller 提供的 RESTFul API 的统一前缀。
    • 意义:通过该前缀,避免 Swagger、Actuator 意外通过 Nginx 暴露出来给外部,带来安全性问题
    • 这样,Nginx 只需要配置转发到 /api/* 的所有接口即可。
  • GlobalExceptionHandler
  • GlobalResponseBodyHandler
  • 创建 CorsFilter Bean,解决跨域问题
  • 创建 RequestBodyCacheFilter Bean,可重复读取请求内容
  • 创建 RestTemplate 实例
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
package cn..framework.web.config;

@AutoConfiguration
@EnableConfigurationProperties(WebProperties.class)
public class YudaoWebAutoConfiguration implements WebMvcConfigurer {

    @Resource
    private WebProperties webProperties;

    @Value("${spring.application.name}")
    private String applicationName;

    @Override
    public void configurePathMatch(PathMatchConfigurer configurer) {
        configurePathMatch(configurer, webProperties.getAdminApi());
        configurePathMatch(configurer, webProperties.getAppApi());
    }

    /**
     * 设置 API 前缀,仅仅匹配 controller 包下的
     *
     * @param configurer 配置
     * @param api        API 配置
     */
    private void configurePathMatch(PathMatchConfigurer configurer, WebProperties.Api api) {
        AntPathMatcher antPathMatcher = new AntPathMatcher(".");
        configurer.addPathPrefix(api.getPrefix(), clazz -> clazz.isAnnotationPresent(RestController.class)
                && antPathMatcher.match(api.getController(), clazz.getPackage().getName())); // 仅仅匹配 controller 包
    }

    @Bean
    public GlobalExceptionHandler globalExceptionHandler(ApiErrorLogCommonApi apiErrorLogApi) {
        return new GlobalExceptionHandler(applicationName, apiErrorLogApi);
    }

    @Bean
    public GlobalResponseBodyHandler globalResponseBodyHandler() {
        return new GlobalResponseBodyHandler();
    }

    @Bean
    @SuppressWarnings("InstantiationOfUtilityClass")
    public WebFrameworkUtils webFrameworkUtils(WebProperties webProperties) {
        // 由于 WebFrameworkUtils 需要使用到 webProperties 属性,所以注册为一个 Bean
        return new WebFrameworkUtils(webProperties);
    }

    // ========== Filter 相关 ==========
}

参数校验

参考:Java 参数校验validation和validator区别

使用场景

对 RESTful API 接口(Controller 接收的参数)、Service 接口的形参赋值等进行统一参数校验,以保证最终数据入库的正确性。

  • 例如说,用户注册时,会校验手机格式的正确性,密码非弱密码。
  • 如果参数校验不通过,会抛出 ConstraintViolationException 异常,被全局的异常处理捕获,返回“请求参数不正确”的响应。

绝大多数情况下,选用 Hibernate Validator 注解。

方式有:

  1. 一般对于复杂的业务参数校验:可通过校验类单独的校验方法进行处理;

    • 常规做法:业务逻辑中要(手动)加数据校验后,再把数据写入数据库;校验失败抛出 IllegalArgumentException
    • 缺点:代码冗余臃肿复杂,每个人抛出的异常都不同。
  2. 通常对于与业务无关、简单的参数校验:可采用 javax.validationhibernate-validator 验证框架,通过注解的方式实现校验。

    1. Bean Validation 规范:是 Java 定义的一套基于注解的参数验证规范,属于 JSR(Java Specification Requests 规范)。

      • 只是一项标准,规定了一些校验注解的规范,但没有实现,如 @Null、@NotNull、@Pattern 等,
      • 位于 javax.validation.constraints 包下;
    2. hibernate validator 框架:是对这个规范的实现

      • 并增加了一些其他校验注解,如 @NotBlank、@NotEmpty、@Length 等,

      • 位于 org.hibernate.validator.constraints 包下。

      • 不要以为 Hibernate 仅仅是一个 ORM 框架,这只是它的 Hibernate ORM 所提供的。

1
2
3
4
5
6
7
8
9
10
11
 // 复杂业务参数校验,参数很多,入参用对象封装起来
 public void validate(UserDTO userDTO) {
     Long uid = userDTO.getUid();
     String username = userDTO.getName();
     if (uid == null || uid == 0L) {
         throw new IllegalArgumentException("uid 不能为空");
    }
     if (StringUtils.isBlank(username)) {
         throw new IllegalArgumentException("username 不能为空");
     }
 }
1
2
import javax.validation.constraints.NotNull;
import org.hibernate.validator.constraints.NotBlank;
1
2
3
4
5
{
  "code": 400,
  "data": null,
  "msg": "请求参数不正确:密码不能为空"
}

Spring Validation

在使用 Spring 的项目中,因为 Spring Validation 提供了对 Bean Validation 的内置封装支持(底层调用 Hibernate Validator),可以使用 @Validated 注解,实现声明式校验,而无需直接调用 Bean Validation 提供的 API 方法。

  • 在实现原理上,也是基于 Spring AOP 拦截,实现校验相关的操作。

友情提示:这一点,类似 Spring Transaction 事务,通过 @Transactional 注解,实现声明式事务

Hibernate 校验模式

  1. 普通模式(默认):校验所有属性,返回所有的验证失败信息;
  2. 快速失败返回模式:只要有一个校验失败就返回。
1
2
3
4
5
6
// 设置failFast方式: true 快速失败返回模式,false 普通模式
ValidatorFactory validatorFactory = Validation.byProvider( HibernateValidator.class )
    .configure()
    .failFast(true)
    .buildValidatorFactory();
Validator validator = validatorFactory.getValidator();

@Valid VS @Validated

作用:用于验证注解是否符合要求、统一参数校验。

  • 直接加在变量、参数之前,在变量中添加验证信息的要求(用@NotNull字段验证注解),当不符合要求时就会在方法中返回 message 中的错误提示信息。

用法:绝大多数场景下,使用 @Validated 注解即可。在有嵌套校验的场景,使用 @Valid 注解添加到成员属性上。

@Valid

  1. 所属包:属于 javax.validation 包下,是 Bean Validation 所定义的,由 JDK 提供;
  2. 可标注在:构造器方法返回、方法和方法参数、成员属性上;多了成员变量,这就导致,如果有嵌套对象时,只能使用 @Valid 注解。
  3. 不支持分组验证
  4. 级联校验/嵌套验证:用于标注嵌套对象(包含其他对象的数组、集合、map、对象的引用)或其内部(另一对象)的成员属性,进行属性验证;
    • 用于内部时,外部可配合@Valid@Validated 进行嵌套验证;
    • 在检查当前对象的同时也会检查该字段所引用的对象;
  5. 异常:如果验证失败,将抛出 MethodArgumentNotValidException

@Validated

@Valid 更强大;

  1. 所属包:属于 org.springframework.validation.annotation 包下,是 Spring Validation 所定义的,由 Spring 提供;
  2. 可标注在:、方法和方法参数上,不能用在成员属性上;
  3. 额外支持分组验证:在方法参数验证时,根据不同的分组采用不同的验证机制。
    1. 不同 Controller 对于同一 POJO 实体类的属性约束可能不一样。如,
      1. 当新增数据时,因为 id 在数据库里是自增字段,不支持手动赋值,需要 id 属性为空(@Null);
      2. 当更新数据时,需要 id 属性不为空(@NotNull),才能确定更新哪一条记录。
    2. 首先在实体类中定义组别接口进行区分,然后在注解中加上对应的组别接口,不加则是默认组别;如 @Validated({Item.add.class}) Item item
    3. 因为 @Valid 不支持分组,所以用 @Validated 标注 Controller 方法的形参;并在 @Validated 中指定分组,不在指定组中的其他校验不会生效,还需把其他属性的校验一并加入到组中;
  4. 嵌套验证:用于标注嵌套对象属性;内部需配合 @Valid 进行嵌套验证;
  5. Spring Validation @Validated 注解,实现声明式校验。

嵌套验证

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// 嵌套校验

// UserVO.java
public class UserVO {
    
    private String id;

    @Valid //如果不在此属性上,添加 @Valid 注解,就会导致 User.nickname 属性,不会进行校验。
    private UserProfile profile;

}

// User.java
public class User {

    @NotBlank
    private String nickname;

}

注解

字段验证注解:用于校验 Controller 方法中用 @Valid、@Validated 修饰的参数(实体类对象)。

从定义位置分为:

  • javax.validation 内置基础校验注解,由 JDK 提供。
  • hibernate validator 支持的注解,由 Spring 提供。

@NotNull、@NotEmpty、@NotBlank

@NotNull(message = "name不能为空"):元素不能为 null(必须有定义);不检查长度 size ;

  1. @Null:元素必须为 null;JDK 提供的。
  2. @NotEmpty:用于字符串、集合、Map、数组。不能为 null ** 或 长度size != 0(String、Collection、Map 的 isEmpty() 方法);如 usernameHibernate Validator 提供**的。
  3. @NotBlank:只用于 String,不仅不能为 null,而且至少包含一个非空白字符trim() 后 size>0)。Hibernate Validator 提供的。如手机号。
1
2
3
4
5
6
7
String name1 = null;
String name2 = "";
String name3 = " ";
// 		   	name1, 	name2, 	name3
@NotNull:  	false, 	true,	true
@NotEmpty: 	false, 	false, 	true
@NotBlank: 	false, 	false, 	false
  @NotNull @NotEmpty @NotBlank
定义 元素不能为 null;不检查长度 size 不能为 **null ** 或 长度size != 0 不仅不能为 null,而且至少包含一个非空白字符
实质 必须有定义 String、Collection、Map 的 isEmpty() 方法 trim() 后 size>0
用途   用于字符串、集合、Map、数组 只用于 String
场景 结果判空 username,手机验证码 手机号
提供 **JDK ** Hibernate Validator Hibernate Validator
String name1 = null; false false false
String name3 = ""; true false false
String name3 = " "; ` true true false

常用注解

@Size(min=4, max=15):元素大小必须在指定范围内;可是字符串、数组、集合、map 等;

  1. @DecimalMin(value)/@DecimalMax(value):必须是一个(BigDecimal 的字符串表示形式的) >= / <= 指定值的数字,可以是小数。
分类 注解 来源 功能
判空 @NotBlank Hibernate 只能用于字符串不为 null ,并且字符串 #trim() 以后 length 要大于 0
判空 @NotEmpty Hibernate 集合对象的元素不为 0 ,即集合不为空,也可以用于字符串不为 null
判空 @NotNull JDK 不能为 null
  @Pattern(value) Hibernate 被注释的元素必须符合指定的正则表达式
大小 @Max(value) JDK 必须是一个 Long 类型的数字,该字段的值只能 <= 该值
大小 @Min(value) JDK 该字段的值只能大于或等于该值
大小 @Range(min=, max=) Hibernate 检被注释的元素必须在合适的范围内
大小 @Size(max, min) JDK 检查该字段的 size 是否在 minmax 之间,可以是字符串、数组、集合、Map 等。
大小 @Length(max, min) Hibernate 被注释的字符串的大小必须在指定的范围内。
  @AssertFalse JDK 被注释的元素必须为 false
  @AssertTrue JDK 被注释的元素必须为 true
  @Email Hibernate 被注释的元素必须是电子邮箱地址
  @URL(protocol=,host=,
port=,regexp=,flags=)
Hibernate 被注释的字符串必须是一个有效的 URL
  @CreditCardNumber Hibernate 对信用卡进行一个大致的校验;
@Pattern
1
2
3
4
5
@Schema(description = "手机验证码", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024")
@NotEmpty(message = "手机验证码不能为空")
@Length(min = 4, max = 6, message = "手机验证码长度为 4-6 位")
@Pattern(regexp = "^[0-9]+$", message = "手机验证码必须都是数字")
private String code;

不常用注解

分类 注解 来源 功能
  @Null JDK 必须为 null
数字 @DecimalMax(value)   被注释的元素必须是一个数字,其值必须小于等于指定的最大值
数字 @DecimalMin(value)   被注释的元素必须是一个数字,其值必须大于等于指定的最小值
数字 @Digits(integer, fraction)   被注释的元素必须是一个数字,其值必须在可接受的范围内
数字 @Positive JDK 判断正数
数字 @PositiveOrZero JDK 判断正数或 0
数字 @Negative JDK 判断负数
数字 @NegativeOrZero JDK 判断负数或 0
日期 @Future JDK 被注释的元素必须是一个将来的日期
日期 @FutureOrPresent JDK 判断日期是否是将来或现在日期
日期 @Past JDK 检查该字段的日期是在过去
  @PastOrPresent JDK 判断日期是否是过去或现在日期
  @SafeHtml   判断提交的 HTML 是否安全。例如说,不能包含 JavaScript 脚本等等
@InEnum
1
2
3
@Schema(description = "配送方式", requiredMode = Schema.RequiredMode.REQUIRED, example = "1")
@InEnum(value = DeliveryTypeEnum.class, message = "配送方式不正确")
private Integer deliveryType;

@DateTimeFormat 时间传参

对于日期格式化,针对 Date 类型字段:

  1. 接收前端校验注解,@DateTimeFormat 接受前台的时间格式传到后台的格式;
  2. 后端返回给前端日期格式化,使用场景数据库存储的是 yyyy-MM-dd HH:mm:ss,但前端需要 yyyy-MM-dd 时可用 @JsonFormat
1
2
3
4
5
@DateTimeFormat(pattern="yyyy-MM-dd")
private Date born;

@JsonFormat(timezone = "GMT+8", pattern = "yyyy-MM-dd")
private Date born;
Query 时间传参

Query 时间传参,指的是 GET 请求、或者 POSTform-data 请求。比如,由前端的日期时间控件传的参数。

① 后端接收时间参数时,需要添加 SpringMVC 的 @DateTimeFormat 注解,并设置时间格式。例如说:

1
2
3
4
5
6
7
// JobLogPageReqVO.java
@DateTimeFormat(pattern = FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND)
private LocalDateTime beginTime;

// UserPageReqVO.java
@DateTimeFormat(pattern = FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND)
private LocalDateTime[] createTime;

② 前端传递时间参数时,需要时间格式yyyy-MM-dd HH:mm:ss,和上面的 FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND 对应。例如说前端 yudao-ui-admin-vue3 项目:

  • views/infra/job/logger/index.vuebeginTimeendTime 参数
Request Body 时间传参

Request Body 时间传参,指的是 PostPUT 等请求,通过 JSON 格式

① 后端接收时间参数时,需要添加 SpringMVC 的 @RequestBody 注解,使用 LocalDateTime 属性进行接收。

② 前端传递时间参数时,需要时间格式为 Long 时间戳。例如说:

  • views/system/tenant/TenantForm.vueexpireTime 参数

 注解

Response Body 时间响应

JSON 返回的时间,使用 LocalDateTime 定义属性,会被序列化为 Long 时间戳进行相应。

例如说 TenantRespVO 的 createTime 属性。

如何自定义 JSON 时间格式?
为什么使用 Long 时间戳呢?
  • 每个项目希望展示的时间格式可能不同,有希望 yyyy-MM-dd HH:mm:ss,也有希望 yyyy/MM/dd HH:mm:ss,又或者是其它。

  • Long 时间戳是比较标准的,没有任何“产品需求”的味道,所以使用它。 至于业务希望展示成什么样子,可以通过前端封装统一的 format 方法去实现,更加规范。

它是通过 LocalDateTime 自定义的 TimestampLocalDateTimeSerializer TimestampLocalDateTimeDeserializer 实现,之后进行如下配置:

YudaoJacksonAutoConfiguration

全局配置时间格式

如果你想 JSON 全局配置成 yyyy-MM-dd HH:mm:ss 或其它时间格式,通过使用 Jackson 内置的 LocalDateTimeSerializer 和 LocalDateTimeDeserializer 即可,如下图所示:

全局配置时间格式

局部配置时间格式

如果只是部分 VO 的字段想自定义 yyyy-MM-dd HH:mm:ss 或其它时间格式,可通过 Jackson 内置的 @JsonFormat 注解,如下所示:

1
2
3
4
5
6
// UserRespVO.java

@JsonSerialize(using = LocalDateTimeSerializer.class) // 序列化(响应)
@JsonDeserialize(using = LocalDateDeserializer.class) // 反序列化(请求)
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private LocalDateTime createTime;

自定义校验注解

如果 Validator 内置的参数校验注解不满足需求时,也可以自定义参数校验的注解。

开发自定义约束一共只要两步

  1. 编写自定义约束的注解
  2. 编写自定义的校验器 ConstraintValidator

可以按照 @NotNull 等基础校验注解源码的写法。用 @Retention

1
2
3
4
5
6
7
8
9
10
11
12
@Target({ElementType.METHOD, ElementType.FIELD, ElementType.ANNOTATION_TYPE, ElementType.CONSTRUCTOR, ElementType.PARAMETER, ElementType.TYPE_USE})
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Constraint(
        validatedBy = {IsMobileValidator.class}
)
public @interface IsMobile {
    boolean required() default true;
    String message() default "手机号码格式错误";
    Class<?>[] groups() default {};
    Class<? extends Payload>[] payload() default {};
}

以手机号校验为例。

编写自定义注解

① 第一步,新建 @Mobile 注解,并设置自定义校验器为 MobileValidator (opens new window)接口(加 @Target 等注解)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
@Target({
        ElementType.METHOD,
        ElementType.FIELD,
        ElementType.ANNOTATION_TYPE,
        ElementType.CONSTRUCTOR,
        ElementType.PARAMETER,
        ElementType.TYPE_USE
})
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Constraint(
        validatedBy = MobileValidator.class // 设置校验器
)
public @interface Mobile {

    String message() default "手机号格式不正确";

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

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

}
编写自定义校验器

新建 MobileValidator (opens new window)校验器,实现 ConstraintValidator 接口

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public class MobileValidator implements ConstraintValidator<Mobile, String> {

    @Override
    public void initialize(Mobile annotation) {
    }

    @Override
    public boolean isValid(String value, ConstraintValidatorContext context) {
        // 如果手机号为空,默认不校验,即校验通过
        if (StrUtil.isEmpty(value)) {
            return true;
        }
        // 校验手机
        return ValidationUtils.isMobile(value);
    }

}
参数上添加注解

在需要手机格式校验的参数上添加 @Mobile 注解。示例代码如下:

1
2
3
4
5
6
7
public class AppAuthLoginReqVO {

    @NotEmpty(message = "手机号不能为空")
    @Mobile // <=== here
    private String mobile;

}

使用步骤

更多关于 Validator 的使用,可以系统阅读 《芋道 Spring Boot 参数校验 Validation 入门 》 (opens new window)文章。

  • 例如说,手动参数校验、分组校验、国际化 i18n 等等。

引入依赖

  1. 引入参数校验的 spring-boot-starter-validation 依赖。一般不需要做,项目默认已经引入。

    • 如果项目使用了 Spring Boot,spring-boot-starter-web 包中已集成了 hibernate-validator 框架,无需再添加 hibernate validator 依赖。
    1
    2
    3
    4
     <dependency>
         <groupId>org.springframework.boot</groupId>
         <artifactId>spring-boot-starter-validation</artifactId>
     </dependency>
    
  2. 如果不是 Spring Boot 项目,需添加 hibernate.validator 依赖。

    1
    2
    3
    4
    5
     <dependency>
         <groupId>org.hibernate.validator</groupId>
         <artifactId>hibernate-validator</artifactId>
         <version>6.0.13.Final</version>
     </dependency>
    

在类、方法参数上加注解

  1. 在需要参数校验的类上,添加 @Validated 注解,表示类中所有接口都需要进行参数校验。例如说 Controller、Service 类
    • 疑问:Controller 做了参数校验后,Service 是否需要做参数校验?
    • 是需要的。Service 可能会被别的 Service 进行调用,也会存在参数不正确的情况,所以必须进行参数校验。
  2. 方法参数上添加注解:
    1. 如果方法的参数是 Bean 类型,则添加 @Valid注解,并在 Bean(POJO) 类上添加参数校验的注解。
    2. 如果方法的参数是普通类型,则直接添加参数校验的注解。
Controller 类
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// Controller 示例
@RestController
@RequestMapping("/person")
@Validated // 标注此类需校验
public class AuthController {

    // Bean 类型
    // 嵌套验证:@Valid 修饰入参,表示校验实体类的属性
    @PostMapping("/login")
    public CommonResult<AuthLoginRespVO> login(@RequestBody @Valid AuthLoginReqVO reqVO) {}
    
    //普通类型
    @GetMapping(value = "/get")
    public CommonResult<DictDataRespVO> getDictData(@RequestParam("id") @NotNull(message = "编号不能为空") Long id) {}
    
    @GetMapping("/get/{id}")
    // 嵌套验证:@Valid 修饰入参,表示校验实体类的属性
    public ResponseEntity<Integer> getPersonByID(@Valid @PathVariable("id") @Max(value=5, message="超过id范围") Integer id) {
        return ResponseEntity.ok().body(id);
    }
}
Service 接口

相比在 Controller 添加参数校验来说,在 Service 进行参数校验,会更加安全可靠。

  • 个人建议的话,Controller 的参数校验可以不做,Service 的参数校验一定要做

  • 和 UserController 的方法是一致的,包括注解。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// UserService.java

@Service
@Validated
public class UserService {

    private Logger logger = LoggerFactory.getLogger(getClass());

    public void get(@Min(value = 1L, message = "编号必须大于 0") Integer id) {
        logger.info("[get][id: {}]", id);
    }

    public void add(@Valid UserAddDTO addDTO) {
        logger.info("[add][addDTO: {}]", addDTO);
    }
}

接口

1
2
3
4
5
6
7
@Service
@Validated
// Service 示例,一般放在接口上
public interface AdminAuthService {
    
    String login(@Valid AuthLoginReqVO reqVO, String userIp, String userAgent);
}
Bean 类
1
2
3
4
5
6
7
8
9
10
11
12
13
// Bean 类的示例。一般建议添加参数注解到属性上。原因:采用 Lombok 后,很少使用 getter 方法
public class AuthLoginReqVO {

    @NotEmpty(message = "登录账号不能为空")
    @Length(min = 4, max = 16, message = "账号长度为 4-16 位")
    @Pattern(regexp = "^[A-Za-z0-9]+$", message = "账号格式为数字以及字母")
    private String username;

    @NotEmpty(message = "密码不能为空")
    @Length(min = 4, max = 16, message = "密码长度为 4-16 位")
    private String password;
    
}
POJO 类

如用于 DTO 类。

在 POJO 实体类上加上具体的约束注解。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
public class Prop {
    @NotNull(message = "pid不能为空")
    @Min(value = 1, message = "pid必须为正整数")
    private Long pid;

    @NotBlank(message = "pidName不能为空")
    private String pidName;
    
    @PastOrPresent
   	private LocalDateTime createtime;
}

@Getter
@Setter
@NoArgsConstructor
public class Item {
	// id 属性在不同 controller 中的约束不一样,不能在 POJO 中约束,需用到分组;
	// controller 新增数据时,要求前端传入的id必须为空,因为id在数据库里是自增字段,不支持手动赋值;
	@Null(mesage = "添加时id必须为空", groups = {Item.add.class})
	// Controller 更新数据时,要求前端传入的id不能为空;
    @NotNull(message = "修改时id不能为空", groups = {update.class})
    @Min(value = 1, message = "id必须为正整数")
    private Long id;
    
    @NotBlank(message = "年龄不能为空")
    @Pattern(regexp = "^[0-9]{1,3}$", message = "年龄不正确")
    private String age;

	@Valid // 嵌套验证,必须用@Valid标记嵌套成员对象,且@Validated不能用在成员属性上;
	// 不加则不会对props里的Prop对象进行字段验证,即入参验证不检查pid和pidname
    @NotNull(message = "props不能为空")
    @Size(min = 1, message = "至少有一个属性")
    private List<Prop> props;
    
    public interface add{};
    
    public interface update{};
}

分组校验

暂时没有这方面的诉求。即使有,也是拆分不同的 Bean 类。

在一些业务场景下,需要使用分组校验,即相同的 Bean 对象(在同一个字段上加校验注解),根据校验分组,使用不同的校验规则。

  • 使用分组校验,核心在于添加上 @Validated 注解,并设置对应的校验分组。
UserUpdateStatusDTO

创建 UserUpdateStatusDTO 类,为用户更新状态 DTO 。

  • 创建了 Group01 和 Group02 接口,作为两个校验分组。不一定要定义在 UserUpdateStatusDTO 类中,这里仅仅是为了方便。
  • status 字段,在 Group01 校验分组时,必须为 true ;在 Group02 校验分组时,必须为 false
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// UserUpdateStatusDTO.java

public class UserUpdateStatusDTO {

    /**
     * 分组 01 ,要求状态必须为 true
     */
    public interface Group01 {}

    /**
     * 状态 02 ,要求状态必须为 false
     */
    public interface Group02 {}
    
    /**
     * 状态
     */
    @AssertTrue(message = "状态必须为 true", groups = Group01.class)
    @AssertFalse(message = "状态必须为 false", groups = Group02.class)
    private Boolean status;

    // ... 省略 set/get 方法
}
UserController

修改 UserController 类,增加两个修改状态的 API 接口。

  • 对于 #updateStatusTrue(updateStatusDTO) 方法,在 updateStatusDTO 参数上,添加了 @Validated 注解,并且设置校验分组为 Group01 。校验不通过报错 “请求参数不合法:状态必须为 true”。
  • 对于 #updateStatusFalse(updateStatusDTO) 方法,我们在 updateStatusDTO 参数上,添加了 @Validated 注解,并且设置校验分组为 Group02 。校验不通过报错 “请求参数不合法:状态必须为 false“。
1
2
3
4
5
6
7
8
9
10
11
// UserController.java

@PostMapping("/update_status_true")
public void updateStatusTrue(@Validated(UserUpdateStatusDTO.Group01.class) UserUpdateStatusDTO updateStatusDTO) {
    logger.info("[updateStatusTrue][updateStatusDTO: {}]", updateStatusDTO);
}

@PostMapping("/update_status_false")
public void updateStatusFalse(@Validated(UserUpdateStatusDTO.Group02.class) UserUpdateStatusDTO updateStatusDTO) {
    logger.info("[updateStatusFalse][updateStatusDTO: {}]", updateStatusDTO);
}

Bean Validation 手动校验

示例代码对应仓库:lab-22-validation-01

在上面的示例中,使用的主要是 Spring Validation 的声明式注解。然而在少数业务场景下,可能需要手动使用 Bean Validation API 进行参数校验。

修改 UserServiceTest 测试类,增加手动参数校验的示例。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// UserServiceTest.java

@Autowired // 注入 Validator Bean 对象
private Validator validator;

@Test
public void testValidator() {
    // 打印,查看 validator 的类型 // <1.2>
    System.out.println(validator);

    // 创建 UserAddDTO 对象,在 DTO 内添加相应的约束注解
    UserAddDTO addDTO = new UserAddDTO();
    // 校验 // <3>
    Set<ConstraintViolation<UserAddDTO>> result = validator.validate(addDTO);
    // 打印校验结果 // <4>
    for (ConstraintViolation<UserAddDTO> constraintViolation : result) {
        // 属性:消息
        System.out.println(constraintViolation.getPropertyPath() + ":" + constraintViolation.getMessage());
    }
}
  • <1.2> 处,打印 validator 的类型。输出如下:

    1
      org.springframework.validation.beanvalidation.LocalValidatorFactoryBean@48c3205a
    
    • validator 的类型为 LocalValidatorFactoryBean 。LocalValidatorFactoryBean 提供 JSR-303、JSR-349 的支持,同时兼容 Hibernate Validator 。
    • 在 Spring Boot 体系中,使用 ValidationAutoConfiguration 自动化配置类,默认创建 LocalValidatorFactoryBean 作为 Validator Bean 。
  • <3> 处,调用 Validator#validate(T object, Class<?>... groups) 方法,进行参数校验。

  • <4> 处,打印校验结果。如果校验通过,则返回的 Set<ConstraintViolation<?>> 集合为空。

    • 输出如下:
    1
    2
      username:登录账号不能为空
      password:密码不能为空
    

处理参数校验异常

因为 UserController 使用了 @Validated 注解,那么 Spring Validation 就会使用 AOP 切面进行参数校验。而该切面的拦截器,使用的是 MethodValidationInterceptor

如果直接将校验的结果返回给前端,提示内容的可阅读性是比较差的,所以需要对校验抛出的异常进行处理。

  • GlobalExceptionHandler 中,使用 @ExceptionHandler 注解,实现自定义的异常处理。

#get(id) 方法的返回的结果是 status = 500 ,而 #add(addDTO) 方法的返回的结果是 status = 400

  1. 对于 #get(id) 方法,在 MethodValidationInterceptor 拦截器中,校验到参数不正确,会抛出 ConstraintViolationException 异常。
  2. 对于 #add(addDTO) 方法,因为 addDTO 是个 POJO 对象,所以会走 SpringMVC 的 DataBinder 机制,它会调用 DataBinder#validate(Object... validationHints) 方法,进行校验。在校验不通过时,会抛出 BindException

在 SpringMVC 中,默认使用 DefaultHandlerExceptionResolver 处理异常。

  • 对于 BindException 异常,处理成 400 的状态码。
  • 对于 ConstraintViolationException 异常,没有特殊处理,所以处理成 500 的状态码。

不通过示例 1

校验参数错误码

  • 校验参数不通过的错误码用:
    • 也可以定义为枚举类。
1
ErrorCode BAD_REQUEST = new ErrorCode(400, "请求参数不正确");

处理 Validator 校验不通过异常

  • 将每个约束的错误内容提示,拼接起来,使用 ; 分隔。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
// GlobalExceptionHandler.java

@ResponseBody
@ExceptionHandler(value = ConstraintViolationException.class)
public CommonResult constraintViolationExceptionHandler(HttpServletRequest req, ConstraintViolationException ex) {
    
    logger.debug("[constraintViolationExceptionHandler]", ex);
    // 拼接错误
    StringBuilder detailMessage = new StringBuilder();
    for (ConstraintViolation<?> constraintViolation : ex.getConstraintViolations()) {
        // 使用 ; 分隔多个错误
        if (detailMessage.length() > 0) {
            detailMessage.append(";");
        }
        // 拼接内容到其中
        detailMessage.append(constraintViolation.getMessage());
    }
    
    // 包装 CommonResult 结果
    return CommonResult.error(ServiceExceptionEnum.INVALID_REQUEST_PARAM_ERROR.getCode(),
            ServiceExceptionEnum.INVALID_REQUEST_PARAM_ERROR.getMessage() + ":" + detailMessage.toString());
}

 /**
   * 处理 Validator 校验不通过产生的异常
   */
@ExceptionHandler(value = ConstraintViolationException.class)
public CommonResult<?> constraintViolationExceptionHandler(ConstraintViolationException ex) {
    log.warn("[constraintViolationExceptionHandler]", ex);
    ConstraintViolation<?> constraintViolation = ex.getConstraintViolations().iterator().next();
    return CommonResult.error(BAD_REQUEST.getCode(), String.format("请求参数不正确:%s", constraintViolation.getMessage()));
}

重新请求 UserController#get(id) 对应的接口,响应结果如下:

constraintViolationExceptionHandler

处理 BindException 异常

修改 GlobalExceptionHandler 类,增加 #bindExceptionHandler(...) 方法,处理 BindException 异常。

  • 将每个约束的错误内容提示,拼接起来,使用 ; 分隔。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
// GlobalExceptionHandler.java

@ResponseBody
@ExceptionHandler(value = BindException.class)
public CommonResult bindExceptionHandler(HttpServletRequest req, BindException ex) {
    
    logger.debug("[bindExceptionHandler]", ex);
    // 拼接错误
    StringBuilder detailMessage = new StringBuilder();
    for (ObjectError objectError : ex.getAllErrors()) {
        // 使用 ; 分隔多个错误
        if (detailMessage.length() > 0) {
            detailMessage.append(";");
        }
        // 拼接内容到其中
        detailMessage.append(objectError.getDefaultMessage());
    }
    
    // 包装 CommonResult 结果
    return CommonResult.error(ServiceExceptionEnum.INVALID_REQUEST_PARAM_ERROR.getCode(),
            ServiceExceptionEnum.INVALID_REQUEST_PARAM_ERROR.getMessage() + ":" + detailMessage.toString());
}

 /**
     * 处理 SpringMVC 参数绑定不正确,本质上也是通过 Validator 校验
     */
@ExceptionHandler(BindException.class)
public CommonResult<?> bindExceptionHandler(BindException ex) {
    log.warn("[handleBindException]", ex);
    FieldError fieldError = ex.getFieldError();
    assert fieldError != null; // 断言,避免告警
    return CommonResult.error(BAD_REQUEST.getCode(), String.format("请求参数不正确:%s", fieldError.getDefaultMessage()));
}
  • 重新请求 UserController#add(addDTO) 对应的接口,响应结果如下:

bindExceptionHandler

加入自定义校验,因为传入的请求参数 gender 的值为 null ,显然不在 GenderEnum 范围内,所以校验不通过,输出 "性别必须是 [1, 2]"

响应结果

通用返回

参考:Spring Boot SpringMVC 入门

CommonResult

项目在实践时,将状态码放在 Response Body 响应内容中返回。一共有 3 个字段,通过 CommonResult (opens new window)定义如下:

  1. 在 RESTful API 成功时,定义(Controller 对应方法的)返回类型为 CommonResult,并调用 #success(T data) (opens new window)方法来返回。
    • CommonResult 的 data 字段是泛型,建议定义对应的 VO 类,而不是使用 Map 类。
  2. 在 RESTful API 失败时,通过抛出 Exception 异常,具体在 「2. 异常处理」 小节。
    • 失败时的 code 字段,使用全局的错误码。

CommonResult_01

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 成功响应
{
    code: 0,
    data: {
        id: 1,
        username: "yudaoyuanma"
    }
}

// 失败响应
{
    code: 233666,
    message: "徐妈太丑了"
}

可以增加 success 字段吗?

  • 有些团队在实践时,会增加了 success 字段,通过 truefalse 表示成功还是失败。 这个看每个团队的习惯吧。
  • 还是偏好基于约定,返回 0表示成功。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
package cn.iocoder.yudao.framework.common.pojo;

/**
 * 通用返回
 */
@Data
public class CommonResult<T> implements Serializable {

    /**
     * 错误码
     * @see ErrorCode#getCode()
     */
    private Integer code;
    /**
     * 错误提示,用户可阅读
     * @see ErrorCode#getMsg() ()
     */
    private String msg;
    /**
     * 返回数据
     */
    private T data;

    /**
     * 将传入的 result 对象,转换成另外一个泛型结果的对象
     * 因为 A 方法返回的 CommonResult 对象,不满足调用其的 B 方法的返回,所以需要进行转换。
     *
     * @param result 传入的 result 对象
     * @param <T> 返回的泛型
     * @return 新的 CommonResult 对象
     */
    public static <T> CommonResult<T> error(CommonResult<?> result) {
        return error(result.getCode(), result.getMsg());
    }

    public static <T> CommonResult<T> error(Integer code, String message) {
        Assert.notEquals(GlobalErrorCodeConstants.SUCCESS.getCode(), code, "code 必须是错误的!");
        CommonResult<T> result = new CommonResult<>();
        result.code = code;
        result.msg = message;
        return result;
    }

    public static <T> CommonResult<T> error(ErrorCode errorCode, Object... params) {
        Assert.notEquals(GlobalErrorCodeConstants.SUCCESS.getCode(), errorCode.getCode(), "code 必须是错误的!");
        CommonResult<T> result = new CommonResult<>();
        result.code = errorCode.getCode();
        result.msg = ServiceExceptionUtil.doFormat(errorCode.getCode(), errorCode.getMsg(), params);
        return result;
    }

    public static <T> CommonResult<T> error(ErrorCode errorCode) {
        return error(errorCode.getCode(), errorCode.getMsg());
    }

    public static <T> CommonResult<T> success(T data) {
        CommonResult<T> result = new CommonResult<>();
        result.code = GlobalErrorCodeConstants.SUCCESS.getCode();
        result.data = data;
        result.msg = "";
        return result;
    }

    public static boolean isSuccess(Integer code) {
        return Objects.equals(code, GlobalErrorCodeConstants.SUCCESS.getCode());
    }

    @JsonIgnore // 避免 jackson 序列化
    public boolean isSuccess() {
        return isSuccess(code);
    }

    @JsonIgnore // 避免 jackson 序列化
    public boolean isError() {
        return !isSuccess();
    }

    // ========= 和 Exception 异常体系集成 =========

    /**
     * 判断是否有异常。如果有,则抛出 {@link ServiceException} 异常
     */
    public void checkError() throws ServiceException {
        //
        if (isSuccess()) {
            return;
        }
        // 业务异常
        throw new ServiceException(code, msg);
    }

    /**
     * 判断是否有异常。如果有,则抛出 {@link ServiceException} 异常
     * 如果没有,则返回 {@link #data} 数据
     */
    @JsonIgnore // 避免 jackson 序列化
    public T getCheckedData() {
        checkError();
        return data;
    }

    public static <T> CommonResult<T> error(ServiceException serviceException) {
        return error(serviceException.getCode(), serviceException.getMessage());
    }
}

使用 @ControllerAdvice

在 Spring MVC 中,可以使用 @ControllerAdvice 注解,通过 Spring AOP 拦截修改 Controller 方法的返回结果,从而实现全局的统一返回。

为什么项目不采用这种方式呢?

  • 主要原因是,这样的方式“破坏”了方法的定义,导致一些隐性的问题。例如说,Swagger 接口定义错误,展示的响应结果不是 CommonResult。
  • 还有个原因,部分 RESTful API 不需要自动包装 CommonResult 结果。例如说,第三方支付回调只需要返回 "success" 字符串。

ErrorCode 错误码

错误码,对应 ErrorCode (opens new window)类,枚举项目中的错误,全局唯一,方便定位是谁的错、错在哪。

分成两类:

  1. 全局的系统错误码、
  2. 模块的业务错误码。

全局的系统错误码

定义在 GlobalErrorCodeConstants 接口中

全局的系统错误码,使用 0-999 错误码段,和 HTTP 响应状态码 (opens new window)对应。

  • 虽然说,HTTP 响应状态码作为业务使用表达能力偏弱,但是使用在系统层面还是非常不错的。

新增的有:

  • 423:请求失败,请稍后重试。比如并发请求,不允许。
  • 429:请求过于频繁,请稍后重试。
  • 9XX:自定义错误段
    • 900:重复请求。

CommonResult_08

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
public interface GlobalErrorCodeConstants {

    ErrorCode SUCCESS = new ErrorCode(0, "成功");

    // ========== 客户端错误段 ==========

    ErrorCode BAD_REQUEST = new ErrorCode(400, "请求参数不正确");
    ErrorCode UNAUTHORIZED = new ErrorCode(401, "账号未登录");
    ErrorCode FORBIDDEN = new ErrorCode(403, "没有该操作权限");
    ErrorCode NOT_FOUND = new ErrorCode(404, "请求未找到");
    ErrorCode METHOD_NOT_ALLOWED = new ErrorCode(405, "请求方法不正确");
    ErrorCode LOCKED = new ErrorCode(423, "请求失败,请稍后重试"); // 并发请求,不允许
    ErrorCode TOO_MANY_REQUESTS = new ErrorCode(429, "请求过于频繁,请稍后重试");

    // ========== 服务端错误段 ==========

    ErrorCode INTERNAL_SERVER_ERROR = new ErrorCode(500, "系统异常");
    ErrorCode NOT_IMPLEMENTED = new ErrorCode(501, "功能未实现/未开启");
    ErrorCode ERROR_CONFIGURATION = new ErrorCode(502, "错误的配置项");

    // ========== 自定义错误段 ==========
    ErrorCode REPEATED_REQUESTS = new ErrorCode(900, "重复请求,请稍后重试"); // 重复请求
    ErrorCode DEMO_DENY = new ErrorCode(901, "演示模式,禁止写操作");

    ErrorCode UNKNOWN = new ErrorCode(999, "未知错误");

}

模块的业务错误码

模块的业务错误码,按照模块分配错误码的区间,避免模块之间的错误码冲突。

  1. ① 业务错误码一共 10 位,分成 4 段,在 ServiceErrorCodeRange 类中分配。
  2. ② 每个业务模块,定义自己的 ErrorCodeConstants 错误码枚举类。

规则与代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
package cn..framework.common.exception.enums;

/**
 * 业务异常的错误码区间,解决:解决各模块错误码定义,避免重复,在此只声明不做实际使用
 *
 * 一共 10 位,分成四段
 *
 * 第一段,1 位,类型
 *      1 - 业务级别异常
 *      x - 预留
 * 第二段,3 位,系统类型
 *      001 - 用户系统
 *      002 - 商品系统
 *      003 - 订单系统
 *      004 - 支付系统
 *      005 - 优惠劵系统
 *      ... - ...
 * 第三段,3 位,模块,服务?
 *      不限制规则。
 *      一般建议,每个系统里面,可能有多个模块,可以再去做分段。以用户系统为例子:
 *          001 - OAuth2 模块
 *          002 - User 模块
 *          003 - MobileCode 模块
 * 第四段,3 位,错误码
 *       不限制规则。
 *       一般建议,每个模块自增。
 *
 */
public class ServiceErrorCodeRange {

    // 模块 infra 错误码区间 [1-001-000-000 ~ 1-002-000-000)
    // 模块 system 错误码区间 [1-002-000-000 ~ 1-003-000-000)
    // 模块 report 错误码区间 [1-003-000-000 ~ 1-004-000-000)
    // 模块 member 错误码区间 [1-004-000-000 ~ 1-005-000-000)
    // 模块 mp 错误码区间 [1-006-000-000 ~ 1-007-000-000)
    // 模块 pay 错误码区间 [1-007-000-000 ~ 1-008-000-000)
    // 模块 bpm 错误码区间 [1-009-000-000 ~ 1-010-000-000)

    // 模块 product 错误码区间 [1-008-000-000 ~ 1-009-000-000)
    // 模块 trade 错误码区间 [1-011-000-000 ~ 1-012-000-000)
    // 模块 promotion 错误码区间 [1-013-000-000 ~ 1-014-000-000)

    // 模块 crm 错误码区间 [1-020-000-000 ~ 1-021-000-000)

    // 模块 ai 错误码区间 [1-022-000-000 ~ 1-023-000-000)

}

yudao-module-system 模块举例子,代码如下:

CommonResult_10

ServiceException

定义 ServiceException (opens new window)异常类,继承 RuntimeException 异常类(非受检),用于定义业务异常。

CommonResult_05

ServerException

todo

统一响应和异常处理

异常相关的统一响应、异常处理、业务异常、错误码这 4 块的内容。

统一响应

后端提供 RESTful API 给前端时,需要响应前端 API 调用是否成功。

  • 因此,需要有统一响应,而不能是每个接口定义自己的风格。

一般来说,统一响应返回信息如下:

  1. 成功时,返回成功的状态码 + 数据。后续,前端会将数据渲染到页面上。
  2. 失败时,返回失败的状态码 + 错误提示。一般,前端会将原因弹出提示给用户。

在标准的 RESTful API 的定义,是推荐使用 HTTP 响应状态码 (opens new window)作为状态码。一般来说,我们实践很少这么去做,主要原因如下:

  • 业务返回的错误状态码很多,HTTP 响应状态码无法很好的映射。例如说,活动还未开始、订单已取消等等
  • 学习成本高,开发者对 HTTP 响应状态码不是很了解。例如说,可能只知道 200、403、404、500 几种常见的

GlobalResponseBodyHandler

全局响应结果处理器

  • 不同于在网上看到的很多文章,会选择自动将 Controller 返回结果包上 {@link CommonResult},
    • 在 onemall 中,是 Controller 在返回时,主动自己包上 {@link CommonResult}
  • 原因是,GlobalResponseBodyHandler 本质上是 AOP,它不应该改变 Controller 返回的数据结构
    • 目前,主要作用是,记录 Controller 的返回结果,
    • 方便 {@link cn.iocoder.yudao.framework.apilog.core.filter.ApiAccessLogFilter} 记录访问日志
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
package cn.framework.web.core.handler;

/**
 * 全局响应结果(ResponseBody)处理器
 */
@ControllerAdvice
public class GlobalResponseBodyHandler implements ResponseBodyAdvice {

    @Override
    @SuppressWarnings("NullableProblems") // 避免 IDEA 警告
    public boolean supports(MethodParameter returnType, Class converterType) {
        if (returnType.getMethod() == null) {
            return false;
        }
        // 只拦截返回结果为 CommonResult 类型
        return returnType.getMethod().getReturnType() == CommonResult.class;
    }

    @Override
    @SuppressWarnings("NullableProblems") // 避免 IDEA 警告
    public Object beforeBodyWrite(Object body, MethodParameter returnType, MediaType selectedContentType, Class selectedConverterType,
                                  ServerHttpRequest request, ServerHttpResponse response) {
        // 记录 Controller 结果
        WebFrameworkUtils.setCommonResult(((ServletServerHttpRequest) request).getServletRequest(), (CommonResult<?>) body);
        return body;
    }
}

统一异常处理

Global 全局异常

分为两类:

  1. RESTful API 发生异常时:需要拦截 Exception 异常,转换成统一响应的格式
    1. Spring MVC 的异常:通过 @ControllerAdvice + @ExceptionHandler 注解,指定异常的类型,转换成对应的 CommonResult 响应。
    2. Filter 的异常:通过 try catch 的方式,手动调用 globalExceptionHandler.allExceptionHandler(request, ex);,返回 CommonResult<?>。
  2. Service 发生业务异常时:封装 ServiceException 统一业务异常,里面有错误码和错误提示,然后进行 throw 抛出。
  3. RPC 服务 API异常:
  4. Gateway 全局异常处理器:

GlobalExceptionHandler

全局异常处理器

1
2
3
4
5
6
7
8
9
/**
 * 全局异常处理器,将 Exception 翻译成 CommonResult + 对应的异常编号
 */
@RestControllerAdvice
@AllArgsConstructor
@Slf4j
public class GlobalExceptionHandler {
    ....
}

RESTfuk API 异常处理

RESTful API 发生异常时,需要拦截 Exception 异常,转换成统一响应的格式,否则前端无法处理。

Spring MVC 的异常

在 Spring MVC 中,通过 @ControllerAdvice + @ExceptionHandler 注解,声明将指定类型的异常,转换成对应的 CommonResult 响应。

  • @ControllerAdvice 注解:
  • @ExceptionHandler 注解:指定异常的类型

实现的代码,可见 GlobalExceptionHandler (opens new window)类。

  • @SuppressWarnings:允许选择性地取消特定代码段(即,类或方法)中的警告
    • 作用是给编译器一条指令,告诉它对被批注的代码元素内部的某些警告保持静默。批注J2SE 提供的

CommonResult_03

defaultExceptionHandler
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26

/**
     * 处理系统异常,兜底处理所有的一切
     */
@ExceptionHandler(value = Exception.class)
public CommonResult<?> defaultExceptionHandler(HttpServletRequest req, Throwable ex) {
    // 特殊:如果是 ServiceException 的异常,则直接返回
    // 例如说:https://gitee.com/zhijiantianya/yudao-cloud/issues/ICSSRM、https://gitee.com/zhijiantianya/yudao-cloud/issues/ICT6FM
    if (ex.getCause() != null && ex.getCause() instanceof ServiceException) {
        return serviceExceptionHandler((ServiceException) ex.getCause());
    }

    // 情况一:处理表不存在的异常
    CommonResult<?> tableNotExistsResult = handleTableNotExists(ex);
    if (tableNotExistsResult != null) {
        return tableNotExistsResult;
    }

    // 情况二:处理异常
    log.error("[defaultExceptionHandler]", ex);
    // 插入异常日志
    createExceptionLog(req, ex);
    // 返回 ERROR CommonResult
    // forlai,  [http://member-server/rpc-api/member/address/get?id=21&userId=1] [MemberAddressApi#getAddress(Long,Long)]: [Load balancer does not contain an instance for the service member-server]
    return CommonResult.error(INTERNAL_SERVER_ERROR.getCode(), INTERNAL_SERVER_ERROR.getMsg());
}

Filter 的异常

在请求被 Spring MVC 处理之前,是先经过 Filter 处理的,此时发生异常时,是无法通过 @ExceptionHandler 注解来处理的。

  • 只能通过 try catch 的方式来实现,代码如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
try {
    // 1.1 基于 token 构建登录用户
    loginUser = buildLoginUserByToken(token, userType);
    // 1.2 模拟 Login 功能,方便日常开发调试
    if (loginUser == null) {
        loginUser = mockLoginUser(request, token, userType);
    }
} catch (Throwable ex) {
    CommonResult<?> result = globalExceptionHandler.allExceptionHandler(request, ex);
    ServletUtils.writeJSON(response, result);
    return;
}

/**
     * 处理所有异常,主要是提供给 Filter 使用
     * 因为 Filter 不走 SpringMVC 的流程,但是我们又需要兜底处理异常,所以这里提供一个全量的异常处理过程,保持逻辑统一。
     *
     * @param request 请求
     * @param ex 异常
     * @return 通用返回
     */
public CommonResult<?> allExceptionHandler(HttpServletRequest request, Throwable ex) {
    if (ex instanceof MissingServletRequestParameterException) {
        return missingServletRequestParameterExceptionHandler((MissingServletRequestParameterException) ex);
    }
    if (ex instanceof MethodArgumentTypeMismatchException) {
        return methodArgumentTypeMismatchExceptionHandler((MethodArgumentTypeMismatchException) ex);
    }
    if (ex instanceof MethodArgumentNotValidException) {
        return methodArgumentNotValidExceptionExceptionHandler((MethodArgumentNotValidException) ex);
    }
    if (ex instanceof BindException) {
        return bindExceptionHandler((BindException) ex);
    }
    if (ex instanceof ConstraintViolationException) {
        return constraintViolationExceptionHandler((ConstraintViolationException) ex);
    }
    if (ex instanceof ValidationException) {
        return validationException((ValidationException) ex);
    }
    if (ex instanceof MaxUploadSizeExceededException) {
        return maxUploadSizeExceededExceptionHandler((MaxUploadSizeExceededException) ex);
    }
    if (ex instanceof NoHandlerFoundException) {
        return noHandlerFoundExceptionHandler((NoHandlerFoundException) ex);
    }
    if (ex instanceof HttpRequestMethodNotSupportedException) {
        return httpRequestMethodNotSupportedExceptionHandler((HttpRequestMethodNotSupportedException) ex);
    }
    if (ex instanceof HttpMediaTypeNotSupportedException) {
        return httpMediaTypeNotSupportedExceptionHandler((HttpMediaTypeNotSupportedException) ex);
    }
    // 业务异常
    if (ex instanceof ServiceException) {
        return serviceExceptionHandler((ServiceException) ex);
    }
    if (ex instanceof AccessDeniedException) {
        return accessDeniedExceptionHandler(request, (AccessDeniedException) ex);
    }
    return defaultExceptionHandler(request, ex);
}

CommonResult_04

业务异常处理

在 Service 发生业务异常时,如果进行返回呢?例如说,用户名已经存在,商品库存不足等。常用的方案选择,主要有两种:

  • 方案一,直接使用 CommonResult 统一响应结果,里面有错误码和错误提示,然后进行 return 返回
  • 方案二,封装 ServiceException 统一业务异常,里面有错误码和错误提示,然后进行 throw 抛出

选择方案一 CommonResult 会存在两个问题:

  1. 因为 Spring @Transactional 声明式事务,是基于异常进行回滚的,如果使用 CommonResult 返回,则事务回滚会非常麻烦。
  2. 当调用别的方法时,如果别人返回的是 CommonResult 对象,还需要不断的进行判断,写起来挺麻烦的。

因此,项目采用方案二 ServiceException 异常

serviceExceptionHandler

定义 ServiceException (opens new window)异常类,继承 RuntimeException 异常类(非受检),用于定义业务异常。

为什么继承 RuntimeException 异常?

  • 大多数业务场景下,无需处理 ServiceException 业务异常,而是通过 GlobalExceptionHandler 统一处理,转换成对应的 CommonResult 对象,进而提示给前端即可。
  • 如果真的需要处理 ServiceException 时,通过 try catch 的方式进行主动捕获。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
/**
     * 处理业务异常 ServiceException
     *
     * 例如说,商品库存不足,用户手机号已存在。
     */
@ExceptionHandler(value = ServiceException.class)
public CommonResult<?> serviceExceptionHandler(ServiceException ex) {
    // 不包含的时候,才进行打印,避免 ex 堆栈过多
    if (!IGNORE_ERROR_MESSAGES.contains(ex.getMessage())) {
        // 即使打印,也只打印第一层 StackTraceElement,并且使用 warn 在控制台输出,更容易看到
        try {
            StackTraceElement[] stackTraces = ex.getStackTrace();
            for (StackTraceElement stackTrace : stackTraces) {
                if (ObjUtil.notEqual(stackTrace.getClassName(), ServiceExceptionUtil.class.getName())) {
                    log.warn("[serviceExceptionHandler]\n\t{}", stackTrace);
                    break;
                }
            }
        } catch (Exception ignored) {
            // 忽略日志,避免影响主流程
        }
    }
    return CommonResult.error(ex.getCode(), ex.getMessage());
}

ServiceExceptionUtil

在 Service 需抛出业务异常时,通过调用 ServiceExceptionUtil (opens new window)#exception(ErrorCode errorCode, Object... params) 方法来构建 ServiceException 异常,然后使用 throw 进行抛出。

为什么使用 ServiceExceptionUtil 来构建 ServiceException 异常?

  • 目的在于,格式化异常信息提示。
  • 错误提示的内容,支持使用管理后台进行动态配置,所以通过 ServiceExceptionUtil 获取内容的配置与格式化。
1
2
3
4
// ServiceExceptionUtil.java

public static ServiceException exception(ErrorCode errorCode) { /** 省略参数 */ }
public static ServiceException exception(ErrorCode errorCode, Object... params) { /** 省略参数 */ }

CommonResult_06

业务异常

1
2
3
4
5
6
7
8
9
10
11
@Override
public DeliveryExpressDO validateDeliveryExpress(Long id) {
    DeliveryExpressDO deliveryExpress = deliveryExpressMapper.selectById(id);
    if (deliveryExpress == null) {
        throw exception(EXPRESS_NOT_EXISTS); // 定义在 api 模块的 enums/interface ErrorCodeConstants 中
    }
    if (deliveryExpress.getStatus().equals(CommonStatusEnum.DISABLE.getStatus())) {
        throw exception(EXPRESS_STATUS_NOT_ENABLE);
    }
    return deliveryExpress;
}
Assert
1
2
3
4
5
6
7
8
9
10
11
12
13
14
@Override
public void beforeDeliveryOrder(TradeOrderDO order) {
    //
    if (!TradeOrderTypeEnum.isCombination(order.getType())) {
        return;
    }
    // 校验订单拼团是否成功
    CombinationRecordRespDTO combinationRecord = combinationRecordApi.getCombinationRecordByOrderId(order.getUserId(), order.getId()).getCheckedData();
    // Assert
    Assert.notNull(combinationRecord, "订单({})对应的拼团记录不存在", order.getId());
    if (!CombinationRecordStatusEnum.isSuccess(combinationRecord.getStatus())) {
        throw exception(ORDER_DELIVERY_FAIL_COMBINATION_RECORD_STATUS_NOT_SUCCESS);
    }
}

RPC 服务 - API 异常日志

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
@FeignClient(name = RpcConstants.INFRA_NAME) // TODO:fallbackFactory =
@Tag(name = "RPC 服务 - API 异常日志")
public interface ApiErrorLogCommonApi {

    String PREFIX = RpcConstants.INFRA_PREFIX + "/api-error-log";

    @PostMapping(PREFIX + "/create")
    @Operation(summary = "创建 API 异常日志")
    CommonResult<Boolean> createApiErrorLog(@Valid @RequestBody ApiErrorLogCreateReqDTO createDTO);

    /**
     * 【异步】创建 API 异常日志
     *
     * @param createDTO 异常日志 DTO
     */
    @Async //用来声明一个异步方法,而 await是用来等待异步方法执行
    default void createApiErrorLogAsync(ApiErrorLogCreateReqDTO createDTO) {
        createApiErrorLog(createDTO).checkError();
    }
}
  • @Async:用来声明一个异步方法,
  • @Await:用来等待异步方法执行
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
@FeignClient(name = RpcConstants.INFRA_NAME) // TODO:fallbackFactory =
@Tag(name = "RPC 服务 - API 访问日志")
public interface ApiAccessLogCommonApi {

    String PREFIX = RpcConstants.INFRA_PREFIX + "/api-access-log";

    @PostMapping(PREFIX + "/create")
    @Operation(summary = "创建 API 访问日志")
    CommonResult<Boolean> createApiAccessLog(@Valid @RequestBody ApiAccessLogCreateReqDTO createDTO);

    /**
     * 【异步】创建 API 访问日志
     *
     * @param createDTO 访问日志 DTO
     */
    @Async //用来声明一个异步方法,而 await是用来等待异步方法执行
    default void createApiAccessLogAsync(ApiAccessLogCreateReqDTO createDTO) {
        createApiAccessLog(createDTO).checkError();
    }
}

Gateway 全局异常处理器

Gateway 的全局异常处理器:将 Exception 翻译成 CommonResult + 对应的异常编号。

  • ErrorWebExceptionHandler
  • Mono
  • ServerWebExchange:是一个HTTP请求-响应交互的契约。提供对HTTP请求和响应的访问,并公开额外的服务器端处理相关属性和特性,如请求属性。
    • 服务网络交换器:存放着重要的请求-响应属性、请求实例和响应实例等等,有点像Context的角色。
    • 注意到过滤器(包括GatewayFilterGlobalFilter和过滤器链GatewayFilterChain),都依赖到ServerWebExchange
  • ServerHttpResponse:用于表示服务器端的HTTP响应
    • ServerHttpResponse response = serverWebExchange .getResponse();
  • WebFrameworkUtils.writeJSON(exchange, result)
  • ResponseStatusException
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
package cn..gateway.handler;

/**
 * Gateway 的全局异常处理器,将 Exception 翻译成 CommonResult + 对应的异常编号
 *
 * 在功能上,和 yudao-spring-boot-starter-web 的 GlobalExceptionHandler 类是一致的
 *
 */
@Component
@Order(-1) // 保证优先级高于默认的 Spring Cloud Gateway 的 ErrorWebExceptionHandler 实现
@Slf4j
public class GlobalExceptionHandler implements ErrorWebExceptionHandler {

    @Override
    public Mono<Void> handle(ServerWebExchange exchange, Throwable ex) {
        // 已经 commit,则直接返回异常
        ServerHttpResponse response = exchange.getResponse();
        if (response.isCommitted()) {
            return Mono.error(ex);
        }

        // 转换成 CommonResult
        CommonResult<?> result;
        if (ex instanceof ResponseStatusException) {
            result = responseStatusExceptionHandler(exchange, (ResponseStatusException) ex);
        } else {
            result = defaultExceptionHandler(exchange, ex);
        }

        // 返回给前端
        return WebFrameworkUtils.writeJSON(exchange, result);
    }

    /**
     * 处理 Spring Cloud Gateway 默认抛出的 ResponseStatusException 异常
     */
    private CommonResult<?> responseStatusExceptionHandler(ServerWebExchange exchange,
                                                           ResponseStatusException ex) {
        // TODO 芋艿:这里要精细化翻译,默认返回用户是看不懂的
        ServerHttpRequest request = exchange.getRequest();
        log.error("[responseStatusExceptionHandler][uri({}/{}) 发生异常]", request.getURI(), request.getMethod(), ex);
        return CommonResult.error(ex.getRawStatusCode(), ex.getReason());
    }

    /**
     * 处理系统异常,兜底处理所有的一切
     */
    @ExceptionHandler(value = Exception.class)
    public CommonResult<?> defaultExceptionHandler(ServerWebExchange exchange,
                                                   Throwable ex) {
        ServerHttpRequest request = exchange.getRequest();
        log.error("[defaultExceptionHandler][uri({}/{}) 发生异常]", request.getURI(), request.getMethod(), ex);
        // TODO 芋艿:是否要插入异常日志呢?
        // 返回 ERROR CommonResult
        return CommonResult.error(INTERNAL_SERVER_ERROR.getCode(), INTERNAL_SERVER_ERROR.getMsg());
    }
}

Filter 过滤器

Filter对web服务器管理的所有资源进行拦截,例如实现URL级别的权限访问控制、过滤敏感词汇等。

大致流程:Filter对用户请求进行预处理,接着将请求交给Servlet进行处理并生成响应,最后Filter再对服务器响应进行后处理

  • Filter接口中有一个doFilter方法,里面编写我们的业务逻辑,配置对哪个资源进行拦截,在调用service方法之前,都会先调用一下filter的doFilter方法,
  • 也可以编写多个Filter,这些Filter组合起来称之为一个Filter链,也可以控制先调用哪个Filter。
    • web服务器会创建一个代表Filter链的FilterChain对象传递给该方法。
    • 在doFilter方法中,开发人员如果调用了FilterChain对象的doFilter方法,则web服务器会检查FilterChain对象中是否还有filter,如果有,则调用第2个filter,如果没有,则调用目标资源。

Filter的创建和销毁由WEB服务器负责。 web 应用程序启动时,web 服务器将创建Filter 的实例对象,并调用其init方法,读取web.xml配置,完成对象的初始化功能,从而为后续的用户请求作好拦截的准备工作(filter对象只会创建一次,init方法也只会执行一次)。

doFilter

  • doFilter是整个过滤器最底层的概念Filter接口中的方法。
  • Spring的OncePerRequestFilter类:实际上是一个实现了Filter接口的抽象类。spring对Filter进行了一些封装处理,能够确保在一次请求只通过一次filter,而不需要重复执行,因为在执行完后会在request设置标识符。
  • doFilterInternal是OncePerRequestFilter 中的一个抽象方法。
  • abstract class ApiRequestFilter extends OncePerRequestFilter:过滤 /admin-api、/app-api 等 API 请求的过滤器
  • CacheRequestBodyFilter extends OncePerRequestFilter:Request Body 缓存 Filter,实现它的可重复读取

拦截器

在SpringBoot中可以使用HandlerInterceptorAdapter这个适配器来实现自己的拦截器。这样就可以拦截所有的请求并做相应的处理。

  • 应用场景:请求日志,权限,JVM性能输出等。

在HandlerInterceptorAdapter中主要提供了以下的方法:

  1. preHandle:在方法被调用前执行。如果返回true,则继续调用下一个拦截器。如果返回false,则中断执行。
  2. postHandle:在方法执行后调用。
  3. afterCompletion:在整个请求处理完毕后进行回调,也就是调用方已经拿到响应时。

拦截器和过滤器的区别

  • 拦截器是基于java的反射机制的,而过滤器是基于函数回调
  • 拦截器不依赖与servlet容器,过滤器依赖于servlet容器
  • 拦截器只能对action请求起作用,而过滤器则可以对几乎所有的请求起作用。
  • 拦截器可以访问action上下文、值栈里的对象,而过滤器不能访问。
  • 在action的生命周期中,拦截器可以多次被调用,而过滤器只能在容器初始化时被调用一次。
  • 拦截器可以获取ioc中的service bean实现业务逻辑,拦截器可以获取ioc中的service bean实现业务逻辑,在拦截器里注入一个service,可以调用业务逻辑。

总结:过滤器包裹住servlet,servlet包裹住拦截器

触发时机:过滤器是在请求进入容器后,但请求进入servlet之前进行预处理的。请求结束返回也是,是在servlet处理完后,返回给前端之前。

  • 过滤器的触发时机是容器后,servlet之前,所以过滤器的 doFilter(ServletRequest request, ServletResponse response, FilterChain chain) 的入参是ServletRequest ,而不是httpservletrequest。因为过滤器是在httpservlet之前。

chain.doFilter(request, response);这个方法的调用作为分水岭。事实上调用Servlet的doService()方法是在chain.doFilter(request, response);这个方法中进行的。

拦截器是被包裹在过滤器之中的。

  • 拦截器的preHandle()方法是在过滤器的chain.doFilter(request, response)方法的前一步执行,不是doFilter(request,response,chain)之前。
  • postHandle()方法在return ModelAndView之前,可以操控Controller的内容。 afterCompletion()方法是在过滤器返回给前端前一步执行。也就是doFilter(request,response,chain)方法return之前执行。
  • 在我们项目中用的最多是preHandle这个方法,而未用其他的,框架提供了一个已经实现了拦截器接口的适配器类HandlerInterceptorAdapter,继承这个类重写一下需要用到的方法就行了,可以少几行代码,这种方式Java中很多地方都有体现。

API 日志

API 日志:包含两类

  1. API 访问日志:记录用户访问 API 的访问日志,定期归档历史日志。
  2. 异常日志:记录用户访问 API 的系统异常,方便日常排查问题与告警。
0%