Spring Boot: 数据验证

最后更新:2026-08-26

数据验证是 API 的第一道防线——无效输入不应进入业务逻辑,越早拒绝越好。

1. 你将学到


2. 一个 API 开发者的真实故事

(1) 痛点:脏数据入库

Alice 发现 OrderFlow 数据库中出现了奇怪的数据:订单数量为 -5、邮箱格式为 "abc"、商品价格为 0。Bob 报告说有用户通过 API 提交了负数库存的商品,导致报表统计异常。Alice 之前在 Service 层写了一堆 if-else 校验代码,既冗长又容易遗漏。

(2) Bean Validation 的解法

用注解声明校验规则,Spring 自动触发验证:

JAVA
public record CreateOrderRequest(
    @NotNull Long productId,
    @Min(1) @Max(100) Integer quantity,
    @Email String customerEmail
) {}

无效请求在进入 Controller 之前就被拒绝了。

(3) 收益

Alice 用 Bean Validation 替换了所有手动校验代码,Controller 代码量减少 40%,再也不会遗漏校验规则。数据库中不再出现脏数据。


3. Bean Validation 注解体系

(1) 验证执行流程

100%
graph TD
    A["Client Request<br/>@RequestBody"] --> B{"@Valid<br/>Triggered?"}
    B -->|Yes| C["Hibernate Validator<br/>Check Constraints"]
    C --> D{"All Valid?"}
    D -->|Yes| E["Controller Method<br/>Executes"]
    D -->|No| F["MethodArgumentNotValidException<br/>400 Bad Request"]
    B -->|No| G["Skip Validation<br/>Potential dirty data"]

(3) 验证触发方式

注解 用途 放置位置
@Valid 触发级联验证(包括嵌套对象) 方法参数、字段
@Validated 支持分组验证 类、方法参数
@Validated(Group.class) 指定验证分组 方法参数

▶ 示例: Controller 参数验证

JAVA
@RestController
@RequestMapping("/api/v1/orders")
public class OrderController {

    @PostMapping
    public ResponseEntity<Order> createOrder(
            @Valid @RequestBody CreateOrderRequest request) {
        // If validation fails, MethodArgumentNotValidException is thrown
        // before reaching this line
        Order order = orderService.createOrder(request);
        return ResponseEntity.status(HttpStatus.CREATED).body(order);
    }
}

public record CreateOrderRequest(
    @NotNull(message = "Product ID is required")
    Long productId,

    @Min(value = 1, message = "Quantity must be at least 1")
    @Max(value = 100, message = "Quantity cannot exceed 100")
    Integer quantity,

    @Email(message = "Invalid email format")
    String customerEmail
) {}

输出:

TEXT 📖 仅展示
// 执行成功

4. 常用约束注解

(1) 约束注解速查

注解 适用类型 说明 示例
@NotNull 所有类型 不能为 null @NotNull Long id
@NotBlank String 不能为空/null/纯空格 @NotBlank String name
@NotEmpty String/Collection 不能为空/null @NotEmpty List<String> tags
@Size String/Collection 长度/大小范围 @Size(min=2, max=100)
@Min / @Max 数字类型 值范围 @Min(0) @Max(99999)
@Positive 数字类型 正数 @Positive BigDecimal price
@Email String 邮箱格式 @Email String email
@Pattern String 正则匹配 @Pattern(regexp="^[A-Z]")
@Past / @Future 日期类型 过去/未来 @Past LocalDate birthDate

▶ 示例: Product DTO 验证

JAVA
public record CreateProductRequest(
    @NotBlank(message = "Product name is required")
    @Size(min = 2, max = 200, message = "Name must be 2-200 characters")
    String name,

    @NotNull(message = "Price is required")
    @Positive(message = "Price must be positive")
    @DecimalMin(value = "0.01", message = "Price must be at least 0.01")
    BigDecimal price,

    @NotNull(message = "Stock is required")
    @Min(value = 0, message = "Stock cannot be negative")
    Integer stock,

    @Email(message = "Supplier email must be valid")
    String supplierEmail,

    @Pattern(regexp = "^[A-Z]{3}-\\d{4}$", message = "SKU format: XXX-0000")
    String sku
) {}

输出:

TEXT 📖 仅展示
// 执行成功
注解对比 null "" " " "abc"
@NotNull
@NotBlank
@NotEmpty
🔥 易错: String 类型验证优先用 @NotBlank 而非 @NotNull,因为空字符串通常也是无效的。


5. 自定义验证器

(1) 实现步骤

  1. 定义约束注解
  2. 实现 ConstraintValidator<A, T>
  3. 在 DTO 字段上使用

▶ 示例: 自定义 @ValidOrderQuantity 验证器

JAVA
// Step 1: Define constraint annotation
@Target({ElementType.FIELD, ElementType.PARAMETER})
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = OrderQuantityValidator.class)
public @interface ValidOrderQuantity {
    String message() default "Order quantity exceeds product stock limit";
    Class<?>[] groups() default {};
    Class<? extends Payload>[] payload() default {};
}

// Step 2: Implement ConstraintValidator
public class OrderQuantityValidator
        implements ConstraintValidator<ValidOrderQuantity, Integer> {

    private static final int MAX_QUANTITY_PER_ITEM = 100;

    @Override
    public boolean isValid(Integer quantity, ConstraintValidatorContext context) {
        if (quantity == null) {
            return true; // Let @NotNull handle null check
        }
        return quantity >= 1 && quantity <= MAX_QUANTITY_PER_ITEM;
    }
}

// Step 3: Use in DTO
public record CreateOrderRequest(
    @NotNull Long productId,
    @ValidOrderQuantity Integer quantity
) {}

输出:

TEXT 📖 仅展示
// 执行成功

6. 分组验证

(1) 按场景区分校验规则

Create 和 Update 场景通常需要不同的验证规则:

场景 Product ID Name Price
Create 自动生成,不需要 必填 必填
Update 必填(标识修改谁) 可选 可选

▶ 示例: 分组验证

JAVA
// Define group interfaces
public interface Create {}
public interface Update {}

// DTO with group-aware validation
public record ProductRequest(
    @Null(groups = Create.class, message = "ID must be null for creation")
    @NotNull(groups = Update.class, message = "ID is required for update")
    Long id,

    @NotBlank(groups = Create.class, message = "Name is required for creation")
    @Size(min = 2, max = 200)
    String name,

    @NotNull(groups = Create.class, message = "Price is required for creation")
    @Positive BigDecimal price
) {}

输出:

TEXT 📖 仅展示
// 执行成功
JAVA
@RestController
@RequestMapping("/api/v1/products")
public class ProductController {

    @PostMapping
    public ResponseEntity<Product> create(
            @Validated(Create.class) @RequestBody ProductRequest request) {
        // Only Create group validations are applied
        // ...
        return ResponseEntity.status(HttpStatus.CREATED).build();
    }

    @PutMapping("/{id}")
    public ResponseEntity<Product> update(
            @PathVariable Long id,
            @Validated(Update.class) @RequestBody ProductRequest request) {
        // Only Update group validations are applied
        // ...
        return ResponseEntity.ok().build();
    }
}

▶ 示例: 嵌套验证

JAVA
public record CreateOrderRequest(
    @NotNull Long productId,
    @ValidOrderQuantity Integer quantity,
    @Valid @NotNull ShippingAddress shippingAddress
) {}

public record ShippingAddress(
    @NotBlank String street,
    @NotBlank String city,
    @NotBlank String zipCode,
    @Pattern(regexp = "^[A-Z]{2}$") String country
) {}

输出:

TEXT 📖 仅展示
// 执行成功
📌 重点: 嵌套对象必须加 @Valid,否则嵌套字段验证不生效。@Validated 不支持嵌套级联验证。


7. 验证错误响应格式化

(1) 统一错误响应格式

JAVA
@RestControllerAdvice
public class ValidationExceptionHandler {

    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ResponseEntity<Map<String, Object>> handleValidation(
            MethodArgumentNotValidException ex) {
        Map<String, Object> body = new LinkedHashMap<>();
        body.put("timestamp", Instant.now());
        body.put("status", HttpStatus.BAD_REQUEST.value());

        List<Map<String, String>> errors = ex.getBindingResult()
            .getFieldErrors().stream()
            .map(fe -> Map.of(
                "field", fe.getField(),
                "message", fe.getDefaultMessage() != null ? fe.getDefaultMessage() : "",
                "rejectedValue", fe.getRejectedValue() != null ? fe.getRejectedValue().toString() : "null"
            ))
            .toList();
        body.put("errors", errors);
        return ResponseEntity.badRequest().body(body);
    }
}
💻 输出:

JSON
{
  "timestamp": "2024-01-15T10:00:00Z",
  "status": 400,
  "errors": [
    {"field": "quantity", "message": "Quantity must be at least 1", "rejectedValue": "0"},
    {"field": "customerEmail", "message": "Invalid email format", "rejectedValue": "abc"}
  ]
}

8. 综合示例:OrderFlow 完整验证体系

JAVA
// Validation groups
package com.orderflow.validation;
public interface Create {}
public interface Update {}

// Custom validator: @ValidShippingAddress
@Target({ElementType.FIELD})
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = ShippingAddressValidator.class)
public @interface ValidShippingAddress {
    String message() default "Invalid shipping address";
    Class<?>[] groups() default {};
    Class<? extends Payload>[] payload() default {};
}

public class ShippingAddressValidator
        implements ConstraintValidator<ValidShippingAddress, String> {
    @Override
    public boolean isValid(String address, ConstraintValidatorContext ctx) {
        if (address == null || address.isBlank()) return false;
        return address.length() >= 10 && address.length() <= 500;
    }
}

// DTOs
public record CreateOrderRequest(
    @NotNull(groups = Create.class) Long productId,
    @Min(value = 1, message = "Quantity must be at least 1")
    @Max(value = 100, message = "Quantity cannot exceed 100")
    Integer quantity,
    @Email String customerEmail,
    @ValidShippingAddress String shippingAddress
) {}

public record CreateProductRequest(
    @Null(groups = Create.class) @NotNull(groups = Update.class) Long id,
    @NotBlank(groups = Create.class) @Size(min = 2, max = 200) String name,
    @NotNull(groups = Create.class) @Positive BigDecimal price,
    @Min(0) Integer stock,
    @Pattern(regexp = "^[A-Z]{3}-\\d{4}$", message = "SKU: XXX-0000") String sku
) {}

// Controller
@RestController
@RequestMapping("/api/v1/orders")
public class OrderController {
    @PostMapping
    public ResponseEntity<Void> create(
            @Validated(Create.class) @RequestBody CreateOrderRequest req) {
        // Validation passed, proceed to business logic
        return ResponseEntity.status(HttpStatus.CREATED).build();
    }
}

❓ 常见问题

Q @Valid 和 @Validated 有什么区别?
A @Valid 是 JSR-380 标准注解,支持嵌套级联验证。@Validated 是 Spring 扩展注解,支持分组验证。需要分组用 @Validated,需要嵌套用 @Valid,两者可以组合使用。
Q 验证失败返回什么?
A Spring Boot 默认返回 400 Bad Request + JSON 错误信息。可通过 @RestControllerAdvice 自定义格式,本课提供了统一格式方案。
Q @NotBlank、@NotEmpty、@NotNull 怎么选?
A String 用 @NotBlank(不允许 null、空串、纯空格),Collection 用 @NotEmpty(不允许 null、空集合),其他类型用 @NotNull。
Q 分组验证和默认验证可以同时生效吗?
A 默认情况下,指定分组后 Default 组不生效。如果想同时生效,让自定义组继承 Default:public interface Create extends Default {}
Q 自定义验证器中能注入 Spring Bean 吗?
A 可以。ConstraintValidator 由 Spring 容器管理,可以在 isValid 方法中使用 @Autowired 注入 Bean(如查询数据库验证唯一性)。
Q 如何国际化验证错误消息?
Aresources/ValidationMessages.properties 中定义消息 key,如 order.quantity.invalid=Order quantity must be between {min} and {max},支持多语言文件。

📖 小节


📝 作业

  1. 基础题(难度⭐):为 OrderFlow 的 CreateOrderRequest 和 CreateProductRequest 添加 Bean Validation 注解,验证无效输入返回 400 错误。

  2. 进阶题(难度⭐⭐):实现分组验证——Create 时 name 和 price 必填,Update 时 id 必填且 name/price 可选。实现自定义 @ValidShippingAddress 验证器。

  3. 挑战题(难度⭐⭐⭐):创建一个 @UniqueProductSku 验证器,注入 ProductRepository 检查 SKU 是否已存在,实现数据库级别的唯一性校验,思考验证器与业务层的职责边界。

Web-Tutorial.com

Web-Tutorial 技术团队

由多位开发者共同维护的编程教程平台。每篇教程由对应领域的开发者编写和审核,确保内容准确可靠。如发现任何问题,欢迎向我们反馈。

100%

🙏 帮我们做得更好

我们是刚上线的编程教程站,几个人的小团队,精力有限。页面虽经检查,难免还有疏漏——链接失效、排版错乱、内容有误、语言生硬……

如果您发现了,麻烦告诉我们,我们会在收到反馈后第一时间进行修复,再次感谢您的光临 🙏