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("注册成功");
}
}这种写法的问题:
- 代码重复:每个字段的校验逻辑都类似,但重复编写
- 业务逻辑淹没:真正的核心业务只有几行,却被大量校验代码包围
- 难以维护:校验规则分散各处,修改规则需要修改多处
- 违反单一职责: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的优雅解决方案,这不仅仅是技术的升级,更是编程思维的转变:
- 声明式优于命令式:用注解声明规则,而不是用代码描述过程
- 集中优于分散:校验规则集中在DTO中,而不是分散在各处
- 配置优于编码:修改校验规则只需改配置,不需改代码
- 契约优于实现:定义清晰的接口契约,而不是隐式的约定
Spring Validator让我们超越了简单的CRUD,让参数校验成为了一种优雅的艺术。记住:好的代码不仅是要能运行,更是要能清晰地表达意图。
通过合理使用Spring Validator,我们可以:
- 减少70%以上的重复校验代码
- 提高代码的可读性和可维护性
- 实现更优雅的错误处理和用户体验
- 构建更健壮、更可靠的系统
这就是超越CRUD的编程艺术。