前言
在我们日常开发中,数据校验是我们绕不开的一环,而用Spring Validation进行校验,基本上成为我们进行数据校验的首选组件,今天的话题就来聊下如何利用Spring Validation进行优雅校验
Spring Validate
Spring Validate简介
Spring框架的验证功能主要基于JSR 303/JSR 349 Bean Validation规范,这是一套标准的Java注解驱动的数据验证API。Spring提供了对Bean Validation的深度集成,使得在Web应用中进行数据校验变得既强大又简便。
Spring Validate主要功能
- 注解驱动的验证:
Spring支持Bean Validation注解,如@NotNull, @Size, @Pattern, @Email, @Min, @Max等,可以直接在实体类的属性上标注,进行自动的数据校验。
- 自定义校验注解:
开发者可以创建自定义的校验注解和校验器,以适应更复杂或特定于业务的验证逻辑。
- 集成到Spring MVC:
在Spring MVC中,可以使用@Valid或@Validated注解配合BindingResult对象来捕获和处理校验错误,通常在控制器方法的参数中使用。
- 错误消息定制:
可以通过资源文件或直接在注解中定义错误消息,以便向用户提供更友好的错误信息。
- 组验证:
支持按组进行验证,允许在不同的场景下应用不同的验证规则集。
- 嵌套对象验证:
当对象中有嵌套的其他对象时,Spring可以递归地进行验证,确保整个数据结构的有效性。
- 验证器接口Validator:
提供了Validator接口,允许开发者实现自定义的验证逻辑,而不使用注解。
- 错误处理:
提供了灵活的错误处理机制,可以根据业务需求选择合适的错误处理策略,如返回HTTP状态码、重定向到错误页面等。
- 国际化支持:
支持多语言的错误消息,可以通过不同的资源文件为不同语言的用户提供相应的错误信息。
如何使用
1、项目中引入相应的GAV
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
2、在需要校验实体的属性上,加上相关注解
示例:
3、在需要进行校验的控制器方法写上相应注解以及BindingResult
示例
@PostMapping("addOther")
public AjaxResult addOther(@Validated @RequestBody UserDTO userDTO, BindingResult bindingResult){
if(bindingResult.hasErrors()){
Map<String,String> errorMap = new LinkedHashMap<>();
bindingResult.getFieldErrors().forEach(fieldError -> {
errorMap.put(fieldError.getField(), fieldError.getDefaultMessage());
});
return AjaxResult.fail("数据校验失败",String.valueOf(HttpStatus.BAD_REQUEST.value()),errorMap);
}
return AjaxResult.success(userDTO);
}
注: BindingResult要和实体一一匹配,比如你写的方法有2个实体,则方法需要写成如下
@PostMapping("addOther")
public AjaxResult addOther(@Validated @RequestBody UserDTO userDTO, BindingResult bindingResult,@Validated @RequestBody User user,BindingResult useBindingResultr){
}
上面的写法是常规写法,但是存在一些问题,比如有多个方法需要校验,那要写一堆BindingResult ,这样代码可读性就比较差。因此我们可以做如下改造
通过定义全局异常处理器来处理
@RestControllerAdvice(basePackages = "com.github.lybgeek")
@RequiredArgsConstructor
@Slf4j
public class ResultResponseBodyAdvice implements ResponseBodyAdvice<Object> {
private final MessageSource messageSource;
@ResponseStatus(HttpStatus.BAD_REQUEST)
@ExceptionHandler(MethodArgumentNotValidException.class)
public AjaxResult validationException(MethodArgumentNotValidException exception){
Map<String,String> errorMap = new LinkedHashMap<>();
exception.getBindingResult().getFieldErrors().forEach(fieldError -> {
errorMap.put(fieldError.getField(), fieldError.getDefaultMessage());
});
log.error("validate error:{}",exception.getMessage());
String message = messageSource.getMessage("message.validate.error",null,"validate error", LocaleContextHolder.getLocale());
return AjaxResult.fail(message,String.valueOf(HttpStatus.BAD_REQUEST.value()),errorMap);
}
}
4、分组校验
当有些属性在新增时不需要校验,但在修改需要校验,我们就可以利用分组校验
示例:
实体层添加在相应的校验注解上,并通过group属性进行分组
在需要校验的控制层方法上加@Validated注解,并添加分组属性
示例
@PostMapping("update")
public UserDTO update(@Validated(CrudValidate.Update.class) @RequestBody UserDTO userDTO){
UserDTO updateUser = userService.update(userDTO);
System.out.println("updateUser:" + updateUser);
return updateUser;
}
5、自定义注解校验
当Spring Validate提供的原生注解,不满足我们的校验需求时,我们可以通过自定义注解校验
示例
a、 定义自定义校验注解
@Documented
@Constraint(
validatedBy = {UniqueConstraintValidator.class}
)
@Target({ElementType.METHOD, ElementType.FIELD, ElementType.ANNOTATION_TYPE, ElementType.CONSTRUCTOR, ElementType.PARAMETER, ElementType.TYPE_USE})
@Retention(RetentionPolicy.RUNTIME)
public @interface Unique {
String message() default "{javax.validation.constraints.Unique.message}";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
Class<? extends UniqueCheckService> checkUniqueBeanClass();
String checkField() default "";
}
b、 自定义校验规则逻辑
@Component
@Scope("prototype")
@Slf4j
public class UniqueConstraintValidator implements ConstraintValidator<Unique,Object>, ApplicationContextAware {
private ApplicationContext applicationContext;
private UniqueCheckService uniqueCheckService;
private String checkField;
@Override
public void initialize(Unique constraintAnnotation) {
uniqueCheckService = applicationContext.getBean(constraintAnnotation.checkUniqueBeanClass());
checkField = constraintAnnotation.checkField();
}
@Override
public boolean isValid(Object value, ConstraintValidatorContext context) {
if(value == null){
return true;
}
log.info(">>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>> uniqueCheckService:{},checkField:{},value:{}",uniqueCheckService,checkField,value);
return !uniqueCheckService.checkUnique(value,checkField);
}
@Override
public void setApplicationContext(ApplicationContext applicationContext) throws BeansException {
this.applicationContext = applicationContext;
}
}
注: 这边校验规则的bean用原型模式,是为了避免线程安全问题,实际得根据具体业务场景定
c、 在需要校验的属性上,加上自定义注解
示例
5、国际化
当我们的业务有国际化场景需求, Spring Validate也支持校验信息的国际化
示例
以spring boot项目为示例
a、 在项目的application.yml做如下配置
spring:
messages:
# 注意需要创建messages.properties文件来做兜底
basename: i18n/messages #代表将国际化文件放在i18n文件夹下,并以messages作为文件名前缀,而不是指国际化文件存放在i18n/messages文件夹。
encoding: UTF-8
注: 配置文件中i18n/messages的含义是将国际化文件放在i18n文件夹下,并以messages作为文件名前缀,而不是指国际化文件存放在i18n/messages文件夹。
国际化文件夹位置如下示例
注: 其中messages.properties文件来做兜底。
数据校验国际化相关输出内容配置化,如下
messages_zh_CN.properties
message.id.not.empty=ID不能为空
message.username.not.empty=用户名不能为空
message.username.unique=用户名已经存在
message.password.not.empty=密码不能为空
message.password.length=密码的长度必须在{min}-{max}之间
message.email.format.error=邮箱格式错误
message.mobile.unique=手机号码已经存在
message.mobile.format.error=手机号码格式错误
message.user.not.exist=不存在用户ID为{0}的用户
message.validate.error=数据校验失败
message.exception.error=系统异常
messages_en_US.properties
message.id.not.empty=id must not be empty
message.username.not.empty=username must not be empty
message.username.unique=username must be unique
message.password.not.empty=password must not be empty
message.password.length=the password length must be between {min}-{max}.
message.mobile.unique=mobile must be unique
message.mobile.format.error=mobile format error
message.email.format.error=email format error
message.user.not.exist=the user with ID {0} does not exist
message.validate.error=data validation error
message.exception.error=system exception
b、 在需要校验的实体上做如下配置
@Data
@AllArgsConstructor
@NoArgsConstructor
@Builder
public class UserDTO {
@NotNull(message = "{message.id.not.empty}",groups = {CrudValidate.Update.class})
private Long id;
@NotEmpty(message = "{message.username.not.empty}")
@Unique(message = "{message.username.unique}",checkUniqueBeanClass = UserService.class)
private String username;
@NotEmpty(message = "{message.password.not.empty}")
@Size(min = 6,max = 32,message = "{message.password.length}")
private String password;
@Email(message = "{message.email.format.error}")
private String email;
@Pattern(regexp = "^1[3-9]\\d{9}$",message = "{message.mobile.format.error}")
@Unique(message = "{message.mobile.unique}",checkUniqueBeanClass = MobileCheckService.class,checkField = "mobile")
private String mobile;
}
做了如上配置,如果springboot的版本是在2.6.x版本之后,即可生效。Spring Boot 2.6.x版本之后已支持验证注解message属性引用Spring Boot自身国际化配置。在Spring Boot 2.5.x版本中以及之前,Spring Boot Validation默认只支持读取resources/ValidationMessages.properties系列文件的中的国际化属性,且中文需要进行ASCII转码才可正确显示。具体可以查看官方issue
https://github.com/spring-projects/spring-boot/pull/17530
如果我们想要在2.5.x版本以及之前,使用Spring Boot spring.messages设置的国际化文件,我们可以做如下配置
@Configuration
@ComponentScan(basePackages = {"com.github.lybgeek.validate.constraint","com.github.lybgeek.validate.advice"})
public class ValidateAutoConfiguration implements WebMvcConfigurer {
@Autowired
private MessageSource messageSource;
/**
* @see <a href="https://github.com/spring-projects/spring-boot/pull/17530">...</a>
* 在Spring Boot 2.5.x版本中以及之前,Spring Boot Validation默认只支持读取resources/ValidationMessages.properties系列文件的中的国际化属性,
* 且中文需要进行ASCII转码才可正确显示,Spring Boot 2.6.x版本之后已支持验证注解message属性引用Spring Boot自身国际化配置。
* @return
*/
@Override
public Validator getValidator() {
LocalValidatorFactoryBean bean = new LocalValidatorFactoryBean();
//仅兼容Spring Boot spring.messages设置的国际化文件和原hibernate-validator的国际化文件
//不再支持resource/ValidationMessages.properties
bean.setValidationMessageSource(this.messageSource);
return bean;
}
}
不过这样配置后,就会导致原先ValidationMessages.properties不再生效
c、国际化验证
编写单测
@Test
public void testAdd(){
UserDTO userDTO = new UserDTO();
userDTO.setEmail("lisi@qq.com");
userDTO.setPassword("123456");
userDTO.setUsername("lisi");
userDTO.setMobile("13600000006");
String language = getLanguage();
System.out.println("language:"+language);
String result = Forest.post(BASE_URL + "user/add")
.contentTypeJson()
.addHeader("Accept-Language", language)
.addBody(JSONUtil.toJsonStr(userDTO)).execute(String.class);
System.out.println(result);
}
spring国际化,默认是使用 AcceptHeaderLocaleResolver解析器,即通过在header配置"Accept-Language",进行语言传递
示例效果如下
{"message":"data validation error","code":"400","data":{"email":"email format error","mobile":"mobile format error","username":"username must not be empty","password":"the password length must be between 6-32."},"success":false}
在我们实际开发中,前端通过header传递国际化语言可能不大方便,有时候我们会直接把传递的语言放在url的请求参数中,形如
BASE_URL + "user/save?lang="+language
基于上述需求,我们需做如下配置
@Configuration
@ComponentScan(basePackages = {"com.github.lybgeek.validate.constraint","com.github.lybgeek.validate.advice"})
public class ValidateAutoConfiguration implements WebMvcConfigurer {
@Override
public void addInterceptors(InterceptorRegistry registry) {
// 如果需要支持修改语言,则LocaleResolver要改成SessionLocaleResolver或者其他,不能用默认的
// AcceptHeaderLocaleResolver
LocaleChangeInterceptor interceptor = new LocaleChangeInterceptor();
interceptor.setParamName("lang");
registry.addInterceptor(interceptor).addPathPatterns("/**");
}
@Bean
public LocaleResolver localeResolver() {
// 默认AcceptHeaderLocaleResolver实现国际化
SessionLocaleResolver localeResolver = new SessionLocaleResolver();
localeResolver.setDefaultLocale(Locale.SIMPLIFIED_CHINESE);
return localeResolver;
}
}
单测示例如下
@Test
public void testSaveErrorWithI18n(){
UserDTO userDTO = new UserDTO();
userDTO.setEmail("123");
userDTO.setPassword("123");
userDTO.setMobile("123");
String language = getLanguage();
System.out.println("language:"+language);
String result = Forest.post(BASE_URL + "user/save?lang="+language)
.contentTypeJson()
// 当LocaleResolver为AcceptHeaderLocaleResolver,支持header传递Accept-Language,该模式为默认模式
// 本示例我们改成成通过url传递参数,因此我们需做一定改造,该地方查看com.github.lybgeek.validate.autoconfigure.ValidateAutoConfiguration
// .addHeader("Accept-Language", language)
.addBody(JSONUtil.toJsonStr(userDTO)).execute(String.class);
System.out.println(result);
}
测试效果
{"message":"数据校验失败","code":"400","data":{"username":"用户名不能为空","password":"密码的长度必须在6-32之间","email":"邮箱格式错误","mobile":"手机号码格式错误"},"success":false}
注: 这边有个小细节要注意,传递语言值需用中划线,而非下划线,比如中文,则需写成zh-CN而非zh_CN
6、通过service层进行校验
有些场景我们可能不是通过controller进行数据校验,而是直接通过service进行校验。我们得做如下配置
示例
/***
* 同时定义了接口和实现类, @Valid加在service接口上, 不是实现类上
*/
@Validated
public interface UserService extends UniqueCheckService {
UserDTO save(@Valid UserDTO userDTO);
@Validated(CrudValidate.Update.class)
UserDTO update(@Valid UserDTO userDTO);
}
注: 同时配置接口和实现,校验注解需要写在接口上,否则会报错
编写service单测
@Test
public void testAddErrorWithI18n(){
UserDTO userDTO = new UserDTO();
userDTO.setEmail("12345");
userDTO.setPassword("12345");
userDTO.setMobile("123");
System.out.println(JSONUtil.toJsonStr(userService.save(userDTO)));
}
注: 因为示例的springboot版本低于2.6.x,且service层是无法感知WebMvcConfigurer做的校验器变更,因此如果我们需要做国际化配置,配置文件只能调整成ValidationMessages.properties
示例配置如下
单测效果如下
save.userDTO.mobile: 手机号码格式错误 !!!, save.userDTO.username: 用户名不能为空 !!!, save.userDTO.email: 邮箱格式错误 !!!, save.userDTO.password: 密码的长度必须在6-32之间 !!!
走的数据校验提示语,来自ValidationMessages_zh_CN.properties配置
总结
本文主要介绍Spring Validate一些比较常用的校验,这边有个小建议,就是数据校验提示信息,最好做成外部配置化,而非写死在代码里,尤其现在不少企业在探索出海业务,对国际化的支持是一个必选项,在代码写死数据校验提示,不是一个好的选择项
demo链接
https://github.com/lyb-geek/springboot-learning/tree/master/springboot-validate
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。