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

1. Spring Validator

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

public interface Validator {
    // 检测Validator是否支持校验提供的Class
    boolean supports(Class<?> clazz);

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

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

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>),也可以进行自定义:

@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:

@Data
public class Employee {
    private int id;
    private String name;
    private String role;
}

2、自定义验证器:

@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:

@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:

@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实体:

@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校验:

@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、定义全局异常处理器:

@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、编写单元测试代码:

@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的一种扩展,他们都可用在方法参数上以启用参数自动校验,但是只有前者可以定义当前需要校验的分组,而后者只能将所有参数全部校验;

看一个例子:

public interface DeleteChecks {}

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

    @NotEmpty(groups = {DeleteChecks.class})
    private List<Long> ids = new ArrayList<>();
}
@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


相关阅读