侧边栏壁纸
博主头像
洋洋的技术笔记

行动起来,活在当下

  • 累计撰写 18 篇文章
  • 累计创建 2 个标签
  • 累计收到 0 条评论

目 录CONTENT

文章目录

Spring Boot参数校验与自定义校验

洋洋的技术笔记
2026-11-11 / 0 评论 / 0 点赞 / 4 阅读 / 0 字

Spring Boot参数校验与自定义校验

参数校验的目标不是把所有错误都挡在 Controller 之外,而是在系统边界尽早发现无效输入,并用统一、可理解的错误响应告诉调用方如何修正。

上一篇《Spring Boot统一异常处理方案》解决了异常如何转换成稳定 API 响应的问题。本文继续补齐入口侧的参数校验:使用 Bean Validation 完成常见约束、分组校验和嵌套校验,通过自定义注解沉淀业务规则,并将校验异常接入统一错误处理。

环境说明:示例使用 Spring Boot 3.x、Java 17、Jakarta Validation 3 和 Hibernate Validator。Spring Boot 3 使用 jakarta.validation.,不要再沿用旧项目中的 javax.validation. 包名。


一、为什么需要参数校验

下面这段代码在很多 Controller 中并不少见:

@PostMapping("/users")
public UserResponse create(@RequestBody CreateUserRequest request) {
    if (request.username() == null || request.username().isBlank()) {
        throw new IllegalArgumentException("用户名不能为空");
    }
    if (request.age() == null || request.age() < 0 || request.age() > 150) {
        throw new IllegalArgumentException("年龄不合法");
    }
    if (request.email() == null || !request.email().contains("@")) {
        throw new IllegalArgumentException("邮箱格式不正确");
    }
    return userService.create(request);
}

随着字段增加,Controller 会被大量重复判断占满,而且不同接口的错误格式、校验顺序和提示文案容易不一致。

Bean Validation 把基础约束放到请求对象上:

public record CreateUserRequest(
        @NotBlank(message = "用户名不能为空") String username,
        @NotNull(message = "年龄不能为空")
        @Min(value = 0, message = "年龄不能小于0")
        @Max(value = 150, message = "年龄不能大于150") Integer age,
        @NotBlank(message = "邮箱不能为空")
        @Email(message = "邮箱格式不正确") String email
) {
}

Controller 只需要增加 @Valid:

@PostMapping("/users")
public UserResponse create(@Valid @RequestBody CreateUserRequest request) {
    return userService.create(request);
}

声明式校验让规则更清晰,但并不意味着业务校验全部消失。唯一性、跨字段规则、数据库状态和业务权限仍需要 Service 或自定义校验配合。


二、引入校验依赖

Spring Boot 3 项目可以添加:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-validation</artifactId>
</dependency>

同时需要 Web 依赖:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
</dependency>

spring-boot-starter-validation 会引入 Jakarta Validation API 与 Hibernate Validator。不要把旧教程中的 validation-api 和 Spring Boot 2 的 javax 示例直接混用到 Spring Boot 3。

常见导入如下:

import jakarta.validation.Valid;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;

@Valid 来自 Jakarta Validation,@Validated 来自 Spring。Controller 请求体通常使用 @Valid,方法级参数校验和分组处理常使用 @Validated。


三、常用约束注解

注解

主要用途

注意事项

@NotNull

值不能为 null

空字符串、空集合仍可能通过

@NotEmpty

不能为 null,长度也不能为 0

适用于字符串、集合、数组等

@NotBlank

不能为 null,去除空白后长度大于 0

仅适用于字符序列

@Size

限制长度或元素数量

不是数值大小判断

@Min、@Max

限制数值上下限

适用于数值类型

@DecimalMin、@DecimalMax

限制十进制数值边界

常用于 BigDecimal

@Positive、@PositiveOrZero

必须为正数或非负数

根据是否允许 0 选择

@Negative、@NegativeOrZero

必须为负数或非正数

根据业务边界选择

@Email

检查邮箱基本格式

不保证邮箱真实存在

@Pattern

使用正则表达式

注意正则可读性和 ReDoS 风险

@Past、@PastOrPresent

过去时间

检查时间语义

@Future、@FutureOrPresent

未来时间

检查时间语义

@AssertTrue、@AssertFalse

布尔值必须为真或假

适合简单标志约束

3.1 @NotNull、@NotEmpty 与 @NotBlank

@NotNull
String username;

只要求不为 null,因此 "" 和 " " 仍可能通过。

@NotEmpty
String username;

要求非空,但 " " 仍有长度,因此可能通过。

@NotBlank
String username;

会检查去除首尾空白后的内容,适合用户名、标题等文本字段。

不要盲目给所有字段使用 @NotBlank。可选字段可以允许 null,只有提供值时才校验格式:

@Email(message = "邮箱格式不正确")
private String email;

此时 email = null 可以通过,email = "invalid" 会失败。

3.2 数值校验注意包装类型

@NotNull(message = "年龄不能为空")
@Min(value = 0, message = "年龄不能小于0")
@Max(value = 150, message = "年龄不能大于150")
private Integer age;

如果只用 @Min 和 @Max,age = null 可以通过,因为约束注解通常把 null 视为“未提供”,由其他注解决定是否必填。


四、请求体校验

4.1 创建用户请求

import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.Size;

public record CreateUserRequest(
        @NotBlank(message = "用户名不能为空")
        @Size(min = 2, max = 20, message = "用户名长度应为2~20个字符")
        String username,

        @NotBlank(message = "手机号不能为空")
        @Pattern(regexp = "^1[3-9]\\d{9}$", message = "手机号格式不正确")
        String phone,

        @NotBlank(message = "邮箱不能为空")
        @Email(message = "邮箱格式不正确")
        String email,

        @NotNull(message = "年龄不能为空")
        @Min(value = 0, message = "年龄不能小于0")
        @Max(value = 150, message = "年龄不能大于150")
        Integer age
) {
}

手机号正则只是常见中国大陆号段的格式示例,不代表号码真实可用,也不应替代必要的验证码验证。

4.2 Controller 使用 @Valid

import jakarta.validation.Valid;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping("/api/users")
public class UserController {

    private final UserService userService;

    public UserController(UserService userService) {
        this.userService = userService;
    }

    @PostMapping
    public UserResponse create(
            @Valid @RequestBody CreateUserRequest request
    ) {
        return userService.create(request);
    }
}

如果请求体缺少字段、类型错误或校验失败,应在统一异常处理器中转换为 400,而不是直接返回 500。


五、路径变量与请求参数校验

对 @PathVariable、@RequestParam 使用约束注解时,需要在类上增加 @Validated:

import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.Positive;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

@Validated
@RestController
@RequestMapping("/api/users")
public class UserQueryController {

    @GetMapping("/{id}")
    public UserResponse getById(
            @Positive(message = "用户 ID 必须为正整数")
            @PathVariable Long id
    ) {
        return userService.getById(id);
    }

    @GetMapping
    public List<UserResponse> page(
            @Min(value = 1, message = "页码最小为1")
            @RequestParam(defaultValue = "1") int page,

            @Min(value = 1, message = "每页数量最小为1")
            @Max(value = 100, message = "每页数量最大为100")
            @RequestParam(defaultValue = "20") int size
    ) {
        return userService.page(page, size);
    }
}

@Validated 激活 Spring 的方法校验。不同 Spring Framework 版本可能通过不同异常类型暴露错误,常见包括:

  • ConstraintViolationException;

  • HandlerMethodValidationException;

  • MethodArgumentTypeMismatchException。

统一异常处理时应根据实际版本和调用结果补齐,不要只处理请求体的 MethodArgumentNotValidException。


六、嵌套对象与集合校验

6.1 嵌套对象需要 @Valid

public record AddressRequest(
        @NotBlank(message = "省份不能为空") String province,
        @NotBlank(message = "城市不能为空") String city,
        @NotBlank(message = "详细地址不能为空") String detail
) {
}
public record UpdateProfileRequest(
        @NotBlank(message = "昵称不能为空") String nickname,
        @Valid AddressRequest address
) {
}

如果 address 字段上没有 @Valid,内部约束不会自动执行。需要允许地址可选时,可以保留字段为 null;一旦提供地址,就校验其内部字段。

6.2 集合元素也需要 @Valid

public record BatchCreateUserRequest(
        @NotEmpty(message = "用户列表不能为空")
        @Size(max = 100, message = "单次最多创建100个用户")
        List<@Valid CreateUserRequest> users
) {
}

这里有两个层次:

  • @NotEmpty、@Size 约束集合本身;

  • @Valid 让集合中的每个请求对象执行内部约束。

校验错误通常包含索引和属性路径,例如 users[0].phone,前端可以据此定位具体元素。


七、分组校验

创建和更新通常具有不同规则:创建时密码必填,更新时密码可选;创建时 ID 为空,更新时 ID 必填。分组校验可以复用同一个请求对象。

7.1 定义分组接口

public interface ValidationGroups {

    interface Create {
    }

    interface Update {
    }
}

7.2 在约束上指定分组

import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Null;
import jakarta.validation.constraints.Size;

public record UserRequest(
        @Null(groups = ValidationGroups.Create.class,
                message = "创建时不能传入用户 ID")
        @NotNull(groups = ValidationGroups.Update.class,
                message = "更新时用户 ID 不能为空")
        Long id,

        @NotBlank(message = "用户名不能为空")
        String username,

        @NotBlank(groups = ValidationGroups.Create.class,
                message = "创建时密码不能为空")
        @Size(min = 8, max = 64,
                groups = ValidationGroups.Create.class,
                message = "密码长度应为8~64个字符")
        String password
) {
}

username 没有指定分组,因此属于默认组 Default。

7.3 Controller 指定当前分组

@PostMapping
public UserResponse create(
        @Validated(ValidationGroups.Create.class)
        @RequestBody UserRequest request
) {
    return userService.create(request);
}

@PutMapping
public UserResponse update(
        @Validated(ValidationGroups.Update.class)
        @RequestBody UserRequest request
) {
    return userService.update(request);
}

这里使用 @Validated(分组),而不是只写 @Valid。

7.4 默认组不会自动附带

指定 Create.class 后,不会自动验证默认组中的 username。如果希望同时校验默认约束,可以显式加入 Default.class:

@Validated({ValidationGroups.Create.class, Default.class})

需要导入:

import jakarta.validation.groups.Default;

也可以让业务分组继承 Default:

public interface Create extends Default {
}

分组越多,规则越难理解。只有同一模型在不同生命周期确实存在差异时才使用分组,不必为每个接口都创建一个组。


八、自定义字段校验注解

当多个字段反复使用同一业务规则时,可以把约束封装成自定义注解。以下以“手机号或固定电话二选一格式”为例。

8.1 定义约束注解

import jakarta.validation.Constraint;
import jakarta.validation.Payload;
import java.lang.annotation.Documented;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

@Documented
@Constraint(validatedBy = ContactPhoneValidator.class)
@Target({
        ElementType.FIELD,
        ElementType.METHOD,
        ElementType.PARAMETER,
        ElementType.ANNOTATION_TYPE
})
@Retention(RetentionPolicy.RUNTIME)
public @interface ContactPhone {

    String message() default "联系电话格式不正确";

    Class<?>[] groups() default {};

    Class<? extends Payload>[] payload() default {};
}

@Constraint 指定实际校验器,groups 和 payload 是 Bean Validation 约束注解的标准组成。

8.2 实现 ConstraintValidator

import jakarta.validation.ConstraintValidator;
import jakarta.validation.ConstraintValidatorContext;
import java.util.regex.Pattern;

public class ContactPhoneValidator
        implements ConstraintValidator<ContactPhone, String> {

    private static final Pattern MOBILE =
            Pattern.compile("^1[3-9]\\d{9}$");
    private static final Pattern LANDLINE =
            Pattern.compile("^0\\d{2,3}-?\\d{7,8}$");

    @Override
    public boolean isValid(
            String value,
            ConstraintValidatorContext context
    ) {
        if (value == null || value.isBlank()) {
            return true;
        }

        String normalized = value.trim();
        return MOBILE.matcher(normalized).matches()
                || LANDLINE.matcher(normalized).matches();
    }
}

null 或空白值交给 @NotBlank 等必填约束处理。这样自定义格式校验只负责格式,不重复决定字段是否必填。

8.3 使用自定义注解

public record ContactRequest(
        @NotBlank(message = "联系人不能为空") String name,

        @NotBlank(message = "联系电话不能为空")
        @ContactPhone(message = "请输入正确的手机号或固定电话")
        String phone
) {
}

自定义注解让错误提示、正则规则和复用方式集中在一处,减少不同 DTO 之间的重复代码。


九、类级校验:处理跨字段规则

字段级注解难以表达“两次密码一致”“结束日期晚于开始日期”等规则,这时可以使用类级约束。

9.1 定义类级注解

@Documented
@Constraint(validatedBy = PasswordMatchesValidator.class)
@Target({ElementType.TYPE, ElementType.ANNOTATION_TYPE})
@Retention(RetentionPolicy.RUNTIME)
public @interface PasswordMatches {

    String message() default "两次输入的密码不一致";

    Class<?>[] groups() default {};

    Class<? extends Payload>[] payload() default {};
}

9.2 校验整个请求对象

import jakarta.validation.ConstraintValidator;
import jakarta.validation.ConstraintValidatorContext;

public class PasswordMatchesValidator
        implements ConstraintValidator<PasswordMatches, PasswordRequest> {

    @Override
    public boolean isValid(
            PasswordRequest value,
            ConstraintValidatorContext context
    ) {
        if (value == null) {
            return true;
        }

        String password = value.password();
        String confirmPassword = value.confirmPassword();

        if (password == null || confirmPassword == null) {
            return true;
        }

        return password.equals(confirmPassword);
    }
}
@PasswordMatches
public record PasswordRequest(
        @NotBlank(message = "密码不能为空")
        @Size(min = 8, max = 64, message = "密码长度应为8~64个字符")
        String password,

        @NotBlank(message = "确认密码不能为空")
        String confirmPassword
) {
}

类级错误默认关联整个对象,不一定映射到某个具体字段。若前端需要将错误定位到 confirmPassword,可以在校验器中自定义错误节点:

if (!password.equals(confirmPassword)) {
    context.disableDefaultConstraintViolation();
    context.buildConstraintViolationWithTemplate(
            "两次输入的密码不一致"
    ).addPropertyNode("confirmPassword")
     .addConstraintViolation();
    return false;
}
return true;

不要把密码、身份证号等敏感值写入校验日志或错误响应。


十、在校验器中使用 Spring Bean

某些校验需要读取配置或调用轻量查询服务。Hibernate Validator 支持注入 Spring Bean,但应谨慎控制校验器的副作用。

@Component
public class UniqueUsernameValidator
        implements ConstraintValidator<UniqueUsername, String> {

    private final UserDirectory userDirectory;

    public UniqueUsernameValidator(UserDirectory userDirectory) {
        this.userDirectory = userDirectory;
    }

    @Override
    public boolean isValid(
            String value,
            ConstraintValidatorContext context
    ) {
        if (value == null || value.isBlank()) {
            return true;
        }
        return !userDirectory.exists(value.trim());
    }
}

数据库唯一性不能只依赖应用层校验。并发请求可能同时通过检查,仍应在数据库中建立唯一索引,并处理插入时的唯一键冲突。

对于复杂、耗时或依赖外部服务的规则,通常更推荐在 Service 中显式执行,而不是让 DTO 校验器承担大量业务逻辑和网络请求。


十一、手动调用 Validator

在 Service、单元测试或非 Web 场景中,可以注入 Validator 手动校验:

import jakarta.validation.ConstraintViolation;
import jakarta.validation.Validator;
import java.util.Set;
import org.springframework.stereotype.Service;

@Service
public class UserValidationService {

    private final Validator validator;

    public UserValidationService(Validator validator) {
        this.validator = validator;
    }

    public void validate(CreateUserRequest request) {
        Set<ConstraintViolation<CreateUserRequest>> violations =
                validator.validate(request);

        if (!violations.isEmpty()) {
            String message = violations.stream()
                    .map(violation -> violation.getPropertyPath()
                            + ": " + violation.getMessage())
                    .distinct()
                    .reduce((left, right) -> left + "; " + right)
                    .orElse("请求参数不合法");
            throw new IllegalArgumentException(message);
        }
    }
}

手动校验适合非 Controller 调用链,但如果项目已经使用统一异常处理,建议抛出项目自己的参数异常,避免各处返回不同错误格式。


十二、统一校验错误响应

上一篇已经定义了全局异常处理器,本文为其增加字段级错误结构:

public record FieldViolation(
        String field,
        String message
) {
}

可以在原有 ApiError 中增加:

List<FieldViolation> errors;

如果现有接口不希望修改顶层结构,也可以将 errors 作为可选字段,仅在校验失败时返回。

12.1 请求体校验失败

import jakarta.servlet.http.HttpServletRequest;
import java.util.List;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;

@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ApiError> handleMethodArgumentNotValid(
        MethodArgumentNotValidException ex,
        HttpServletRequest request
) {
    List<FieldViolation> errors = ex.getBindingResult()
            .getFieldErrors()
            .stream()
            .map(error -> new FieldViolation(
                    error.getField(),
                    error.getDefaultMessage()
            ))
            .toList();

    return badRequest("请求参数校验失败", errors, request);
}

响应示例:

{
  "status": 400,
  "code": "INVALID_PARAMETER",
  "message": "请求参数校验失败",
  "path": "/api/users",
  "traceId": "4f8c0a1d7f2c",
  "errors": [
    {
      "field": "phone",
      "message": "手机号格式不正确"
    },
    {
      "field": "age",
      "message": "年龄不能大于150"
    }
  ]
}

12.2 方法参数校验失败

import jakarta.validation.ConstraintViolationException;

@ExceptionHandler(ConstraintViolationException.class)
public ResponseEntity<ApiError> handleConstraintViolation(
        ConstraintViolationException ex,
        HttpServletRequest request
) {
    List<FieldViolation> errors = ex.getConstraintViolations()
            .stream()
            .map(violation -> new FieldViolation(
                    violation.getPropertyPath().toString(),
                    violation.getMessage()
            ))
            .toList();

    return badRequest("请求参数校验失败", errors, request);
}

12.3 新版 Spring 方法校验异常

较新的 Spring Framework 还可能抛出 HandlerMethodValidationException:

import org.springframework.web.method.annotation.HandlerMethodValidationException;

@ExceptionHandler(HandlerMethodValidationException.class)
public ResponseEntity<ApiError> handleMethodValidation(
        HandlerMethodValidationException ex,
        HttpServletRequest request
) {
    return badRequest("请求参数校验失败", List.of(), request);
}

可以进一步从 ex.getAllValidationResults() 提取字段错误,但不同小版本的 API 结构可能不同。应以项目实际依赖为准,并用真实请求确认响应。

同时,@ModelAttribute 和表单绑定还可能产生 BindException。一个健壮的全局处理器应覆盖项目实际使用的异常类型,而不是只复制某一种教程写法。


十三、完整示例:创建用户流程

请求对象:

public record CreateUserRequest(
        @NotBlank(message = "用户名不能为空")
        @Size(min = 2, max = 20, message = "用户名长度应为2~20个字符")
        String username,

        @NotBlank(message = "手机号不能为空")
        @ContactPhone(message = "请输入正确的手机号或固定电话")
        String phone,

        @NotBlank(message = "邮箱不能为空")
        @Email(message = "邮箱格式不正确")
        String email,

        @NotNull(message = "年龄不能为空")
        @Min(value = 0, message = "年龄不能小于0")
        @Max(value = 150, message = "年龄不能大于150")
        Integer age
) {
}

Controller:

@Validated
@RestController
@RequestMapping("/api/users")
public class UserController {

    private final UserService userService;

    public UserController(UserService userService) {
        this.userService = userService;
    }

    @PostMapping
    public UserResponse create(
            @Valid @RequestBody CreateUserRequest request
    ) {
        return userService.create(request);
    }

    @GetMapping("/{id}")
    public UserResponse getById(
            @Positive(message = "用户 ID 必须为正整数")
            @PathVariable Long id
    ) {
        return userService.getById(id);
    }
}

测试请求:

curl -i \
  -H 'Content-Type: application/json' \
  -d '{"username":"洋","phone":"123","email":"invalid","age":200}' \
  http://localhost:8080/api/users

应返回 400,并在 errors 中分别指出用户名长度、电话格式、邮箱格式和年龄边界问题。

正常请求:

curl -i \
  -H 'Content-Type: application/json' \
  -d '{"username":"洋洋","phone":"13800138000","email":"test@example.com","age":30}' \
  http://localhost:8080/api/users

校验通过后,Controller 才把请求交给 Service。


十四、使用 MockMvc 验证校验契约

import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;

@WebMvcTest(UserController.class)
class UserControllerValidationTest {

    @Autowired
    private MockMvc mockMvc;

    @Test
    void shouldRejectInvalidCreateUserRequest() throws Exception {
        String body = """
                {
                  "username": "洋",
                  "phone": "123",
                  "email": "invalid",
                  "age": 200
                }
                """;

        mockMvc.perform(post("/api/users")
                        .contentType(MediaType.APPLICATION_JSON)
                        .content(body))
                .andExpect(status().isBadRequest())
                .andExpect(jsonPath("$.code").value("INVALID_PARAMETER"))
                .andExpect(jsonPath("$.errors").isArray());
    }

    @Test
    void shouldRejectInvalidUserId() throws Exception {
        mockMvc.perform(org.springframework.test.web.servlet
                        .request.MockMvcRequestBuilders.get("/api/users/0"))
                .andExpect(status().isBadRequest());
    }
}

如果 Controller 依赖 UserService,切片测试中需要使用当前 Spring Boot 版本支持的 Mockito Bean 注解提供模拟对象。较新版本可使用 @MockitoBean,旧版本可使用 @MockBean。

测试至少覆盖:

  • 正常请求通过;

  • 请求体字段校验失败返回 400;

  • 路径变量和请求参数校验失败返回 400;

  • 嵌套对象和集合元素校验路径正确;

  • 分组规则只应用于目标场景;

  • 自定义注解和跨字段规则生效;

  • 错误响应不包含密码等敏感字段值。


十五、常见问题与最佳实践

15.1 校验没有生效

按顺序检查:

  1. 是否引入 spring-boot-starter-validation;

  2. 请求体是否有 @Valid;

  3. 嵌套对象和集合元素是否有 @Valid;

  4. 路径变量、请求参数所在类是否有 @Validated;

  5. 分组是否同时遗漏了 Default;

  6. 是否使用了错误的 javax. 与 jakarta. 包名组合;

  7. 是否被自定义 Controller Advice 或过滤器提前处理。

15.2 不要重复承担所有业务校验

适合放在 Bean Validation 中的是:

  • 必填项、长度、格式、范围;

  • 嵌套结构完整性;

  • 可复用的跨字段规则;

  • 不依赖复杂外部状态的通用约束。

适合放在 Service 中的是:

  • 用户是否启用、资源是否存在;

  • 当前用户是否有权操作;

  • 复杂库存、订单状态和流程规则;

  • 需要事务或多表协作的业务判断。

15.3 不要只依赖前端校验

前端校验用于改善输入体验,后端校验用于保护系统边界。请求可以绕过浏览器直接发送,因此前后端都需要合理校验。

15.4 校验不等于清理输入

@Email、@Pattern 只能判断格式,不会自动清理 HTML、脚本或危险字符。输出编码、参数化查询、权限控制和安全过滤仍需单独完成。

15.5 错误提示不要泄露敏感信息

错误响应应描述字段和修正方式,不要回显密码、完整身份证号、访问令牌或内部 SQL。日志中也要避免输出整个请求体。

15.6 数据库约束是最后防线

格式校验和唯一性检查不能替代数据库约束。唯一索引、非空约束、外键和事务仍是保证数据一致性的必要手段。


十六、总结

一套完整的 Spring Boot 参数校验方案应做到:

  1. 使用 spring-boot-starter-validation 和 Jakarta Validation。

  2. 请求体使用 @Valid,方法参数所在类使用 @Validated。

  3. 根据字段语义选择 @NotNull、@NotEmpty、@NotBlank 和范围约束。

  4. 嵌套对象与集合元素通过 @Valid 级联校验。

  5. 分组处理创建、更新等生命周期差异,注意默认组不会自动附带。

  6. 字段级自定义注解复用通用格式规则,类级约束处理跨字段关系。

  7. 校验器保持简单,复杂业务规则和数据库唯一性仍需 Service 与数据库保障。

  8. 统一处理请求体、路径变量和请求参数产生的不同校验异常。

  9. 错误响应提供稳定字段路径和可读提示,不泄露敏感信息。

  10. 使用 MockMvc 固定校验规则与 API 错误契约。

参数校验不是简单地增加几个注解,而是把输入边界、业务规则和统一异常响应连成完整流程。

下一站预告

下一篇《Spring Boot拦截器与过滤器实战》将介绍 Filter 与 HandlerInterceptor 的执行顺序、适用场景和常见误区,并结合请求追踪、权限检查与统一异常处理构建清晰的 Web 请求链路。


作者:洋洋技术笔记 发布日期:2026-11-11 系列:Spring Boot实战 - 第8篇


原文链接:洋洋的技术笔记

0

评论区