本文 的 原文 地址
原始的内容,请参考 本文 的 原文 地址
本文作者:
- 第一作者 老架构师 肖恩(肖恩 是尼恩团队 高级架构师,负责写此文的第一稿,初稿 )
- 第二作者 老架构师 尼恩 (45岁老架构师, 负责 提升此文的 技术高度,让大家有一种 俯视 技术、俯瞰技术、 技术自由 的感觉)
参数校验简介
什么是SpringBoot 参数校验?
在实际开发中,拿到数据后的第一步通常是检查数据是否正确。
如果只是简单的格式错误(比如字段为空、数字超出范围),我们可以通过注解来快速判断并提示用户。
但有些校验涉及业务逻辑,比如“购买金额 = 单价 × 数量”,这种就不能靠简单注解完成,需要更灵活的方式处理。
怎么灵活的处理参数校验? 可以用 Spring 提供的 Validator 接口 来实现复杂规则校验。
不过,硬编码写校验逻辑太麻烦了。
现在主流做法是使用 JSR 303 标准,它是一套 Java 提供的数据校验规范,支持通过注解方式直接写在类属性上,比如:
@NotNull String name;
@Min(18) int age;
Spring 从 3.0 版本开始支持 JSR 303,不同版本支持的功能略有不同:
Spring 版本 | 支持标准 | 验证框架版本 | 主要功能 |
---|---|---|---|
Spring 3.0 | JSR 303 | Hibernate Validator 4 | 基础注解校验 |
Spring 4.0 | JSR 349 | Hibernate Validator 5 | 分组校验、方法校验 |
Spring 5.0+ | JSR 380 | Hibernate Validator 6 | 完整支持 Bean Validation 2.0 |
常用注解一览表
注解 | 作用 | 示例 |
---|---|---|
@NotNull |
不为 null | @NotNull String name |
@NotBlank |
字符串非空且不能全是空格 | @NotBlank String password |
@Min / @Max |
数值最小/最大限制 | @Min(18) int age |
@Email |
检查邮箱格式 | @Email String email |
@Pattern |
正则匹配 | @Pattern(regexp="^[a-zA-Z0-9]+$") |
@Future |
必须是未来时间 | @Future LocalDate expireDate |
更多注解可以参考官方文档或 IDE 自动提示。
JSR 303 实现版本:Hibernate Validator
Hibernate Validator 是 JSR 303 的一个具体实现。
除了支持所有标准注解外,Hibernate Validator 还扩展了一些实用注解,比如:
注解 | 说明 |
---|---|
@URL |
检查是否是合法 URL |
@Length |
字符串长度范围限制 |
@Range |
数值或字符串必须在指定范围内 |
总结一句话:
使用 JSR 303 + Hibernate Validator 可以让我们在 Spring Boot 中轻松实现数据合法性校验,减少手动判断代码,提高开发效率
参数校验实操
第一步:引入依赖
Spring Boot 提供了一个模块叫:spring-boot-starter-validation
,它已经集成了 Hibernate Validator,你只需要引入依赖就可以直接使用。
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
这样就能在 Controller 或 DTO 中使用注解进行自动校验了。
第二步:定义一个 全局异常处理器 GlobalExceptionHandler
这是一个统一处理程序错误的地方,叫做全局异常处理器。
它的作用是:当程序出错时,自动捕获这些错误,并返回友好的提示信息,而不是直接崩溃。
@Slf4j
@ControllerAdvice
public class GlobalExceptionHandler {
//参数校验异常
@ExceptionHandler(MethodArgumentNotValidException.class)
@ResponseBody
public Result error(MethodArgumentNotValidException e){
log.warn(e.getMessage());
return Result.fail()
.code(ResultCodeEnum.ARGUMENT_VALID_ERROR.getCode())
.message(e.getBindingResult().getFieldError().getDefaultMessage());
}
//参数校验异常
@ExceptionHandler(BindException.class)
@ResponseBody
public Result error(BindException e){
log.warn(e.getMessage());
StringBuilder sb = new StringBuilder();
for (ObjectError error : e.getBindingResult().getAllErrors()) {
sb.append(error.getDefaultMessage());
}
return Result.fail()
.code(ResultCodeEnum.ARGUMENT_VALID_ERROR.getCode())
.message(sb.toString());
}
//参数校验异常
@ExceptionHandler(ConstraintViolationException.class)
@ResponseBody
public Result error(ConstraintViolationException e){
log.warn(e.getMessage());
StringBuilder sb = new StringBuilder();
for (ConstraintViolation<?> violation : e.getConstraintViolations()) {
sb.append(violation.getMessage());
}
return Result.fail()
.code(ResultCodeEnum.ARGUMENT_VALID_ERROR.getCode())
.message(sb.toString());
}
//全局异常
@ExceptionHandler(Exception.class)
@ResponseBody
public Result error(Exception e){
log.warn(e.getMessage());
return Result.fail().message("执行了全局异常处理");
}
}
下面是它处理几种常见错误的方式:
(1) 参数验证错误(MethodArgumentNotValidException):
当用户提交的数据格式不对时(比如注册时少填了手机号),会返回具体哪个字段错了。
(2) 绑定参数错误(BindException):
这也是参数问题的一种,但可能涉及多个字段出错。这个方法会把所有错误信息拼在一起返回。
(3) 违反约束规则的错误(ConstraintViolationException):
比如某个字段必须大于0,但用户输入了-1。这时也会收集所有错误提示并返回。
(4) 其他所有未处理的异常(Exception):
如果发生了没特别处理的错误,就走这个兜底的方法,返回一个通用错误提示。
第三步:定义实体类
下面是两个 Java 类,用于接收用户新增和修改时传入的数据。
它们分别定义了创建用户和更新用户时需要填写的字段,并通过注解对字段做了基本校验。
(1)创建用户(UserCreateVO)
这个类用于创建新用户,包含以下字段:
- 用户名:不能为空
- 姓名:不能为空
- 手机号:必须是11位数字
- 性别:不能为空(用整数表示,比如 0 表示女,1 表示男)
@Data
public class UserCreateVO {
@NotBlank(message = "用户名不能为空")
private String userName;
@NotBlank(message = "姓名不能为空")
private String name;
@Size(min=11,max=11,message = "手机号长度不符合要求")
private String phone;
@NotNull(message = "性别不能为空")
private Integer sex;
}
(2)更新用户(UserUpdateVO)
这个类用于更新已有用户信息,相比创建用户,多了一个 id 字段,用来指定要更新的是哪个用户。
- id:不能为空,表示用户的唯一标识
- 其他字段与创建用户一致
@Data
public class UserUpdateVO {
@NotBlank(message = "id不能为空")
private String id;
@NotBlank(message = "用户名不能为空")
private String userName;
@NotBlank(message = "姓名不能为空")
private String name;
@Size(min=11,max=11,message = "手机号长度不符合要求")
private String phone;
@NotNull(message = "性别不能为空")
private Integer sex;
}
第四步:定义请求接口
这是一个用户管理的接口类,主要处理用户的创建和更新操作
- 创建用户:
/user/create
- 更新用户:
/user/update
代码如下:
@Validated
@RestController
@RequestMapping("/user")
public class UserController {
@PostMapping("create")
public Result createUser(@Validated @RequestBody UserCreateVO userCreateVo){
return Result.success("参数校验成功");
}
@PostMapping("update")
public Result updateUser(@Validated @RequestBody UserUpdateVO userUpdateVo){
return Result.success("参数校验成功");
}
}
第五步:校验测试
新增接口测试
下面这个请求,是故意没有填写用户名的,用来测试接口在缺少参数时的反应。
create接口的POST 操作
POST http://localhost:8080/user/create
Content-Type: application/json
{
"userName": "",
"name": "sean",
"phone": "12345678901",
"sex": 1
}
系统会返回一个响应结果,截图如下:
更新接口测试
接下来可以测试更新用户信息的接口,具体操作类似上面的流程,只是接口和参数可能会有变化。
update 接口的POST 操作
POST http://localhost:8080/user/update
Content-Type: application/json
{
"id": "",
"userName": "sean",
"name": "sean",
"phone": "12345678901",
"sex": 1
}
返回结果截图如下:
第六步:路径传参校验
有时候我们会通过 URL 地址传参数,比如根据 ID 获取用户信息。
这时候我们可以用 @PathVariable
来接收参数,并加上校验规则。
示例代码如下:
@GetMapping("getUserById/{id}")
public Result getUserById(@PathVariable @Size(min=2,max=5,message = "id长度不符合要求") String id){
return Result.success("参数校验成功");
}
测试说明:
当我们访问类似
/getUserById/ab
的地址时,因为 id 是 "ab",符合长度要求,所以能正常返回“校验通过”。如果我们访问
/getUserById/a
,长度不够,就会返回提示:“id长度要在2到5个字符之间”。getUserById 的get 请求参考demo:
GET http://localhost:8080/user/getUserById/1234567890
结果:
可以看到,校验规则也是生效的。
第七步:分组校验
我们前边写了两个 VO 类:UserCreateVO 和 UserUpdateVO。
但是开发时一般都是一个VO, 一个VO如何实现不同方法,有不同的校验规则呢?
比如我们新增的时候一般不需要 id,但是修改的时候需要传入 id。
分组校验接口
在做数据校验时,有时候需要根据不同的操作(比如新增或修改)来使用不同的校验规则。
我们可以通过定义“分组”来实现这一点。
下面是一个简单的例子:
public class UserGroup {
// 定义一个用于创建用户的校验分组
public interface CreateGroup extends Default {
}
// 定义一个用于更新用户的校验分组
public interface UpdateGroup extends Default {
}
}
这样,在实际校验时就可以指定使用哪一组规则,比如新增用户时用 CreateGroup
,修改用户信息时用 UpdateGroup
。
统一VO校验类
这是一个用户信息的 Java 类 UserVo
,用于接收前端传来的用户数据。
它定义了以下几个字段和校验规则:
- id:用户唯一标识,不能为空,仅在更新用户时使用。
- userName:用户名,不能为空,新增和更新都要校验。
- name:真实姓名,不能为空,新增和更新都要校验。
- phone:手机号,必须是合法格式,新增和更新都要校验。
- sex:性别,不能为空,新增和更新都要校验。
@Data
public class UserVo {
@NotBlank(message = "id不能为空",groups = UserGroup.UpdateGroup.class)
private String id;
@NotBlank(message = "用户名不能为空",groups =
{UserGroup.CreateGroup.class,UserGroup.UpdateGroup.class})
private String userName;
@NotBlank(message = "姓名不能为空",groups =
{UserGroup.CreateGroup.class,UserGroup.UpdateGroup.class})
private String name;
// @Size(min=11,max=11,message = "手机号长度不符合要求",groups =
// {UserGroup.CreateGroup.class,UserGroup.UpdateGroup.class})
@PhoneValid(message = "请填写正确的手机号", groups =
{UserGroup.CreateGroup.class,UserGroup.UpdateGroup.class})
private String phone;
@NotNull(message = "性别不能为空",groups =
{UserGroup.CreateGroup.class,UserGroup.UpdateGroup.class})
private Integer sex;
}
修改接口
注意:分组校验不能使用@Valid , 需要换 一个 注解
参数上边添加 @Validated 接口,并指定分组名称,并统一使用 UserVO 类接收参数。
@Validated
@RestController
@RequestMapping("/user")
public class UserController {
@PostMapping("create")
public Result createUser(@Validated(UserGroup.CreateGroup.class) @RequestBody UserVo userVO) {
return Result.success("参数校验成功");
}
@PostMapping("update")
public Result updateUser(@Validated(UserGroup.UpdateGroup.class) @RequestBody UserVo userVO) {
return Result.success("参数校验成功");
}
@GetMapping("getUserById/{id}")
public Result getUserById(@PathVariable @Size(min=2,max=5,message = "id长度不符合要求") String id){
return Result.success("参数校验成功");
}
}
- 使用
@Validated
实现分组校验,不能用@Valid
- 所有参数统一封装到
UserVO
中 - 校验失败会自动返回错误信息,成功则继续执行
- URL 路径参数也可以加校验规则,比如长度限制等
第八步:嵌套对象校验
存在嵌套对象校验的时候,使用 @Valid 注解解决。
添加一个角色实体
这是一个用来保存角色信息的类,主要包含角色名称。
@Data
public class Role {
@NotBlank(message = "角色名称不能为空")
private String roleName;
}
修改VO类
在 User 类中引入角色实体,并加入 @Valid 注解。
@Data
public class UserVo {
@NotBlank(message = "id不能为空",groups = UserGroup.UpdateGroup.class)
private String id;
@NotBlank(message = "用户名不能为空",groups =
{UserGroup.CreateGroup.class,UserGroup.UpdateGroup.class})
private String userName;
@NotBlank(message = "姓名不能为空",groups =
{UserGroup.CreateGroup.class,UserGroup.UpdateGroup.class})
private String name;
// @Size(min=11,max=11,message = "手机号长度不符合要求",groups =
// {UserGroup.CreateGroup.class,UserGroup.UpdateGroup.class})
@PhoneValid(message = "请填写正确的手机号", groups =
{UserGroup.CreateGroup.class,UserGroup.UpdateGroup.class})
private String phone;
@NotNull(message = "性别不能为空",groups =
{UserGroup.CreateGroup.class,UserGroup.UpdateGroup.class})
private Integer sex;
@Valid
@NotNull(message = "角色信息不能为空")
private Role role;
}
校验测试
测试新增接口 create 接口,故意不传角色信息:
POST http://localhost:8080/user/create
Content-Type: application/json
{
"userName": "xxx",
"name": "sean",
"phone": "13345678901",
"sex": 1,
"role": {
"roleName": ""
}
}
结果:
修改测试也一样结果
两大注解的对比:@Valid
和 @Validated
对比
一般Controller 层,可以用 @Valid
校验请求数据格式
一般Service 层,用 @Validated
校验业务规则
从原理上解释这两个注解
@Valid的触发时机是在
HandlerMethodArgumentResolver
这个组件封装解析参数是触发@Validated如果在Service层中使用,是AOP机制,通过
MethodValidationInterceptor
AOP代理在方法调用是触发- @Validated如果只是在Controller层方法中使用,Spring MVC 会将其视为
@Valid
的增强版,仍通过RequestResponseBodyMethodProcessor
触发校验
通过一个表个对比这两个注解
对比维度 | @Valid (JSR-380 标准) |
@Validated (Spring 扩展) |
---|---|---|
来源 | Java 官方标准(javax.validation ) |
Spring 框架扩展(org.springframework ) |
作用目标 | 字段、方法参数、嵌套对象 | 类、方法、参数 |
主要用途 | 标记需要校验的对象 | 触发校验流程(支持分组和方法级校验) |
触发机制 | 通过 RequestResponseBodyMethodProcessor 在数据绑定时触发 |
通过 MethodValidationInterceptor (AOP代理) 在方法调用时触发 |
校验阶段 | 参数绑定阶段 | 方法调用阶段 |
分组校验 | 不支持 | 支持(通过 groups 属性) |
嵌套对象校验 | 需要显式标注 @Valid |
自动级联校验(无需 @Valid ) |
方法级校验 | 仅支持参数校验 | 支持方法参数和返回值校验 |
校验触发时机 | 数据绑定时(如接收 HTTP 请求) | 方法调用时(通过 Spring AOP 代理) |
异常类型 | MethodArgumentNotValidException |
ConstraintViolationException |
典型应用场景 | Controller 层校验请求数据 | Service 层校验业务参数和返回值 |
代码示例 | @PostMapping void save(@Valid @RequestBody User user) |
@Validated class Service { void update(@Min(1) Long id) } |
@Valid
:是"校验标签",贴在数据上声明需要检查什么(What to validate)。@Validated
:是"校验开关",控制如何检查和扩展功能(How to validate)。嵌套对象:混合使用(
@Validated
类 +@Valid
字段)
快速失败配置
默认情况下,参数校验会把所有规则都检查一遍,等全部检查完才告诉你哪里错了。
这种方式效率不高,尤其在有很多规则的时候。
我们可以通过开启“快速失败”模式来优化:一旦发现错误,就立刻停止后续检查。
快速失败配置 方式如下:
/**
* 参数校验相关配置
*/
@Configuration
public class ValidConfig {
/**
* 快速返回校验器
* @return
*/
@Bean
public Validator validator(){
ValidatorFactory validatorFactory = Validation.byProvider(HibernateValidator.class)
.configure()
//快速失败模式
.failFast(true)
.buildValidatorFactory();
return validatorFactory.getValidator();
}
/**
* 设置快速校验,返回方法校验处理器
* 使用MethodValidationPostProcessor注入后,会启动自定义校验器
* @return
*/
@Bean
public MethodValidationPostProcessor methodValidationPostProcessor(){
MethodValidationPostProcessor methodValidationPostProcessor = new MethodValidationPostProcessor();
methodValidationPostProcessor.setValidator(validator());
return methodValidationPostProcessor;
}
}
自定义校验规则
有时候,框架自带的数据检查功能不够用,我们就得自己写规则来验证数据。
下面以手机号验证为例,教你怎么自定义一个验证注解。
定义注解
@Documented
@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.METHOD, ElementType.FIELD, ElementType.ANNOTATION_TYPE, ElementType.CONSTRUCTOR, ElementType.PARAMETER, ElementType.TYPE_USE})
@Constraint(validatedBy = PhoneValidator.class)
public @interface PhoneValid {
String message() default "请填写正确的手机号";
Class<?>[ ] groups() default {};
Class<? extends Payload>[ ] payload() default {};
}
@Constraint 注解是 Hibernate Validator 的注解,用于实现自定义校验规则,通过 validatedBy 参数指定进行参数校验的实现类。
校验实现类
我们要实现一个手机号的校验功能,需要创建一个类 PhoneValidator
,让它实现 Java 的 ConstraintValidator
接口。
public class PhoneValidator implements ConstraintValidator<PhoneValid,Object> {
/**
* 11位手机号的正则表达式,以13、14、15、17、18头
* ^:匹配字符串的开头
* 13\d:匹配以13开头的手机号码
* 14[579]:匹配以145、147、149开头的手机号
* 15[^4\D]:匹配以15开头且第3位数字不为4的手机号码
* 17[^49\D]:匹配以17开头且第3位数字部位4或9的手机号码
* 18\d :匹配以18开头的手机号码
* \d{8}:匹配手机号码的后8位,即剩余的8个数字
* $:匹配字符串的结尾
*/
public static final String REGEX_PHONE="^(13\\d|14[579]|15[^4\\D]|17[^49\\D]|18\\d)\\d{8}$";
//初始化注解
@Override
public void initialize(PhoneValid constraintAnnotation) {
ConstraintValidator.super.initialize(constraintAnnotation);
}
//校验参数,true表示校验通过,false表示校验失败
@Override
public boolean isValid(Object o, ConstraintValidatorContext constraintValidatorContext) {
String phone = String.valueOf(o);
if(phone.length()!=11){
return false;
}
//正则校验
return phone.matches(REGEX_PHONE);
}
}
在 ConstraintValidator 中,第一个参数为注解,即 Annotation,第二个参数是泛型。
这个正则表达式用来判断手机号是否合法:
- 必须是11位数字
- 开头只能是:13、145/147/149、15开头但第三位不能是4、17开头但第三位不能是4或9、18
- 后面8位可以是任意数字
在实体中添加自定义注解
测试自定义校验
调用 create 接口,根据自定义校验正则,手机号必须13、14、15、17、18开头
POST http://localhost:8080/user/create
Content-Type: application/json
{
"userName": "xxx",
"name": "sean",
"phone": "12345678901",
"sex": 1,
"role": {
"roleName": ""
}
}
结果:
Service层 参数校验 的介绍和实操
上面实操都是讲的控制层(Controller)的校验,Spring Boot参数校验 在Service也可以使用。
Service层主要校验业务方法参数/返回值,确保业务规则合规,如金额范围、状态合法性等。
支持分组校验、方法级校验、嵌套对象自动级联(需@Valid
),需配合全局异常处理返回友好错误。
Service层的参数,通过@Validated
触发AOP代理,利用Hibernate Validator执行JSR-380校验,基于注解(如@NotNull
、@Min
)定义规则,失败抛出ConstraintViolationException
下面介绍下基本使用方法
(1) UserService
@Service
@Validated // 必须添加此注解
public class UserService {
public String createUser(@Valid UserCreateVO vo) {
return "service 校验通过";
}
}
(2) UserUpdateVO
@Data
public class UserUpdateVO {
@NotBlank(message = "id不能为空")
private String id;
@NotBlank(message = "用户名不能为空")
private String userName;
@NotBlank(message = "姓名不能为空")
private String name;
@Size(min=11,max=11,message = "手机号长度不符合要求")
private String phone;
@NotNull(message = "性别不能为空")
private Integer sex;
}
(3) 测试
@SpringBootTest
class UserServiceTest {
@Resource
UserService userService;
@Test
void createUser() {
UserCreateVO vo = new UserCreateVO();
// vo.setUserName("");
vo.setName("sean");
vo.setPhone("12345678901");
vo.setSex(18);
System.out.println(userService.createUser(vo));
}
}
执行结果
因为userName为空,校验失败
刨根问底:Controller 层校验的实现原理
Spring Boot 在 Controller 层做参数校验,是通过 @Valid
注解来触发的。
它遵循 JSR-380 标准,底层由 MethodValidationPostProcessor
和 LocalValidatorFactoryBean
来完成。
整个校验流程如下:
(1) 请求进来时,参数会被绑定到一个对象上。
(2) Spring 会用 Hibernate Validator 检查这个对象字段上的约束注解(比如 @NotNull
、@Size
等)。
(3) 校验结果会保存在 BindingResult
中。
(4) 如果校验失败,就会抛出 MethodArgumentNotValidException
异常。
(5) 最终,这个异常会被 ExceptionHandler
捕获,并返回友好的错误信息给前端。
这套机制和 Spring MVC 的参数解析流程紧密结合,使用起来非常方便。
一、整体架构图
(1) 前端请求到达 DispatcherServlet
:
这是 Spring MVC 的入口,负责协调整个请求流程。
(2) 调用 RequestMappingHandlerAdapter
执行方法 :
它负责找到对应的方法来处理请求。
(3) 使用 RequestResponseBodyMethodProcessor
解析参数 :
它会检查是否有需要校验的参数,并进行处理。
(4) 创建 WebDataBinder
来绑定和校验数据 :
它会根据配置决定是否启用参数校验。
(5) 从 LocalValidatorFactoryBean
获取校验器 :
这个类负责生成一个真正的校验工具。
(6) 最终由 HibernateValidator
执行参数校验 :
它是实际做数据校验的工具,比如判断字段是否为空、长度是否合适等。
二、启动初始化流程
Spring Boot 在启动时会自动配置数据校验功能。
这个过程主要涉及几个关键步骤:
(1) 加载自动配置:
Spring Boot 启动时,会加载校验相关的自动配置类 ValidationAutoConfiguration
。
(2) 创建校验工厂 Bean:
该配置类会创建一个 LocalValidatorFactoryBean
实例。
(3) 初始化校验器:
这个工厂 Bean 会去初始化 Hibernate 提供的校验器 HibernateValidator
。
(4) 注册为 Bean:
最终,这个校验器会被注册成 Spring 容器中的一个 Bean,供其他组件使用。
关键源码:
这段代码的作用就是创建了一个 LocalValidatorFactoryBean
对象,并把它交给 Spring 管理。
它内部会负责加载和管理 HibernateValidator
。
// ValidationAutoConfiguration.java
@Bean
@Role(BeanDefinition.ROLE_INFRASTRUCTURE)
public LocalValidatorFactoryBean defaultValidator() {
LocalValidatorFactoryBean factoryBean = new LocalValidatorFactoryBean();
// 配置消息插值器等
return factoryBean;
}
三、请求处理全流程
当我们通过浏览器或其他客户端发送一个请求给服务器时,Spring MVC 框架会按照一定的流程来处理这个请求,尤其是对传入的参数进行校验。
整个流程可以简单理解为以下几个步骤:
(1) 接收到请求:服务器接收到 HTTP 请求。
(2) 找到对应的处理方法:框架根据请求路径找到要执行的 Controller 方法。
(3) 准备处理参数:创建一个“数据绑定器”来把请求中的数据转换成 Java 对象。
(4) 获取校验规则:如果参数需要校验(比如不能为空、长度限制等),就准备好对应的校验器。
(5) 开始校验参数:如果参数没问题,继续执行 Controller 中的方法,并返回 200 成功响应。如果参数有问题,停止执行,直接返回 400 错误和错误信息。
整个流程就是:接收请求 → 找到方法 → 准备参数 → 校验参数 → 执行方法或报错返回。
这是一套标准的 Spring MVC 参数校验机制,帮助我们确保传入的数据是合法的。
四、核心源码深度解析
1. 参数解析入口(RequestResponseBodyMethodProcessor)
这个方法的作用是处理控制器中带有 @RequestBody
注解的方法参数。
它的主要任务是:读取请求数据、转换成 Java 对象、做参数校验、返回结果。
核心流程如下:
(1) 读取请求体并转为对象
从请求中拿到原始数据(比如 JSON),然后根据参数类型转成对应的 Java 对象。
(2) 创建数据绑定器
创建一个数据绑定器(WebDataBinder),用于后续的参数校验。
(3) 执行参数校验
如果参数上有校验注解(如 @Valid),就进行校验。
(4) 检查是否有错误
如果校验失败,就抛出异常,阻止方法继续执行。
源码如下:
// RequestResponseBodyMethodProcessor.java
public Object resolveArgument(MethodParameter parameter,
ModelAndViewContainer mavContainer,
NativeWebRequest webRequest,
WebDataBinderFactory binderFactory) throws Exception {
// 1. 读取请求体并转换对象
Object arg = readWithMessageConverters(webRequest, parameter, parameter.getGenericParameterType());
// 2. 创建数据绑定器(关键点)
WebDataBinder binder = binderFactory.createBinder(
webRequest, arg, getValidationHints(parameter));
// 3. 执行校验(核心逻辑)
validateIfApplicable(binder, parameter);
// 4. 检查校验结果
if (binder.getBindingResult().hasErrors()) {
throw new MethodArgumentNotValidException(parameter, binder.getBindingResult());
}
return arg;
}
2. 校验触发逻辑(validateIfApplicable)
在处理请求参数时,Spring 会检查方法参数上有没有校验相关的注解,比如 @Valid
或 @Validated
。
如果有的话,就会触发参数的校验流程。
这个过程主要分为两个步骤:
(1) 查找注解:
从方法参数中找出所有注解。
(2) 判断是否需要校验:
如果是 @Valid
或以 Valid
开头的注解,就按默认规则校验。如果是 @Validated
注解,还可以指定分组校验,根据分组信息来决定校验哪些字段。
一旦确认要校验,就会调用 binder.validate()
方法开始执行校验逻辑。
protected void validateIfApplicable(WebDataBinder binder, MethodParameter parameter) {
Annotation[] annotations = parameter.getParameterAnnotations();
for (Annotation ann : annotations) {
// 处理@Valid和@Validated注解
Object[] validationHints = determineValidationHints(ann);
if (validationHints != null) {
binder.validate(validationHints); // 触发校验
break;
}
}
}
// 识别校验注解
protected Object[] determineValidationHints(Annotation ann) {
Class<? extends Annotation> annotationType = ann.annotationType();
if ("javax.validation.Valid".equals(annotationType.getName()) ||
annotationType.getSimpleName().startsWith("Valid")) {
return new Object[0]; // 返回空数组表示默认分组
}
// 处理@Validated的分组逻辑
if (ann instanceof Validated validatedAnn) {
return validatedAnn.value();
}
return null;
}
3. 校验执行过程(DataBinder)
当你调用 WebDataBinder.validate
方法时,整个校验过程会按以下步骤进行:
(1) 找到要校验的数据对象
先获取你传进来的目标对象(比如一个表单提交过来的 Java 对象)。
(2) 拿到对应的校验工具
从配置中取出一个合适的校验器(Validator),准备开始校验。
(3) 执行校验操作
使用这个校验器对目标对象进行检查,比如判断字段是否为空、长度是否符合要求等。
(4) 收集校验结果
把校验过程中发现的问题记录下来,比如哪些字段有问题、错误信息是什么。
(5) 把结果存起来
最后把这些错误信息保存到 BindingResult
中,供后续使用,比如返回给前端展示。
下面这段代码是实际执行校验的地方:
// DataBinder.java
public void validate(Object... validationHints) {
Object target = getTarget();
if (target != null) {
// 获取校验器并执行校验
getValidator().validate(target,
getValidationHints(validationHints)); // 处理分组
}
}
这段代码的意思是:如果目标对象存在,就用当前的校验器去校验它,并根据传入的参数决定是否按特定分组来校验。
4. Hibernate Validator 执行细节
下面是一个对象校验方法的简化说明。它用来检查一个对象的数据是否符合设定的规则。
// ValidatorImpl.java
public <T> Set<ConstraintViolation<T>> validate(T object, Class<?>... groups) {
// 1. 获取Bean的元数据(缓存机制)
BeanMetaData<T> rootBeanMetaData = beanMetaDataManager.getBeanMetaData(object.getClass());
// 2. 创建校验上下文
ValidationContext<T> validationContext = getValidationContextBuilder()
.forValidate(object, rootBeanMetaData);
// 3. 执行实际校验
validateConstraintsForGroups(validationContext, groups);
// 4. 返回校验结果
return validationContext.getFailingConstraints();
}
这个方法的作用是验证某个对象是否满足我们定义的规则(比如不能为空、必须是数字等)。流程如下:
(1) 获取对象的结构信息:
先看看这个对象长什么样,有哪些字段需要校验(用的是缓存,提高效率)。
(2) 准备校验环境:
把对象和它的结构信息放在一起,准备好开始校验。
(3) 执行校验:
根据指定的规则组,检查对象的每个字段有没有问题。
(4) 返回错误信息:
如果有不合规的地方,就返回这些错误;没有的话就返回空集合。
五、异常处理机制
1. 异常转换流程
在处理请求时,如果数据有问题,系统会按以下流程返回错误信息:
(1) 验证数据:
系统先检查传入的数据是否符合要求。
(2) 记录错误:
如果有问题,会记录具体的错误内容。
(3) 抛出异常:
系统识别到错误后,会抛出一个“参数不合法”的异常。
(4) 返回错误:
最后,系统会把错误信息以 400 错误码的形式返回给调用方。
2. 默认异常处理(ResponseEntityExceptionHandler)
这是一个处理请求参数错误的方法。当用户提交的数据不符合要求时,这个方法会生成一个结构化的错误信息返回给用户。
主要流程如下:
(1) 捕获异常:
当出现参数校验失败的错误(MethodArgumentNotValidException
)时,进入这个处理方法。
(2) 构造错误响应体:
创建一个基础错误对象 ProblemDetail
,包含错误状态、提示信息等。
(3) 提取具体错误信息:
从异常中取出所有字段错误信息,格式化为“字段名: 错误原因”。
(4) 返回错误响应:
调用统一异常处理方法,将错误信息返回给客户端。
// ResponseEntityExceptionHandler.java
@ExceptionHandler(MethodArgumentNotValidException.class)
protected ResponseEntity<Object> handleMethodArgumentNotValid(
MethodArgumentNotValidException ex, HttpHeaders headers,
HttpStatus status, WebRequest request) {
// 构造错误响应体
ProblemDetail body = createProblemDetail(ex, status,
"Invalid request content",
null, null, request);
// 转换错误信息
body.setProperty("errors", ex.getBindingResult().getAllErrors().stream()
.map(error -> {
if (error instanceof FieldError fieldError) {
return fieldError.getField() + ": " + fieldError.getDefaultMessage();
}
return error.getDefaultMessage();
})
.collect(Collectors.toList()));
return handleExceptionInternal(ex, body, headers, status, request);
}
六、校验注解处理流程
当系统需要处理一个请求时,会经历以下几个关键步骤来进行数据校验:
(1) 数据状态确认:
判断当前数据是否是有效的或已验证的。
(2) 处理器识别:
由 RequestResponseBodyMethodProcessor
识别并准备处理这个请求的数据。
(3) 创建绑定器:
生成一个 WebDataBinder
,用于后续的数据绑定和校验。
(4) 获取校验规则:
从配置中取出对应的 Validator
校验器。
(5) 解析约束条件:
分析字段上的注解规则(比如 @NotNull、@Size 等)。
(6) 执行校验:
对数据按照规则进行检查。
(7) 判断结果:
如果通过,继续后续流程;
如果失败,进入错误处理流程。
(8) 收集错误信息:
把不符合规则的地方记录下来。
(9) 转换为字段错误:
将错误信息转成标准的 FieldError
格式。
(10) 保存到结果对象:
最终把这些错误存入 `BindingResult`,供程序使用。
刨根问底:Service 层校验的实现原理
Spring Boot Service层校验通过@Validated
触发,基于AOP代理实现:
(1) 代理机制:
MethodValidationPostProcessor
为@Validated
类创建代理,拦截方法调用;
(2) 校验时机:
MethodValidationInterceptor
在方法执行前校验参数,执行后校验返回值;
(3) 异常处理:
失败时抛出ConstraintViolationException
,需全局捕获;
(4) 嵌套校验:
自动级联(无需@Valid
),依赖Hibernate Validator执行JSR-380规则。
一、整体架构图
整个流程主要涉及几个关键步骤:
(1) 初始化校验器(Validator)
Spring 会通过 LocalValidatorFactoryBean
创建一个校验工具 ValidatorImpl
,它负责具体的校验工作。
(2) 创建拦截器(Interceptor)
MethodValidationPostProcessor
会创建一个拦截器 MethodValidationInterceptor
,它的作用是在方法执行前后做参数和返回值的检查。
(3) 代理目标服务类(ServiceImpl)
被 `@Validated` 注解标记的服务类会被代理,这样在调用其方法时,就能触发参数校验逻辑。
(4) 执行校验逻辑
拦截器在调用方法前,使用 ValidatorImpl
对参数进行校验;方法执行后,还会校验返回值是否符合要求。
二、启动初始化流程
Spring 在启动时会准备两个关键组件:MethodValidationPostProcessor
和 LocalValidatorFactoryBean
。
LocalValidatorFactoryBean
会去创建一个真正的校验工具HibernateValidator
。MethodValidationPostProcessor
会拿到这个校验工具,并创建一个 AOP 切面,用来拦截带有@Validated
注解的类,做参数校验。
关键源码:
// MethodValidationPostProcessor.java
public void afterPropertiesSet() {
// 创建切入点(匹配@Validated注解的类)
Pointcut pointcut = new AnnotationMatchingPointcut(this.validatedAnnotationType, true);
// 创建通知(校验拦截器)
this.advisor = new DefaultPointcutAdvisor(pointcut, createMethodValidationAdvice(this.validator));
}
protected Advice createMethodValidationAdvice(Validator validator) {
return new MethodValidationInterceptor(validator);
}
总结一句话:Spring 启动时准备好校验工具,并通过 AOP 对需要校验的方法进行拦截处理。
三、方法调用校验流程
流程说明:
(1) 用户调用服务方法
用户通过代理对象调用一个服务方法。
(2) 方法被拦截器拦截
拦截器(MethodValidationInterceptor)会先介入处理。
(3) 参数校验开始
拦截器调用校验器(ValidatorImpl)来检查传入的参数是否合法。
(4) 校验结果判断
如果参数没问题:继续执行真正的服务方法,并返回结果。如果参数有问题:直接抛出异常,告诉用户哪里不对。
(5) 返回值也做校验(可选)
有些时候还会对方法返回的结果进行一次检查,确保输出也符合要求。
这个流程的核心就是:调用 → 拦截 → 参数检查 → 成功就执行,失败就报错。
四、核心源码深度解析
1. 校验拦截器实现
下面是一个拦截器方法的逻辑,用于在调用方法前后进行参数和返回值的校验。
// MethodValidationInterceptor.java
public Object invoke(MethodInvocation invocation) throws Throwable {
// 1. 参数校验
Set<ConstraintViolation<Object>> violations = this.validator.forExecutables()
.validateParameters(
invocation.getThis(),
invocation.getMethod(),
invocation.getArguments());
if (!violations.isEmpty()) {
throw new ConstraintViolationException(violations);
}
// 2. 执行方法
Object returnValue = invocation.proceed();
// 3. 返回值校验
violations = this.validator.forExecutables()
.validateReturnValue(
invocation.getThis(),
invocation.getMethod(),
returnValue);
if (!violations.isEmpty()) {
throw new ConstraintViolationException(violations);
}
return returnValue;
}
这个方法主要做了三件事:
(1) 参数校验:检查传给方法的参数是否符合规范。
(2) 执行方法:如果参数没问题,就正常执行方法。
(3) 返回值校验:检查方法返回的结果是否符合要求。
流程图:
2. 校验执行细节(Hibernate Validator)
在调用某个方法之前,检查它的参数是否符合设定的规则。比如某个参数不能为 null,或者必须是正数等。
基本流程:
(1) 获取这个方法有哪些参数需要校验
(2) 依次检查每个参数
拿到当前参数的值
构造一个上下文环境,用来记录当前校验的状态
- 根据规则对这个参数进行校验
(3) 最后返回所有不符合规则的地方(如果有)
流程图:
// ValidatorImpl.java
public <T> Set<ConstraintViolation<T>> validateParameters(T object, Method method,
Object[] parameterValues, Class<?>... groups) {
// 获取方法约束元数据
MethodConstraintMappingContext<T> methodContext = getConstraintsForMethod(method);
// 遍历每个参数
for (int i = 0; i < parameterValues.length; i++) {
Object value = parameterValues[i];
ValueContext<T, Object> valueContext = ValueContext.getLocalExecutionContext(
validatorScopedContext, object, value, i);
// 执行校验
validateConstraintsForCurrentGroup(valueContext,
methodContext.getParameterConstraints(i),
groups);
}
return violations;
}
3. 约束验证过程
基本步骤:
(1) 获取参数值:拿到要验证的数据。
(2) 查找约束注解:看看这个数据上有没有设置校验规则。
(3) 创建校验上下文:准备一个环境,用来做后续的校验工作。
(4) 匹配约束验证器:找到能处理这条规则的“检查员”。
(5) 执行验证逻辑:让“检查员”开始检查数据是否符合规则。
(6) 判断是否通过验证:如果通过,继续检查下一条规则。如果不通过,记录问题。
(7) 收集到结果集:把所有发现的问题汇总起来,返回给用户。
五、分组校验实现原理
当你调用校验方法,并指定只校验某个分组(比如 MyGroup.class
)时,系统会按照以下流程处理:
(1) 发起校验请求:你告诉校验器(Validator)要校验哪些分组。
(2) 查找约束规则:校验器去查看所有规则(Constraint),找出哪些规则属于你指定的分组。
(3) 判断是否需要校验:对每条规则,校验器判断它是否匹配你传入的分组。如果匹配,就执行这条规则的校验。
(4) 返回结果:校验完成后,把结果返回给你。
关键源码:
// ConstraintDescriptorImpl.java
public Set<Class<?>> getGroups() {
return this.annotationDescriptor.getGroups();
}
// ValidatorImpl.java
protected boolean isValidationRequired(ConstraintDescriptor<?> descriptor,
Class<?>[] groups) {
// 检查约束分组是否匹配
return groups.length == 0 ||
!Collections.disjoint(Arrays.asList(groups), descriptor.getGroups());
}
六、异常处理机制
1. 异常转换流程
在程序中,当数据校验出错时,处理流程如下:
(1) 校验器(Validator) 先检查输入的数据有没有问题,并把所有发现的问题整理成一个列表(ConstraintViolation集合)。
(2) 拦截器(MethodValidationInterceptor) 接收到这些问题后,会抛出一个异常(ConstraintViolationException),告诉系统哪里出错了。
(3) 异常处理器(ExceptionHandler) 捕获到这个异常后,会把它转换成一个友好的错误信息。
(4) 最后,这个错误信息会被返回给调用接口的客户端(Client)。
整个过程是为了让调用者能清楚地知道输入哪里不对,方便他们调整数据。
2. 自定义异常处理示例
基本工作流程如下:
(1) 拦截所有 ConstraintViolationException
类型的异常(通常是参数校验失败引发的)
(2) 从异常中提取出每个字段的错误信息
(3) 如果有字段路径,就把路径和错误信息一起整理出来
(4) 最后统一返回 HTTP 状态码 400 和错误详情
源码如下:
@RestControllerAdvice
public class ServiceValidationHandler {
@ExceptionHandler(ConstraintViolationException.class)
public ResponseEntity<ErrorResponse> handleConstraintViolation(ConstraintViolationException ex) {
List<String> errors = ex.getConstraintViolations().stream()
.map(v -> {
String path = v.getPropertyPath().toString();
return StringUtils.hasText(path) ? path + ": " + v.getMessage() : v.getMessage();
})
.collect(Collectors.toList());
return ResponseEntity.badRequest()
.body(new ErrorResponse("VALIDATION_FAILED", errors));
}
}
七、与Controller层校验的差异
特性 | Service 层校验 | Controller 层校验 |
---|---|---|
激活方式 | 类级别@Validated |
参数级别@Valid /@Validated |
实现机制 | 基于AOP代理 | 基于参数解析器 |
校验目标 | 方法参数和返回值 | 主要针对请求体 |
异常类型 | ConstraintViolationException |
MethodArgumentNotValidException |
分组校验 | 必须显式指定 | 支持默认分组 |
返回值校验 | 支持 | 不支持 |
- Controller 层校验:适合在接收前端请求时,对传入的数据做初步检查。
- Service 层校验:用于业务逻辑中更严格的参数和结果验证,适用于复杂场景。
- 两者都依赖同一个验证机制(如 Hibernate Validator),只是使用方式和适用范围不同。
超底层原理:SpringMVC参数解析机制
HandlerMethodArgumentResolver 是 Spring MVC 中处理控制器方法参数的核心接口,它定义了如何将 HTTP 请求中的各种数据解析为控制器方法的参数。
下面我将从多个维度详细解析其工作原理。
一、核心架构图
在 Spring MVC 中,处理请求参数的核心流程是这样的:
当浏览器发来一个请求时,Spring 需要根据请求里的数据,给控制器方法的参数赋值。这个工作是由一组叫 参数解析器 的组件完成的。
这些参数解析器都实现了同一个接口:HandlerMethodArgumentResolver
,它有两个主要任务:
(1) supportsParameter:
判断自己能不能处理某个参数。
(2) resolveArgument:
真正去获取参数的值。
常见的几种参数解析器包括:
RequestResponseBodyMethodProcessor
:用来处理加了@RequestBody
注解的参数,从请求体中读取数据。RequestParamMethodArgumentResolver
:处理加了@RequestParam
注解的参数,从请求参数中提取值。ModelAttributeMethodProcessor
:处理加了@ModelAttribute
注解的参数,把请求数据封装成对象。
二、工作流程
1. 整体处理流程
基本流程:
(1) 前端控制器(DispatcherServlet)
收到请求后,会调用处理器适配器(HandlerAdapter)来处理这个请求。
(2) 处理器适配器
会把方法需要的参数交给参数解析器(ArgumentResolvers)去处理。
(3) 参数解析器会逐个检查每个参数:
先判断自己能不能解析这个参数(supportsParameter)。
如果可以,就去解析它的值(resolveArgument)。
(4) 所有参数解析完成后,返回一个参数值数组给处理器适配器。
(5) 最后,完成请求处理
处理器适配器通过反射调用控制器(Controller)里的具体方法,完成请求处理。
2. 解析器链工作流程
基本流程:
(1) 开始
流程启动。
(2) 获取第一个解析器’:
尝试使用第一个解析器来处理参数。
(3) 判断是否支持该参数?
如果支持,就调用
resolveArgument
来获取参数值,然后返回结果。如果不支持,就去获取下一个解析器,重复这个判断过程。
(4) 循环查找解析器:
直到找到能处理该参数的解析器为止。
三、核心源码解析
1. 接口定义
用于处理控制器方法中的参数解析。它有两个核心方法:
(1) supportsParameter
看看这个参数能不能被当前的解析器处理。
(2) resolveArgument
从请求中提取出参数的具体值,比如从 URL、Body 或者 Session 中取数据。
public interface HandlerMethodArgumentResolver {
/**
* 判断是否支持该参数
*/
boolean supportsParameter(MethodParameter parameter);
/**
* 解析参数值
*/
Object resolveArgument(MethodParameter parameter,
ModelAndViewContainer mavContainer,
NativeWebRequest webRequest,
WebDataBinderFactory binderFactory) throws Exception;
}
2. 典型实现示例(RequestParam)
这个类的作用是处理控制器方法中的参数,特别是那些带有 @RequestParam
注解的参数。
主要流程:
判断是否支持该参数 检查参数是否有
@RequestParam
注解,或者是不是基本类型(如 String、int 等)。如果是,就由这个类来处理。解析参数值 根据参数名从请求中取出对应的值。
设置默认值和类型转换 如果取不到值但有默认值,就用默认值。最后把值转成参数需要的类型。
// RequestParamMethodArgumentResolver.java
public boolean supportsParameter(MethodParameter parameter) {
// 检查是否有@RequestParam注解或简单类型参数
return parameter.hasParameterAnnotation(RequestParam.class) ||
(this.useDefaultResolution &&
BeanUtils.isSimpleProperty(parameter.getParameterType()));
}
public Object resolveArgument(MethodParameter parameter, ...) throws Exception {
// 获取请求参数名
String name = determineName(parameter);
// 从请求中获取值
Object arg = resolveName(name, parameter, webRequest);
// 类型转换
if (arg == null && defaultValue != null) {
arg = defaultValue;
}
return convertIfNecessary(name, arg, parameter);
}
3. 参数解析入口(RequestMappingHandlerAdapter)
篇幅太长,请参见原文