跳到主内容
创建时间: 2026-10-04最后更新: 2026-10-04

DTO 已经使用 @NotBlank 和 @Size 保护单个服务名称。真实请求还可能包含目标列表、嵌套配置和时间窗口,仅给最外层参数加上 @Valid,不会自动表达这些规则。

本章使用 Java 21、Spring Boot 4.1.1,增加一个「检查计划预览」接口。它只校验并预览输入,不保存计划、不启动调度,也不会向客户端提供的地址发请求。

1. 约束放在哪里

项目继续使用 Web MVC 和已有的 Validation Starter;如果尚未添加后者,在 pom.xml 中补入:

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. 嵌套对象与集合

先声明完整请求类型,窗口类型会在下一节创建:

src/main/java/cn/usehook/spring/controller/dto/CheckPlanRequest.java
01
package cn.usehook.spring.controller.dto;
02
03
import jakarta.validation.Valid;
04
import jakarta.validation.constraints.Max;
05
import jakarta.validation.constraints.Min;
06
import jakarta.validation.constraints.NotBlank;
07
import jakarta.validation.constraints.NotNull;
08
import jakarta.validation.constraints.Size;
09
import java.util.List;
10
11
public record CheckPlanRequest(
12
@NotNull @Valid CheckWindow window,
13
@NotNull @Size(min = 1, max = 20)
14
List<@NotNull @Valid Target> targets
15
) {
16
public record Target(
17
@NotBlank @Size(max = 50) String service,
18
@NotNull @Min(1) @Max(30) Integer timeoutSeconds
19
) {}
20
}

列表自身和列表元素是不同的检查对象。@Size 限制数量,不能代替列表的 @NotNull;元素上的 @NotNull 拒绝 [null],@Valid 才沿对象图进入 Target。

timeoutSeconds 使用 Integer,使字段缺失和显式 null 都能由 @NotNull 拦截。只声明 @Min(1) 不会要求包装类型必须存在。

一次失败可以准确指向第二个目标:

validation-paths.txt
1
targets[1].service
2
targets[1].timeoutSeconds
3
window.to

不要为了支持数组而把 Controller 参数直接改成裸 List<Target>。使用请求对象可以自然容纳整个列表的约束和将来新增的请求元数据。

3. 跨字段约束

时间窗口使用 Instant 表达两个确定的时间点。结束必须严格晚于开始,这条关系不能由某一个字段的 @NotNull 表达:

src/main/java/cn/usehook/spring/controller/dto/CheckWindow.java
01
package cn.usehook.spring.controller.dto;
02
03
import cn.usehook.spring.validation.ValidCheckWindow;
04
import jakarta.validation.constraints.NotNull;
05
import java.time.Instant;
06
07
@ValidCheckWindow
08
public record CheckWindow(
09
@NotNull Instant from,
10
@NotNull Instant to
11
) {}

为它声明类级约束:

src/main/java/cn/usehook/spring/validation/ValidCheckWindow.java
01
package cn.usehook.spring.validation;
02
03
import jakarta.validation.Constraint;
04
import jakarta.validation.Payload;
05
import java.lang.annotation.Documented;
06
import java.lang.annotation.ElementType;
07
import java.lang.annotation.Retention;
08
import java.lang.annotation.RetentionPolicy;
09
import java.lang.annotation.Target;
10
11
@Documented
12
@Target(ElementType.TYPE)
13
@Retention(RetentionPolicy.RUNTIME)
14
@Constraint(validatedBy = CheckWindowValidator.class)
15
public @interface ValidCheckWindow {
16
String message() default "to must be later than from";
17
Class<?>[] groups() default {};
18
Class<? extends Payload>[] payload() default {};
19
}

实现约束时,把错误挂到用户需要修改的 to 字段:

src/main/java/cn/usehook/spring/validation/CheckWindowValidator.java
01
package cn.usehook.spring.validation;
02
03
import cn.usehook.spring.controller.dto.CheckWindow;
04
import jakarta.validation.ConstraintValidator;
05
import jakarta.validation.ConstraintValidatorContext;
06
07
public class CheckWindowValidator
08
implements ConstraintValidator<ValidCheckWindow, CheckWindow> {
09
10
@Override
11
public boolean isValid(CheckWindow value, ConstraintValidatorContext context) {
12
if (value == null || value.from() == null || value.to() == null) {
13
return true;
14
}
15
if (value.to().isAfter(value.from())) {
16
return true;
17
}
18
context.disableDefaultConstraintViolation();
19
context.buildConstraintViolationWithTemplate(
20
context.getDefaultConstraintMessageTemplate()
21
).addPropertyNode("to").addConstraintViolation();
22
return false;
23
}
24
}

这里让空值通过自定义检查,因为空值已经由 @NotNull 负责。不能假设字段约束必定先执行,也不应该在字段缺失时制造第二条误导性的时间顺序错误。

addPropertyNode("to") 将类级规则转换成字段错误;外层级联再补上 window,最终形成 window.to。如果不指定属性节点,错误属于整个窗口对象,处理器就需要额外读取对象级错误。

Validator 可能被复用,实例字段不能保存本次请求内容。本例没有可变状态,也不需要把 Validator 手动注册为业务 Service。

4. 接入 MVC

创建独立 Controller,避免改变原来的单次心跳接口:

src/main/java/cn/usehook/spring/controller/CheckPlanController.java
01
package cn.usehook.spring.controller;
02
03
import cn.usehook.spring.controller.dto.CheckPlanRequest;
04
import jakarta.validation.Valid;
05
import jakarta.validation.constraints.Max;
06
import jakarta.validation.constraints.Min;
07
import java.util.List;
08
import java.util.Map;
09
import java.util.Objects;
10
import org.springframework.http.HttpStatus;
11
import org.springframework.http.ProblemDetail;
12
import org.springframework.web.bind.MethodArgumentNotValidException;
13
import org.springframework.web.bind.annotation.ExceptionHandler;
14
import org.springframework.web.bind.annotation.GetMapping;
15
import org.springframework.web.bind.annotation.PostMapping;
16
import org.springframework.web.bind.annotation.RequestBody;
17
import org.springframework.web.bind.annotation.RequestMapping;
18
import org.springframework.web.bind.annotation.RequestParam;
19
import org.springframework.web.bind.annotation.RestController;
20
import org.springframework.web.method.annotation.HandlerMethodValidationException;
21
22
@RestController
23
@RequestMapping("/api/v1/health/plans")
24
public class CheckPlanController {
25
26
@PostMapping("/preview")
27
public Map<String, Integer> preview(@Valid @RequestBody CheckPlanRequest request) {
28
return Map.of("targetCount", request.targets().size());
29
}
30
31
@GetMapping("/limit")
32
public Map<String, Integer> limit(
33
@RequestParam(name = "value") @Min(1) @Max(20) int value
34
) {
35
return Map.of("value", value);
36
}
37
38
@ExceptionHandler(MethodArgumentNotValidException.class)
39
public ProblemDetail invalidBody(MethodArgumentNotValidException exception) {
40
List<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();
46
ProblemDetail problem = ProblemDetail.forStatusAndDetail(
47
HttpStatus.BAD_REQUEST, "One or more request fields are invalid"
48
);
49
problem.setProperty("errors", errors);
50
return problem;
51
}
52
53
@ExceptionHandler(HandlerMethodValidationException.class)
54
public ProblemDetail invalidMethod(HandlerMethodValidationException exception) {
55
return ProblemDetail.forStatusAndDetail(
56
exception.isForReturnValue() ? HttpStatus.INTERNAL_SERVER_ERROR : HttpStatus.BAD_REQUEST,
57
exception.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 只用于该方案中的用户资源入口。

terminal.bash
1
curl -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,并把第二个目标的名称设为空白:

request.json
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。

src/test/java/cn/usehook/spring/validation/CheckPlanValidationTests.java
01
package cn.usehook.spring.validation;
02
03
import cn.usehook.spring.controller.dto.CheckPlanRequest;
04
import cn.usehook.spring.controller.dto.CheckWindow;
05
import jakarta.validation.Validation;
06
import java.time.Instant;
07
import java.util.List;
08
import org.junit.jupiter.api.Test;
09
10
import static org.assertj.core.api.Assertions.assertThat;
11
12
class CheckPlanValidationTests {
13
14
@Test
15
void reportsNestedFieldAndWindowPaths() {
16
Instant start = Instant.parse("2026-10-04T08:00:00Z");
17
CheckPlanRequest request = new CheckPlanRequest(
18
new CheckWindow(start, start.minusSeconds(1)),
19
List.of(
20
new CheckPlanRequest.Target("spring", 3),
21
new CheckPlanRequest.Target(" ", 3)
22
)
23
);
24
try (var factory = Validation.buildDefaultValidatorFactory()) {
25
var violations = factory.getValidator().validate(request);
26
assertThat(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 官方指南。