在Web应用开发中,参数校验是保证系统健壮性的第一道防线。传统的校验方式通常会在Controller层堆积大量if-else判断,这种写法存在几个明显问题:校验逻辑与业务代码高度耦合、相同校验规则重复编写、错误信息格式不统一。我在实际项目中见过最夸张的Controller方法,其中60%的代码都是参数校验逻辑。
Spring Validation提供的JSR-303标准注解(如@NotNull、@Size)虽然能解决部分问题,但面对复杂业务场景时仍显不足。比如需要校验手机号格式、身份证号合法性、业务状态流转等场景时,标准注解就力不从心了。这时就需要我们通过自定义注解来扩展校验能力。
创建一个完整的自定义校验注解需要以下几个核心组件:
java复制@Target({ElementType.FIELD, ElementType.PARAMETER})
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = PhoneNumberValidator.class)
public @interface PhoneNumber {
String message() default "手机号格式不正确";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
}
关键元注解说明:
@Constraint:指定实际执行校验的逻辑类message:校验失败时的默认提示信息groups:支持校验分组payload:扩展校验的元信息传递校验器类需要实现ConstraintValidator接口,这里以手机号校验为例:
java复制public class PhoneNumberValidator implements ConstraintValidator<PhoneNumber, String> {
private static final Pattern PHONE_PATTERN =
Pattern.compile("^1[3-9]\\d{9}$");
@Override
public boolean isValid(String value, ConstraintValidatorContext context) {
if (value == null) {
return true; // 与@NotNull分工明确
}
return PHONE_PATTERN.matcher(value).matches();
}
}
重要经验:在校验器中处理null值要特别小心。通常我们会让@NotNull负责非空检查,自定义校验器只负责格式验证,这种职责分离能避免重复报错。
在实际项目中,我们通常需要支持多语言错误提示。Spring Validation已经内置了MessageSource支持:
properties复制# messages.properties
PhoneNumber.invalid=手机号格式不正确
在注解定义中引用国际化key:
java复制String message() default "{PhoneNumber.invalid}";
有时我们需要校验多个字段之间的关系,比如开始时间不能晚于结束时间。这种场景需要使用类级别注解:
java复制@Target({ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = TimeRangeValidator.class)
public @interface ValidTimeRange {
// 注解配置...
}
校验器实现示例:
java复制public boolean isValid(TimeRequest request, ConstraintValidatorContext context) {
if (request.getStartTime() == null || request.getEndTime() == null) {
return true; // 由@NotNull处理
}
return !request.getStartTime().after(request.getEndTime());
}
某些校验规则需要根据运行时条件决定,比如不同用户类型有不同的密码强度要求。这时可以利用校验注解的payload属性传递动态参数:
java复制@Constraint(validatedBy = DynamicPasswordValidator.class)
public @interface DynamicPassword {
// ...
Class<? extends Payload>[] payload() default {};
}
// 使用示例
@DynamicPassword(payload = UserType.ADMIN.class)
private String password;
默认情况下,校验失败会抛出MethodArgumentNotValidException。我们可以通过@RestControllerAdvice统一处理:
java复制@RestControllerAdvice
public class ValidationExceptionHandler {
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ErrorResult> handleValidationException(
MethodArgumentNotValidException ex) {
List<FieldError> errors = ex.getBindingResult().getFieldErrors();
// 构建统一的错误响应结构
return ResponseEntity.badRequest().body(ErrorResult.from(errors));
}
}
通过groups属性可以实现不同场景下的差异化校验:
java复制public interface CreateGroup {}
public interface UpdateGroup {}
@NotNull(groups = CreateGroup.class)
private Long id;
在Controller中使用:
java复制@PostMapping
public void create(@Validated(CreateGroup.class) @RequestBody User user)
ValidationAutoConfiguration扩展默认配置WebMvcConfigurer覆盖默认validator@Validated注解@Valid注解@EnableWebMvc导致默认配置被覆盖当校验大量数据时,可能会遇到性能瓶颈。可以通过以下方式优化:
initialize方法预加载耗资源对象完善的测试应该包含:
java复制@SpringBootTest
class PhoneNumberValidatorTest {
@Autowired
private Validator validator;
@Test
void shouldPassValidPhoneNumber() {
User user = new User("13800138000");
Set<ConstraintViolation<User>> violations = validator.validate(user);
assertTrue(violations.isEmpty());
}
}
在大型项目中,建议将校验体系分为三个层次:
这种分层设计既能保证代码清晰度,又能满足不同复杂度的校验需求。我在一个电商项目中采用这种架构后,参数校验相关的bug减少了约70%,同时代码可维护性显著提升。