AI摘要

文章以“用户注册”为例,展示从手写if-else到Spring Validator的演进:先用JSR-303注解+@Valid替代冗余校验,再自定义注解解决密码一致性、枚举、数据库唯一性等复杂规则,结合分组、嵌套、方法级校验及统一异常处理,把60行“校验地狱”压缩成10行清晰业务,实现70%代码瘦身,并给出性能优化与最佳实践,最终让参数校验成为可配置、可复用、可扩展的“优雅艺术”。
前言:在我工作的第二年,接手了一个老项目的用户注册模块。代码里到处都是这样的校验逻辑:if (username == null || username.isEmpty()) { throw new RuntimeException("用户名不能为空"); },重复、冗长且难以维护。直到我深入学习了Spring Validator,才发现参数校验可以如此优雅。现在,我已经用它重构了十几个接口,让代码从"校验地狱"变成了"优雅艺术"。

一、 从丑陋的校验代码说起

先看看我曾经维护的那个"反例":

@RestController
@RequestMapping("/user")
public class UserController {
    
    @PostMapping("/register")
    public Result register(@RequestBody Map<String, Object> params) {
        // 1. 参数非空校验
        if (params == null || params.isEmpty()) {
            return Result.error("参数不能为空");
        }
        
        // 2. 逐个字段校验
        String username = (String) params.get("username");
        if (username == null || username.trim().isEmpty()) {
            return Result.error("用户名不能为空");
        }
        if (username.length() < 6 || username.length() > 20) {
            return Result.error("用户名长度必须在6-20位之间");
        }
        if (!username.matches("^[a-zA-Z0-9_]+$")) {
            return Result.error("用户名只能包含字母、数字和下划线");
        }
        
        String password = (String) params.get("password");
        if (password == null || password.trim().isEmpty()) {
            return Result.error("密码不能为空");
        }
        if (password.length() < 8 || password.length() > 32) {
            return Result.error("密码长度必须在8-32位之间");
        }
        if (!password.matches("^(?=.*[a-z])(?=.*[A-Z])(?=.*\\d).+$")) {
            return Result.error("密码必须包含大小写字母和数字");
        }
        
        String email = (String) params.get("email");
        if (email == null || email.trim().isEmpty()) {
            return Result.error("邮箱不能为空");
        }
        if (!email.matches("^[A-Za-z0-9+_.-]+@[A-Za-z0-9.-]+$")) {
            return Result.error("邮箱格式不正确");
        }
        
        // 3. 业务校验(查数据库)
        User existingUser = userRepository.findByUsername(username);
        if (existingUser != null) {
            return Result.error("用户名已存在");
        }
        
        existingUser = userRepository.findByEmail(email);
        if (existingUser != null) {
            return Result.error("邮箱已注册");
        }
        
        // 4. 真正的业务逻辑(被淹没在大量校验代码中)
        User user = new User();
        user.setUsername(username);
        user.setPassword(passwordEncoder.encode(password));
        user.setEmail(email);
        userRepository.save(user);
        
        return Result.success("注册成功");
    }
}

这种写法的问题:

  1. 代码重复:每个字段的校验逻辑都类似,但重复编写
  2. 业务逻辑淹没:真正的核心业务只有几行,却被大量校验代码包围
  3. 难以维护:校验规则分散各处,修改规则需要修改多处
  4. 违反单一职责:Controller既要处理请求,又要做详细校验

二、 Spring Validator的优雅解决方案

第一步:使用JSR-303标准注解

Spring内置支持JSR-303(Bean Validation)标准,我们可以用注解声明校验规则:

// 创建DTO对象,使用注解声明校验规则
@Data
public class RegisterRequest {
    @NotBlank(message = "用户名不能为空")
    @Size(min = 6, max = 20, message = "用户名长度必须在6-20位之间")
    @Pattern(regexp = "^[a-zA-Z0-9_]+$", message = "用户名只能包含字母、数字和下划线")
    private String username;
    
    @NotBlank(message = "密码不能为空")
    @Size(min = 8, max = 32, message = "密码长度必须在8-32位之间")
    @Pattern(
        regexp = "^(?=.*[a-z])(?=.*[A-Z])(?=.*\\d).+$",
        message = "密码必须包含至少一个小写字母、一个大写字母和一个数字"
    )
    private String password;
    
    @NotBlank(message = "确认密码不能为空")
    private String confirmPassword;
    
    @NotBlank(message = "邮箱不能为空")
    @Email(message = "邮箱格式不正确")
    private String email;
    
    @Pattern(
        regexp = "^1[3-9]\\d{9}$", 
        message = "手机号格式不正确"
    )
    private String phone;
    
    @Min(value = 18, message = "年龄必须大于等于18岁")
    @Max(value = 100, message = "年龄必须小于等于100岁")
    private Integer age;
    
    @NotNull(message = "注册时间不能为空")
    @Past(message = "注册时间必须是过去的时间")
    private LocalDateTime registerTime;
}

常用校验注解:

  • @NotNull:值不能为null
  • @NotBlank:字符串不能为null且至少包含一个非空白字符
  • @NotEmpty:集合、数组、Map、字符串不能为空
  • @Size(min, max):长度限制
  • @Pattern(regexp):正则表达式匹配
  • @Min / @Max:数值范围
  • @Email:邮箱格式
  • @Past / @Future:时间过去/未来

第二步:在Controller中使用校验

@RestController
@RequestMapping("/user/v2")
@Validated  // 注意:需要在类级别添加@Validated
public class UserControllerV2 {
    
    @PostMapping("/register")
    public Result register(@Valid @RequestBody RegisterRequest request) {
        // 1. 业务校验(需要查数据库的)
        validateBusiness(request);
        
        // 2. 核心业务逻辑(现在非常清晰)
        User user = new User();
        user.setUsername(request.getUsername());
        user.setPassword(passwordEncoder.encode(request.getPassword()));
        user.setEmail(request.getEmail());
        user.setPhone(request.getPhone());
        user.setAge(request.getAge());
        user.setRegisterTime(request.getRegisterTime());
        
        userRepository.save(user);
        
        return Result.success("注册成功");
    }
    
    private void validateBusiness(RegisterRequest request) {
        User existingUser = userRepository.findByUsername(request.getUsername());
        if (existingUser != null) {
            throw new BusinessException("用户名已存在");
        }
        
        existingUser = userRepository.findByEmail(request.getEmail());
        if (existingUser != null) {
            throw new BusinessException("邮箱已注册");
        }
    }
}

关键点:

  • @Valid:触发JSR-303校验
  • @Validated:Spring的注解,支持分组校验等功能
  • 校验失败会自动抛出MethodArgumentNotValidException

第三步:统一异常处理

校验失败需要统一的响应格式:

@RestControllerAdvice
@Slf4j
public class GlobalExceptionHandler {
    
    /**
     * 处理JSR-303校验异常
     */
    @ExceptionHandler(MethodArgumentNotValidException.class)
    public Result handleValidationException(MethodArgumentNotValidException ex) {
        // 收集所有错误信息
        List<String> errors = ex.getBindingResult().getFieldErrors()
            .stream()
            .map(error -> error.getField() + ": " + error.getDefaultMessage())
            .collect(Collectors.toList());
        
        // 也可以只返回第一个错误
        String firstError = ex.getBindingResult().getFieldErrors()
            .stream()
            .findFirst()
            .map(error -> error.getField() + ": " + error.getDefaultMessage())
            .orElse("参数校验失败");
        
        log.warn("参数校验失败: {}", errors);
        
        return Result.error("VALIDATION_ERROR", firstError);
    }
    
    /**
     * 处理Spring的@Validated校验异常(用于方法参数校验)
     */
    @ExceptionHandler(ConstraintViolationException.class)
    public Result handleConstraintViolationException(ConstraintViolationException ex) {
        String error = ex.getConstraintViolations()
            .stream()
            .map(violation -> violation.getPropertyPath() + ": " + violation.getMessage())
            .collect(Collectors.joining("; "));
        
        log.warn("参数校验失败: {}", error);
        
        return Result.error("VALIDATION_ERROR", error);
    }
}

三、 进阶用法:自定义校验器

当标准注解无法满足需求时,我们可以创建自定义校验器。

场景1:密码一致性校验(跨字段校验)

步骤1:创建自定义注解

@Target({ElementType.TYPE, ElementType.ANNOTATION_TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = PasswordMatchesValidator.class)
@Documented
public @interface PasswordMatches {
    String message() default "密码和确认密码不匹配";
    Class<?>[] groups() default {};
    Class<? extends Payload>[] payload() default {};
}

步骤2:实现校验逻辑

public class PasswordMatchesValidator implements ConstraintValidator<PasswordMatches, Object> {
    
    @Override
    public void initialize(PasswordMatches constraintAnnotation) {
        // 初始化逻辑(如果需要)
    }
    
    @Override
    public boolean isValid(Object obj, ConstraintValidatorContext context) {
        if (!(obj instanceof RegisterRequest)) {
            return true; // 如果不是RegisterRequest类型,跳过校验
        }
        
        RegisterRequest request = (RegisterRequest) obj;
        String password = request.getPassword();
        String confirmPassword = request.getConfirmPassword();
        
        // 如果密码为null,由@NotBlank注解处理
        if (password == null || confirmPassword == null) {
            return true;
        }
        
        boolean isValid = password.equals(confirmPassword);
        
        if (!isValid) {
            // 自定义错误消息,指向confirmPassword字段
            context.disableDefaultConstraintViolation();
            context.buildConstraintViolationWithTemplate(context.getDefaultConstraintMessageTemplate())
                .addPropertyNode("confirmPassword")
                .addConstraintViolation();
        }
        
        return isValid;
    }
}

步骤3:在DTO上使用自定义注解

@Data
@PasswordMatches(message = "两次输入的密码不一致")  // 类级别注解
public class RegisterRequest {
    // ... 其他字段和注解
    
    private String password;
    private String confirmPassword;
}

场景2:枚举值校验

步骤1:创建枚举校验注解

@Target({ElementType.FIELD, ElementType.PARAMETER})
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = EnumValueValidator.class)
@Documented
public @interface EnumValue {
    Class<? extends Enum<?>> enumClass();
    String message() default "无效的枚举值";
    Class<?>[] groups() default {};
    Class<? extends Payload>[] payload() default {};
}

步骤2:实现枚举校验器

public class EnumValueValidator implements ConstraintValidator<EnumValue, Object> {
    
    private Class<? extends Enum<?>> enumClass;
    private Set<String> enumValues;
    
    @Override
    public void initialize(EnumValue constraintAnnotation) {
        this.enumClass = constraintAnnotation.enumClass();
        this.enumValues = Arrays.stream(enumClass.getEnumConstants())
            .map(Enum::name)
            .collect(Collectors.toSet());
    }
    
    @Override
    public boolean isValid(Object value, ConstraintValidatorContext context) {
        if (value == null) {
            return true; // null值由@NotNull等注解处理
        }
        
        // 支持字符串和枚举类型
        if (value instanceof String) {
            return enumValues.contains(value);
        } else if (enumClass.isInstance(value)) {
            return true;
        }
        
        return false;
    }
}

步骤3:使用枚举校验

// 定义枚举
public enum UserType {
    CUSTOMER, MERCHANT, ADMIN, GUEST
}

// 在DTO中使用
@Data
public class UserUpdateRequest {
    
    @EnumValue(
        enumClass = UserType.class,
        message = "用户类型必须是CUSTOMER、MERCHANT、ADMIN或GUEST"
    )
    private String userType;
    
    // 或者直接使用枚举类型
    @NotNull(message = "用户类型不能为空")
    private UserType type;
}

场景3:数据库唯一性校验

步骤1:创建数据库校验注解

@Target({ElementType.FIELD, ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = UniqueValidator.class)
@Documented
public @interface Unique {
    String message() default "该值已存在";
    Class<?>[] groups() default {};
    Class<? extends Payload>[] payload() default {};
    
    // 指定校验的服务类和方法
    Class<?> service();
    String methodName() default "existsByField";
    String fieldName() default "";
}

步骤2:实现数据库校验器

public class UniqueValidator implements ConstraintValidator<Unique, Object> {
    
    private String message;
    private Class<?> serviceClass;
    private String methodName;
    private String fieldName;
    
    @Autowired
    private ApplicationContext applicationContext;
    
    @Override
    public void initialize(Unique constraintAnnotation) {
        this.message = constraintAnnotation.message();
        this.serviceClass = constraintAnnotation.service();
        this.methodName = constraintAnnotation.methodName();
        this.fieldName = constraintAnnotation.fieldName().isEmpty() 
            ? null 
            : constraintAnnotation.fieldName();
    }
    
    @Override
    @SuppressWarnings("unchecked")
    public boolean isValid(Object value, ConstraintValidatorContext context) {
        if (value == null) {
            return true;
        }
        
        // 获取服务实例
        Object service = applicationContext.getBean(serviceClass);
        
        try {
            // 动态调用方法
            Method method = serviceClass.getMethod(methodName, Object.class);
            boolean exists = (boolean) method.invoke(service, value);
            
            // 不存在则校验通过
            return !exists;
            
        } catch (Exception e) {
            log.error("调用校验方法失败: {}", e.getMessage());
            return false;
        }
    }
}

步骤3:创建校验服务

@Service
public class UserValidationService {
    
    @Autowired
    private UserRepository userRepository;
    
    /**
     * 校验用户名是否已存在
     */
    public boolean existsByUsername(String username) {
        return userRepository.existsByUsername(username);
    }
    
    /**
     * 通用校验方法
     */
    public boolean existsByField(Object value) {
        if (value instanceof String) {
            String strValue = (String) value;
            // 这里可以判断是哪个字段
            if (strValue.contains("@")) {
                return userRepository.existsByEmail(strValue);
            } else {
                return userRepository.existsByUsername(strValue);
            }
        }
        return false;
    }
}

步骤4:在DTO中使用

@Data
public class RegisterRequestV3 {
    
    @NotBlank(message = "用户名不能为空")
    @Size(min = 6, max = 20, message = "用户名长度必须在6-20位之间")
    @Unique(
        service = UserValidationService.class,
        methodName = "existsByUsername",
        message = "用户名已存在"
    )
    private String username;
    
    @NotBlank(message = "邮箱不能为空")
    @Email(message = "邮箱格式不正确")
    @Unique(
        service = UserValidationService.class,
        methodName = "existsByField",
        message = "邮箱已注册"
    )
    private String email;
}

四、 高级特性:分组校验

在实际业务中,我们经常需要在不同场景下使用不同的校验规则。比如:

  • 注册时:所有字段都需要校验
  • 更新时:只校验更新的字段
  • 管理员操作:校验规则更宽松

Spring Validator提供了分组校验功能。

步骤1:定义分组接口

public interface ValidationGroups {
    
    // 注册时的校验组
    interface Register {}
    
    // 更新时的校验组
    interface Update {}
    
    // 管理员操作的校验组
    interface Admin {}
}

步骤2:在DTO中指定分组

@Data
public class UserDTO {
    
    @NotNull(groups = {ValidationGroups.Register.class, ValidationGroups.Update.class})
    private Long id;
    
    @NotBlank(groups = ValidationGroups.Register.class)
    @Size(min = 6, max = 20, groups = ValidationGroups.Register.class)
    private String username;
    
    @NotBlank(groups = ValidationGroups.Register.class)
    @Size(min = 8, max = 32, groups = ValidationGroups.Register.class)
    private String password;
    
    @Email(groups = {ValidationGroups.Register.class, ValidationGroups.Update.class})
    private String email;
    
    @Pattern(
        regexp = "^1[3-9]\\d{9}$", 
        groups = {ValidationGroups.Register.class, ValidationGroups.Update.class}
    )
    private String phone;
    
    // 管理员可以设置特殊状态
    @NotNull(groups = ValidationGroups.Admin.class)
    private Integer status;
}

步骤3:在Controller中使用分组

@RestController
@RequestMapping("/user/v3")
@Validated
public class UserControllerV3 {
    
    // 注册接口:使用Register分组
    @PostMapping("/register")
    public Result register(@Validated(ValidationGroups.Register.class) @RequestBody UserDTO userDTO) {
        // 实现注册逻辑
        return Result.success("注册成功");
    }
    
    // 更新接口:使用Update分组
    @PutMapping("/{id}")
    public Result update(
        @PathVariable Long id,
        @Validated(ValidationGroups.Update.class) @RequestBody UserDTO userDTO) {
        
        userDTO.setId(id);
        // 实现更新逻辑
        return Result.success("更新成功");
    }
    
    // 管理员接口:使用Admin分组
    @PutMapping("/admin/{id}/status")
    @PreAuthorize("hasRole('ADMIN')")
    public Result updateStatus(
        @PathVariable Long id,
        @Validated(ValidationGroups.Admin.class) @RequestBody UserDTO userDTO) {
        
        userDTO.setId(id);
        // 管理员操作逻辑
        return Result.success("状态更新成功");
    }
}

步骤4:分组继承

分组可以继承,让校验规则更有层次:

public interface ValidationGroups {
    
    interface Base {}
    
    interface Register extends Base {}
    
    interface Update extends Base {}
    
    interface Admin extends Base {}
}

// 在DTO中使用
@Data
public class UserDTOV2 {
    
    // Base组的所有场景都校验
    @NotNull(groups = ValidationGroups.Base.class)
    private Long id;
    
    // Register组特有
    @NotBlank(groups = ValidationGroups.Register.class)
    private String username;
    
    // Register和Update都校验
    @NotBlank(groups = {ValidationGroups.Register.class, ValidationGroups.Update.class})
    private String password;
}

五、 嵌套对象校验

当DTO包含其他对象时,可以使用嵌套校验:

@Data
public class OrderRequest {
    
    @NotNull(message = "订单信息不能为空")
    @Valid  // 关键:启用嵌套校验
    private OrderInfo orderInfo;
    
    @NotNull(message = "收货地址不能为空")
    @Valid
    private Address address;
    
    @NotNull(message = "支付信息不能为空")
    @Valid
    private PaymentInfo paymentInfo;
    
    @Valid
    private List<OrderItem> items; // 集合中的对象也会被校验
}

@Data
public class OrderInfo {
    @NotBlank(message = "订单号不能为空")
    private String orderNo;
    
    @NotNull(message = "订单金额不能为空")
    @Positive(message = "订单金额必须大于0")
    private BigDecimal amount;
}

@Data
public class Address {
    @NotBlank(message = "收货人不能为空")
    private String receiver;
    
    @NotBlank(message = "手机号不能为空")
    @Pattern(regexp = "^1[3-9]\\d{9}$", message = "手机号格式不正确")
    private String phone;
    
    @NotBlank(message = "详细地址不能为空")
    private String detail;
}

@Data
public class OrderItem {
    @NotNull(message = "商品ID不能为空")
    private Long productId;
    
    @NotBlank(message = "商品名称不能为空")
    private String productName;
    
    @NotNull(message = "数量不能为空")
    @Min(value = 1, message = "数量必须大于0")
    private Integer quantity;
    
    @NotNull(message = "单价不能为空")
    @Positive(message = "单价必须大于0")
    private BigDecimal price;
}

六、 方法参数校验

除了Controller层的参数校验,我们还可以在Service层进行方法参数校验:

@Service
@Validated  // 类级别注解
public class UserService {
    
    /**
     * 方法参数校验
     */
    public User createUser(
        @NotBlank String username,
        @NotBlank @Email String email,
        @NotNull @Min(18) Integer age) {
        
        // 参数已经通过校验,可以直接使用
        User user = new User();
        user.setUsername(username);
        user.setEmail(email);
        user.setAge(age);
        
        return userRepository.save(user);
    }
    
    /**
     * 返回值校验
     */
    @NotNull
    public User getUserById(@NotNull Long id) {
        return userRepository.findById(id)
            .orElseThrow(() -> new BusinessException("用户不存在"));
    }
    
    /**
     * 集合校验
     */
    public void batchCreateUsers(@NotEmpty List<@Valid UserDTO> users) {
        // 校验通过后处理
        users.forEach(this::createUser);
    }
}

注意:方法参数校验需要以下配置:

@Configuration
public class ValidatorConfig {
    
    @Bean
    public MethodValidationPostProcessor methodValidationPostProcessor() {
        return new MethodValidationPostProcessor();
    }
}

七、 自定义校验消息

1. 使用消息文件

创建messages.properties

# 默认消息
user.username.notblank=用户名不能为空
user.username.size=用户名长度必须在{min}到{max}位之间
user.email.invalid=邮箱格式不正确

# 国际化消息(英文)
user.username.notblank.en=Username cannot be empty

在注解中使用:

@Data
public class UserDTO {
    
    @NotBlank(message = "{user.username.notblank}")
    @Size(min = 6, max = 20, message = "{user.username.size}")
    private String username;
    
    @Email(message = "{user.email.invalid}")
    private String email;
}

2. 动态消息

@Target(ElementType.FIELD)
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = DynamicMessageValidator.class)
public @interface DynamicRange {
    String message() default "值必须在{min}和{max}之间";
    Class<?>[] groups() default {};
    Class<? extends Payload>[] payload() default {};
    
    int min();
    int max();
}

public class DynamicMessageValidator implements ConstraintValidator<DynamicRange, Integer> {
    
    private int min;
    private int max;
    
    @Override
    public void initialize(DynamicRange constraintAnnotation) {
        this.min = constraintAnnotation.min();
        this.max = constraintAnnotation.max();
    }
    
    @Override
    public boolean isValid(Integer value, ConstraintValidatorContext context) {
        if (value == null) {
            return true;
        }
        
        if (value < min || value > max) {
            // 动态设置消息
            String template = context.getDefaultConstraintMessageTemplate();
            String message = template
                .replace("{min}", String.valueOf(min))
                .replace("{max}", String.valueOf(max));
            
            context.disableDefaultConstraintViolation();
            context.buildConstraintViolationWithTemplate(message)
                .addConstraintViolation();
            
            return false;
        }
        
        return true;
    }
}

八、 性能优化

1. Validator复用

默认情况下,Spring每次校验都会创建新的Validator实例。我们可以配置复用:

@Configuration
public class ValidatorConfiguration {
    
    @Bean
    public Validator validator() {
        return Validation.buildDefaultValidatorFactory()
            .getValidator();
    }
    
    @Bean
    public LocalValidatorFactoryBean localValidatorFactoryBean() {
        LocalValidatorFactoryBean factory = new LocalValidatorFactoryBean();
        factory.setValidationMessageSource(messageSource());
        return factory;
    }
}

2. 缓存校验结果

对于频繁校验的相同数据,可以缓存结果:

@Component
public class CachedValidator {
    
    private final Validator validator;
    private final Cache<String, Set<ConstraintViolation<Object>>> cache;
    
    public CachedValidator(Validator validator) {
        this.validator = validator;
        this.cache = Caffeine.newBuilder()
            .maximumSize(1000)
            .expireAfterWrite(5, TimeUnit.MINUTES)
            .build();
    }
    
    public <T> Set<ConstraintViolation<T>> validate(T object, Class<?>... groups) {
        String key = generateKey(object, groups);
        
        return cache.get(key, k -> {
            @SuppressWarnings("unchecked")
            Set<ConstraintViolation<T>> violations = (Set<ConstraintViolation<T>>) 
                validator.validate(object, groups);
            return violations;
        });
    }
    
    private String generateKey(Object object, Class<?>... groups) {
        // 生成基于对象内容和校验组的key
        return object.toString() + Arrays.toString(groups);
    }
}

九、 实战:重构用户注册接口

让我们用学到的知识重构开头的用户注册接口:

最终版本

// 1. 定义DTO
@Data
@PasswordMatches(message = "两次输入的密码不一致")
public class RegisterRequestDTO {
    
    @NotBlank(message = "用户名不能为空")
    @Size(min = 6, max = 20, message = "用户名长度必须在6-20位之间")
    @Pattern(regexp = "^[a-zA-Z0-9_]+$", message = "用户名只能包含字母、数字和下划线")
    @Unique(
        service = UserValidationService.class,
        methodName = "existsByUsername",
        message = "用户名已存在"
    )
    private String username;
    
    @NotBlank(message = "密码不能为空")
    @Size(min = 8, max = 32, message = "密码长度必须在8-32位之间")
    @Pattern(
        regexp = "^(?=.*[a-z])(?=.*[A-Z])(?=.*\\d).+$",
        message = "密码必须包含大小写字母和数字"
    )
    private String password;
    
    @NotBlank(message = "确认密码不能为空")
    private String confirmPassword;
    
    @NotBlank(message = "邮箱不能为空")
    @Email(message = "邮箱格式不正确")
    @Unique(
        service = UserValidationService.class,
        methodName = "existsByEmail",
        message = "邮箱已注册"
    )
    private String email;
    
    @Pattern(
        regexp = "^1[3-9]\\d{9}$", 
        message = "手机号格式不正确"
    )
    private String phone;
    
    @Min(value = 18, message = "必须年满18岁")
    @Max(value = 100, message = "年龄不能超过100岁")
    private Integer age;
}

// 2. 定义Service接口
public interface UserRegistrationService {
    
    /**
     * 用户注册
     * @param request 注册请求
     * @return 用户ID
     */
    Long register(RegisterRequestDTO request);
}

// 3. 实现Service
@Service
@Validated
@Transactional
@Slf4j
public class UserRegistrationServiceImpl implements UserRegistrationService {
    
    @Autowired
    private UserRepository userRepository;
    
    @Autowired
    private PasswordEncoder passwordEncoder;
    
    @Override
    public Long register(@Valid RegisterRequestDTO request) {
        log.info("开始注册用户: {}", request.getUsername());
        
        // 1. 创建用户实体
        User user = new User();
        user.setUsername(request.getUsername());
        user.setPassword(passwordEncoder.encode(request.getPassword()));
        user.setEmail(request.getEmail());
        user.setPhone(request.getPhone());
        user.setAge(request.getAge());
        user.setRegisterTime(LocalDateTime.now());
        user.setStatus(UserStatus.ACTIVE);
        
        // 2. 保存用户
        User savedUser = userRepository.save(user);
        
        // 3. 发送注册成功通知(异步)
        sendRegistrationNotification(savedUser);
        
        log.info("用户注册成功: {}, ID: {}", savedUser.getUsername(), savedUser.getId());
        
        return savedUser.getId();
    }
    
    @Async
    protected void sendRegistrationNotification(User user) {
        // 发送邮件、短信等通知
        log.debug("发送注册通知给用户: {}", user.getEmail());
    }
}

// 4. 简化的Controller
@RestController
@RequestMapping("/api/v1/users")
@Validated
@Slf4j
public class UserRegistrationController {
    
    @Autowired
    private UserRegistrationService userRegistrationService;
    
    @PostMapping("/register")
    public Result<Long> register(@Valid @RequestBody RegisterRequestDTO request) {
        log.info("收到注册请求: {}", request.getUsername());
        
        Long userId = userRegistrationService.register(request);
        
        return Result.success("注册成功", userId);
    }
}

// 5. 统一异常处理
@RestControllerAdvice
@Slf4j
public class GlobalValidationHandler {
    
    /**
     * 处理请求体校验异常
     */
    @ExceptionHandler(MethodArgumentNotValidException.class)
    @ResponseStatus(HttpStatus.BAD_REQUEST)
    public Result<List<String>> handleValidationException(MethodArgumentNotValidException ex) {
        List<String> errors = ex.getBindingResult().getFieldErrors()
            .stream()
            .sorted(Comparator.comparing(FieldError::getField))
            .map(error -> String.format("%s: %s", error.getField(), error.getDefaultMessage()))
            .collect(Collectors.toList());
        
        log.warn("请求参数校验失败: {}", errors);
        
        return Result.error("VALIDATION_ERROR", "参数校验失败", errors);
    }
    
    /**
     * 处理业务异常
     */
    @ExceptionHandler(BusinessException.class)
    @ResponseStatus(HttpStatus.BAD_REQUEST)
    public Result<String> handleBusinessException(BusinessException ex) {
        log.warn("业务异常: {}", ex.getMessage());
        return Result.error("BUSINESS_ERROR", ex.getMessage());
    }
}

对比效果

重构前:

  • 代码行数:60+行
  • 核心业务逻辑占比:20%
  • 维护难度:高(校验逻辑分散)

重构后:

  • 代码行数:核心Controller只有10行
  • 核心业务逻辑占比:80%
  • 维护难度:低(校验规则集中声明)
  • 扩展性:高(新增字段只需在DTO中添加注解)

十、 最佳实践总结

基于五年经验,我总结了Spring Validator的最佳实践:

1. 分层校验策略

┌─────────────────────────┐
│   Controller层:基础校验  │
│   - 数据格式、必填、范围  │
└────────────┬────────────┘
              │
┌────────────▼────────────┐
│   Service层:业务校验    │
│   - 唯一性、状态、权限    │
└─────────────────────────┘

2. DTO设计原则

// 好:职责单一,只负责数据传输和基础校验
@Data
public class RegisterDTO {
    @NotBlank private String username;
    @Email private String email;
}

// 不好:混入业务逻辑
@Data
public class RegisterDTO {
    private String username;
    private String email;
    
    // 不要在DTO中写业务逻辑
    public boolean isValid() {
        return username != null && email.contains("@");
    }
}

3. 校验分组使用时机

public interface OrderValidation {
    // 根据业务场景使用不同的分组
    interface Create {}    // 创建订单
    interface Update {}    // 更新订单  
    interface Cancel {}    // 取消订单
    interface Pay {}       // 支付订单
}

4. 错误消息优化

# 分层错误消息
validation.common.required={0}不能为空
validation.user.username.invalid=用户名格式不正确

# 在注解中使用
@NotBlank(message = "{validation.common.required}")
private String username;

5. 性能考虑

  • 对于频繁调用的接口,考虑缓存校验结果
  • 避免在循环中进行复杂的校验
  • 合理使用@Valid@Validated的传播

十一、 常见问题解答

Q1:@Valid和@Validated有什么区别?

A:

  • @Valid:JSR-303标准注解,只能用于方法参数和成员变量
  • @Validated:Spring扩展注解,支持分组校验和方法级别校验

Q2:校验失败时如何返回自定义错误码?

@ExceptionHandler(MethodArgumentNotValidException.class)
public Result handleValidationException(MethodArgumentNotValidException ex) {
    FieldError firstError = ex.getBindingResult().getFieldErrors().get(0);
    
    // 根据字段名和错误类型返回不同错误码
    String field = firstError.getField();
    String code = firstError.getCode(); // 注解名称,如NotBlank
    
    String errorCode = "VALIDATION_" + field.toUpperCase() + "_" + code.toUpperCase();
    
    return Result.error(errorCode, firstError.getDefaultMessage());
}

Q3:如何校验集合中的每个元素?

// 使用@Valid注解
public Result batchCreate(@RequestBody @Valid List<@Valid UserDTO> users) {
    // 集合本身和每个元素都会被校验
}

十二、 总结

从原始的if-else校验到Spring Validator的优雅解决方案,这不仅仅是技术的升级,更是编程思维的转变:

  1. 声明式优于命令式:用注解声明规则,而不是用代码描述过程
  2. 集中优于分散:校验规则集中在DTO中,而不是分散在各处
  3. 配置优于编码:修改校验规则只需改配置,不需改代码
  4. 契约优于实现:定义清晰的接口契约,而不是隐式的约定

Spring Validator让我们超越了简单的CRUD,让参数校验成为了一种优雅的艺术。记住:好的代码不仅是要能运行,更是要能清晰地表达意图

通过合理使用Spring Validator,我们可以:

  • 减少70%以上的重复校验代码
  • 提高代码的可读性和可维护性
  • 实现更优雅的错误处理和用户体验
  • 构建更健壮、更可靠的系统

这就是超越CRUD的编程艺术。

版权声明 ▶ 本网站名称:黄磊的博客
▶ 本文标题:超越CRUD:利用Spring的Validator做更优雅的参数校验
▶ 本文链接:https://www.huangleicole.com/backend-related/69.html
▶ 转载本站文章需要遵守:商业转载请联系站长,非商业转载请注明出处!!

如果觉得我的文章对你有用,请随意赞赏