(五)数据校验从入门到规范,Java开发者必备——后端数据校验实战指南
引言:为什么后端必须做数据校验?
作为刚入门的Java后端开发者,我曾有过一个误区:数据校验是前端的事,后端只要接收参数、处理业务逻辑就好。直到一次项目上线,因为前端校验被绕过(比如通过Postman直接发送请求、修改前端代码关闭校验、使用抓包工具篡改参数),大量非法数据涌入数据库——负数的订单金额、长度超过限制的用户名、格式错误的手机号、空的核心字段、包含特殊字符的敏感信息,不仅导致数据库数据混乱,还引发了后续业务逻辑异常(如订单金额为负导致结算错误)、统计数据失真,甚至出现了空指针报错、SQL注入的潜在风险。
后来在导师李哥的指导下我才明白:前端校验是“面子”,用于提升用户体验(即时提示错误,避免无效请求);后端校验是“里子”,是数据安全的最后一道防线,也是企业级开发的必备规范。无论前端是否做校验、校验是否被绕过,后端都必须对所有接收的参数(请求参数、表单数据、接口调用参数、第三方接口返回数据等)进行严格校验,避免非法数据进入业务层、数据库,减少系统异常,降低维护成本,同时规避数据安全风险。
本文将彻底讲透后端数据校验,从核心概念、常用校验方式,到Spring Boot整合实战、避坑技巧,再到企业级规范和延伸场景,结合真实开发场景和可复制代码,补充更多细节知识点和实战案例,新手也能轻松上手,快速掌握后端数据校验的核心用法,应对各类开发场景。
一、核心认知:后端数据校验是什么?校验什么?
1. 什么是后端数据校验?
后端数据校验,是指在后端接收前端请求、处理业务逻辑之前,对请求参数的合法性、完整性、格式正确性、安全性进行全面检查,不符合规则的参数直接拒绝处理,返回清晰的错误提示,确保只有合法、安全的数据能进入后续业务流程(如数据库操作、业务计算、接口调用)。
核心目的:过滤非法数据、防范数据安全风险,保障数据安全和业务逻辑正常运行,减少异常报错和数据冗余,降低系统维护成本,提升系统健壮性。
补充说明:后端数据校验不仅针对前端传入的参数,还包括第三方接口返回的数据、定时任务触发的参数、系统内部接口调用的参数,只要是进入业务层的各类数据,都需要进行校验,做到“凡数据必校验”。
2. 后端数据校验的核心范围(必校验项)
李哥告诉我,后端数据校验不是“眉毛胡子一把抓”,重点关注以下5类参数,覆盖95%以上的开发场景,每个类别补充具体校验细节和实战注意事项:
| 校验类型 | 说明 | 示例 | 补充注意事项 |
|---|---|---|---|
| 非空校验 | 核心必填字段,不允许为空(null、空字符串、纯空格字符串) | 用户名、密码、订单ID、用户ID、接口必填参数 | 区分“允许空”和“允许空字符串”,如备注字段可允许空,但不允许纯空格;核心字段必须严格非空 |
| 格式校验 | 参数格式符合业务规则、行业标准或系统约定 | 手机号必须是11位数字、邮箱符合xxx@xxx.xxx格式、身份证号符合18位(含X)格式、日期符合yyyy-MM-dd格式 | 格式校验需结合正则表达式或专业工具类,避免正则漏洞(如手机号正则需兼容新号段) |
| 长度校验 | 字符串、数组、集合的长度在指定范围,数值的位数(整数位、小数位)符合要求 | 用户名长度2-20位、密码长度6-18位、订单备注不超过500字、金额保留2位小数 | 字符串需区分“字节长度”和“字符长度”(如中文占2个字节),避免因字符编码导致校验失效 |
| 数值校验 | 数值在指定范围(正数、负数、区间),数值类型符合要求(整数、小数) | 年龄1-120岁、订单金额≥0、数量≥1、折扣0-1之间、手机号为纯数字(无字母/特殊字符) | 注意数值溢出问题,如金额字段建议用BigDecimal,避免用double/float导致精度丢失 |
| 自定义校验 | 符合业务自定义规则,无法用内置校验满足的场景 | 密码必须包含字母+数字+特殊字符、两次密码一致、身份证号实名校验、用户名不能包含敏感词、订单状态流转合法 | 自定义校验需兼顾灵活性和性能,避免复杂逻辑嵌入校验器 |
3. 后端数据校验的核心原则
-
全面性:所有前端传入、第三方接口返回、系统内部传递的参数,都必须校验;无差别对待所有数据,不遗漏任何一个核心参数和非核心参数(非核心参数也可能引发异常)。
-
独立性:后端校验不依赖前端,即使前端未做校验、校验被绕过(如抓包篡改参数),后端也能独立拦截非法数据,不依赖任何前端传递的校验标识。
-
明确性:校验失败时,返回清晰、具体的错误提示(明确哪个参数、违反了什么规则、正确格式是什么),方便前端提示用户、后端排查问题,避免模糊提示(如“参数错误”)。
-
高效性:校验逻辑尽量简单、高效,避免复杂计算、数据库查询、远程接口调用,不影响接口响应速度;复杂校验逻辑需放在业务层,而非校验层。
-
一致性:同一类型参数的校验规则保持一致(如所有手机号校验用同一正则、所有密码长度要求一致),避免出现规则混乱,降低维护成本。
-
安全性:校验过程中需防范数据安全风险,如过滤特殊字符(避免SQL注入、XSS攻击)、校验敏感信息格式(如身份证号、手机号脱敏前的格式校验)。
二、后端数据校验的3种常用方式(从简单到规范)
后端数据校验有3种主流方式,从简单的手动校验,到Spring提供的注解校验,再到自定义校验,适用于不同场景。李哥帮我梳理了每种方式的用法、优缺点、实战细节和延伸场景,新手可根据项目场景(简单接口、复杂业务、企业级项目)选择合适的方式,也可结合使用。
方式1:手动校验(入门级,适用于简单场景)
手动校验是最基础的方式,通过if-else判断参数是否符合规则,无需依赖任何框架,适合参数较少、校验逻辑简单的场景(如单个接口、简单表单、临时接口、小型demo项目),也可用于复杂校验逻辑的补充。
实战示例(Spring Boot接口)
package com.example.demo.controller;
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;
import java.math.BigDecimal;
import java.util.HashMap;
import java.util.Map;
import java.util.regex.Pattern;
@RestController
@RequestMapping("/user")
public class UserController {
// 正则表达式(提取为常量,便于维护)
private static final String EMAIL_REGEX = "^[a-zA-Z0-9_-]+@[a-zA-Z0-9_-]+(\\.[a-zA-Z0-9_-]+)+$";
private static final String PHONE_REGEX = "^1[3-9]\\d{9}$";
// 敏感词(示例,实际可从配置文件或数据库读取)
private static final String[] SENSITIVE_WORDS = {"敏感词1", "敏感词2"};
// 注册接口(手动校验参数)
@PostMapping("/register")
public Map<String, Object> register(@RequestBody User user) {
Map<String, Object> result = new HashMap<>();
// 1. 非空校验
if (user.getUsername() == null || user.getUsername().trim().isEmpty()) {
result.put("code", 400);
result.put("msg", "用户名不能为空,且不能为纯空格");
return result;
}
if (user.getPassword() == null || user.getPassword().trim().isEmpty()) {
result.put("code", 400);
result.put("msg", "密码不能为空,且不能为纯空格");
return result;
}
if (user.getEmail() == null || user.getEmail().trim().isEmpty()) {
result.put("code", 400);
result.put("msg", "邮箱不能为空,且不能为纯空格");
return result;
}
// 非必填参数,校验格式(如手机号,有值则校验格式)
if (user.getPhone() != null && !user.getPhone().trim().isEmpty()) {
if (!Pattern.matches(PHONE_REGEX, user.getPhone().trim())) {
result.put("code", 400);
result.put("msg", "手机号格式不正确,需为11位有效数字");
return result;
}
}
// 数值参数校验(年龄、金额)
if (user.getAge() == null) {
result.put("code", 400);
result.put("msg", "年龄不能为空");
return result;
}
if (user.getAge() < 1 || user.getAge() > 120) {
result.put("code", 400);
result.put("msg", "年龄必须在1-120岁之间");
return result;
}
if (user.getBalance() == null) {
result.put("code", 400);
result.put("msg", "初始余额不能为空");
return result;
}
if (user.getBalance().compareTo(BigDecimal.ZERO) < 0) {
result.put("code", 400);
result.put("msg", "初始余额不能为负数");
return result;
}
// 2. 长度校验
String username = user.getUsername().trim();
if (username.length() < 2 || username.length() > 20) {
result.put("code", 400);
result.put("msg", "用户名长度必须在2-20位之间(中文算1位)");
return result;
}
// 密码字节长度校验(中文占2个字节,避免密码过短)
if (user.getPassword().getBytes().length < 6 || user.getPassword().getBytes().length > 18) {
result.put("code", 400);
result.put("msg", "密码长度必须在6-18字节之间(中文占2字节)");
return result;
}
// 3. 格式校验(邮箱)
if (!Pattern.matches(EMAIL_REGEX, user.getEmail().trim())) {
result.put("code", 400);
result.put("msg", "邮箱格式不正确,示例:xxx@xxx.com");
return result;
}
// 4. 自定义敏感词校验
for (String sensitiveWord : SENSITIVE_WORDS) {
if (username.contains(sensitiveWord)) {
result.put("code", 400);
result.put("msg", "用户名包含敏感词,请修改");
return result;
}
}
// 校验通过,处理注册业务
result.put("code", 200);
result.put("msg", "注册成功");
return result;
}
// 静态内部类(模拟用户参数)
static class User {
private String username;
private String password;
private String email;
private String phone; // 非必填
private Integer age;
private BigDecimal balance; // 初始余额
// get/set方法
public String getUsername() { return username; }
public void setUsername(String username) { this.username = username; }
public String getPassword() { return password; }
public void setPassword(String password) { this.password = password; }
public String getEmail() { return email; }
public void setEmail(String email) { this.email = email; }
public String getPhone() { return phone; }
public void setPhone(String phone) { this.phone = phone; }
public Integer getAge() { return age; }
public void setAge(Integer age) { this.age = age; }
public BigDecimal getBalance() { return balance; }
public void setBalance(BigDecimal balance) { this.balance = balance; }
}
}
手动校验的延伸用法
- 提取校验工具类:当多个接口需要相同的校验逻辑(如手机号、邮箱校验),可将校验逻辑提取为工具类,避免代码冗余,示例:
package com.example.demo.util;
import java.math.BigDecimal;
import java.util.regex.Pattern;
public class ValidationUtil {
// 正则表达式常量
private static final String EMAIL_REGEX = "^[a-zA-Z0-9_-]+@[a-zA-Z0-9_-]+(\\.[a-zA-Z0-9_-]+)+$";
private static final String PHONE_REGEX = "^1[3-9]\\d{9}$";
// 手机号校验
public static boolean isPhoneValid(String phone) {
if (phone == null || phone.trim().isEmpty()) {
return false;
}
return Pattern.matches(PHONE_REGEX, phone.trim());
}
// 邮箱校验
public static boolean isEmailValid(String email) {
if (email == null || email.trim().isEmpty()) {
return false;
}
return Pattern.matches(EMAIL_REGEX, email.trim());
}
// 金额校验(非负)
public static boolean isAmountValid(BigDecimal amount) {
if (amount == null) {
return false;
}
return amount.compareTo(BigDecimal.ZERO) >= 0;
}
}
- 结合异常处理:手动校验失败时,可抛出自定义异常,由全局异常处理器统一捕获,避免每个接口都返回Map,简化代码(后续全局异常处理章节详细说明)。
优缺点
-
优点:简单易懂,无需依赖任何框架,灵活度极高(可自定义任意复杂校验逻辑),开发成本低(适合小型项目),调试方便;
-
缺点:代码冗余,大量if-else占据业务代码,维护成本高,不适用于参数较多、校验逻辑复杂的场景(如企业级项目的多字段表单),容易出现校验逻辑遗漏,可复用性差。
方式2:JSR380注解校验(主流,适用于大部分场景)
JSR380是Java官方定义的一套数据校验规范(JSR是Java Specification Requests的缩写,即Java规范请求),提供了一系列常用的校验注解(如@NotNull、@NotBlank、@Email等),Spring Boot已默认集成该规范(依赖spring-boot-starter-web即可,无需额外导入依赖),能大幅简化校验代码,实现校验逻辑与业务逻辑分离,是企业开发中最常用的方式,适用于90%以上的常规校验场景。
补充说明:JSR380的实现框架主要有Hibernate Validator(最常用,Spring Boot默认集成),除了实现官方定义的注解,还扩展了一些实用注解(如@NotBlank、@Length等),后续实战均基于Hibernate Validator。
1. 常用校验注解(必记)
整理了开发中最常用的15个注解(含官方注解和Hibernate扩展注解),分类别说明,无需死记硬背,用到时查阅即可,补充注解细节和使用注意事项:
| 类别 | 注解 | 作用 | 适用类型 | 示例 | 注意事项 |
|---|---|---|---|---|---|
| 非空校验 | @NotNull | 校验参数不为null | 所有类型(基本类型、引用类型) | @NotNull(message = “用户ID不能为空”) | 对基本类型无效(如int,默认值为0,永远不为null),需用包装类型 |
| 非空校验 | @NotBlank | 校验字符串不为null、且去除空格后不为空 | String | @NotBlank(message = “用户名不能为空”) | 仅适用于String,自动去除首尾空格后校验 |
| 非空校验 | @NotEmpty | 校验字符串不为null、集合/数组不为空(长度>0) | String、Collection、数组 | @NotEmpty(message = “角色列表不能为空”) | String类型仅校验非null和长度>0,不处理纯空格 |
| 格式校验 | 校验字符串符合邮箱格式 | String | @Email(message = “邮箱格式不正确”) | 可通过regexp属性自定义邮箱正则,适配特殊邮箱格式 | |
| 格式校验 | @Pattern | 校验字符串符合指定正则表达式 | String | @Pattern(regexp = “^1[3-9]\d{9}$”, message = “手机号格式不正确”) | 正则表达式需准确,避免漏洞,可提取为常量 |
| 长度校验 | @Length | 校验字符串长度在指定范围 | String | @Length(min = 2, max = 20, message = “用户名长度2-20位”) | 按字符长度计算,中文算1位 |
| 长度校验 | @Size | 校验集合、数组、字符串的长度在指定范围 | String、Collection、数组 | @Size(min = 1, max = 5, message = “角色列表需1-5个角色”) | 适用于多类型,String类型与@Length功能类似 |
| 数值校验 | @Min | 校验数值不小于指定值 | 数值类型(Integer、Long、BigDecimal等) | @Min(value = 1, message = “年龄不能小于1岁”) | 支持整数和小数,需用包装类型 |
| 数值校验 | @Max | 校验数值不大于指定值 | 数值类型 | @Max(value = 120, message = “年龄不能大于120岁”) | 支持整数和小数,需用包装类型 |
| 数值校验 | @Positive | 校验数值为正数(大于0) | 数值类型 | @Positive(message = “订单金额必须为正数”) | 不包含0,若需包含0用@PositiveOrZero |
| 数值校验 | @Negative | 校验数值为负数(小于0) | 数值类型 | @Negative(message = “退款金额必须为负数”) | 不包含0,若需包含0用@NegativeOrZero |
| 数值校验 | @DecimalMin | 校验小数不小于指定值(支持小数参数) | 数值类型(主要用于BigDecimal) | @DecimalMin(value = “0.01”, message = “订单金额不能小于0.01”) | 适合需要精确小数的场景(如金额) |
| 数值校验 | @DecimalMax | 校验小数不大于指定值(支持小数参数) | 数值类型(主要用于BigDecimal) | @DecimalMax(value = “999999.99”, message = “订单金额不能超过999999.99”) | 适合需要精确小数的场景(如金额) |
| 日期校验 | @Past | 校验日期是过去的时间 | 日期类型(LocalDateTime、Date) | @Past(message = “创建时间必须是过去的时间”) | 不包含当前时间,若需包含用@PastOrPresent |
| 日期校验 | @Future | 校验日期是未来的时间 | 日期类型 | @Future(message = “预约时间必须是未来的时间”) | 不包含当前时间,若需包含用@FutureOrPresent |
2. 实战示例(Spring Boot整合注解校验)
无需复杂配置,只需3步,即可实现参数校验,多场景适配、非必填参数校验、注解组合使用等细节:
步骤1:实体类添加校验注解
package com.example.demo.entity;
import jakarta.validation.constraints.*;
import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDateTime;
@Data
public class UserDTO { // DTO:数据传输对象,用于接收前端请求参数
// 新增时无需校验ID,修改时必须校验ID(后续分组校验会用到)
private Long id;
// 非空校验+长度校验(组合使用)
@NotBlank(message = "用户名不能为空,且不能为纯空格")
@Length(min = 2, max = 20, message = "用户名长度必须在2-20位之间(中文算1位)")
@Pattern(regexp = "^[a-zA-Z0-9\\u4e00-\\u9fa5]+$", message = "用户名仅支持中文、字母、数字")
private String username;
// 非空校验+长度校验(密码用字节长度校验,适配中文)
@NotBlank(message = "密码不能为空,且不能为纯空格")
@Size(min = 6, max = 18, message = "密码长度必须在6-18字节之间(中文占2字节)")
private String password;
// 非空校验+邮箱格式校验(自定义邮箱正则,适配企业邮箱)
@NotBlank(message = "邮箱不能为空,且不能为纯空格")
@Email(regexp = "^[a-zA-Z0-9_-]+@[a-zA-Z0-9_-]+(\\.[a-zA-Z0-9_-]+)+$", message = "邮箱格式不正确,示例:xxx@xxx.com")
private String email;
// 非必填参数:有值则校验格式(手机号)
@Pattern(regexp = "^1[3-9]\\d{9}$", message = "手机号格式不正确,需为11位有效数字", groups = {ValidateGroup.PhoneCheck.class})
private String phone;
// 非空校验+数值范围校验(年龄)
@NotNull(message = "年龄不能为空")
@Min(value = 1, message = "年龄不能小于1岁")
@Max(value = 120, message = "年龄不能大于120岁")
private Integer age;
// 非空校验+金额校验(精确到2位小数)
@NotNull(message = "初始余额不能为空")
@DecimalMin(value = "0.00", message = "初始余额不能为负数")
@DecimalMax(value = "999999.99", message = "初始余额不能超过999999.99")
private BigDecimal balance;
// 日期校验(创建时间必须是过去的时间)
@NotNull(message = "创建时间不能为空")
@Past(message = "创建时间必须是过去的时间")
private LocalDateTime createTime;
// 分组校验接口(提前定义,后续使用)
public interface ValidateGroup {
// 手机号校验分组(非必填,有值则校验)
interface PhoneCheck {}
// 新增分组
interface AddGroup {}
// 修改分组
interface UpdateGroup {}
}
}
步骤2:接口添加@Valid/@Validated注解(触发校验)
在接口的参数前添加@Valid或@Validated注解,告诉Spring Boot对该参数进行校验,两者区别:
-
@Valid:JSR380官方注解,仅触发基础校验,不支持分组校验;
-
@Validated:Spring扩展注解,支持分组校验、批量校验,功能更强大,推荐使用。
package com.example.demo.controller;
import com.example.demo.entity.UserDTO;
import org.springframework.validation.BindingResult;
import org.springframework.validation.annotation.Validated;
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;
import java.util.HashMap;
import java.util.Map;
@RestController
@RequestMapping("/user")
public class UserController {
// 注册接口(注解校验,非必填参数校验+分组校验)
@PostMapping("/register")
public Map<String, Object> register(
// @Validated指定分组,触发手机号校验(有值则校验)
@Validated(UserDTO.ValidateGroup.PhoneCheck.class)
@RequestBody UserDTO userDTO,
BindingResult bindingResult) {
Map<String, Object> result = new HashMap<>();
// 校验失败:获取所有错误信息并返回(优化,之前只返回第一个)
if (bindingResult.hasErrors()) {
StringBuilder errorMsg = new StringBuilder();
bindingResult.getFieldErrors().forEach(fieldError -> {
// 拼接错误信息:参数名 + 错误提示
errorMsg.append(fieldError.getField()).append(":").append(fieldError.getDefaultMessage()).append(";");
});
// 去除最后一个分号
String msg = errorMsg.toString().substring(0, errorMsg.length() - 1);
result.put("code", 400);
result.put("msg", msg);
return result;
}
// 校验通过,处理注册业务
result.put("code", 200);
result.put("msg", "注册成功");
result.put("data", userDTO);
return result;
}
// 修改接口(分组校验,仅校验修改时必填的参数)
@PostMapping("/update")
public Map<String, Object> update(
@Validated(UserDTO.ValidateGroup.UpdateGroup.class)
@RequestBody UserDTO userDTO,
BindingResult bindingResult) {
Map<String, Object> result = new HashMap<>();
if (bindingResult.hasErrors()) {
String errorMsg = bindingResult.getFieldError().getDefaultMessage();
result.put("code", 400);
result.put("msg", errorMsg);
return result;
}
// 处理修改业务
result.put("code", 200);
result.put("msg", "修改成功");
return result;
}
}
步骤3:测试校验效果
通过Postman发送请求,测试以下场景,验证校验效果:
-
核心参数为空(如用户名为空、年龄为空):返回对应错误提示;
-
参数格式错误(如邮箱为xxx、手机号为10位):返回格式错误提示;
-
参数长度超标(如用户名为1位、密码为5字节):返回长度错误提示;
-
数值异常(如年龄为0、余额为负数):返回数值错误提示;
-
非必填参数有值但格式错误(如手机号为10位):返回对应错误提示;
-
多参数错误(如用户名过短+邮箱格式错误):返回所有错误提示。
3. 进阶:全局异常处理(优化错误返回)
上面的示例中,每个接口都需要添加BindingResult获取错误信息,代码仍有冗余。李哥教我用Spring Boot的全局异常处理,统一捕获校验异常,返回标准化的错误响应,无需在每个接口中处理,同时补充自定义业务异常的处理,适配更多场景。
步骤1:定义自定义业务异常
package com.example.demo.exception;
import lombok.Getter;
// 自定义业务异常(用于业务层校验失败)
@Getter
public class BusinessException extends RuntimeException {
// 错误码
private final Integer code;
// 错误提示
private final String msg;
public BusinessException(Integer code, String msg) {
super(msg);
this.code = code;
this.msg = msg;
}
// 静态方法,简化异常抛出
public static void throwException(Integer code, String msg) {
throw new BusinessException(code, msg);
}
}
步骤2:编写全局异常处理器
package com.example.demo.config;
import com.example.demo.exception.BusinessException;
import org.springframework.validation.BindException;
import org.springframework.validation.FieldError;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import java.util.HashMap;
import java.util.Map;
// 全局异常处理器(拦截所有Controller的异常)
@RestControllerAdvice
public class GlobalExceptionHandler {
// 捕获参数校验异常(@Valid/@Validated触发的异常,POST请求JSON参数)
@ExceptionHandler(MethodArgumentNotValidException.class)
public Map<String, Object> handleMethodArgumentNotValidException(MethodArgumentNotValidException e) {
Map<String, Object> result = new HashMap<>();
// 获取所有错误信息,拼接返回
StringBuilder errorMsg = new StringBuilder();
for (FieldError fieldError : e.getBindingResult().getFieldErrors()) {
errorMsg.append(fieldError.getField()).append(":").append(fieldError.getDefaultMessage()).append(";");
}
String msg = errorMsg.toString().substring(0, errorMsg.length() - 1);
result.put("code", 400);
result.put("msg", msg);
result.put("data", null);
return result;
}
// 捕获参数校验异常(表单提交、GET请求参数)
@ExceptionHandler(BindException.class)
public Map<String, Object> handleBindException(BindException e) {
Map<String, Object> result = new HashMap<>();
String errorMsg = e.getBindingResult().getFieldError().getDefaultMessage();
result.put("code", 400);
result.put("msg", errorMsg);
result.put("data", null);
return result;
}
// 捕获自定义业务异常(业务层校验失败)
@ExceptionHandler(BusinessException.class)
public Map<String, Object> handleBusinessException(BusinessException e) {
Map<String, Object> result = new HashMap<>();
result.put("code", e.getCode());
result.put("msg", e.getMsg());
result.put("data", null);
return result;
}
// 捕获其他所有异常(系统异常)
@ExceptionHandler(Exception.class)
public Map<String, Object> handleException(Exception e) {
Map<String, Object> result = new HashMap<>();
// 打印异常堆栈,便于排查问题(生产环境可替换为日志输出)
e.printStackTrace();
result.put("code", 500);
result.put("msg", "系统异常,请联系管理员");
result.put("data", null);
return result;
}
}
步骤3:优化接口代码(移除BindingResult)
优化后,接口中无需再添加BindingResult,代码更简洁,同时支持业务层抛出异常,由全局异常处理器统一处理:
@PostMapping("/register")
public Map<String, Object> register(
@Validated(UserDTO.ValidateGroup.PhoneCheck.class)
@RequestBody UserDTO userDTO) {
Map<String, Object> result = new HashMap<>();
// 业务层校验(示例:用户名是否已存在)
boolean usernameExists = checkUsernameExists(userDTO.getUsername());
if (usernameExists) {
// 抛出自定义业务异常,由全局异常处理器捕获
BusinessException.throwException(400, "用户名已存在,请更换");
}
// 校验通过,处理注册业务
result.put("code", 200);
result.put("msg", "注册成功");
result.put("data", userDTO);
return result;
}
// 模拟业务层校验:用户名是否已存在
private boolean checkUsernameExists(String username) {
// 实际场景中查询数据库
return false; // 模拟不存在
}
4. 注解校验的延伸用法
(1)分组校验进阶(适配多场景)
同一个DTO可能用于多个接口(如新增、修改、查询),不同接口的校验规则不同,可通过分组校验实现,分组继承、多分组组合使用:
// 1. 定义分组接口(支持继承)
public interface ValidateGroup {
// 基础分组(所有接口都需要校验的参数)
interface BaseGroup {}
// 新增分组(继承基础分组,新增额外校验)
interface AddGroup extends BaseGroup {}
// 修改分组(继承基础分组,新增额外校验)
interface UpdateGroup extends BaseGroup {}
}
// 2. 实体类添加分组校验
@Data
public class UserDTO {
// 基础分组:无需校验(所有接口都无需校验)
private String remark;
// 基础分组:必须校验(所有接口都需要校验)
@NotBlank(message = "用户名不能为空", groups = ValidateGroup.BaseGroup.class)
private String username;
// 新增分组:必须校验(仅新增接口需要)
@NotBlank(message = "密码不能为空", groups = ValidateGroup.AddGroup.class)
private String password;
// 修改分组:必须校验(仅修改接口需要)
@NotNull(message = "用户ID不能为空", groups = ValidateGroup.UpdateGroup.class)
private Long id;
}
// 3. 接口指定分组
// 新增接口:校验基础分组+新增分组
@PostMapping("/add")
public Map<String, Object> add(@Validated({ValidateGroup.BaseGroup.class, ValidateGroup.AddGroup.class}) @RequestBody UserDTO userDTO) {
// 新增逻辑
}
// 修改接口:校验基础分组+修改分组
@PostMapping("/update")
public Map<String, Object> update(@Validated({ValidateGroup.BaseGroup.class, ValidateGroup.UpdateGroup.class}) @RequestBody UserDTO userDTO) {
// 修改逻辑
}
(2)批量校验(集合参数校验)
当接口接收集合参数(如批量新增用户、批量修改订单)时,需对集合中的每个元素进行校验,只需在集合前添加@Valid注解即可:
// 批量注册接口(校验集合中的每个UserDTO)
@PostMapping("/batchRegister")
public Map<String, Object> batchRegister(@Valid @RequestBody List<UserDTO> userDTOList) {
Map<String, Object> result = new HashMap<>();
// 校验通过,处理批量注册业务
result.put("code", 200);
result.put("msg", "批量注册成功");
return result;
}
(3)请求参数校验(GET请求、路径参数)
注解校验不仅适用于JSON参数,也适用于GET请求的请求参数、路径参数,只需在参数前添加对应注解,并在Controller类上添加@Validated注解:
@RestController
@RequestMapping("/user")
@Validated // 必须添加,否则GET请求参数校验不生效
public class UserController {
// 路径参数校验(用户ID)
@GetMapping("/{id}")
public Map<String, Object> getUserById(
@PathVariable
@NotNull(message = "用户ID不能为空")
@Min(value = 1, message = "用户ID必须为正数")
Long id) {
Map<String, Object> result = new HashMap<>();
// 处理查询逻辑
result.put("code", 200);
result.put("msg", "查询成功");
return result;
}
// GET请求参数校验(分页查询)
@GetMapping("/list")
public Map<String, Object> getUserList(
@RequestParam
@NotNull(message = "页码不能为空")
@Min(value = 1, message = "页码必须为正数")
Integer pageNum,
@RequestParam
@NotNull(message = "每页条数不能为空")
@Min(value = 1, message = "每页条数必须为正数")
@Max(value = 100, message = "每页条数不能超过100")
Integer pageSize) {
Map<String, Object> result = new HashMap<>();
// 处理分页查询逻辑
result.put("code", 200);
result.put("msg", "查询成功");
return result;
}
}
优缺点
-
优点:代码简洁,无需手动写if-else,校验逻辑与业务逻辑分离,维护成本低,适用于大部分场景(企业级项目主流选择);支持分组校验、批量校验、多场景适配,可扩展性强;
-
缺点:无法满足复杂的自定义校验场景(如密码必须包含字母+数字+特殊字符、两次密码一致、身份证号实名校验);对复杂业务逻辑的校验支持不足,需结合自定义校验或业务层校验。
方式3:自定义校验(进阶,适用于复杂业务场景)
当JSR380的内置注解无法满足业务需求时,就需要自定义校验。比如:密码必须包含字母+数字+特殊字符、两次密码一致、身份证号符合校验规则(含校验码)、用户名不能包含敏感词、订单状态流转合法、自定义数值范围(如0-100且为偶数)等场景,都需要通过自定义校验实现。
自定义校验需要两步:创建自定义校验注解 + 实现校验逻辑(Validator),注解参数、校验逻辑优化、多场景自定义校验案例,适配企业级复杂业务需求。
实战示例1:自定义密码校验(必须包含字母+数字+特殊字符)
步骤1:创建自定义校验注解
package com.example.demo.validator;
import jakarta.validation.Constraint;
import jakarta.validation.Payload;
import java.lang.annotation.*;
// 注解作用范围:字段、方法参数
@Target({ElementType.FIELD, ElementType.PARAMETER})
// 注解生命周期:运行时
@Retention(RetentionPolicy.RUNTIME)
// 指定校验器(后续实现)
@Constraint(validatedBy = PasswordValidator.class)
public @interface PasswordCheck {
// 校验失败提示信息(默认值)
String message() default "密码必须包含字母、数字和特殊字符,长度6-18位";
// 是否需要特殊字符(可配置,默认需要)
boolean needSpecialChar() default true;
// 最小长度(可配置,默认6)
int minLength() default 6;
// 最大长度(可配置,默认18)
int maxLength() default 18;
// 分组校验(可选,用于不同场景的校验)
Class<?>[] groups() default {};
// 负载信息(可选,用于传递额外信息)
Class<? extends Payload>[] payload() default {};
}
步骤2:实现校验逻辑(Validator)
package com.example.demo.validator;
import jakarta.validation.ConstraintValidator;
import jakarta.validation.ConstraintValidatorContext;
import java.util.regex.Pattern;
public class PasswordValidator implements ConstraintValidator<PasswordCheck, String> {
// 特殊字符正则(可配置,提取为常量)
private static final String SPECIAL_CHAR_REGEX = "[!@#$%^&*()_+-=\\[\\]{};':\"\\\\|,.<>/?]";
// 是否需要特殊字符
private boolean needSpecialChar;
// 最小长度
private int minLength;
// 最大长度
private int maxLength;
// 初始化方法:读取注解中的配置参数
@Override
public void initialize(PasswordCheck constraintAnnotation) {
this.needSpecialChar = constraintAnnotation.needSpecialChar();
this.minLength = constraintAnnotation.minLength();
this.maxLength = constraintAnnotation.maxLength();
}
// 核心校验逻辑:返回true表示校验通过,false表示校验失败
@Override
public boolean isValid(String password, ConstraintValidatorContext context) {
// 1. 非空校验(可结合@NotBlank,这里无需重复,避免冗余)
if (password == null || password.trim().isEmpty()) {
return false;
}
String trimPassword = password.trim();
// 2. 长度校验(使用注解配置的参数)
if (trimPassword.length() < minLength || trimPassword.length() > maxLength) {
// 自定义错误提示(覆盖默认提示,显示配置的长度)
context.disableDefaultConstraintViolation();
context.buildConstraintViolationWithTemplate(
"密码长度必须在" + minLength + "-" + maxLength + "位之间"
).addConstraintViolation();
return false;
}
// 3. 校验是否包含字母和数字
boolean hasLetter = false;
boolean hasNumber = false;
for (char c : trimPassword.toCharArray()) {
if (Character.isLetter(c)) {
hasLetter = true;
} else if (Character.isDigit(c)) {
hasNumber = true;
}
}
if (!hasLetter || !hasNumber) {
context.disableDefaultConstraintViolation();
context.buildConstraintViolationWithTemplate(
"密码必须包含字母和数字"
).addConstraintViolation();
return false;
}
// 4. 校验是否包含特殊字符(根据配置决定)
if (needSpecialChar) {
if (!Pattern.matches(".*" + SPECIAL_CHAR_REGEX + ".*", trimPassword)) {
context.disableDefaultConstraintViolation();
context.buildConstraintViolationWithTemplate(
"密码必须包含特殊字符(!@#$%^&*等)"
).addConstraintViolation();
return false;
}
}
return true;
}
}
步骤3:使用自定义校验注解
在实体类字段上添加自定义注解,可根据不同场景配置参数,和内置注解用法一致:
@Data
public class UserDTO {
// 其他字段省略...
// 场景1:默认配置(需要特殊字符,长度6-18位)
@NotBlank(message = "密码不能为空")
@PasswordCheck(message = "密码格式不正确")
private String password;
// 场景2:不需要特殊字符,长度8-20位(如后台管理员密码)
@NotBlank(message = "后台密码不能为空")
@PasswordCheck(
needSpecialChar = false,
minLength = 8,
maxLength = 20,
message = "后台密码必须包含字母和数字,长度8-20位"
)
private String adminPassword;
}
实战示例2:两次密码一致校验(如注册时的密码确认)
这种场景需要校验两个字段的关系,无法通过单个字段的注解实现,需要对整个实体类进行校验,错误提示优化、非空校验联动。
步骤1:创建自定义校验注解(作用于类)
package com.example.demo.validator;
import jakarta.validation.Constraint;
import jakarta.validation.Payload;
import java.lang.annotation.*;
// 注解作用范围:类
@Target({ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = PasswordConfirmValidator.class)
public @interface PasswordConfirm {
// 密码字段名(可配置,默认password)
String passwordField() default "password";
// 确认密码字段名(可配置,默认confirmPassword)
String confirmPasswordField() default "confirmPassword";
// 校验失败提示信息
String message() default "两次密码不一致";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
}
步骤2:实现校验逻辑(校验整个实体类)
package com.example.demo.validator;
import jakarta.validation.ConstraintValidator;
import jakarta.validation.ConstraintValidatorContext;
import org.springframework.util.ReflectionUtils;
import java.lang.reflect.Field;
public class PasswordConfirmValidator implements ConstraintValidator<PasswordConfirm, Object> {
// 密码字段名
private String passwordField;
// 确认密码字段名
private String confirmPasswordField;
@Override
public void initialize(PasswordConfirm constraintAnnotation) {
// 读取注解配置的字段名
this.passwordField = constraintAnnotation.passwordField();
this.confirmPasswordField = constraintAnnotation.confirmPasswordField();
}
// 校验逻辑:通过反射获取两个字段的值,比较是否一致
@Override
public boolean isValid(Object obj, ConstraintValidatorContext context) {
try {
// 1. 通过反射获取密码字段和确认密码字段的值
Field passwordField = obj.getClass().getDeclaredField(this.passwordField);
Field confirmPasswordField = obj.getClass().getDeclaredField(this.confirmPasswordField);
// 设置字段可访问(突破private修饰)
passwordField.setAccessible(true);
confirmPasswordField.setAccessible(true);
// 获取字段值(String类型)
String password = (String) passwordField.get(obj);
String confirmPassword = (String) confirmPasswordField.get(obj);
// 2. 非空校验(依赖@NotBlank注解,这里只需比较,避免重复校验)
if (password == null || confirmPassword == null) {
return false;
}
// 3. 比较两次密码是否一致(去除空格后比较)
return password.trim().equals(confirmPassword.trim());
} catch (NoSuchFieldException | IllegalAccessException e) {
// 反射异常(字段名错误),返回校验失败
e.printStackTrace();
return false;
}
}
}
步骤3:使用注解(作用于实体类)
// 场景1:默认字段名(password和confirmPassword)
@Data
@PasswordConfirm(message = "两次密码不一致") // 作用于类
public class UserDTO {
// 其他字段省略...
@NotBlank(message = "密码不能为空")
@PasswordCheck(message = "密码格式不正确")
private String password;
@NotBlank(message = "确认密码不能为空")
private String confirmPassword;
}
// 场景2:自定义字段名(如pwd和repwd)
@Data
@PasswordConfirm(
passwordField = "pwd",
confirmPasswordField = "repwd",
message = "两次密码不一致"
)
public class AdminDTO {
@NotBlank(message = "密码不能为空")
private String pwd;
@NotBlank(message = "确认密码不能为空")
private String repwd;
}
补充:校验失败提示优化(可选)
默认情况下,两次密码一致校验的错误提示会关联整个实体类,前端无法明确知道是哪个字段出错。可通过修改校验器,指定错误提示关联的字段(如confirmPassword),让提示更精准:
@Override
public boolean isValid(Object obj, ConstraintValidatorContext context) {
try {
Field passwordField = obj.getClass().getDeclaredField(this.passwordField);
Field confirmPasswordField = obj.getClass().getDeclaredField(this.confirmPasswordField);
passwordField.setAccessible(true);
confirmPasswordField.setAccessible(true);
String password = (String) passwordField.get(obj);
String confirmPassword = (String) confirmPasswordField.get(obj);
if (password == null || confirmPassword == null) {
return false;
}
// 密码不一致时,指定错误提示关联确认密码字段
if (!password.trim().equals(confirmPassword.trim())) {
context.disableDefaultConstraintViolation();
// 关联confirmPassword字段,前端可针对性提示
context.buildConstraintViolationWithTemplate(context.getDefaultConstraintMessageTemplate())
.addPropertyNode(confirmPasswordField.getName())
.addConstraintViolation();
return false;
}
return true;
} catch (NoSuchFieldException | IllegalAccessException e) {
e.printStackTrace();
return false;
}
}
实战示例3:身份证号自定义校验(含校验码校验,企业级常用)
身份证号校验属于复杂格式校验,内置注解无法满足(需校验18位长度、前17位为数字、最后一位为数字或X,且校验码符合规则),需通过自定义校验实现,校验码算法,适配18位身份证号(15位可自行扩展)。
步骤1:创建自定义校验注解
package com.example.demo.validator;
import jakarta.validation.Constraint;
import jakarta.validation.Payload;
import java.lang.annotation.*;
// 作用于字段、方法参数
@Target({ElementType.FIELD, ElementType.PARAMETER})
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = IdCardValidator.class)
public @interface IdCardCheck {
String message() default "身份证号格式不正确(需为18位有效身份证号)";
// 分组校验
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
}
步骤2:实现校验逻辑(含校验码算法)
package com.example.demo.validator;
import jakarta.validation.ConstraintValidator;
import jakarta.validation.ConstraintValidatorContext;
public class IdCardValidator implements ConstraintValidator<IdCardCheck, String> {
// 身份证号校验码算法权重
private static final int[] WEIGHTS = {7, 9, 10, 5, 8, 4, 2, 1, 6, 3, 7, 9, 10, 5, 8, 4, 2};
// 校验码对应值(0-10,10对应X)
private static final char[] CHECK_CODES = {'1', '0', 'X', '9', '8', '7', '6', '5', '4', '3', '2'};
@Override
public boolean isValid(String idCard, ConstraintValidatorContext context) {
// 1. 非空校验(结合@NotBlank,避免重复)
if (idCard == null || idCard.trim().isEmpty()) {
return false;
}
String trimIdCard = idCard.trim().toUpperCase(); // 统一转为大写,适配X
// 2. 长度校验(18位)
if (trimIdCard.length() != 18) {
context.disableDefaultConstraintViolation();
context.buildConstraintViolationWithTemplate("身份证号必须为18位")
.addConstraintViolation();
return false;
}
// 3. 前17位为数字校验
for (int i = 0; i < 17; i++) {
char c = trimIdCard.charAt(i);
if (!Character.isDigit(c)) {
context.disableDefaultConstraintViolation();
context.buildConstraintViolationWithTemplate("身份证号前17位必须为数字")
.addConstraintViolation();
return false;
}
}
// 4. 校验码校验(核心逻辑)
int sum = 0;
for (int i = 0; i < 17; i++) {
// 前17位数字乘以对应权重求和
sum += (trimIdCard.charAt(i) - '0') * WEIGHTS[i];
}
// 计算校验码(sum % 11 对应 CHECK_CODES 的索引)
char checkCode = CHECK_CODES[sum % 11];
if (trimIdCard.charAt(17) != checkCode) {
context.disableDefaultConstraintViolation();
context.buildConstraintViolationWithTemplate("身份证号校验码错误,格式不正确")
.addConstraintViolation();
return false;
}
// 所有校验通过
return true;
}
}
步骤3:使用注解
@Data
public class UserDTO {
// 其他字段省略...
@NotBlank(message = "身份证号不能为空")
@IdCardCheck(message = "身份证号格式不正确,请输入18位有效身份证号")
private String idCard;
}
自定义校验的延伸用法(企业级优化)
- 自定义校验结合配置文件:将敏感词、正则表达式、校验规则(如密码长度、身份证号规则)提取到application.yml配置文件,避免硬编码,便于动态修改,示例:
# application.yml
validation:
password:
min-length: 6
max-length: 18
need-special-char: true
sensitive-words: 敏感词1,敏感词2,敏感词3
id-card:
length: 18
// 校验器中读取配置
@Component
public class PasswordValidator implements ConstraintValidator<PasswordCheck, String> {
@Value("${validation.password.min-length}")
private int minLength;
@Value("${validation.password.max-length}")
private int maxLength;
@Value("${validation.password.need-special-char}")
private boolean needSpecialChar;
// 其余逻辑不变,使用配置文件中的参数替代硬编码
}
- 自定义校验分组扩展:结合JSR380的分组校验,让自定义校验适配不同场景(如新增用户需校验身份证号,修改用户无需校验),只需在注解中指定groups参数即可:
@IdCardCheck(message = "身份证号格式不正确", groups = ValidateGroup.AddGroup.class)
private String idCard;
- 复杂业务场景的自定义校验:如订单状态流转校验(已取消订单不能再次支付、已完成订单不能修改),需结合业务数据,可在校验器中注入Service,实现业务逻辑联动(注意:避免校验逻辑过于复杂,影响接口性能):
// 订单状态流转校验注解
@Target({ElementType.FIELD, ElementType.PARAMETER})
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = OrderStatusValidator.class)
public @interface OrderStatusCheck {
String message() default "订单状态流转不合法";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
}
// 校验器(注入Service,查询订单当前状态)
@Component
public class OrderStatusValidator implements ConstraintValidator<OrderStatusCheck, OrderDTO> {
@Autowired
private OrderService orderService;
@Override
public boolean isValid(OrderDTO orderDTO, ConstraintValidatorContext context) {
// 1. 查询订单当前状态
Order order = orderService.getById(orderDTO.getId());
if (order == null) {
context.disableDefaultConstraintViolation();
context.buildConstraintViolationWithTemplate("订单不存在")
.addConstraintViolation();
return false;
}
// 2. 校验状态流转(如当前状态为已取消,不能修改为支付中)
Integer currentStatus = order.getStatus();
Integer targetStatus = orderDTO.getStatus();
if (currentStatus == 3 && targetStatus == 2) { // 3=已取消,2=支付中
context.disableDefaultConstraintViolation();
context.buildConstraintViolationWithTemplate("已取消订单不能再次支付")
.addConstraintViolation();
return false;
}
// 其他状态流转规则...
return true;
}
}
自定义校验的优缺点
-
优点:灵活性极高,可满足任何复杂业务场景的校验需求,适配企业级项目的个性化校验规则;可与JSR380注解校验结合使用,兼顾简洁性和灵活性;可扩展、可配置,复用性强。
-
缺点:开发成本高于注解校验,需编写注解和校验器两个类;校验逻辑复杂时,可能影响接口响应速度;需注意反射、Service注入等细节,避免出现异常。
三、企业级后端数据校验规范(必遵循)
李哥强调,后端数据校验不仅要会用,还要遵循规范,避免因校验不规范导致系统隐患、维护成本增加。结合企业实际开发经验,整理了以下核心规范,覆盖校验全流程:
1. 校验层级规范(明确校验位置,避免重复校验)
后端校验分为3个层级,各司其职,避免重复校验,提升效率:
-
接口层校验(Controller层):负责请求参数的合法性、格式、长度、非空等基础校验,优先使用JSR380注解校验,简单高效;非必填参数有值则校验,无值则跳过。
-
业务层校验(Service层):负责复杂业务逻辑校验(如用户名是否已存在、订单状态流转是否合法、库存是否充足),通过自定义异常抛出校验失败信息,由全局异常处理器统一处理。
-
数据层校验(DAO层):负责数据库层面的校验(如唯一约束、外键约束),避免非法数据写入数据库;可结合数据库约束(如UNIQUE、NOT NULL)和MyBatis的参数校验,双重保障。
2. 错误提示规范(明确、具体,提升用户体验和排查效率)
-
提示信息需包含3个核心要素:参数名 + 违反的规则 + 正确格式/范围(示例:“用户名:长度必须在2-20位之间(中文算1位)”,而非“参数错误”)。
-
错误提示语言简洁、易懂,避免技术术语(如用户端提示“手机号格式不正确,需为11位有效数字”,而非“手机号正则匹配失败”)。
-
多参数错误时,返回所有错误提示(而非仅返回第一个),方便前端一次性提示用户修改,减少请求次数。
-
区分用户端和管理端提示:用户端提示简洁友好,管理端提示可增加更多技术细节(如“用户名包含敏感词【敏感词1】,请修改”),便于排查问题。
3. 校验性能规范(避免影响接口响应速度)
-
校验逻辑尽量简单,避免在校验器中执行数据库查询、远程接口调用、复杂计算(此类逻辑放在业务层)。
-
高频接口的校验逻辑需优化,避免重复创建校验器、重复编译正则表达式(将正则、常量提取为静态常量)。
-
集合参数校验(如批量新增),需控制集合大小(如单次批量不超过100条),避免因校验大量数据导致接口超时。
4. 安全性校验规范(防范数据安全风险)
-
过滤特殊字符:对用户名、备注、评论等字符串参数,过滤SQL注入(如’、"、union等)、XSS攻击(如
5. 代码规范(提升可维护性)
-
校验注解统一管理:相同类型参数的校验规则保持一致(如所有手机号用同一正则、所有密码长度要求一致),避免规则混乱。
-
自定义校验器统一存放:将所有自定义注解和校验器放在validator包下,便于查找和维护。
-
避免硬编码:将正则表达式、敏感词、校验规则(如长度、数值范围)提取为常量或配置文件,便于修改和复用。
-
注释规范:自定义校验器、校验工具类需添加注释,说明校验逻辑、适用场景、参数含义,便于后续维护。
四、避坑指南(新手必看)
结合我自己踩过的坑和李哥的经验,整理了后端数据校验中最常见的6个坑,避免大家重复踩坑:
-
坑1:混淆@NotNull、@NotBlank、@NotEmpty的用法——比如用@NotNull校验String类型(无法校验纯空格),导致纯空格参数通过校验,需根据场景选择对应注解(String类型优先用@NotBlank)。
-
坑2:忽略非必填参数的校验——非必填参数有值时,若格式错误,仍会导致业务异常(如手机号非必填,但传入10位数字),需实现“有值则校验,无值则跳过”。
-
坑3:密码用字符长度校验中文密码——中文占2个字节,若用@Length校验(按字符长度),会导致密码实际长度不足(如6个中文字符,字符长度为6,字节长度为12),需用@Size或手动校验字节长度。
-
坑4:金额用double/float类型校验——double/float存在精度丢失问题(如0.1+0.2≠0.3),金额校验需用BigDecimal类型,结合@DecimalMin、@DecimalMax注解。
-
坑5:GET请求参数校验未添加@Validated——GET请求的路径参数、请求参数,需在Controller类上添加@Validated注解,否则校验不生效。
-
坑6:自定义校验器中注入Service失败——自定义校验器需添加@Component注解,且校验逻辑中避免在initialize方法中使用Service(initialize方法执行时,Service可能未初始化),可在isValid方法中使用。
五、延伸场景(企业级进阶)
除了基础的接口参数校验,后端数据校验还有以下延伸场景,覆盖更多企业开发需求:
1. 第三方接口返回数据校验
调用第三方接口时,第三方返回的数据可能不规范(如字段为空、格式错误),需对返回数据进行校验,避免非法数据进入业务层。可使用JSR380注解或自定义校验,对第三方返回的DTO进行校验,校验失败则记录日志、重试或返回错误提示。
// 第三方接口返回DTO
@Data
public class ThirdPartyUserDTO {
@NotNull(message = "第三方用户ID不能为空")
private String thirdPartyId;
@NotBlank(message = "第三方用户名不能为空")
@Length(min = 2, max = 50, message = "第三方用户名长度2-50位")
private String thirdPartyName;
@Pattern(regexp = "^1[3-9]\\d{9}$", message = "第三方手机号格式不正确")
private String phone;
}
// 调用第三方接口后校验
public void handleThirdPartyData(ThirdPartyUserDTO thirdPartyUserDTO) {
// 使用Validator手动触发校验
ValidatorFactory factory = Validation.buildDefaultValidatorFactory();
Validator validator = factory.getValidator();
Set<ConstraintViolation<ThirdPartyUserDTO>> violations = validator.validate(thirdPartyUserDTO);
if (!violations.isEmpty()) {
// 校验失败,记录日志并处理
StringBuilder errorMsg = new StringBuilder();
violations.forEach(violation -> errorMsg.append(violation.getMessage()).append(";"));
log.error("第三方接口返回数据校验失败:{}", errorMsg);
// 重试或抛出异常
BusinessException.throwException(500, "第三方数据异常,请稍后重试");
}
// 校验通过,处理业务逻辑
}
2. 定时任务参数校验
定时任务触发时,可能会传递参数(如定时同步数据的时间范围、同步数量),需对这些参数进行校验,避免定时任务执行失败。可结合手动校验或注解校验,在定时任务方法执行前进行参数校验。
3. 批量操作校验(优化)
批量操作(如批量删除、批量修改)时,除了校验集合中的每个元素,还需校验集合大小(避免批量操作过多导致系统压力)、批量操作的权限(如批量删除需管理员权限)、批量操作的合法性(如批量删除的订单不能是已支付状态)。
4. 多环境校验规则适配
开发环境、测试环境、生产环境的校验规则可能不同(如开发环境密码长度可放宽至4位,生产环境需6-18位),可通过配置文件+Profile实现多环境适配,无需修改代码。
六、总结
后端数据校验是Java后端开发的基础,也是保障系统安全和健壮性的关键,核心是“凡数据必校验、凡校验必规范”。本文从入门到进阶,讲解了3种核心校验方式,结合实战案例、企业级规范、避坑指南和延伸场景,覆盖了99%以上的开发场景:
-
手动校验:入门级,适用于简单接口、小型项目,灵活但冗余;
-
JSR380注解校验:主流选择,适用于大部分场景,简洁高效,实现校验与业务分离;
-
自定义校验:进阶,适用于复杂业务场景,可满足个性化校验需求。
实际开发中,无需拘泥于单一方式,可结合使用(如注解校验做基础校验,自定义校验做复杂校验,手动校验做补充),同时遵循企业级规范,避开常见坑,才能写出安全、高效、易维护的校验代码。
新手建议:先掌握JSR380注解校验(覆盖大部分场景),再学习自定义校验和全局异常处理,最后熟悉企业级规范,逐步提升自己的校验能力,应对各类开发场景。
更多推荐




所有评论(0)