Spring Boot 数据校验与全局异常处理最佳实践
Spring Boot 数据校验与全局异常处理最佳实践
引言
数据校验是保障应用程序数据完整性和安全性的关键环节。Spring Boot 提供了强大的数据校验支持,结合全局异常处理机制,可以构建健壮的错误处理体系。本文将深入探讨数据校验的各种技术手段和全局异常处理的最佳实践。
一、数据校验基础
1.1 JSR-303/JSR-380 规范
JSR-303(Bean Validation 1.0)和 JSR-380(Bean Validation 2.0)定义了 Java Bean 校验的标准规范。Spring Boot 默认集成了 Hibernate Validator,这是 JSR-380 的参考实现。
1.2 常用校验注解
| 注解 | 说明 | 示例 |
|---|---|---|
| @NotNull | 字段不能为空 | @NotNull(message = "ID不能为空") |
| @NotBlank | 字符串不能为空且长度大于0 | @NotBlank(message = "名称不能为空") |
| @NotEmpty | 集合/数组不能为空 | @NotEmpty(message = "列表不能为空") |
| @Size | 字符串/集合长度范围 | @Size(min=2, max=50) |
| @Min/@Max | 数值最小值/最大值 | @Min(1) @Max(100) |
| @Positive/@Negative | 正数/负数 | @Positive(message = "数量必须为正数") |
| 邮箱格式校验 | @Email(message = "邮箱格式不正确") | |
| @Pattern | 正则表达式校验 | @Pattern(regexp="^\d{11}$") |
| @Valid | 级联校验 | @Valid private Address address |
二、请求参数校验
2.1 添加依赖
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-validation</artifactId> </dependency>2.2 请求体校验
import lombok.Data; import javax.validation.Valid; import javax.validation.constraints.*; import java.math.BigDecimal; import java.util.List; @Data public class OrderCreateRequest { @NotBlank(message = "客户ID不能为空") @Size(max = 50, message = "客户ID长度不能超过50") private String customerId; @NotNull(message = "订单金额不能为空") @Positive(message = "订单金额必须大于0") private BigDecimal amount; @Size(max = 200, message = "备注长度不能超过200") private String remark; @NotEmpty(message = "订单项不能为空") @Size(max = 100, message = "订单项数量不能超过100") @Valid private List<OrderItemRequest> items; @Data public static class OrderItemRequest { @NotBlank(message = "商品ID不能为空") private String productId; @NotNull(message = "数量不能为空") @Positive(message = "数量必须大于0") private Integer quantity; @NotNull(message = "单价不能为空") @Positive(message = "单价必须大于0") private BigDecimal unitPrice; } }2.3 Controller 层校验
import org.springframework.http.ResponseEntity; import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.*; import javax.validation.Valid; import javax.validation.constraints.Min; import javax.validation.constraints.Pattern; @RestController @RequestMapping("/api/orders") @Validated public class OrderController { private final OrderService orderService; public OrderController(OrderService orderService) { this.orderService = orderService; } @PostMapping public ResponseEntity<OrderResponse> createOrder( @Valid @RequestBody OrderCreateRequest request) { OrderResponse response = orderService.createOrder(request); return ResponseEntity.status(HttpStatus.CREATED).body(response); } @GetMapping("/{orderId}") public ResponseEntity<OrderResponse> getOrderById( @Pattern(regexp = "^ord-\\d+$", message = "订单ID格式不正确") @PathVariable String orderId) { OrderResponse response = orderService.getOrderById(orderId); return ResponseEntity.ok(response); } @GetMapping public ResponseEntity<PageResponse<OrderResponse>> listOrders( @Min(value = 0, message = "页码不能小于0") @RequestParam(defaultValue = "0") int page, @Min(value = 1, message = "每页大小不能小于1") @Max(value = 100, message = "每页大小不能超过100") @RequestParam(defaultValue = "10") int size) { Page<OrderResponse> orders = orderService.listOrders(page, size); return ResponseEntity.ok(PageResponse.from(orders)); } }三、自定义校验注解
3.1 创建自定义注解
import javax.validation.Constraint; import javax.validation.Payload; import java.lang.annotation.*; @Documented @Constraint(validatedBy = PhoneValidator.class) @Target({ElementType.FIELD, ElementType.PARAMETER}) @Retention(RetentionPolicy.RUNTIME) public @interface Phone { String message() default "手机号格式不正确"; Class<?>[] groups() default {}; Class<? extends Payload>[] payload() default {}; boolean required() default true; }3.2 实现校验器
import javax.validation.ConstraintValidator; import javax.validation.ConstraintValidatorContext; import java.util.regex.Pattern; public class PhoneValidator implements ConstraintValidator<Phone, String> { private static final Pattern PHONE_PATTERN = Pattern.compile( "^1[3-9]\\d{9}$" ); private boolean required; @Override public void initialize(Phone constraintAnnotation) { this.required = constraintAnnotation.required(); } @Override public boolean isValid(String phone, ConstraintValidatorContext context) { if (!required && (phone == null || phone.isEmpty())) { return true; } if (phone == null || phone.isEmpty()) { return false; } return PHONE_PATTERN.matcher(phone).matches(); } }3.3 使用自定义注解
@Data public class UserCreateRequest { @NotBlank(message = "用户名不能为空") @Size(min = 2, max = 50, message = "用户名长度必须在2-50之间") private String username; @NotBlank(message = "密码不能为空") @Size(min = 6, message = "密码长度不能少于6位") private String password; @Phone(message = "手机号格式不正确") private String phone; @Email(message = "邮箱格式不正确") private String email; }四、全局异常处理
4.1 创建全局异常处理器
import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.validation.FieldError; import org.springframework.web.bind.MethodArgumentNotValidException; import org.springframework.web.bind.MissingServletRequestParameterException; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException; import javax.validation.ConstraintViolation; import javax.validation.ConstraintViolationException; import java.time.LocalDateTime; import java.util.HashMap; import java.util.Map; import java.util.stream.Collectors; @RestControllerAdvice public class GlobalExceptionHandler { @ExceptionHandler(MethodArgumentNotValidException.class) public ResponseEntity<ErrorResponse> handleValidationException( MethodArgumentNotValidException ex) { Map<String, String> errors = ex.getBindingResult() .getFieldErrors() .stream() .collect(Collectors.toMap( FieldError::getField, FieldError::getDefaultMessage )); ErrorResponse response = ErrorResponse.builder() .code(HttpStatus.BAD_REQUEST.value()) .message("请求参数校验失败") .timestamp(LocalDateTime.now()) .details(errors) .build(); return ResponseEntity.badRequest().body(response); } @ExceptionHandler(ConstraintViolationException.class) public ResponseEntity<ErrorResponse> handleConstraintViolationException( ConstraintViolationException ex) { Map<String, String> errors = ex.getConstraintViolations() .stream() .collect(Collectors.toMap( violation -> violation.getPropertyPath().toString(), ConstraintViolation::getMessage )); ErrorResponse response = ErrorResponse.builder() .code(HttpStatus.BAD_REQUEST.value()) .message("请求参数校验失败") .timestamp(LocalDateTime.now()) .details(errors) .build(); return ResponseEntity.badRequest().body(response); } @ExceptionHandler(MissingServletRequestParameterException.class) public ResponseEntity<ErrorResponse> handleMissingParameterException( MissingServletRequestParameterException ex) { Map<String, String> details = new HashMap<>(); details.put(ex.getParameterName(), "必填参数缺失"); ErrorResponse response = ErrorResponse.builder() .code(HttpStatus.BAD_REQUEST.value()) .message("请求参数缺失") .timestamp(LocalDateTime.now()) .details(details) .build(); return ResponseEntity.badRequest().body(response); } @ExceptionHandler(MethodArgumentTypeMismatchException.class) public ResponseEntity<ErrorResponse> handleTypeMismatchException( MethodArgumentTypeMismatchException ex) { Map<String, String> details = new HashMap<>(); details.put(ex.getName(), "参数类型不正确,期望类型: " + (ex.getRequiredType() != null ? ex.getRequiredType().getSimpleName() : "unknown")); ErrorResponse response = ErrorResponse.builder() .code(HttpStatus.BAD_REQUEST.value()) .message("请求参数类型错误") .timestamp(LocalDateTime.now()) .details(details) .build(); return ResponseEntity.badRequest().body(response); } }4.2 错误响应模型
import lombok.Builder; import lombok.Data; import java.time.LocalDateTime; import java.util.Map; @Data @Builder public class ErrorResponse { private int code; private String message; private LocalDateTime timestamp; private Map<String, String> details; private String path; }4.3 自定义业务异常
import lombok.Getter; @Getter public class BusinessException extends RuntimeException { private final int code; private final String message; public BusinessException(int code, String message) { super(message); this.code = code; this.message = message; } public static BusinessException notFound(String message) { return new BusinessException(HttpStatus.NOT_FOUND.value(), message); } public static BusinessException badRequest(String message) { return new BusinessException(HttpStatus.BAD_REQUEST.value(), message); } public static BusinessException forbidden(String message) { return new BusinessException(HttpStatus.FORBIDDEN.value(), message); } public static BusinessException conflict(String message) { return new BusinessException(HttpStatus.CONFLICT.value(), message); } }4.4 处理自定义业务异常
@RestControllerAdvice public class GlobalExceptionHandler { @ExceptionHandler(BusinessException.class) public ResponseEntity<ErrorResponse> handleBusinessException( BusinessException ex) { ErrorResponse response = ErrorResponse.builder() .code(ex.getCode()) .message(ex.getMessage()) .timestamp(LocalDateTime.now()) .build(); return ResponseEntity.status(ex.getCode()).body(response); } @ExceptionHandler(Exception.class) public ResponseEntity<ErrorResponse> handleGenericException(Exception ex) { ErrorResponse response = ErrorResponse.builder() .code(HttpStatus.INTERNAL_SERVER_ERROR.value()) .message("服务器内部错误") .timestamp(LocalDateTime.now()) .build(); // 记录日志 log.error("Unexpected error occurred", ex); return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(response); } }五、Service 层校验
5.1 使用 Validator 进行手动校验
import org.springframework.stereotype.Service; import org.springframework.validation.annotation.Validated; import javax.validation.ConstraintViolation; import javax.validation.Validator; import java.util.Set; @Service @Validated public class OrderService { private final Validator validator; private final OrderRepository orderRepository; public OrderService(Validator validator, OrderRepository orderRepository) { this.validator = validator; this.orderRepository = orderRepository; } public OrderResponse createOrder(OrderCreateRequest request) { // 手动校验 Set<ConstraintViolation<OrderCreateRequest>> violations = validator.validate(request); if (!violations.isEmpty()) { String errorMessage = violations.stream() .map(v -> v.getPropertyPath() + ": " + v.getMessage()) .collect(Collectors.joining(", ")); throw BusinessException.badRequest(errorMessage); } // 业务校验 validateOrderRequest(request); // 创建订单逻辑 Order order = Order.builder() .customerId(request.getCustomerId()) .amount(request.getAmount()) .remark(request.getRemark()) .status(OrderStatus.PENDING) .build(); order = orderRepository.save(order); // 保存订单项 // ... return OrderResponse.from(order); } private void validateOrderRequest(OrderCreateRequest request) { // 检查客户是否存在 if (!customerRepository.existsById(request.getCustomerId())) { throw BusinessException.badRequest("客户不存在"); } // 检查库存 for (OrderItemRequest item : request.getItems()) { Product product = productRepository.findById(item.getProductId()) .orElseThrow(() -> BusinessException.badRequest("商品不存在: " + item.getProductId())); if (product.getStock() < item.getQuantity()) { throw BusinessException.badRequest("商品库存不足: " + product.getName()); } } } }六、校验分组
6.1 定义校验分组
public interface ValidationGroups { interface Create {} interface Update {} interface Delete {} }6.2 使用校验分组
import javax.validation.constraints.*; import javax.validation.groups.Default; @Data public class UserUpdateRequest { @NotNull(message = "用户ID不能为空", groups = {ValidationGroups.Update.class}) private Long id; @Size(min = 2, max = 50, message = "用户名长度必须在2-50之间", groups = {ValidationGroups.Create.class, ValidationGroups.Update.class}) private String username; @Email(message = "邮箱格式不正确", groups = {ValidationGroups.Create.class, ValidationGroups.Update.class}) private String email; @Phone(message = "手机号格式不正确", groups = Default.class) private String phone; }6.3 Controller 层指定校验分组
@RestController @RequestMapping("/api/users") public class UserController { @PutMapping public ResponseEntity<UserResponse> updateUser( @Validated(ValidationGroups.Update.class) @RequestBody UserUpdateRequest request) { UserResponse response = userService.updateUser(request); return ResponseEntity.ok(response); } }七、国际化校验消息
7.1 配置消息源
spring: messages: basename: messages encoding: UTF-87.2 创建国际化资源文件
# messages.properties validation.phone=手机号格式不正确 validation.email=邮箱格式不正确 validation.notBlank=不能为空 validation.size=长度必须在{min}-{max}之间 validation.min=最小值为{value} validation.max=最大值为{value} validation.positive=必须为正数# messages_zh_CN.properties validation.phone=手机号格式不正确 validation.email=邮箱格式不正确 validation.notBlank=不能为空 validation.size=长度必须在{min}-{max}之间 validation.min=最小值为{value} validation.max=最大值为{value} validation.positive=必须为正数7.3 使用国际化消息
@Data public class UserCreateRequest { @NotBlank(message = "{validation.notBlank}") @Size(min = 2, max = 50, message = "{validation.size}") private String username; @Email(message = "{validation.email}") private String email; @Phone(message = "{validation.phone}") private String phone; }八、最佳实践
8.1 校验层次策略
┌─────────────────────────────────────────────────────────────┐ │ Controller 层 │ │ - 请求参数格式校验(类型、格式、基本约束) │ ├─────────────────────────────────────────────────────────────┤ │ Service 层 │ │ - 业务规则校验(业务逻辑约束、状态校验) │ │ - 数据完整性校验(关联数据存在性检查) │ ├─────────────────────────────────────────────────────────────┤ │ Repository 层 │ │ - 数据库约束校验(唯一约束、外键约束) │ └─────────────────────────────────────────────────────────────┘8.2 错误响应规范
- 统一响应格式:使用统一的 ErrorResponse 结构
- 明确错误码:使用 HTTP 状态码 + 业务错误码
- 详细错误信息:提供字段级别的错误详情
- 国际化支持:支持多语言错误消息
8.3 性能优化建议
- 避免重复校验:在 Controller 层校验后,Service 层只需做业务校验
- 异步校验:对于复杂校验逻辑,考虑异步处理
- 缓存校验结果:对于高频校验规则,缓存校验结果
8.4 安全考虑
- 输入过滤:对用户输入进行严格校验,防止注入攻击
- 敏感信息保护:错误响应中不暴露敏感信息
- 日志脱敏:记录日志时对敏感数据进行脱敏处理
结语
数据校验和异常处理是构建健壮应用的重要组成部分。通过合理使用 JSR-380 校验注解、自定义校验器和全局异常处理器,可以实现全面的数据校验和统一的错误处理机制。在实际项目中,应根据业务需求设计分层校验策略,结合国际化和安全考虑,构建高质量的错误处理体系。
