DTO 已经使用 @NotBlank 和 @Size 保护单个服务名称。真实请求还可能包含目标列表、嵌套配置和时间窗口,仅给最外层参数加上 @Valid,不会自动表达这些规则。
本章使用 Java 21、Spring Boot 4.1.1,增加一个「检查计划预览」接口。它只校验并预览输入,不保存计划、不启动调度,也不会向客户端提供的地址发请求。
1. 约束放在哪里
项目继续使用 Web MVC 和已有的 Validation Starter;如果尚未添加后者,在 pom.xml 中补入:
1<dependency>2<groupId>org.springframework.boot</groupId>3<artifactId>spring-boot-starter-validation</artifactId>4</dependency>
版本由 Spring Boot Parent 管理。不要只添加 Validation API,否则只有注解和接口,没有真正执行规则的实现。
这次输入分为三层:
| 层次 | 规则 | 表达方式 |
|---|---|---|
| 计划 | 必须提供窗口,目标数量为 1 到 20 | @NotNull、@Size |
| 列表元素 | 不能是 null,每个目标都要检查字段 | 容器元素约束与 @Valid |
| 时间窗口 | 两个时间都存在,结束晚于开始 | 字段约束与类级自定义约束 |
服务是否存在、当前用户能否操作它,仍然属于 Service 与权限系统的职责。校验器不应查询数据库、调用 HTTP 服务或修改状态。
2. 嵌套对象与集合
先声明完整请求类型,窗口类型会在下一节创建:
01package cn.usehook.spring.controller.dto;0203import jakarta.validation.Valid;04import jakarta.validation.constraints.Max;05import jakarta.validation.constraints.Min;06import jakarta.validation.constraints.NotBlank;07import jakarta.validation.constraints.NotNull;08import jakarta.validation.constraints.Size;09import java.util.List;1011public record CheckPlanRequest(12@NotNull @Valid CheckWindow window,13@NotNull @Size(min = 1, max = 20)14List<@NotNull @Valid Target> targets15) {16public record Target(17@NotBlank @Size(max = 50) String service,18@NotNull @Min(1) @Max(30) Integer timeoutSeconds19) {}20}
列表自身和列表元素是不同的检查对象。@Size 限制数量,不能代替列表的 @NotNull;元素上的 @NotNull 拒绝 [null],@Valid 才沿对象图进入 Target。
timeoutSeconds 使用 Integer,使字段缺失和显式 null 都能由 @NotNull 拦截。只声明 @Min(1) 不会要求包装类型必须存在。
一次失败可以准确指向第二个目标:
1targets[1].service2targets[1].timeoutSeconds3window.to
不要为了支持数组而把 Controller 参数直接改成裸 List<Target>。使用请求对象可以自然容纳整个列表的约束和将来新增的请求元数据。
3. 跨字段约束
时间窗口使用 Instant 表达两个确定的时间点。结束必须严格晚于开始,这条关系不能由某一个字段的 @NotNull 表达:
01package cn.usehook.spring.controller.dto;0203import cn.usehook.spring.validation.ValidCheckWindow;04import jakarta.validation.constraints.NotNull;05import java.time.Instant;0607@ValidCheckWindow08public record CheckWindow(09@NotNull Instant from,10@NotNull Instant to11) {}
为它声明类级约束:
01package cn.usehook.spring.validation;0203import jakarta.validation.Constraint;04import jakarta.validation.Payload;05import java.lang.annotation.Documented;06import java.lang.annotation.ElementType;07import java.lang.annotation.Retention;08import java.lang.annotation.RetentionPolicy;09import java.lang.annotation.Target;1011@Documented12@Target(ElementType.TYPE)13@Retention(RetentionPolicy.RUNTIME)14@Constraint(validatedBy = CheckWindowValidator.class)15public @interface ValidCheckWindow {16String message() default "to must be later than from";17Class<?>[] groups() default {};18Class<? extends Payload>[] payload() default {};19}
实现约束时,把错误挂到用户需要修改的 to 字段:
01package cn.usehook.spring.validation;0203import cn.usehook.spring.controller.dto.CheckWindow;04import jakarta.validation.ConstraintValidator;05import jakarta.validation.ConstraintValidatorContext;0607public class CheckWindowValidator08implements ConstraintValidator<ValidCheckWindow, CheckWindow> {0910@Override11public boolean isValid(CheckWindow value, ConstraintValidatorContext context) {12if (value == null || value.from() == null || value.to() == null) {13return true;14}15if (value.to().isAfter(value.from())) {16return true;17}18context.disableDefaultConstraintViolation();19context.buildConstraintViolationWithTemplate(20context.getDefaultConstraintMessageTemplate()21).addPropertyNode("to").addConstraintViolation();22return false;23}24}
这里让空值通过自定义检查,因为空值已经由 @NotNull 负责。不能假设字段约束必定先执行,也不应该在字段缺失时制造第二条误导性的时间顺序错误。
addPropertyNode("to") 将类级规则转换成字段错误;外层级联再补上 window,最终形成 window.to。如果不指定属性节点,错误属于整个窗口对象,处理器就需要额外读取对象级错误。
Validator 可能被复用,实例字段不能保存本次请求内容。本例没有可变状态,也不需要把 Validator 手动注册为业务 Service。
4. 接入 MVC
创建独立 Controller,避免改变原来的单次心跳接口:
01package cn.usehook.spring.controller;0203import cn.usehook.spring.controller.dto.CheckPlanRequest;04import jakarta.validation.Valid;05import jakarta.validation.constraints.Max;06import jakarta.validation.constraints.Min;07import java.util.List;08import java.util.Map;09import java.util.Objects;10import org.springframework.http.HttpStatus;11import org.springframework.http.ProblemDetail;12import org.springframework.web.bind.MethodArgumentNotValidException;13import org.springframework.web.bind.annotation.ExceptionHandler;14import org.springframework.web.bind.annotation.GetMapping;15import org.springframework.web.bind.annotation.PostMapping;16import org.springframework.web.bind.annotation.RequestBody;17import org.springframework.web.bind.annotation.RequestMapping;18import org.springframework.web.bind.annotation.RequestParam;19import org.springframework.web.bind.annotation.RestController;20import org.springframework.web.method.annotation.HandlerMethodValidationException;2122@RestController23@RequestMapping("/api/v1/health/plans")24public class CheckPlanController {2526@PostMapping("/preview")27public Map<String, Integer> preview(@Valid @RequestBody CheckPlanRequest request) {28return Map.of("targetCount", request.targets().size());29}3031@GetMapping("/limit")32public Map<String, Integer> limit(33@RequestParam(name = "value") @Min(1) @Max(20) int value34) {35return Map.of("value", value);36}3738@ExceptionHandler(MethodArgumentNotValidException.class)39public ProblemDetail invalidBody(MethodArgumentNotValidException exception) {40List<Map<String, String>> errors = exception.getBindingResult()41.getFieldErrors().stream()42.map(error -> Map.of(43"field", error.getField(),44"message", Objects.requireNonNullElse(error.getDefaultMessage(), "invalid value")45)).toList();46ProblemDetail problem = ProblemDetail.forStatusAndDetail(47HttpStatus.BAD_REQUEST, "One or more request fields are invalid"48);49problem.setProperty("errors", errors);50return problem;51}5253@ExceptionHandler(HandlerMethodValidationException.class)54public ProblemDetail invalidMethod(HandlerMethodValidationException exception) {55return ProblemDetail.forStatusAndDetail(56exception.isForReturnValue() ? HttpStatus.INTERNAL_SERVER_ERROR : HttpStatus.BAD_REQUEST,57exception.isForReturnValue()58? "The server produced an invalid response"59: "One or more request parameters are invalid"60);61}62}
局部处理器让本章可以独立验证。继续学习统一异常处理后,可以将同样规则合并进 ApiExceptionHandler,再删除这里的两个局部方法;否则局部规则会优先于全局 Advice。
请求 DTO 的级联失败产生 MethodArgumentNotValidException;直接加在方法参数上的 @Min/@Max 则触发 MVC 方法校验,产生 HandlerMethodValidationException。返回值约束失败是服务端违反自身契约,因此不能一律返回 400。
此处 Controller 不加类级 @Validated,使用 Spring MVC 内置方法校验。Service 上的 @Validated 则是另一条基于代理的方法校验路径,异常类型和调用边界不能直接套用 Controller 的结论。见 Spring MVC 校验说明。
5. 请求与错误路径
在未加入 Security 的学习阶段,可以直接请求;已经完成 JWT 章节时,以下请求应携带具有 monitor Scope 的 TOKEN。新路径位于现有 /api/v1/health/** 授权规则之下,不需要放开 anyRequest().denyAll()。
继续合并资源权限方案后,旧的 health 管理接口会收紧为 SCOPE_monitor.admin,计划预览也需要管理员 Token;monitor 只用于该方案中的用户资源入口。
1curl -i http://localhost:8080/api/v1/health/plans/preview \2-H "Authorization: Bearer $TOKEN" \3-H 'Content-Type: application/json' \4-d '{"window":{"from":"2026-10-04T08:00:00Z","to":"2026-10-04T09:00:00Z"},"targets":[{"service":"spring","timeoutSeconds":3}]}'
成功返回 {"targetCount":1}。随后把 to 改为早于 from,并把第二个目标的名称设为空白:
01{02"window": {03"from": "2026-10-04T08:00:00Z",04"to": "2026-10-04T07:00:00Z"05},06"targets": [07{ "service": "spring", "timeoutSeconds": 3 },08{ "service": " ", "timeoutSeconds": 3 }09]10}
把它保存到 request.json,使用 --data-binary @request.json 发送。响应状态为 400,errors 应包含 window.to 与 targets[1].service,但错误顺序不属于契约。
请求 /api/v1/health/plans/limit?value=0 则进入方法校验分支。非法 JSON 或不能转换为 Instant 的文本更早在消息转换阶段失败,不会进入自定义时间窗口规则。
6. 不启动服务器的规则测试
测试使用已有的 spring-boot-starter-test。若项目尚未加入,在 pom.xml 中添加该 Starter 并指定 <scope>test</scope>,版本仍交给 Parent。
01package cn.usehook.spring.validation;0203import cn.usehook.spring.controller.dto.CheckPlanRequest;04import cn.usehook.spring.controller.dto.CheckWindow;05import jakarta.validation.Validation;06import java.time.Instant;07import java.util.List;08import org.junit.jupiter.api.Test;0910import static org.assertj.core.api.Assertions.assertThat;1112class CheckPlanValidationTests {1314@Test15void reportsNestedFieldAndWindowPaths() {16Instant start = Instant.parse("2026-10-04T08:00:00Z");17CheckPlanRequest request = new CheckPlanRequest(18new CheckWindow(start, start.minusSeconds(1)),19List.of(20new CheckPlanRequest.Target("spring", 3),21new CheckPlanRequest.Target(" ", 3)22)23);24try (var factory = Validation.buildDefaultValidatorFactory()) {25var violations = factory.getValidator().validate(request);26assertThat(violations)27.extracting(violation -> violation.getPropertyPath().toString())28.containsExactlyInAnyOrder("window.to", "targets[1].service");29}30}31}
这段测试验证约束组合和路径,不代替后面的 MVC 测试。HTTP 状态、JSON 错误格式、认证失败是否阻止执行,仍需要在 Web 边界验证。
还应覆盖空列表、null 元素、缺失窗口、结束时间等于开始时间等情况。校验错误响应只返回字段路径和受控消息,不回显令牌、密码或整个请求体。
约束分组适合确有差异的校验阶段;创建和更新字段不同的时候,通常先使用不同请求 DTO。自定义约束的定义和属性路径机制可查阅 Hibernate Validator 官方指南。