logo头像

👨‍💻冷锋のIT小屋

Spring Boot参数验证(下)——Bean Validation在Web中的应用

Spring Boot参数验证(上)--Bean Validation及其Hibernate实现 一篇中,我们介绍了验证标准Bean Validation和其Hibernate实现,在本篇,我们看看它们是如何应用在Spring Boot Web项目中。

1. Spring Validator

其实,Spring很早就有了自己的Bean验证机制,其核心为Validator接口,表示校验器:

1
2
3
4
5
6
7
public interface Validator {
// 检测Validator是否支持校验提供的Class
boolean supports(Class<?> clazz);

// 校验逻辑,校验的结果信息通过errors获取
void validate(@Nullable Object target, Errors errors);
}

Errors接口,用以表示校验失败的错误信息:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public interface Errors {
// 获取被校验的根对象
String getObjectName();

// 校验结果是否有错
boolean hasErrors();

// 获取校验错误数量
int getErrorCount();

// 获取所有错误信息,包括全局错误和字段错误
List<ObjectError> getAllErrors();

// 获取所有字段错误
List<FieldError> getFieldErrors();

……
}

当Bean Validation被标准化过后,从Spring3.X开始,已经完全支持JSR 303(1.0)规范,通过Spring的LocalValidatorFactoryBean实现,它对Spring的Validator接口和javax.validation.Validator接口进行了适配。

1.1. 全局Validator

全局Validator通过上述的LocalValidatorFactoryBean类来提供,只要使用@EnableWebMvc即可(Xml配置开启<mvc:annotation-driven>),也可以进行自定义:

1
2
3
4
5
6
7
8
9
@Configuration
@EnableWebMvc
public class WebConfig extends WebMvcConfigurerAdapter {

@Override
public Validator getValidator(); {
// return "global" validator
}
}

1.2. 私有validator

Spring也支持特定Controller私有的验证器,需要使用@InitBinder将验证器与Controller进行绑定,一个典型的应用场景是:一个Bean的几个属性的校验逻辑在同一个验证器完成。例如:定义如下的Bean,并未使用JSR303,而是使用自定义验证器来校验它的几个属性,示例代码如下:

1、定义Bean:

1
2
3
4
5
6
@Data
public class Employee {
private int id;
private String name;
private String role;
}

2、自定义验证器:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
@Component
public class EmployeeFormValidator implements Validator {
@Override
public boolean supports(Class<?> clazz) {
return Employee.class.equals(clazz);
}

@Override
public void validate(@Nullable Object target, Errors errors) {
// id不能为空
ValidationUtils.rejectIfEmptyOrWhitespace(errors, "id", "id.required");
Employee emp = (Employee) target;
if (emp.getId() <= 0) {
errors.rejectValue("id", "negativeValue", new Object[]{"'id'"}, "id can't be negative");
}
ValidationUtils.rejectIfEmptyOrWhitespace(errors, "name", "name.required", "name cant't be null");
ValidationUtils.rejectIfEmptyOrWhitespace(errors, "role", "role.required", "role cant't be null");
}
}

需要实现Spring的Validator接口,这里使用了Spring提供的ValidationUtils工具类,该验证器将Employee的三个属性都进行了校验。

3、绑定到Controller:

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
@RestController
@RequestMapping("/emp")
public class EmployeeController {
@Autowired
@Qualifier("employeeFormValidator")
private Validator validator;

@InitBinder
private void initBinder(WebDataBinder binder) {
// 绑定验证器
binder.setValidator(validator);
}

@PostMapping(produces = "application/json;charset=utf-8")
public ResultMsg save(@RequestBody @Validated Employee employee,
BindingResult bindingResult, Model model)
{
if (bindingResult.hasErrors()) {
// 校验失败,获取校验错误信息
List<FieldError> errors = bindingResult.getFieldErrors();
StringBuilder sb = new StringBuilder();
for (FieldError error : errors) {
sb.append(String.format("错误字段:%s,错误值:%s,原因:%s",
error.getField(),
error.getRejectedValue(),
error.getDefaultMessage())
).append("\r\n");
}
return ResultMsg.error(MsgDefinition.ILLEGAL_ARGUMENTS.codeOf(), sb.toString());
} else {
return ResultMsg.success(employee);
}
}
}

要开启自动校验功能,需要在Controller校验的Bean上添加Spring的@Validated注解或者Bean Validation的@Valid注解(二者的区别请看文末的特别说明),然后在被校验的Bean参数后加上BindingResult接口,用以接收校验失败的错误信息,该接口扩展了Errors接口。

4、测试

编写单元测试代码,测试Controller:

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
@RunWith(SpringRunner.class)
@SpringBootTest
public class EmployeeControllerTest
{
private MockMvc mockMvc;
@Autowired
protected WebApplicationContext wac;

@Before
public void setUp() {
this.mockMvc = MockMvcBuilders.webAppContextSetup(wac)
.alwaysExpect(MockMvcResultMatchers.status().isOk())
.build();
}

@Test
public void testAdd() throws Exception {
Employee employee = new Employee();
employee.setId(-1);
employee.setName("张三");
// employee.setRole("哈哈");
MvcResult mvcResult = mockMvc.perform(
MockMvcRequestBuilders
.post("/emp")
.accept("application/json;charset=utf-8")
.characterEncoding("utf-8")
// 设置请求的content-type
.contentType("application/json;charset=utf-8")
// 设置json格式请求参数
.content(JsonUtil.toJson(employee))
).andReturn();
MockHttpServletResponse resultResponse = mvcResult.getResponse();
String result = resultResponse.getContentAsString();
System.out.println(result);
// {"rtnCode":"4002","rtnMsg":"错误字段:id,错误值:-1,原因:id can't be negative\r\n错误字段:role,错误值:null,原因:role cant't be null\r\n","data":null,"type":"error"}
}
}

可以看到,校验功能已经启动,Spring进行了参数校验,成功输出校验的错误信息。

上边的内容仅仅简单介绍了Spring的校验机制,更多Spring Validator的详细信息可以看 这里

2. Web中集成Bean Validation

前边说过,Spring从3.0已经全面支持Bean Validation 1.0,在Spring Boot工程中,可以直接使用它来作为Bean校验框架,我们来看看如何使用。

2.1. 编码处理校验结果

前边已经说过,可以在被校验的Bean参数前加上@Valid或者@Validated注意来开启Bean校验,后加上BindingResult接口来获取校验失败信息(见 Spring Boot参数验证(上)----Bean Validation及其Hibernate实现一篇):

  • @Valid:标准JSR-303规范的标记型注解,用来标记验证属性和方法返回值,进行级联和递归校验

  • @Validated:Spring的注解,Spring’s JSR-303规范,是标准JSR-303的一个变种,提供了一个分组功能,可以在入参验证时,根据不同的分组采用不同的验证机制

  • @BindingResult:扩展自Errors接口,表示校验失败的结果

在校验方法参数时,使用@Valid@Validated并无特殊差异,但@Validated注解可以用于类级别,而且支持分组,而@Valid可以用在属性级别约束,用来表示级联校验。关于@Valid@Validated的区别,请查阅相关资料,这里不再赘述。

需要注意的是,校验的Bean和BindingResult作为方法的参数,需要对应。示例代码见上文绑定到Controller章节。

2.2. 编写全局异常处理校验结果

多数情况下,异常处理逻辑基本上是相同的,可以将编码校验工作抽取出来,让Controller层只需要使用注解来标记验证约束,而不需要关注校验结果,只需要校验失败时,自动返回校验失败的信息。

一种方式时,使用Spring Boot的全局异常处理机制。基本思路是:Spring在参数校验失败时,会抛出MethodArgumentNotValidException,只需要编写异常处理器来处理该异常即可。关于如何定义全局异常,可以看 Spring boot全局异常处理和自定义异常页面一文。

我们看看如何实现:

1、定义校验Bean实体:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
@Data
public class Person {
@Size(min = 2, max = 30)
private String name;

@NotEmpty(message = "邮箱地址不能为空")
@Email(message = "邮箱地址格式错误")
private String email;

@Min(value = 18, message = "年龄必须大于18")
@Max(value = 100, message = "年龄必须小于100")
private Integer age;

private Gender gender;

@DateTimeFormat(pattern = "MM/dd/yyyy")
@Past(message = "生日必须为过去的时间")
private Date birthday;

@Phone(message = "号码格式不正确")
private String phone;
}

这里的@Phone为自定义注解,有兴趣可以查阅源码。

2、定义Controller,进行Bean校验:

1
2
3
4
5
6
7
8
@RestController
@RequestMapping("/person")
public class PersonController {
@PostMapping(produces = "application/json;charset=utf-8")
public ResultMsg add(@RequestBody @Valid Person person, Model model) {
return ResultMsg.success(person);
}
}

由于这里请求的数据为json字符串,所以使用@RequestBody注解来接收参数并自动转换Bean。

3、定义全局异常处理器:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
@ControllerAdvice
public class MethodArgumentNotValidExceptionHandler {
@ExceptionHandler(MethodArgumentNotValidException.class)
@ResponseBody
public ResultMsg handleMethodArgumentNotValid(HttpServletRequest req, Exception e)
{
MethodArgumentNotValidException ex = (MethodArgumentNotValidException) e;
BindingResult bindingResult = ex.getBindingResult();

StringBuilder stringBuilder = new StringBuilder();
for (FieldError error : bindingResult.getFieldErrors()) {
String field = error.getField();
Object value = error.getRejectedValue();
String msg = error.getDefaultMessage();
String message = String.format("错误字段:%s,错误值:%s,原因:%s;", field, value, msg);
stringBuilder.append(message).append("\r\n");
}
return ResultMsg.error(MsgDefinition.ILLEGAL_ARGUMENTS.codeOf(), stringBuilder.toString());
}
}

当校验失败时,Spring会抛出MethodArgumentNotValidException异常,该异常会持有校验结果对象BindingResult,从而获得校验失败信息,并转换为请求结果对象,最终会以JSON的格式响应给请求端。

4、编写单元测试代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
@Test
public void testAdd() throws Exception {
Person person = new Person();
person.setName("张三");
person.setEmail("abc123");
person.setAge(10);
person.setBirthday(new Date(System.currentTimeMillis() + 1000 * 10));
person.setPhone("123");
MvcResult mvcResult = mockMvc.perform(
MockMvcRequestBuilders
.post("/person")
.accept("application/json;charset=utf-8")
.characterEncoding("utf-8")
// 设置请求的content-type
.contentType("application/json;charset=utf-8")
// 设置json格式请求参数
.content(JsonUtil.toJson(person))
).andReturn();
MockHttpServletResponse resultResponse = mvcResult.getResponse();
String result = resultResponse.getContentAsString();
Assert.assertTrue(result.contains("\"rtnCode\":\"4002\""));
}

最终结果与预想的一致,json输出结果为:

``{"rtnCode":"4002","rtnMsg":"错误字段:birthday,错误值:Thu Oct 11 11:58:43 CST 2018,原因:``

3. @Validated和@Valid的区别

两者都是用来做bean校验的,前者由Spring提供,后者是java标准定义的,他们的主要区别在于:

1、用的位置不同,@Validated只能用在类、方法和参数上,而@Valid可用于方法、字段、构造器和参数上

2、@Validated可以支持分组,而@Valid不支持,这是最主要的区别

3、@Validated是对@Valid的一种扩展,他们都可用在方法参数上以启用参数自动校验,但是只有前者可以定义当前需要校验的分组,而后者只能将所有参数全部校验;

看一个例子:

1
2
3
4
5
6
7
8
9
10
11
12
public interface DeleteChecks {}

@Data
@EqualsAndHashCode
public class ShoppingCartQuery implements Query {
@NotEmpty
@Equal
private String userOpenId;

@NotEmpty(groups = {DeleteChecks.class})
private List&lt
;Long> ids = new ArrayList<>();
}
1
2
3
4
5
6
7
8
@PostMapping(path = "/_delete_by_query", consumes = MediaType.APPLICATION_JSON_UTF8_VALUE)
public Response deleteByQuery(@Validated(DeleteChecks.class) @RequestBody ShoppingCartQuery query) {
return Response.success(shoppingCartService.batchDelete(query.getUserOpenId(), query.getIds()));
}
@GetMapping({"/", ""})
public Response queryAll(@Validated @RequestBody ShoppingCartQuery query) {
return Response.success(shoppingCartService.findAll(query));
}

我希望通过Spring的Bean校验机制,自动校验ShoppingCartQuery,但是删除和查询方法所校验的属性不同,删除时需要传递ids,而查询时不需要。要达到这个目的,我们必须使用@Validated注解,还需要定义一个分组DeleteChecks,然后删除方法的@Validated注解使用该分组,以此达到分开校验的目的(分组定义不清楚的可以看 这里)。

4. 总结

Spring Boot的Web Starter已经加入了Bean Validation(JSR303)的依赖,可以直接使用。在使用时,只需在需要校验的方法上加上@Valid或者@Validated注解即可,如果需要编码自定义校验结果,则在校验的参数后加上BindingResult参数,注意对应关系;否则,为了模块化需要,也可以屏蔽校验失败业务逻辑,编写全局校验器,校验失败自动返回JSON校验结果即可。

本篇源码见 Github

支付宝打赏 微信打赏

赞赏是不耍流氓的鼓励