Java框架快速入门: Spring Security+OAuth2之字段验证与自定义验证注解实战

概述

在构建安全的 RESTful API 时,数据的合法性校验是不可或缺的一环。Spring Security 负责认证与授权,而 Bean Validation(JSR 380)则为领域对象和 DTO 提供了声明式的约束验证能力。

本文将从零开始,讲解如何在 Spring Boot 项目中集成验证框架,通过内置注解快速实现常见校验,并手把手带你编写一个自定义验证注解,为后续密码等复杂规则打下基础。

纲要

  • 核心概念:Bean ValidationConstraintValidator、自定义约束注解
  • 依赖引入: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 CharSequenceCollectionMap、数组 不能为 null 且长度/大小必须大于 0
@NotBlank String 不能为 null 且去除首尾空格后长度大于 0
@Size(min,max) StringCollectionMap、数组 字符串时为字符长度,集合时为元素个数
@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 并指定对应的验证器类。此外,必须包含 messagegroupspayload 三个属性。

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 注解与对应的验证器,掌握了扩展验证逻辑的完整流程。这套机制不仅提高了代码的可读性,还让校验规则与业务逻辑解耦,为后续实现更复杂的密码验证等规则奠定了坚实基础。

相关推荐
CodeStats1 小时前
【Java 类加载器】Java 类加载器完整体系深度拆解(上):从 JVM 启动到双亲委派模型
java·开发语言·jvm·classloader·双亲委派
带多刺的玫瑰1 小时前
Leecode#29刷题之两数相除
java·python·算法
smj2302_796826521 小时前
解决leetcode第4017题数组中的峰值II
python·算法·leetcode
午彦琳1 小时前
2026.9.9
数据结构·算法·leetcode
luj_17681 小时前
中锋卡位与开放线博弈
c语言·开发语言·网络·经验分享·算法
洋不写bug1 小时前
二叉树(四)经典算法题目解析,公共祖先,二叉树构造,非递归遍历
数据库·算法·二叉树·二叉树构造·公共祖先
hansang_IR2 小时前
【题解】P4454 [CQOI2018] 破解D-H协议
c++·算法·密码学·数论
LuTshoes2 小时前
spring ai 实战-MCP
java·人工智能·spring
梓䈑2 小时前
【动态规划系列】路径问题、子数组 和 子序列问题、背包问题
c++·算法·动态规划