数据校验(Validation)实现全局异常处理 + 国际化 + @Valid 三位一体方案

【投稿赢 iPhone 17】「我的第一个开源项目」故事征集:用代码换C位出道! 10w+人浏览 1.6k人参与

✅ Spring Boot 项目构建完整的输入校验体系

—— 全局异常处理 + 国际化 + @Valid 三位一体方案


在企业级 Spring Boot 项目中,统一、健壮、可维护的输入校验机制 是保障系统稳定性和用户体验的关键。本文将详细说明如何结合:

  • @Valid 参数校验
  • ✅ 国际化消息(多语言支持)
  • ✅ 全局异常处理器(@ControllerAdvice

构建一个 高内聚、低耦合、易扩展 的输入校验体系。


📌 一、目标

实现以下功能:

  1. 所有接口参数自动校验(JSON、表单、路径变量等)
  2. 校验失败返回结构化错误信息
  3. 错误提示支持中文、英文等多语言
  4. 统一异常处理,避免重复代码
  5. 支持嵌套对象校验(级联)
  6. 可扩展自定义业务异常

📁 二、项目结构示例

src/main/java/
├── controller/
│   └── UserController.java
├── dto/
│   ├── UserRegisterDTO.java
│   └── AddressDTO.java
├── exception/
│   ├── GlobalExceptionHandler.java
│   └── ErrorResponse.java
src/main/resources/
├── application.yaml
├── messages.properties          # 默认中文
├── messages_zh.properties      # 中文
├── messages_en.properties       # 英文


📦 三、Maven 依赖(pom.xml

<dependencies>
    <!-- Spring Boot Web -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>

    <!-- 参数校验支持 -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-validation</artifactId>
    </dependency>
</dependencies>

✅ Spring Boot 3.x 使用 jakarta.validation,2.x 使用 javax.validation


🛠️ 四、配置文件:application.yaml

server:
  port: 8080

spring:
  # 配置国际化资源文件
  messages:
    basename: messages        # 资源文件前缀(自动加载 messages.properties)
    encoding: UTF-8           # 文件编码
    cache-duration: 10s       # 缓存时间
    fallback-to-system-locale: true  # 找不到语言时回退系统语言

  # 开启参数绑定异常处理(可选)
  web:
    resources:
      add-mappings: true

🌍 五、国际化资源文件

1. messages.properties(默认中文)

# messages.properties
notblank.username=用户名不能为空
size.username=用户名长度必须在 {min} 到 {max} 个字符之间
email.email=邮箱格式不正确
pattern.phone=手机号格式不正确
min.age=年龄不能小于 {value}
max.age=年龄不能大于 {value}
positive.balance=账户余额必须为正数
decimal.max.discount=折扣率不能大于 {value}
decimal.min.discount=折扣率不能小于 {value}
past.birthday=生日必须是过去的时间
future.membership=会员到期时间必须是将来的时间
notempty.hobbies=兴趣爱好不能为空
size.hobbies=兴趣爱好数量必须在 {min} 到 {max} 之间
size.tags=标签数量不能超过 {max} 个
notblank.province=省不能为空
notblank.city=市不能为空
size.detail=详细地址不能超过 {max} 个字符

2. messages_en.properties(英文)

# messages_en.properties
notblank.username=Username is required
size.username=Username must be between {min} and {max} characters
email.email=Email format is invalid
pattern.phone=Phone number format is invalid
min.age=Age cannot be less than {value}
max.age=Age cannot be greater than {value}
positive.balance=Balance must be positive
decimal.max.discount=Discount cannot be greater than {value}
decimal.min.discount=Discount cannot be less than {value}
past.birthday=Birthday must be in the past
future.membership=Membership expiry must be in the future
notempty.hobbies=Hobbies cannot be empty
size.hobbies=Number of hobbies must be between {min} and {max}
size.tags=Tags cannot exceed {max} items
notblank.province=Province is required
notblank.city=City is required
size.detail=Detail address cannot exceed {max} characters

✅ 注意:{value}, {min}, {max} 是占位符,会自动替换为实际值


📦 六、数据传输对象(DTO)

1. 嵌套地址对象:AddressDTO.java

package com.example.demo.dto;

import jakarta.validation.constraints.*;

/**
 * 地址信息 DTO
 * 用于用户注册中的地址信息
 */
@Getter
@Setter
public class AddressDTO {

    @NotBlank(message = "{notblank.province}")
    private String province;

    @NotBlank(message = "{notblank.city}")
    private String city;

    @NotBlank(message = "{notblank.detail}")
    @Size(max = 200, message = "{size.detail}")
    private String detail;

    // Getters and Setters
}

2. 用户注册对象:UserRegisterDTO.java

package com.example.demo.dto;

import jakarta.validation.Valid;
import jakarta.validation.constraints.*;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.List;

/**
 * 用户注册请求 DTO
 * 包含所有注册字段,并使用 Validation 注解进行校验
 */
@Getter
@Setter
public class UserRegisterDTO {

    @NotBlank(message = "{notblank.username}")
    @Size(min = 3, max = 20, message = "{size.username}")
    private String username;

    @Email(message = "{email.email}")
    private String email;

    @NotBlank(message = "{notblank.phone}")
    @Pattern(regexp = "^1[3-9]\\d{9}$", message = "{pattern.phone}")
    private String phone;

    @Min(value = 18, message = "{min.age}")
    @Max(value = 120, message = "{max.age}")
    private Integer age;

    @Positive(message = "{positive.balance}")
    private BigDecimal balance;

    @DecimalMin(value = "0.0", inclusive = true, message = "{decimal.min.discount}")
    @DecimalMax(value = "1.0", inclusive = true, message = "{decimal.max.discount}")
    private Double discount;

    @NotEmpty(message = "{notempty.hobbies}")
    @Size(min = 1, max = 5, message = "{size.hobbies}")
    private List<String> hobbies;

    @Size(max = 3, message = "{size.tags}")
    private String[] tags;

    @Past(message = "{past.birthday}")
    private LocalDate birthday;

    @Future(message = "{future.membership}")
    private LocalDate membershipExpiryDate;

    // 使用 @Valid 实现级联校验
    @Valid
    private AddressDTO address;

    // Getters and Setters(略)
}

⚠️ 七、全局异常处理器:GlobalExceptionHandler.java

package com.example.demo.exception;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.MessageSource;
import org.springframework.context.i18n.LocaleContextHolder;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.validation.FieldError;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.*;

import jakarta.validation.ConstraintViolation;
import jakarta.validation.ConstraintViolationException;
import java.util.*;

/**
 * 全局异常处理器
 * 统一处理参数校验异常、类型转换异常等,返回标准化错误响应
 */
@RestControllerAdvice
public class GlobalExceptionHandler {

    @Autowired
    private MessageSource messageSource; // 用于加载国际化消息

    /**
     * 处理 @Valid 校验失败异常(@RequestBody 场景)
     */
    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ResponseEntity<ErrorResponse> handleValidationException(
            MethodArgumentNotValidException ex) {

        Locale locale = LocaleContextHolder.getLocale(); // 获取当前语言环境
        List<String> errors = new ArrayList<>();

        // 遍历所有字段错误,获取国际化消息
        for (FieldError fieldError : ex.getBindingResult().getFieldErrors()) {
            String msg = messageSource.getMessage(fieldError, locale);
            errors.add(fieldError.getField() + ": " + msg);
        }

        ErrorResponse response = new ErrorResponse(
                HttpStatus.BAD_REQUEST.value(),
                "参数校验失败",
                errors,
                new Date()
        );

        return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(response);
    }

    /**
     * 处理方法参数校验异常(如 @RequestParam 上使用 @NotBlank)
     */
    @ExceptionHandler(ConstraintViolationException.class)
    public ResponseEntity<ErrorResponse> handleConstraintViolation(
            ConstraintViolationException ex) {

        Locale locale = LocaleContextHolder.getLocale();
        List<String> errors = new ArrayList<>();

        for (ConstraintViolation<?> violation : ex.getConstraintViolations()) {
            String msg = messageSource.getMessage(
                    violation.getMessageTemplate(),
                    violation.getMessageParameters(),
                    locale
            );
            errors.add(violation.getPropertyPath() + ": " + msg);
        }

        ErrorResponse response = new ErrorResponse(
                HttpStatus.BAD_REQUEST.value(),
                "参数约束违反",
                errors,
                new Date()
        );
        return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(response);
    }

    /**
     * 兜底异常:捕获所有未处理的异常
     * 防止服务器错误暴露敏感信息
     */
    @ExceptionHandler(Exception.class)
    public ResponseEntity<ErrorResponse> handleGeneralException(Exception ex) {
        // 生产环境应记录日志
        // logger.error("服务器内部错误", ex);

        ErrorResponse response = new ErrorResponse(
                HttpStatus.INTERNAL_SERVER_ERROR.value(),
                "服务器内部错误,请稍后重试",
                Collections.singletonList("请联系管理员"),
                new Date()
        );
        return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(response);
    }
}

📦 八、统一错误响应体:ErrorResponse.java

package com.example.demo.exception;

import java.util.Date;
import java.util.List;

/**
 * 统一错误响应格式
 * 所有异常返回都使用此结构
 */
@Getter
@Setter
public class ErrorResponse {
    private int status;        // HTTP 状态码
    private String message;    // 错误摘要
    private List<String> details; // 具体错误信息
    private Date timestamp;    // 发生时间

    public ErrorResponse(int status, String message, List<String> details, Date timestamp) {
        this.status = status;
        this.message = message;
        this.details = details;
        this.timestamp = timestamp;
    }

    // Getters and Setters
}

🖥️ 九、Controller 示例:UserController.java

package com.example.demo.controller;

import com.example.demo.dto.UserRegisterDTO;
import org.springframework.web.bind.annotation.*;

/**
 * 用户控制器
 * 演示如何使用 @Valid 进行参数校验
 */
@RestController
@RequestMapping("/api/users")
public class UserController {

    /**
     * 用户注册接口
     * 使用 @Valid 自动触发校验,失败时由 GlobalExceptionHandler 捕获
     */
    @PostMapping("/register")
    public ResponseEntity<String> register(@Valid @RequestBody UserRegisterDTO dto) {
        // 如果走到这里,说明校验通过
        return ResponseEntity.ok("用户 [" + dto.getUsername() + "] 注册成功!");
    }
}

✅ 不需要手动处理 BindingResult,由全局异常处理器统一处理


🧪 十、测试示例

1. 中文请求(默认)

POST http://localhost:8080/api/users/register
Content-Type: application/json
Accept-Language: zh-CN

{
  "username": "ab",
  "email": "invalid",
  "phone": "12345678901",
  "age": 15,
  "balance": -100,
  "discount": 1.5,
  "hobbies": [],
  "tags": ["a","b","c","d"],
  "birthday": "2025-01-01",
  "membershipExpiryDate": "2024-01-01",
  "address": {
    "province": "",
    "city": "北京",
    "detail": ""
  }
}

响应(中文):

{
  "status": 400,
  "message": "参数校验失败",
  "details": [
    "username: 用户名长度必须在 3 到 20 个字符之间",
    "email: 邮箱格式不正确",
    "phone: 手机号格式不正确",
    "age: 年龄不能小于 18",
    "balance: 账户余额必须为正数",
    "discount: 折扣率不能大于 1.0",
    "hobbies: 兴趣爱好不能为空",
    "tags: 标签数量不能超过 3 个",
    "birthday: 生日必须是过去的时间",
    "membershipExpiryDate: 会员到期时间必须是将来的时间",
    "address.province: 省不能为空",
    "address.detail: 详细地址不能为空"
  ],
  "timestamp": "2025-04-05T10:30:00"
}

2. 英文请求

POST http://localhost:8080/api/users/register
Content-Type: application/json
Accept-Language: en-US

{ ... 同上数据 ... }

响应(英文):

{
  "status": 400,
  "message": "参数校验失败",
  "details": [
    "username: Username must be between 3 and 20 characters",
    "email: Email format is invalid",
    ...
  ],
  "timestamp": "2025-04-05T10:30:00"
}

✅ 十一、体系优势总结

特性说明
统一格式所有错误返回结构一致,前端易处理
多语言支持通过 Accept-Language 自动切换
零重复代码无需每个接口写 if (errors.hasErrors())
支持级联校验嵌套对象也能自动校验
易于扩展可添加业务异常、权限异常等
生产就绪错误信息不暴露堆栈,安全可靠

📝 十二、生产建议

  1. 日志记录:在 GlobalExceptionHandler 中添加 logger.error() 记录原始异常。
  2. 自定义业务异常
    public class BusinessException extends RuntimeException {
        private int code;
        // ...
    }
    
  3. 分组校验:使用 @Validated + 分组实现“新增”和“更新”不同规则。
  4. 性能监控:对高频接口的校验性能进行压测。
  5. 文档生成:配合 Swagger,使用 @Schema 注解生成 API 文档。

🎯 总结

通过 @Valid + 国际化 + 全局异常处理 三位一体方案,你可以构建一个:

标准化、国际化、可维护、高可用 的输入校验体系

这套方案已在多个企业级项目中验证,具备高度参考价值,推荐作为 Spring Boot 项目的标准实践。

评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
红包 添加红包
表情包 插入表情
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

龙茶清欢

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值