在Java开发中,数据校验是保证系统健壮性的第一道防线。虽然Spring Validation提供了@NotNull、@Size等基础注解,但面对手机号、身份证号、银行卡号等业务特定规则时,这些通用注解就显得力不从心。本文将带你深入Hibernate Validator的扩展机制,从零开始实现一个可复用的手机号校验注解,并拓展到更复杂的业务场景。
想象这样一个场景:你的用户注册接口需要验证手机号格式。如果直接在业务代码中写正则校验,会有几个明显问题:
if-else校验语句污染Hibernate Validator的扩展机制可以完美解决这些问题。通过自定义注解,你可以:
java复制public class UserDTO {
@Mobile // 自定义的手机号校验注解
private String phone;
// 其他字段...
}
这种声明式校验不仅简洁优雅,还能与Spring Validation无缝集成,自动触发校验并返回标准化的错误信息。
首先创建一个@Mobile注解,核心要素包括:
java复制@Documented
@Target({FIELD, PARAMETER})
@Retention(RUNTIME)
@Constraint(validatedBy = MobileValidator.class)
public @interface Mobile {
String message() default "手机号格式不正确";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
// 可配置的正则表达式参数
String regexp() default "^1[3-9]\\d{9}$";
}
关键点说明:
@Constraint指定校验器的实现类message支持国际化消息配置groups实现分组校验(如区分新增/修改场景)payload可携带元数据供校验器使用创建ConstraintValidator实现类处理实际校验:
java复制public class MobileValidator implements ConstraintValidator<Mobile, String> {
private Pattern pattern;
@Override
public void initialize(Mobile constraintAnnotation) {
// 初始化时编译正则表达式
pattern = Pattern.compile(constraintAnnotation.regexp());
}
@Override
public boolean isValid(String value, ConstraintValidatorContext context) {
if (value == null) {
return true; // 与@NotNull配合使用
}
return pattern.matcher(value).matches();
}
}
校验器设计建议:
initialize中isValid方法保持轻量级@NotNull使用)无需额外配置,Spring Boot自动检测并注册自定义校验器。测试用例:
java复制@RestController
public class UserController {
@PostMapping("/users")
public ResponseEntity<?> createUser(@Valid @RequestBody UserDTO user) {
// 校验通过才会执行
return ResponseEntity.ok().build();
}
}
当校验失败时,Spring会自动返回包含错误信息的400响应:
json复制{
"timestamp": "2023-07-20T08:00:00.000+00:00",
"status": 400,
"errors": [
"phone: 手机号格式不正确"
]
}
身份证校验比手机号更复杂,需要:
可以通过组合多个校验器实现:
java复制public class IdCardValidator implements ConstraintValidator<IdCard, String> {
private static final Pattern BASE_PATTERN = Pattern.compile(
"^[1-9]\\d{5}(18|19|20)\\d{2}(0[1-9]|1[0-2])(0[1-9]|[12]\\d|3[01])\\d{3}[\\dxX]$"
);
@Override
public boolean isValid(String value, ConstraintValidatorContext context) {
if (!BASE_PATTERN.matcher(value).matches()) {
return false;
}
// 校验行政区划代码
if (!validateAreaCode(value.substring(0,6))) {
return false;
}
// 校验生日
if (!validateBirthday(value.substring(6,14))) {
return false;
}
// 18位身份证需要校验校验码
return value.length() != 18 || validateCheckCode(value);
}
// 其他校验方法...
}
典型场景如密码确认字段校验:
java复制@Getter @Setter
public class RegisterDTO {
@NotBlank
private String password;
@NotBlank
@FieldMatch.List({
@FieldMatch(first = "password", second = "confirmPassword")
})
private String confirmPassword;
}
自定义@FieldMatch注解实现:
java复制@Constraint(validatedBy = FieldMatchValidator.class)
public @interface FieldMatch {
String first();
String second();
// 默认实现...
}
public class FieldMatchValidator implements ConstraintValidator<FieldMatch, Object> {
private String firstField;
private String secondField;
@Override
public void initialize(FieldMatch constraintAnnotation) {
this.firstField = constraintAnnotation.first();
this.secondField = constraintAnnotation.second();
}
@Override
public boolean isValid(Object value, ConstraintValidatorContext context) {
try {
Object firstObj = BeanUtils.getProperty(value, firstField);
Object secondObj = BeanUtils.getProperty(value, secondField);
return Objects.equals(firstObj, secondObj);
} catch (Exception e) {
return false;
}
}
}
验证输入值是否在指定枚举范围内:
java复制@EnumValue(enumClass = UserType.class)
private String userType;
// 校验器实现
public class EnumValueValidator implements ConstraintValidator<EnumValue, String> {
private Set<String> allowedValues;
@Override
public void initialize(EnumValue constraintAnnotation) {
Class<? extends Enum<?>> enumClass = constraintAnnotation.enumClass();
allowedValues = Arrays.stream(enumClass.getEnumConstants())
.map(Enum::name)
.collect(Collectors.toSet());
}
@Override
public boolean isValid(String value, ConstraintValidatorContext context) {
return value == null || allowedValues.contains(value);
}
}
在initialize方法中预编译正则表达式:
java复制private Pattern pattern;
@Override
public void initialize(Mobile constraintAnnotation) {
pattern = Pattern.compile(constraintAnnotation.regexp());
}
对于计算密集型校验(如身份证校验码),可以添加缓存:
java复制private static final Cache<String, Boolean> ID_CARD_CACHE =
Caffeine.newBuilder().maximumSize(10_000).build();
@Override
public boolean isValid(String value, ConstraintValidatorContext context) {
if (value == null) return true;
return ID_CARD_CACHE.get(value, k -> {
// 实际校验逻辑
return validateIdCard(k);
});
}
通过groups属性实现按场景校验:
java复制public interface CreateGroup {}
public interface UpdateGroup {}
@NotNull(groups = CreateGroup.class)
@Size(min = 6, groups = UpdateGroup.class)
private String password;
// 在Controller中指定校验组
@PostMapping
public void create(@Validated(CreateGroup.class) @RequestBody User user) {
// ...
}
在ValidationMessages.properties中配置:
properties复制Mobile.phone=手机号格式无效
IdCard.invalid=身份证号不合法
然后在注解中引用:
java复制@Mobile(message = "{Mobile.phone}")
private String phone;
对List中的每个元素校验:
java复制@Valid
private List<@Mobile String> phones;
通过@ControllerAdvice统一处理:
java复制@ControllerAdvice
public class ValidationExceptionHandler {
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ErrorResult> handleValidationException(
MethodArgumentNotValidException ex) {
List<String> errors = ex.getBindingResult()
.getFieldErrors()
.stream()
.map(error -> error.getField() + ": " + error.getDefaultMessage())
.collect(Collectors.toList());
return ResponseEntity.badRequest()
.body(new ErrorResult("VALIDATION_FAILED", errors));
}
}
分层校验:
关注点分离:
性能考量:
可维护性:
文档化:
java复制// Swagger文档示例
@Schema(description = "用户手机号", pattern = "^1[3-9]\\d{9}$")
@Mobile
private String phone;
在实际项目中,我们通过自定义校验注解将手机号校验的代码量减少了70%,同时使校验逻辑更集中、更易于维护。当运营商新增号段时,只需修改一处正则表达式即可全局生效。