Skip to content

参数校验与全局异常处理 ​

S3-023必学进阶约 60 分钟JDK 21企业开发基础 · Spring 核心与 MVC

1. 本节定位 ​

《Spring MVC 请求映射与参数绑定》 把接口跑通了。本节解决两个"丑问题":

java
// 问题一:校验代码淹没业务逻辑
public ResponseEntity<?> create(CreateOrderRequest request) {
    if (request.productId() == null || request.productId().isBlank()) {
        return ResponseEntity.badRequest().body("商品编号不能为空");       // ← 一直写下去
    }
    if (request.quantity() <= 0) { ... }
    if (request.address() == null || request.address().length() > 200) { ... }
    // ... 业务逻辑还没开始
}

// 问题二:每个接口的错误响应格式都不一样
{"error": "..."} / {"msg": "..."} / {"code": 1} / 纯文本

Spring 的答案:校验交给注解、异常交给统一处理器——Controller 里只剩下成功路径。

2. 学习目标 ​

完成本节后,你应该能够:

  • 用 Bean Validation 注解声明校验规则,并知道校验规则的三种触发方式;
  • 说出 @Valid 与 @Validated 的区别;
  • 会用嵌套校验(@Valid 级联)处理"对象里包含对象";
  • 用 @RestControllerAdvice + @ExceptionHandler 实现统一异常处理;
  • 设计一套错误响应结构(code / message / traceId / details);
  • 说出"参数校验"与"业务规则校验"的分工,并知道各自该抛什么异常;
  • 说出"为什么不能把异常堆栈返回给调用方"。

3. 前置知识检查 ​

4. 为什么需要这个知识 ​

先看"没有校验"会发生什么:

java
@PostMapping
public OrderResponse create(@RequestBody CreateOrderRequest request) {
    return orderService.create(request);            // 直接透传
}

前端传 {"productId":null,"quantity":-5,"address":""}——请求进到 Service:

位置可能的后果
Service 里 productId.toUpperCase()NPE → 500
数据库quantity = -5 写进去了 → 脏数据
业务逻辑金额算出负数 → 对账出问题

核心问题:非法数据会一路穿透到底层,然后在某个意想不到的地方爆炸——而且往往在生产环境才暴露。

正确的做法是"在边界拦住它":

text
HTTP 请求  →  【参数校验】← 在这里拦住格式错误(400/422)
           →  Controller
           →  Service  →  【业务校验】← 在这里拦住业务规则错误(409)
           →  数据库   →  【约束】← 最后一道防线([S3-002](../mysql/S3-002))

三层防线,各管各的 ✓ 本节讲前两层。

5. 核心概念 ​

5.1 Bean Validation:声明式校验 ​

做法:把校验规则写在字段上。

java
public record CreateOrderRequest(
        @NotBlank(message = "商品编号不能为空")
        String productId,

        @Min(value = 1, message = "数量必须大于 0")
        @Max(value = 100, message = "单次最多购买 100 件")
        int quantity,

        @NotBlank(message = "收货地址不能为空")
        @Size(max = 200, message = "收货地址长度不能超过 200")
        String address
) {
}

Controller 里只加一个 @Valid:

java
@PostMapping
public ResponseEntity<OrderResponse> create(@Valid @RequestBody CreateOrderRequest request) { }

校验失败时,Spring 抛 MethodArgumentNotValidException——方法体根本不会执行 ✓

5.2 常用校验注解 ​

注解作用注意
@NotNull不能为 null空串会通过 ✗
@NotBlank不能为 null、空串或纯空白用于字符串(最常用)
@NotEmpty不能为 null 且不能为空用于集合/数组/字符串
@Size(min, max)长度 / 大小范围字符串、集合、数组
@Min(v) / @Max(v)数值范围含边界
@Positive / @Negative正数 / 负数语义比 @Min 更直白
@DecimalMin / @DecimalMaxBigDecimal 范围金额场景
@Email邮箱格式宽松校验,不做真实性验证
@Pattern(regexp)正则手机号、身份证
@Past / @Future过去 / 未来时间生日、截止日期
@Null必须为 null少见(如"创建时 id 必须为空")

三个最容易搞混的:

@NotNull@NotEmpty@NotBlank
null❌ 拒绝❌ 拒绝❌ 拒绝
"" 空串✅ 通过❌ 拒绝❌ 拒绝
" " 纯空白✅ 通过✅ 通过❌ 拒绝
适用类型任意字符串、集合只用于字符串

经验:字符串字段几乎总是该用 @NotBlank——因为 "" 和 " " 在业务上通常都算"没填"。

5.3 三种触发方式 ​

方式触发点抛出的异常
@Valid + @RequestBody方法参数是对象MethodArgumentNotValidException
类上加 @Validated + 方法参数上的注解方法参数是简单类型ConstraintViolationException
手动调用 validator.validate(obj)代码里主动校验返回 Set<ConstraintViolation>

第二种用得多,值得展开:

java
@RestController
@Validated                                       // ← 类上必须有这个,方法参数上的校验才生效
@RequestMapping("/api/orders")
public class OrderController {

    @GetMapping("/{orderNo}")
    public OrderResponse find(
            @PathVariable @Pattern(regexp = "ORD-\\d+", message = "订单号格式不正确") String orderNo) { }
}

为什么两种要分开:

@Valid@Validated
用在方法参数(对象)类上(开启方法级校验)
支持分组校验❌✅
抛出MethodArgumentNotValidExceptionConstraintViolationException

实践建议:

场景选择
@RequestBody 的对象@Valid(最常用)
路径变量 / 查询参数的格式校验类上加 @Validated + 参数上写注解
需要分组(新增/修改用不同规则)@Validated(Group.class)

5.4 嵌套校验 ​

问题:对象里包含另一个对象时,默认不会级联校验。

java
public record CreateUserRequest(
        @NotBlank String username,
        AddressRequest address              // ← 不写 @Valid 的话,address 内部不校验
) { }

public record AddressRequest(
        @NotBlank String city,
        @NotBlank String street
) { }

修复:在嵌套字段上加 @Valid:

java
public record CreateUserRequest(
        @NotBlank String username,
        @Valid AddressRequest address       // ← 加了这个,才会校验 address 里的字段
) { }

同理,集合里的元素也要加:

java
public record CreateOrderRequest(
        @NotEmpty @Valid List<OrderItemRequest> items     // ← 每个元素都校验
) { }

这是一个非常容易漏的点——后果是"嵌套对象的非法数据一路穿透" ✓

5.5 全局异常处理 ​

没有它的时候,每个 Controller 都要写 try-catch:

java
@PostMapping
public ResponseEntity<?> create(@RequestBody CreateOrderRequest request) {
    try {
        return ResponseEntity.ok(orderService.create(request));
    } catch (IllegalArgumentException exception) {
        return ResponseEntity.badRequest().body(exception.getMessage());      // ← 每个方法都写
    } catch (Exception exception) {
        return ResponseEntity.status(500).body("系统错误");                    // ← 复读
    }
}

有了它之后,Controller 只剩成功路径:

java
@RestControllerAdvice
public class GlobalExceptionHandler {
    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ResponseEntity<ErrorResponse> handleValidation(MethodArgumentNotValidException exception) {
        List<ErrorResponse.FieldErrorItem> details = exception.getBindingResult().getFieldErrors().stream()
                .map(error -> new ErrorResponse.FieldErrorItem(error.getField(), error.getDefaultMessage()))
                .toList();
        return build(HttpStatus.UNPROCESSABLE_ENTITY, "VALIDATION_FAILED", "参数校验失败", details);
    }

    @ExceptionHandler(BusinessException.class)
    public ResponseEntity<ErrorResponse> handleBusiness(BusinessException exception) {
        return build(exception.getStatus(), exception.getCode(), exception.getMessage(), List.of());
    }

    @ExceptionHandler(Exception.class)
    public ResponseEntity<ErrorResponse> handleUnexpected(Exception exception) {
        log.error("未预期的异常", exception);                                  // ← 详细记日志
        return build(HttpStatus.INTERNAL_SERVER_ERROR, "INTERNAL_ERROR", "服务内部错误,请稍后重试", List.of());
    }
}

三个注解的分工:

注解作用
@RestControllerAdvice声明"全局的控制器增强"(= @ControllerAdvice + @ResponseBody)
@ExceptionHandler指定"处理哪一类异常"
@ResponseStatus简单场景下直接标状态码(无需返回 ResponseEntity)

匹配规则:Spring 会选"最具体的"处理器。

text
抛 ResourceNotFoundException
  → 有 @ExceptionHandler(ResourceNotFoundException.class)?用(但本例没有)
  → 有 @ExceptionHandler(BusinessException.class)?用 ✓(父类匹配)
  → 有 @ExceptionHandler(Exception.class)?兜底

所以"父类 handler 能接住子类异常" ✓ 这也是本项目 BusinessException 设计的用意:一个 handler 统一处理所有业务异常,状态码由异常自己携带。

5.6 统一错误响应设计 ​

json
{
  "code": "VALIDATION_FAILED",
  "message": "参数校验失败",
  "traceId": "4b3a3398-e05b-4ccd-9a39-1f938afaf8cc",
  "details": [
    { "field": "quantity", "message": "数量必须大于 0" },
    { "field": "address", "message": "收货地址不能为空" }
  ]
}
字段给谁看作用
code机器客户端据此做分支、做多语言文案映射(是契约,不能随便改)
message人可直接展示,也可让客户端覆盖(是文案,可随时改)
traceId排查用户报错时给客服,能直接定位日志
details表单逐字段错误,前端能精确标红

traceId 的实践中怎么来的:真实项目用它关联分布式链路追踪:

来源说明
上游传入的 X-Trace-Id 请求头微服务间透传
MDC(日志上下文)日志与响应共用同一个 id
本处用 UUID.randomUUID()最简形式——能关联"这一次请求的日志"就够了

关键:traceId 必须是"能被检索到的"——它要出现在日志里 ✓ 否则给了用户也查不到。

5.7 异常 → 状态码的映射 ​

一张必须设计的对照表:

异常状态码code谁抛
参数校验失败422VALIDATION_FAILEDSpring(@Valid)
参数类型错误400TYPE_MISMATCHSpring(类型转换)
请求体格式错误(JSON 语法错)400MALFORMED_JSONSpring(Jackson)
资源不存在404RESOURCE_NOT_FOUND业务代码
业务规则冲突(如商品下架)409PRODUCT_UNAVAILABLE业务代码
未认证 / Token 失效401UNAUTHORIZED认证过滤器
无权限403FORBIDDEN权限校验
限流429TOO_MANY_REQUESTS限流器
其他所有异常500INTERNAL_ERROR兜底

三个设计要点:

① 400 与 422 的区分

码含义例子
400请求本身有问题JSON 语法错、类型转不过去
422请求格式对,但值不合法quantity = 0

② 业务异常要携带状态码

java
public class BusinessException extends RuntimeException {
    private final String code;
    private final HttpStatus status;        // ← 让异常自己决定状态码

    public BusinessException(String code, String message, HttpStatus status) {
        super(message);
        this.code = code;
        this.status = status;
    }
}

// 使用
throw new BusinessException("PRODUCT_UNAVAILABLE", "商品已下架", HttpStatus.CONFLICT);

为什么不直接在 Controller 里返回 ResponseEntity:那样业务规则就会渗进 Web 层(《Spring MVC 请求映射与参数绑定》 的 9.6 节),Service 层也无法复用(同一个规则在定时任务里也要用)✓

③ 兜底 handler 必须记详细日志

java
@ExceptionHandler(Exception.class)
public ResponseEntity<ErrorResponse> handleUnexpected(Exception exception) {
    log.error("未预期的异常", exception);                    // ← 完整堆栈进日志
    return build(HttpStatus.INTERNAL_SERVER_ERROR, "INTERNAL_ERROR", "服务内部错误,请稍后重试", List.of());
    //     ↑ 对外只给通用提示
}

"日志详细、响应笼统"是一个必须坚持的原则(见 5.8)。

5.8 不要泄露内部细节 ​

反例:

json
{
  "error": "java.sql.SQLSyntaxErrorException: Table 'shop.orders' doesn't exist",
  "stackTrace": "...省略 50 行..."
}

三个危害:

危害说明
泄露内部结构表名、类名、框架版本——给攻击者提供了地图
用户体验差用户看不懂,也无法处理
暴露实现细节以后改表名/改框架都成了"破坏 API"

正确做法:

json
{ "code": "INTERNAL_ERROR", "message": "服务内部错误,请稍后重试", "traceId": "..." }

用户看到通用提示并可以报出 traceId;工程师拿 traceId 去日志里查完整堆栈 ✓ 双方都得到了需要的东西。

5.9 "参数校验"与"业务校验"的分工 ​

这是本节最需要记住的一条边界:

类型判断什么放在哪失败时
参数校验格式、范围、必填DTO 上的注解422
业务校验业务规则(唯一性、状态流转、余额充足)Service 里409 / 422

举例:

校验属于理由
quantity > 0 且 ≤ 100参数校验不需要查任何数据就能判断
商品是否存在业务校验要查数据库
用户名是否已被占用业务校验要查数据库
订单状态是否是 CREATED业务校验要读当前状态
地址长度 ≤ 200参数校验纯格式判断

为什么这条边界重要:

  1. 参数校验放在 DTO 上,能被所有接口复用(创建、修改用同一个规则);
  2. 业务校验放在 Service 里,才能保证"无论从哪个入口进来都被校验"(HTTP 接口、定时任务、消息消费);
  3. 如果把业务校验写在 Controller 里,定时任务调用 Service 时就会绕过它 ✗

一句话:

只在"当前请求的数据"上就能判断的,是参数校验; 需要"查别的数据"才能判断的,是业务校验。

6. 递进示例 ​

6.1 最小示例:一个注解搞定校验 ​

java
public record CreateOrderRequest(
        @NotBlank(message = "商品编号不能为空") String productId,
        @Min(value = 1, message = "数量必须大于 0") int quantity
) { }

@PostMapping
public ResponseEntity<OrderResponse> create(@Valid @RequestBody CreateOrderRequest request) { }

6.2 业务示例:统一异常处理 ​

见 5.5 节与第 7 节。

6.3 反例:在 Controller 里手工校验 ​

java
@PostMapping
public ResponseEntity<?> create(@RequestBody CreateOrderRequest request) {
    if (request.quantity() <= 0) {
        return ResponseEntity.badRequest().body(Map.of("msg", "数量必须大于 0"));     // ✗
    }
    // ... 每个接口都要写一遍,且响应格式各不相同
}

三个问题:重复(每个接口都写)、格式不一(msg / error / message 混用)、规则分散(改一次规则要改所有入口)。

修复:注解 + 全局处理器 ✓

7. 完整可运行代码 ​

文件清单(labs/spring-boot-web/):

text
src/main/java/cn/kb/learn/web/
├── dto/CreateOrderRequest.java                    ← 校验注解
├── exception/BusinessException.java               ← 携带 code + status
├── exception/ResourceNotFoundException.java
├── exception/ErrorResponse.java                   ← 统一错误响应
└── exception/GlobalExceptionHandler.java          ← 全局处理

DTO 里的校验:

java
public record CreateOrderRequest(
        @NotBlank(message = "商品编号不能为空")
        String productId,

        @Min(value = 1, message = "数量必须大于 0")
        @Max(value = 100, message = "单次最多购买 100 件")
        int quantity,

        @NotBlank(message = "收货地址不能为空")
        @Size(max = 200, message = "收货地址长度不能超过 200")
        String address
) {
}

全局异常处理器:

java
@RestControllerAdvice
public class GlobalExceptionHandler {
    private static final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class);

    /** 参数校验失败:@Valid 校验不通过时由 Spring 抛出 */
    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ResponseEntity<ErrorResponse> handleValidation(MethodArgumentNotValidException exception) {
        List<ErrorResponse.FieldErrorItem> details = exception.getBindingResult().getFieldErrors().stream()
                .map(error -> new ErrorResponse.FieldErrorItem(error.getField(), error.getDefaultMessage()))
                .toList();
        return build(HttpStatus.UNPROCESSABLE_ENTITY, "VALIDATION_FAILED", "参数校验失败", details);
    }

    /** 参数类型不匹配:如路径上要 int 却给了字符串 */
    @ExceptionHandler(MethodArgumentTypeMismatchException.class)
    public ResponseEntity<ErrorResponse> handleTypeMismatch(MethodArgumentTypeMismatchException exception) {
        return build(HttpStatus.BAD_REQUEST, "TYPE_MISMATCH", "参数类型不正确:" + exception.getName(), List.of());
    }

    /** 业务异常:错误码与状态码由异常自己携带 */
    @ExceptionHandler(BusinessException.class)
    public ResponseEntity<ErrorResponse> handleBusiness(BusinessException exception) {
        return build(exception.getStatus(), exception.getCode(), exception.getMessage(), List.of());
    }

    /** 兜底:未预期异常记详细日志、对外只给通用提示 */
    @ExceptionHandler(Exception.class)
    public ResponseEntity<ErrorResponse> handleUnexpected(Exception exception) {
        log.error("未预期的异常", exception);
        return build(HttpStatus.INTERNAL_SERVER_ERROR, "INTERNAL_ERROR", "服务内部错误,请稍后重试", List.of());
    }

    private ResponseEntity<ErrorResponse> build(HttpStatus status, String code, String message,
                                                List<ErrorResponse.FieldErrorItem> details) {
        return ResponseEntity.status(status)
                .body(new ErrorResponse(code, message, UUID.randomUUID().toString(), details));
    }
}

运行命令:

powershell
cd labs
./mvnw -pl spring-boot-web package -DskipTests
cd spring-boot-web
./demo.ps1

预期输出(四种错误场景的真实响应):

text
===== 3. 查询不存在的订单(期望 404)=====
{"code":"RESOURCE_NOT_FOUND","message":"订单不存在:ORD-999","traceId":"e29b9399-690a-4cc0-a1ff-fe71f181c28c","details":[]}
[HTTP 404]

===== 4. 校验失败:quantity=0、address 为空(期望 422)=====
{"code":"VALIDATION_FAILED","message":"参数校验失败","traceId":"8ccd253a-9bb2-4103-b8c0-e63c9ef9ee44",
 "details":[{"field":"quantity","message":"数量必须大于 0"},{"field":"address","message":"收货地址不能为空"}]}
[HTTP 422]

===== 5. 业务冲突:商品已下架(期望 409)=====
{"code":"PRODUCT_UNAVAILABLE","message":"商品已下架","traceId":"b87e56fb-2660-4059-ace7-bd128b242bdc","details":[]}
[HTTP 409]

===== 7. 参数类型不匹配:page=abc(期望 400)=====
{"code":"TYPE_MISMATCH","message":"参数类型不正确:page","traceId":"186e6f0a-0a60-480e-818f-799ac6a9a297","details":[]}
[HTTP 400]

四处值得对照:

输出含义
四种错误,同一个响应结构客户端只需写一套错误处理逻辑
details 里有两个字段错误一次性返回所有校验失败——用户不用"改一个、提交、再发现下一个"
code 各不相同(RESOURCE_NOT_FOUND / VALIDATION_FAILED / PRODUCT_UNAVAILABLE / TYPE_MISMATCH)机器可读的契约
每个响应都有 traceId用户报错时能直接定位日志

注意第 4 条:校验失败时 details 里有两个错误——因为 quantity = 0 与 address = "" 同时不合法,Bean Validation 会收集全部错误再一次性抛出 ✓ 这与"抛异常就中断"的写法不同,对用户更友好。

8. Debug 观察点 ​

  1. 在 handleValidation 的第一行打断点:展开 exception.getBindingResult().getFieldErrors()——能看到每个字段的错误对象(字段名、消息、拒绝的值);
  2. 在 CreateOrderRequest 的构造器上打断点:观察校验失败时它根本没被调用——校验发生在反序列化之后、方法调用之前;
  3. 观察异常匹配顺序:在 ResourceNotFoundException 抛出处与 handleBusiness 入口各打一个断点,确认是父类的 handler 接住了子类异常;
  4. 观察 500 的兜底:临时在 Service 里抛一个 NullPointerException,观察日志里有完整堆栈、而响应体里只有通用提示 ✓ 这是"日志详细、响应笼统"的现场;
  5. 验证嵌套校验:给 DTO 加一个嵌套对象字段(不加 @Valid),传一个内部字段非法的请求——观察它没有被校验;加上 @Valid 再试 ✓

9. 常见错误 ​

错误 1:忘了加 @Valid ​

java
@PostMapping
public ResponseEntity<OrderResponse> create(@RequestBody CreateOrderRequest request) { }
//                                            ↑ 少了 @Valid,注解形同虚设

症状:非法数据一路进到 Service,然后在某处 NPE 或写进数据库。

排查:"注解写了但没生效"时,第一件事就是检查 @Valid 加没加。

错误 2:嵌套对象没加 @Valid ​

java
public record CreateUserRequest(
        @NotBlank String username,
        AddressRequest address          // ✗ 没有 @Valid,内部字段不校验
) { }

症状:address.city 传 null 也能通过校验。

修复:@Valid AddressRequest address ✓

这是最容易漏的一点——因为外层校验通过了,看起来"校验生效了"。

错误 3:@Validated 加在了错误的位置 ​

java
public class OrderController {
    @Validated                                   // ✗ 加在方法上无效
    @GetMapping("/{orderNo}")
    public OrderResponse find(@PathVariable @Pattern(regexp = "ORD-\\d+") String orderNo) { }
}

修复:@Validated 加在类上(它开启"方法级校验"这个能力)。

错误 4:把业务校验写进 DTO 注解 ​

java
public record CreateOrderRequest(
        @NotBlank String productId,
        @Min(1) int quantity
) {
}
// 想校验"商品是否存在" → 没有任何注解能做这个(要查数据库)

修复:业务校验放 Service,抛 BusinessException ✓(见 5.9 节的分工表)

错误 5:@ExceptionHandler 顺序写反 ​

java
@ExceptionHandler(Exception.class)                    // 兜底
public ResponseEntity<ErrorResponse> handleAll(Exception e) { }

@ExceptionHandler(BusinessException.class)            // 业务异常
public ResponseEntity<ErrorResponse> handleBusiness(BusinessException e) { }

问题:方法顺序无关——Spring 按异常类型的具体程度匹配 ✓ 所以上面这样写也是对的。

但真正的问题是:如果你忘记写"具体异常"的 handler,所有异常都会落到兜底 handler 上 → 全是 500 ✗

排查技巧:看响应体里的 code——如果是 INTERNAL_ERROR 但你期望的是业务码,说明没有对应的 handler(或异常类型不匹配)。

错误 6:把堆栈返回给前端 ​

见 5.8 节。安全 + 体验双重问题。

自查方法:搜代码里有没有把 exception.getMessage() 或 exception.toString() 直接写进响应的地方 ✓

注意:BusinessException 的消息可以返回(它是精心设计的业务提示)✓ 而**"未预期异常"的消息不能返回**(它可能是 "NullPointerException at ...")✗

错误 7:ConstraintViolationException 没有被处理 ​

症状:用了 @Validated + 参数注解后,校验失败返回 500 而不是 422。

原因:@Validated 的方法级校验抛的是 ConstraintViolationException(不是 MethodArgumentNotValidException)✗ 没写对应的 handler 就落到兜底了。

修复:补一个 handler:

java
@ExceptionHandler(ConstraintViolationException.class)
public ResponseEntity<ErrorResponse> handleConstraintViolation(ConstraintViolationException exception) {
    List<ErrorResponse.FieldErrorItem> details = exception.getConstraintViolations().stream()
            .map(violation -> new ErrorResponse.FieldErrorItem(
                    violation.getPropertyPath().toString(), violation.getMessage()))
            .toList();
    return build(HttpStatus.UNPROCESSABLE_ENTITY, "VALIDATION_FAILED", "参数校验失败", details);
}

10. 动手实验 ​

目标:把"校验 + 异常"的完整链路走一遍。

步骤:

  1. 运行第 7 节的命令,核对四种错误响应的状态码与结构;
  2. 验证"一次性返回全部错误":发一个"三个字段都错"的请求,观察 details 里是不是三条;
  3. 加一个校验规则:给 CreateOrderRequest 加 @Pattern(regexp = "P-\\d+", message = "商品编号格式应为 P-数字"),测试合法与非法的输入;
  4. 加一个嵌套校验:新建 AddressRequest(city, street),把 CreateOrderRequest.address 改成这个对象(加 @Valid),然后故意传 {"city":null} 观察是否被拦住;
  5. 制造 500:在 OrderService.create 里临时加 throw new NullPointerException("测试"),观察:
    • 响应体里的内容(应该是通用提示);
    • 日志里的内容(应该有完整堆栈与 traceId 对应的异常);
  6. 补一个 handler:给 ConstraintViolationException 加处理器,并用 @Validated + @Pattern 校验路径变量,验证 422。

验收方式:能说出第 4 步加 @Valid 前后的差异、第 5 步"响应与日志的差异"。

扩展任务:实现一个自定义校验注解 @Phone(校验中国大陆手机号),并用它替换 @Pattern。说明自定义注解需要哪三个东西(注解定义、校验逻辑 ConstraintValidator、错误消息)。

11. 练习题 ​

基础题

  1. 判断:@NotNull 会拒绝空字符串。(对 / 错)
  2. 填空:@RequestBody 的校验用 ______ 触发,失败抛 ______;类上加 ______ 可开启方法参数的校验,失败抛 ______。
  3. 简答:为什么"业务校验"不能写在 DTO 的注解里?

代码阅读题

java
public record UserRegisterRequest(
        @NotBlank String username,
        @NotBlank @Size(min = 8, max = 32) String password,
        @NotBlank @Email String email,
        ProfileRequest profile
) {
    public record ProfileRequest(
            @NotBlank String nickname,
            @Min(1) @Max(150) int age
    ) { }
}
  1. 有一个请求 {"username":"alice","password":"12345678","email":"a@b.c","profile":{"nickname":null,"age":0}},会被校验拦住吗?会被哪几条规则拦住?
  2. 如何修复?修复后这个请求会被哪几条规则拦住?

编程题

  1. 实现一套完整的"校验 + 异常"方案:
    • 需求:POST /api/users/register,字段:username(4-20 位)、password(8-32 位且必须含数字)、email、age(1-150)、profile(嵌套:nickname、city);
    • 要求:
      • 用 Bean Validation 注解实现所有格式校验(含嵌套校验);
      • 密码"必须含数字"用 @Pattern 实现;
      • 写一个"用户名已存在"的业务校验(在 Service 里抛 BusinessException,状态码 409);
      • 写对应的方法级校验(如 GET /api/users/{id} 的 id 必须是数字且 > 0);
    • 实测 5 种场景并记录状态码:全部合法、格式错误、嵌套字段错误、用户名重复、id 非法;
    • 说明:为什么"用户名已存在"不用 @Pattern 之类的注解实现?

综合任务

  1. 设计一套"错误码体系"并实现:
    • 要求:
      • 定义错误码的命名规范(如 模块_原因:ORDER_NOT_FOUND、USER_DUPLICATED);
      • 定义错误码的分配规则(哪些是通用码、哪些是业务码);
      • 设计错误码与 HTTP 状态码的映射(一个错误码对应一个状态码,还是多个?);
      • 用枚举实现错误码(而不是散落的字符串常量);
    • 写出枚举类与改造后的 GlobalExceptionHandler;
    • 额外要求:说明"错误码要不要在文档里公开?"以及"如何保证前后端对错误码的理解一致"(提示:OpenAPI / 契约测试)。
分级提示

提示 1(方向):第 4 题的关键是 profile 字段上没有 @Valid。

提示 2(关键点):第 6 题的"密码必须含数字"用 @Pattern(regexp = ".*\\d.*")——注意正则里的反斜杠在 Java 字符串里要写成 \\d。

提示 3(接近答案):第 7 题的"错误码用枚举"的好处:编译期检查、IDE 能跳转、能统一维护"码 → 状态码 → 消息"的映射关系。

12. 参考答案 ​

基础题
  1. 错。@NotNull 只拒绝 null——空串 "" 和纯空白 " " 都会通过。

对比:

值@NotNull@NotEmpty@NotBlank
null❌❌❌
""✅ 通过❌❌
" "✅ 通过✅ 通过❌

结论:字符串字段用 @NotBlank。

  1. @Valid;MethodArgumentNotValidException;@Validated;ConstraintViolationException。

记忆方法:@Valid 管"对象参数"、@Validated 管"方法级校验" ✓ 两者抛的异常不同,所以需要两个 handler。

  1. 因为业务校验需要"查别的数据"——而注解是声明式的、在反序列化后就执行(《Spring MVC 请求映射与参数绑定》 的 5.1 节),它拿不到数据库、也拿不到其他服务。

更根本的理由:如果硬要在注解里做业务校验(如写一个 @UsernameUnique 的自定义校验器),它会:

问题说明
耦合数据库校验器里要注入 Repository——校验逻辑与数据访问纠缠
事务边界混乱校验发生在 Controller 层,此时事务还没开始
场景不匹配"更新用户"时不需要校验用户名唯一(用户名不可改),但校验器无法区分
性能每次请求都查一次库,且无法利用缓存

正确分工(5.9 节):格式范围用注解、业务规则用 Service + 异常 ✓

代码阅读题(第 4、5 题)

第 4 题:不会被"完整"校验。

会拦截到的:username、password、email 三条(它们在外层,注解会生效)。

不会拦截到的:profile.nickname 与 profile.age ✗ —— 因为 ProfileRequest profile 这个字段上没有 @Valid。

结果:请求通过校验,进入 Service,profile 里的非法数据(nickname = null、age = 0)一路穿透 ✓

第 5 题:在 profile 字段上加 @Valid。

java
public record UserRegisterRequest(
        @NotBlank String username,
        @NotBlank @Size(min = 8, max = 32) String password,
        @NotBlank @Email String email,
        @Valid ProfileRequest profile                // ← 加这一个注解
) { }

修复后会拦截到的:

字段规则结果
profile.nickname@NotBlank❌ 被拦(是 null)
profile.age@Min(1)❌ 被拦(是 0)

注意 details 里的字段名:会是 profile.nickname、profile.age(带路径)✓

这对前端很有价值:它能直接定位到表单里具体哪个输入框 ✓ 这也是"嵌套校验必须加 @Valid"的实际收益。

编程题(第 6 题)
java
package cn.kb.learn.web.dto;

import jakarta.validation.Valid;
import jakarta.validation.constraints.*;

public record UserRegisterRequest(
        @NotBlank(message = "用户名不能为空")
        @Size(min = 4, max = 20, message = "用户名长度必须在 4-20 之间")
        String username,

        @NotBlank(message = "密码不能为空")
        @Size(min = 8, max = 32, message = "密码长度必须在 8-32 之间")
        @Pattern(regexp = ".*\\d.*", message = "密码必须包含至少一个数字")
        String password,

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

        @Min(value = 1, message = "年龄必须大于 0")
        @Max(value = 150, message = "年龄不能超过 150")
        int age,

        @Valid                                              // ← 嵌套校验的关键
        @NotNull(message = "用户资料不能为空")
        ProfileRequest profile
) {
    public record ProfileRequest(
            @NotBlank(message = "昵称不能为空")
            @Size(max = 32, message = "昵称不能超过 32 个字符")
            String nickname,

            @NotBlank(message = "城市不能为空")
            String city
    ) {
    }
}

Service 里的业务校验:

java
@Service
public class UserService {
    private final Set<String> existingUsernames = ConcurrentHashMap.newKeySet();

    public UserResponse register(UserRegisterRequest request) {
        // 业务校验:需要"查数据"才能判断的,放在这里
        if (!existingUsernames.add(request.username())) {
            throw new BusinessException("USER_DUPLICATED", "用户名已被占用", HttpStatus.CONFLICT);
        }
        // ... 保存用户
    }
}

Controller 的方法级校验:

java
@RestController
@Validated                                                   // ← 类上加,方法参数上的注解才生效
@RequestMapping("/api/users")
public class UserController {

    @GetMapping("/{id}")
    public UserResponse find(
            @PathVariable @Min(value = 1, message = "用户 id 必须大于 0") Long id) {
        return userService.find(id);
    }
}

五种场景的实测结果:

场景请求期望状态码期望 code
全部合法完整正确的请求201—
格式错误password = "1234567"(7 位)422VALIDATION_FAILED + details
嵌套字段错误profile.nickname = null422details 里字段名带 profile.
用户名重复同一个 username 注册两次409USER_DUPLICATED
id 非法GET /api/users/0422VALIDATION_FAILED(来自 @Validated)

为什么"用户名已存在"不用注解实现:

理由说明
要查数据库注解的校验器拿不到 Repository(除非注入,但那会让校验器依赖数据层)
事务语义校验应在业务事务内(且唯一性最终靠数据库唯一约束保证,《锁机制:行锁、表锁与乐观悲观锁》 讲过并发下的"检查-插入"竞态)
场景差异"注册"要查重,"更新资料"不需要(用户名不可改)——注解无法区分
性能与缓存业务校验可以做缓存、可以批量预检;注解做不到

最后一条最重要的提醒:业务校验只能"优化体验",不能"保证正确"。

并发下两个请求可能同时通过"用户名已存在"的检查——唯一性最终靠数据库的 UNIQUE 约束保证(《表设计与约束》)。

应用层校验负责"给用户友好提示",数据库约束负责"数据永远正确" ✓ 两者缺一不可。

综合任务(第 7 题)

错误码体系的四个设计决定:

① 命名规范

text
格式:<资源>_<原因>
  ORDER_NOT_FOUND          资源 + 原因
  ORDER_STATUS_CONFLICT
  USER_DUPLICATED
  PAYMENT_AMOUNT_MISMATCH

通用码(与业务无关):
  VALIDATION_FAILED
  TYPE_MISMATCH
  UNAUTHORIZED
  FORBIDDEN
  INTERNAL_ERROR
规则说明
全大写下划线常量风格的稳定性
资源_原因 结构看到码就知道"哪里出了什么问题"
不用数字码(如 10001)字符串语义自明,日志里更好搜(对比:数字码要靠文档查)
不用 HTTP 状态码做业务码一个状态码可能对应多个业务原因(都是 409:商品下架 / 库存不足 / 状态冲突)

② 错误码用枚举实现

java
package cn.kb.learn.web.exception;

import org.springframework.http.HttpStatus;

public enum ErrorCode {
    // 通用
    VALIDATION_FAILED("参数校验失败", HttpStatus.UNPROCESSABLE_ENTITY),
    TYPE_MISMATCH("参数类型不正确", HttpStatus.BAD_REQUEST),
    INTERNAL_ERROR("服务内部错误,请稍后重试", HttpStatus.INTERNAL_SERVER_ERROR),

    // 订单
    ORDER_NOT_FOUND("订单不存在", HttpStatus.NOT_FOUND),
    ORDER_STATUS_CONFLICT("订单状态不允许该操作", HttpStatus.CONFLICT),

    // 用户
    USER_DUPLICATED("用户名已被占用", HttpStatus.CONFLICT),
    PRODUCT_UNAVAILABLE("商品已下架", HttpStatus.CONFLICT);

    private final String defaultMessage;
    private final HttpStatus status;

    ErrorCode(String defaultMessage, HttpStatus status) {
        this.defaultMessage = defaultMessage;
        this.status = status;
    }

    public String defaultMessage() {
        return defaultMessage;
    }

    public HttpStatus status() {
        return status;
    }
}

异常改造:

java
public class BusinessException extends RuntimeException {
    private final ErrorCode errorCode;

    public BusinessException(ErrorCode errorCode) {
        this(errorCode, errorCode.defaultMessage());
    }

    public BusinessException(ErrorCode errorCode, String message) {
        super(message);
        this.errorCode = errorCode;
    }

    public ErrorCode getErrorCode() {
        return errorCode;
    }
}

使用的样子:

java
throw new BusinessException(ErrorCode.ORDER_NOT_FOUND, "订单不存在:" + orderNo);

对比"散落字符串常量"的好处:

好处说明
编译期检查拼错码名直接编译不过(字符串不会)
IDE 支持能跳转、能查找引用("这个码在哪些地方被抛出?")
映射集中"码 → 状态码 → 默认消息"在一处维护
可枚举能自动生成文档(遍历所有枚举值)
不会重复定义同一个语义只可能有一个常量

③ 错误码与状态码的映射:多对一

text
409 CONFLICT  ←  ORDER_STATUS_CONFLICT
              ←  USER_DUPLICATED
              ←  PRODUCT_UNAVAILABLE

为什么不是一对一:HTTP 状态码是"类别的粗粒度描述",错误码是"具体原因"——一个状态码对应多个业务原因是完全正常的 ✓

反过来的约束:一个错误码只能对应一个状态码 ✓(否则客户端无法根据码推断状态)

④ 错误码要公开吗

要公开,但要用契约的方式:

方式说明
OpenAPI 文档在接口文档里列出"这个接口可能返回哪些错误码"
错误码清单发布一份"所有错误码 + 含义 + 状态码"的表格,前端据此写文案映射
契约测试用测试保证"文档里写的码 = 代码里实际返回的码"(防止文档漂移)

如何保证前后端理解一致:

手段效果
错误码是契约,消息是文案前端只用 code 做逻辑判断,message 仅作兜底展示
前端维护文案映射case 'ORDER_NOT_FOUND': return '该订单已被删除'
契约测试后端改错误码时,测试会红
变更流程删除/重命名错误码必须走"废弃周期"(先新增、后下线)

一条容易忽略的实践:code 一旦发布就不能随便改——它是接口契约的一部分(与 URL、字段名同级)。

想改怎么办:新增一个码,逐步迁移,老码标记为 deprecated——这与"改 URL"的处理方式一样(S3-REST 的版本化)。

改造后的 GlobalExceptionHandler:

java
@RestControllerAdvice
public class GlobalExceptionHandler {
    private static final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class);

    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ResponseEntity<ErrorResponse> handleValidation(MethodArgumentNotValidException exception) {
        List<ErrorResponse.FieldErrorItem> details = exception.getBindingResult().getFieldErrors().stream()
                .map(error -> new ErrorResponse.FieldErrorItem(error.getField(), error.getDefaultMessage()))
                .toList();
        return build(ErrorCode.VALIDATION_FAILED, details);
    }

    @ExceptionHandler(BusinessException.class)
    public ResponseEntity<ErrorResponse> handleBusiness(BusinessException exception) {
        return build(exception.getErrorCode(), exception.getMessage(), List.of());
    }

    @ExceptionHandler(Exception.class)
    public ResponseEntity<ErrorResponse> handleUnexpected(Exception exception) {
        log.error("未预期的异常", exception);
        return build(ErrorCode.INTERNAL_ERROR, ErrorCode.INTERNAL_ERROR.defaultMessage(), List.of());
    }

    private ResponseEntity<ErrorResponse> build(ErrorCode code, List<ErrorResponse.FieldErrorItem> details) {
        return build(code, code.defaultMessage(), details);
    }

    private ResponseEntity<ErrorResponse> build(ErrorCode code, String message,
                                                List<ErrorResponse.FieldErrorItem> details) {
        return ResponseEntity.status(code.status())
                .body(new ErrorResponse(code.name(), message, UUID.randomUUID().toString(), details));
    }
}

注意 code.name():枚举的 name() 正好就是错误码字符串(ORDER_NOT_FOUND)✓ 枚举名即错误码,不需要额外维护一个字符串字段。

13. 掌握标准 ​

  • [ ] 能用 Bean Validation 注解实现格式校验,并知道 @NotBlank / @NotEmpty / @NotNull 的区别;
  • [ ] 知道 @Valid 与 @Validated 的区别及各自抛出的异常;
  • [ ] 记得给嵌套对象加 @Valid(这是最容易漏的一点);
  • [ ] 能用 @RestControllerAdvice + @ExceptionHandler 实现统一异常处理与兜底;
  • [ ] 能设计出带 code / traceId / details 的错误响应;
  • [ ] 能说出"参数校验"与"业务校验"的分工(要不要查数据);
  • [ ] 知道业务校验不能替代数据库约束(并发下仍可能穿透);
  • [ ] 不会把堆栈返回给调用方。

14. 扩展资源 ​

15. 下一步学习 ​

Spring 与 Spring Boot 的核心部分到此完成。接下来可以:

后续知识

暂无

进度只保存在本机浏览器,可随时清除

个人学习项目:AI 生成内容均经人工复核,第三方资源仅作索引