JDK validation 功能
Java EE 规范中用接口定义了 Java Bean 的校验方式,即 Java Bean Validation。
相关信息
常用的 Java EE 规范接口在 jdk 的 javax 包下,如:
- javax.sql —— 数据库访问接口。实现厂商有 mysql/sqlserver/oracle/...
- javax.servlet —— tomcat/jetty
- java.xml —— jaxp(java api for xml processing)/jaxb
- javax.persistence —— hibernate
- javax.transaction —— 分布式实务
- javax.jms —— activemq
使用案例
一:引入校验接口
Java Bean Validation 接口不默认包含在 JDK 中,需要主动引入:
<dependency>
<groupId>javax.validation</groupId>
<artifactId>validation-api</artifactId>
<version>2.0.1.Final</version>
</dependency>
<dependency>
<groupId>jakarta.validation</groupId>
<artifactId>jakarta.validation-api</artifactId>
<version>2.0.1</version>
</dependency>
提示
Java 与 Jakarta 的区别?
Java EE 规范由 JCP(Java Community Process) 通过 JSR(Java Specification Requests,Java 规范提案) 规定。 但 Oracle 收购 Java 后,虽然将 Java EE 规范捐献给 eclipse 基金会管理,但要求更改 java 相关的命名,其中包括 Java EE 代码所在的 javax 包名。 因此,后续 javax 包下的代码统一移动到了 jakarta 下。 所以,可以说 java 与 jakarta 没太大区别,或者说 jakarta 是新版的 java。
相关信息
规范版本:
提案 | 版本 |
---|---|
jsr303 | beanvalidation 1.0 |
jsr349 | beanvalidation 1.1 |
jsr380 | beanvalidation 2.0 |
二:引入校验实现(hibernate-validator)
引入 validation-api
的具体实现之一 hibernate-validator:
<dependency>
<groupId>org.hibernate.validator</groupId>
<artifactId>hibernate-validator</artifactId>
<version>6.0.0.Final</version>
</dependency>
<!-- fix: javax.validation.ValidationException: HV000183: Unable to initialize 'javax.el.ExpressionFactory'. Check that you have the EL dependencies on the classpath, or use ParameterMessageInterpolator instead -->
<dependency>
<groupId>org.apache.tomcat.embed</groupId>
<artifactId>tomcat-embed-el</artifactId>
<version>9.0.65</version>
</dependency>
<dependency>
<groupId>org.hibernate.validator</groupId>
<artifactId>hibernate-validator</artifactId>
<version>8.0.1.Final</version>
</dependency>
注意
validation-api
/jakarta.validation-api
属于接口,没有具体实现,不能直接运行,否则出现如下告警:
javax.validation.NoProviderFoundException: Unable to create a Configuration, because no Bean Validation provider could be found. Add a provider like Hibernate Validator (RI) to your classpath.
at javax.validation.Validation$GenericBootstrapImpl.configure(Validation.java:291)
at javax.validation.Validation.buildDefaultValidatorFactory(Validation.java:103)
三:使用校验规则
package org.example.entity;
import lombok.Builder;
import lombok.Data;
import org.example.entity.validation.MyUrl;
import javax.validation.constraints.*;
import java.time.LocalDateTime;
import java.util.List;
@Builder
@Data
public class User {
public static interface Insert {}
public static interface Update {}
@NotNull(groups = {
Update.class, // 全部注解默认 Default 组。如果校验时指定组,则只执行对应组的校验。
})
private Long id;
@NotBlank(groups = {
Insert.class
})
private String username;
@Min(value = 0, message = "年龄大于{value}岁")
private Integer age;
@PastOrPresent
private LocalDateTime birthDay;
@Email
private String email;
@Pattern(regexp = "^\\p{Print}+$", message = "手机号输入错误")
private String phone;
/**
* 个人网站
*/
private List<@MyUrl String> urls;
}
package org.example.entity;
import lombok.extern.slf4j.Slf4j;
import org.hibernate.validator.HibernateValidator;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
import org.opentest4j.AssertionFailedError;
import javax.validation.ConstraintViolation;
import javax.validation.Validation;
import javax.validation.Validator;
import javax.validation.groups.Default;
import java.time.LocalDateTime;
import java.util.*;
import java.util.stream.Collectors;
@Slf4j
public class UserTest {
// 线程安全
private final static Validator validator = Validation.buildDefaultValidatorFactory().getValidator();
/**
* 对比预期
*
* @param user 被测试类
* @param checkList 有问题的检查项
* @param groups 检查组
*/
private void check(User user, Collection<String> checkList, Class<?> ... groups) {
Set<ConstraintViolation<User>> validate = validator.validate(user, groups);
// 返回校验不通过的项目
Set<String> collect = validate.stream().map(v -> v.getPropertyPath().toString()).collect(Collectors.toSet());
try {
Assertions.assertEquals(checkList.size(), collect.size());
for (String p : checkList) {
boolean contains = collect.contains(p);
Assertions.assertTrue(contains, String.format("contains not %s in %s", p, collect));
}
} catch (AssertionFailedError e) {
String allValidateResult = validate.stream().reduce(new StringBuilder("all validate results: \n"),
StringBuilder::append,
(a, b) -> b).toString();
log.error("{}", allValidateResult);
throw e;
}
}
/**
* 默认校验
*/
@Test
void testDefault() {
User user = User.builder()
.age(18)
.birthDay(LocalDateTime.now())
.phone("")
.email("xxx@gmail.com")
.urls(Arrays.asList(
"http://example.org",
"https://www.example.org:8080/blog/index.jsp#hello-world"
))
.build();
List<String> checklist = Collections.singletonList("phone");
check(user, checklist);
}
/**
* 更新情况校验(不包含默认校验)
*/
@Test
void testUpdate() {
User user = User.builder().build();
List<String> checklist = Collections.singletonList("id");
check(user, checklist, User.Update.class);
}
/**
* 测试快速失败
*/
@Test
void testFailFast() {
User user = User.builder()
.id(null)
.username("")
.email("xxx")
.phone("")
.urls(Arrays.asList(
"!!!"
))
.build();
Validator failFastValidator = Validation
.byProvider(HibernateValidator.class).configure().failFast(true).buildValidatorFactory() // 配置快速失败
.getValidator();
Set<ConstraintViolation<User>> validate = failFastValidator.validate(user, User.Insert.class, User.Update.class, Default.class);
Assertions.assertEquals(1, validate.size()); // 因为快速失败,所有有且只有一个错误信息
}
}
常用注解
Bean Validation Constraint
注解 | 说明 |
---|---|
@Null /@NotNull /@NotEmpty /NotBlank | 非空 |
@AssertTrue /@AssertFalse | 布尔 |
@Min(value) /@Max(value) /@DecimalMin(value) /@DecimalMax(value) /@Size(max, min) /@NegativeOrZero /@Digits(integer, fraction) | 数值 |
@Past /@PastOrPresent /@Future | 时间 |
@Pattern(value) /@Email | 正则 |
Hibernate Validation Constraint
注解 | 说明 |
---|---|
@Length | 长度 |
@Range | 范围 |
@URL | 正则 |
实现原理分析
有多种 bean validation 实现,下面以 hibernate 为例。
一:校验实现的加载
上面例子中,我们业务类上只调用了校验接口(javax.validation.Validator
),运行时就自动调用了校验器的实现(org.hibernate.validator.internal.engine.ValidatorImpl
)。 有这种现象是因为 beanvalidation 使用了 SPI 技术。
SPI(service provider interface,服务提供接口) 是 JDK 提供的一种服务发现机制。 同样使用 SPI 的有 jdbc/slf4j/...
在 beanvalidation 这里具体有两个关键的配置点:
- 在接口中调用服务接口的类加载方法 ——
ServiceLoader<ValidationProvider> loader = ServiceLoader.load( ValidationProvider.class, classloader );
- 在实现中配置服务接口的实现类名 —— 在
META-INF/services/javax.validation.spi.ValidationProvider
中写入org.hibernate.validator.HibernateValidator
这样,运行时就可以获取到服务接口的具体实现了。
二:校验实现的绑定
校验注解会绑定一个校验器实现。当要校验值时,会找到校验器进行校验。
如: @NotBlank
由 org.hibernate.validator.internal.constraintvalidators.hv.NotBlankValidator
实现
绑定过程在 org.hibernate.validator.internal.metadata.core.ConstraintHelper
中:
putConstraint( tmpConstraints, NotBlank.class, NotBlankValidator.class );
List<Class<? extends ConstraintValidator<NotEmpty, ?>>> notEmptyValidators = new ArrayList<>( 11 );
notEmptyValidators.add( NotEmptyValidatorForCharSequence.class );
notEmptyValidators.add( NotEmptyValidatorForCollection.class );
notEmptyValidators.add( NotEmptyValidatorForArray.class );
notEmptyValidators.add( NotEmptyValidatorForMap.class );
notEmptyValidators.add( NotEmptyValidatorForArraysOfBoolean.class );
notEmptyValidators.add( NotEmptyValidatorForArraysOfByte.class );
notEmptyValidators.add( NotEmptyValidatorForArraysOfChar.class );
notEmptyValidators.add( NotEmptyValidatorForArraysOfDouble.class );
notEmptyValidators.add( NotEmptyValidatorForArraysOfFloat.class );
notEmptyValidators.add( NotEmptyValidatorForArraysOfInt.class );
notEmptyValidators.add( NotEmptyValidatorForArraysOfLong.class );
notEmptyValidators.add( NotEmptyValidatorForArraysOfShort.class );
putConstraints( tmpConstraints, NotEmpty.class, notEmptyValidators );
putConstraint( tmpConstraints, NotNull.class, NotNullValidator.class );
putConstraint( tmpConstraints, Null.class, NullValidator.class );
自定义注解
package org.example.entity.validation;
import javax.validation.Constraint;
import javax.validation.Payload;
import javax.validation.ReportAsSingleViolation;
import java.lang.annotation.Documented;
import java.lang.annotation.Retention;
import java.lang.annotation.Target;
import static java.lang.annotation.ElementType.FIELD;
import static java.lang.annotation.ElementType.TYPE_USE;
import static java.lang.annotation.RetentionPolicy.RUNTIME;
/**
* 自定义校验注解:判断是 ip or domain or host
*/
@Documented
@Constraint(validatedBy = { MyUrlValidator.class })
@Target({ FIELD, TYPE_USE })
@Retention(RUNTIME)
public @interface MyUrl {
String message() default "请输入正确的地址";
Class<?>[] groups() default { };
Class<? extends Payload>[] payload() default { };
}
package org.example.entity.validation;
import org.apache.commons.validator.routines.DomainValidator;
import org.apache.commons.validator.routines.InetAddressValidator;
import javax.validation.ConstraintValidator;
import javax.validation.ConstraintValidatorContext;
import java.net.MalformedURLException;
import java.util.Arrays;
import java.util.regex.Pattern;
public class MyUrlValidator implements ConstraintValidator<MyUrl, String> {
// https://www.geeksforgeeks.org/how-to-validate-a-domain-name-using-regular-expression/
private static final Pattern DOMAIN_PATTERN = Pattern.compile("^((?!-)[A-Za-z0-9-]{1,63}(?<!-)\\.)+[A-Za-z]{2,6}$");
// https://www.geeksforgeeks.org/how-to-validate-an-ip-address-using-regular-expressions-in-java/
private static final Pattern IP_PATTERN = Pattern.compile("^(\\d{1,3})\\.(\\d{1,3})\\.(\\d{1,3})\\.(\\d{1,3})$");
@Override
public boolean isValid(String value, ConstraintValidatorContext context) {
return isIp(value)
|| isDomain(value)
|| isUrl(value);
}
boolean isDomain(String value) {
// return DOMAIN_PATTERN.matcher(value).find();
return DomainValidator.getInstance(true).isValid(value); // || "localhost".equals(value);
}
boolean isIp(String value) {
// return IP_PATTERN.matcher(value).find();
return InetAddressValidator.getInstance().isValidInet4Address(value);
}
boolean isUrl(String value) {
java.net.URL url = null;
try {
url = new java.net.URL(value);
} catch (MalformedURLException e) {
return false;
}
String[] validProtocols = new String[] {
"http",
"https"
};
if (!Arrays.asList(validProtocols).contains(url.getProtocol())) {
return false;
}
return true;
}
}
package org.example.entity.validation;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import org.mockito.InOrder;
import org.mockito.Spy;
import org.mockito.junit.jupiter.MockitoExtension;
import javax.validation.ConstraintValidatorContext;
import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class)
public class MyUrlValidatorTest {
@Spy
private MyUrlValidator myUrlValidator = new MyUrlValidator();
@Test
void test() {
ConstraintValidatorContext context = mock(ConstraintValidatorContext.class);
Assertions.assertTrue(myUrlValidator.isValid("127.0.0.1", context));
Assertions.assertTrue(myUrlValidator.isValid("example.org", context));
Assertions.assertTrue(myUrlValidator.isValid("http://example.org:8080/path/to/index.jsp#adfsf?a=1&b=c", context));
InOrder inOrder = inOrder(myUrlValidator);
inOrder.verify(myUrlValidator).isIp(anyString());
inOrder.verify(myUrlValidator).isDomain(anyString());
inOrder.verify(myUrlValidator).isUrl(anyString());
}
@ParameterizedTest
@ValueSource(strings = {
"localhost",
"local-host",
"example.org",
"www.example.org",
"www.example.example.org",
})
void testDomainTrue(String ip) {
boolean o = myUrlValidator.isDomain(ip);
Assertions.assertTrue(o);
}
@ParameterizedTest
@ValueSource(strings = {
"127.0.0.1",
"www.example.or",
"www.example.org:8080",
"http://127.0.0.1",
"http://255.255.255.255",
"http://www.example.org",
"https://www.example.org",
"https://www.example.org:8080",
"https://www.example.org:8080/xxx",
"https://www.example.org:8080/xxx/index.jsp",
"https://www.example.org:8080/xxx/index.jsp#aaaa",
"https://www.example.org:8080/xxx/index.jsp#aaaa?aa=bb&cc=dd",
"-www.example.org",
"www.example.org-",
"-www.example.org-",
})
void testDomainFalse(String ip) {
boolean o = myUrlValidator.isDomain(ip);
Assertions.assertFalse(o);
}
@ParameterizedTest
@ValueSource(strings = {
"127.0.0.1",
"192.168.1.1",
"10.0.0.1",
"255.255.255.255",
})
void testIpTrue(String ip) {
boolean o = myUrlValidator.isIp(ip);
Assertions.assertTrue(o);
}
@ParameterizedTest
@ValueSource(strings = {
"256.0.0.1",
"localhost",
"example.org",
})
void testIpFalse(String ip) {
boolean o = myUrlValidator.isIp(ip);
Assertions.assertFalse(o);
}
@ParameterizedTest
@ValueSource(strings = {
"localhost",
"example.org",
"ftp://www.example.org", // http/https only
})
void testUrlFalse(String ip) {
boolean o = myUrlValidator.isUrl(ip);
Assertions.assertFalse(o);
}
@ParameterizedTest
@ValueSource(strings = {
"http://127.0.0.1",
"http://www.example.org",
"https://www.example.org",
"https://www.example.org:8080",
"https://www.example.org:8080/xxx",
"https://www.example.org:8080/xxx/index.jsp",
"https://www.example.org:8080/xxx/index.jsp#aaaa",
"https://www.example.org:8080/xxx/index.jsp#aaaa?aa=bb&cc=dd",
})
void testUrlTrue(String ip) {
boolean o = myUrlValidator.isUrl(ip);
Assertions.assertTrue(o);
}
}
参考:
- 参数校验 Jakarta Bean Validation 学习 - https://blog.csdn.net/csdn_mrsongyang/article/details/106115243