概述
在构建安全的 RESTful API 时,数据的合法性校验是不可或缺的一环。Spring Security 负责认证与授权,而 Bean Validation(JSR 380)则为领域对象和 DTO 提供了声明式的约束验证能力。
本文将从零开始,讲解如何在 Spring Boot 项目中集成验证框架,通过内置注解快速实现常见校验,并手把手带你编写一个自定义验证注解,为后续密码等复杂规则打下基础。
纲要
- 核心概念:
Bean Validation、ConstraintValidator、自定义约束注解 - 依赖引入:
spring-boot-starter-validation - 内置验证注解速览(表格对比)
- 领域对象与 DTO 的校验实战
- 使用
@NotBlank、@Size、@Email、@Pattern等 - 控制器中配合
@Valid触发验证
- 使用
- 自定义验证注解:
@ValidEmail- 注解定义(
@Constraint) - 验证器实现(
EmailValidator)
- 注解定义(
- 测试与结果验证
- 项目代码结构可视化
依赖引入
Spring Boot 通过起步依赖统一管理版本,只需在 pom.xml 中加入以下依赖即可获得完整的 Bean Validation 支持:
xml
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
引入后,hibernate-validator 等实现会一起被拉取,无需额外配置。
内置验证注解速览
JSR 380 提供了一系列开箱即用的注解,适用于不同的数据类型与校验场景。以下表格整理了最常用的部分:
| 注解 | 适用类型 | 说明 |
|---|---|---|
@NotNull |
任意类型 | 值不能为 null |
@NotEmpty |
CharSequence、Collection、Map、数组 |
不能为 null 且长度/大小必须大于 0 |
@NotBlank |
String |
不能为 null 且去除首尾空格后长度大于 0 |
@Size(min,max) |
String、Collection、Map、数组 |
字符串时为字符长度,集合时为元素个数 |
@Min(value) |
数字类型 | 最小值 |
@Max(value) |
数字类型 | 最大值 |
@Email |
String |
简单的邮件格式校验(较宽松) |
@Pattern(regexp) |
String |
正则表达式匹配 |
@Positive / @PositiveOrZero |
数字类型 | 正数 / 非负数 |
@Negative / @NegativeOrZero |
数字类型 | 负数 / 非正数 |
@Past / @PastOrPresent |
日期时间类型 | 过去的日期 / 过去或现在 |
@Future / @FutureOrPresent |
日期时间类型 | 将来的日期 / 将来或现在 |
这些注解可以叠加使用,并且支持在 Optional 或集合元素上声明。
实战:领域对象与 DTO 校验
项目结构
dir
src/main/java
└── com/example/demo
├── domain
│ └── User.java
├── dto
│ └── UserDTO.java
├── controller
│ └── AuthController.java
├── validation
│ ├── ValidEmail.java
│ └── EmailValidator.java
└── DemoApplication.java
领域实体
User 实体通常与数据库映射,不使用验证注解以避免持久化干扰。这里仅作简单定义,并启用 Lombok 简化代码。
java
package com.example.demo.domain;
import lombok.Data;
import java.io.Serializable;
@Data
public class User implements Serializable {
private String username;
private String password;
private String email;
private String realName;
}
数据传输对象
UserDTO 是前端与后端交互的载体,所有校验规则均施加在此处。注意添加了 matchingPassword 用于"重复输入密码"的场景。
java
package com.example.demo.dto;
import com.example.demo.validation.ValidEmail;
import lombok.Data;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.Size;
@Data
public class UserDTO {
@NotBlank(message = "用户名不能为空")
@Size(min = 4, max = 50, message = "用户名长度必须在4到50个字符之间")
private String username;
@NotBlank(message = "密码不能为空")
@Size(min = 8, max = 30, message = "密码长度必须在8到30个字符之间")
private String password;
@NotBlank(message = "确认密码不能为空")
private String matchingPassword;
@NotBlank(message = "邮箱不能为空")
@ValidEmail // 自定义校验注解
private String email;
@NotBlank(message = "姓名不能为空")
@Size(min = 1, max = 50, message = "姓名长度必须在1到50个字符之间")
private String realName;
}
控制器
在 @RestController 的方法参数上添加 @Valid 注解,Spring 会自动触发校验。
如果校验失败,会抛出 MethodArgumentNotValidException,我们可以通过全局异常处理器定制响应格式,本文为了聚焦核心逻辑,直接使用默认返回(Spring Boot 会自动返回 400 及错误详情)。
java
package com.example.demo.controller;
import com.example.demo.dto.UserDTO;
import org.springframework.web.bind.annotation.*;
import javax.validation.Valid;
@RestController
@RequestMapping("/auth")
public class AuthController {
@PostMapping("/register")
public UserDTO register(@Valid @RequestBody UserDTO userDTO) {
// 暂时不做持久化,直接返回接收到的对象
return userDTO;
}
}
启动应用后,用 REST 客户端发送 POST 请求到 /auth/register,携带不合法数据,即可看到类似如下的错误响应(Spring Boot 默认 JSON 结构):
json
{
"timestamp": "2026-08-04T10:00:00.000+00:00",
"status": 400,
"error": "Bad Request",
"errors": [
{
"field": "username",
"message": "用户名长度必须在4到50个字符之间"
}
]
}
若所有字段均合法,请求成功并返回提交的 JSON。
自定义验证注解:@ValidEmail
内置的 @Email 验证较为宽松(例如 zhangsan@localhost 也能通过),当我们需要更严格的正则约束时,可以自定义注解。
定义注解
自定义注解需要标注 @Constraint 并指定对应的验证器类。此外,必须包含 message、groups、payload 三个属性。
java
package com.example.demo.validation;
import javax.validation.Constraint;
import javax.validation.Payload;
import java.lang.annotation.*;
@Documented
@Constraint(validatedBy = EmailValidator.class)
@Target({ElementType.FIELD, ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
public @interface ValidEmail {
String message() default "邮箱格式不合法";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
}
实现验证器
验证器实现 ConstraintValidator<A extends Annotation, T> 接口,泛型分别指定注解类型和待校验值的类型。我们使用较为严格的正则表达式进行匹配。
java
package com.example.demo.validation;
import javax.validation.ConstraintValidator;
import javax.validation.ConstraintValidatorContext;
import java.util.regex.Pattern;
public class EmailValidator implements ConstraintValidator<ValidEmail, String> {
// 正则:常见邮箱格式,支持中文域名等场景可适当放宽
private static final String EMAIL_REGEX = "^[a-zA-Z0-9_+&*-]+(?:\\.[a-zA-Z0-9_+&*-]+)*@(?:[a-zA-Z0-9-]+\\.)+[a-zA-Z]{2,7}$";
private static final Pattern EMAIL_PATTERN = Pattern.compile(EMAIL_REGEX);
@Override
public void initialize(ValidEmail constraintAnnotation) {
// 初始化逻辑,此处无需额外操作
}
@Override
public boolean isValid(String value, ConstraintValidatorContext context) {
if (value == null) {
return false; // 通常配合 @NotBlank 使用,此处返回 false 或 true 看需求
}
return EMAIL_PATTERN.matcher(value).matches();
}
}
现在,在 UserDTO.email 字段上使用 @ValidEmail 即可应用该自定义规则。
验证流程示意
以下 Mermaid 时序图展示了客户端请求到达后,Spring MVC 如何处理 @Valid 验证。
Validator Controller DispatcherServlet Client Validator Controller DispatcherServlet Client #mermaid-svg-O9niq9dCXJoek54s{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-O9niq9dCXJoek54s .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-O9niq9dCXJoek54s .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-O9niq9dCXJoek54s .error-icon{fill:#552222;}#mermaid-svg-O9niq9dCXJoek54s .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-O9niq9dCXJoek54s .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-O9niq9dCXJoek54s .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-O9niq9dCXJoek54s .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-O9niq9dCXJoek54s .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-O9niq9dCXJoek54s .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-O9niq9dCXJoek54s .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-O9niq9dCXJoek54s .marker{fill:#333333;stroke:#333333;}#mermaid-svg-O9niq9dCXJoek54s .marker.cross{stroke:#333333;}#mermaid-svg-O9niq9dCXJoek54s svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-O9niq9dCXJoek54s p{margin:0;}#mermaid-svg-O9niq9dCXJoek54s .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-O9niq9dCXJoek54s text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-O9niq9dCXJoek54s .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-O9niq9dCXJoek54s .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-O9niq9dCXJoek54s .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-O9niq9dCXJoek54s .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-O9niq9dCXJoek54s #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-O9niq9dCXJoek54s .sequenceNumber{fill:white;}#mermaid-svg-O9niq9dCXJoek54s #sequencenumber{fill:#333;}#mermaid-svg-O9niq9dCXJoek54s #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-O9niq9dCXJoek54s .messageText{fill:#333;stroke:none;}#mermaid-svg-O9niq9dCXJoek54s .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-O9niq9dCXJoek54s .labelText,#mermaid-svg-O9niq9dCXJoek54s .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-O9niq9dCXJoek54s .loopText,#mermaid-svg-O9niq9dCXJoek54s .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-O9niq9dCXJoek54s .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-O9niq9dCXJoek54s .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-O9niq9dCXJoek54s .noteText,#mermaid-svg-O9niq9dCXJoek54s .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-O9niq9dCXJoek54s .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-O9niq9dCXJoek54s .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-O9niq9dCXJoek54s .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-O9niq9dCXJoek54s .actorPopupMenu{position:absolute;}#mermaid-svg-O9niq9dCXJoek54s .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-O9niq9dCXJoek54s .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-O9niq9dCXJoek54s .actor-man circle,#mermaid-svg-O9niq9dCXJoek54s line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-O9niq9dCXJoek54s :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} alt校验失败校验通过 POST /auth/register (JSON)调用 register(@Valid UserDTO)触发 Bean Validation执行所有约束注解(含自定义@ValidEmail)抛出 MethodArgumentNotValidException异常传播400 Bad Request + 错误详情正常返回 UserDTO200 OK + JSON
总结
本文从 Bean Validation 的核心概念出发,介绍了 Spring Boot 中引入验证框架的方式,通过表格速览了常用内置注解,随后以注册场景为例演示了如何在 DTO 上施加约束,并配合 @Valid 触发校验。
最后,我们通过自定义 @ValidEmail 注解与对应的验证器,掌握了扩展验证逻辑的完整流程。这套机制不仅提高了代码的可读性,还让校验规则与业务逻辑解耦,为后续实现更复杂的密码验证等规则奠定了坚实基础。