文章目录
SpringBoot 参数验证
1、为什么要进行参数验证
在日常的接口开发中,为了防止非法参数对业务造成影响,经常需要对接口的参数进行校验,例如登录的时候需要校验用户名和密码是否为空,添加用户的时候校验用户邮箱地址、手机号码格式是否正确。 靠代码对接口参数一个个校验的话就太繁琐了,代码可读性极差。
进行参数验证是软件开发中的一个重要环节,其主要原因包括但不限于以下几点:
- 数据完整性与准确性:确保接收到的数据是完整且准确的,避免因错误或恶意的数据输入导致系统异常或数据损坏。
- 安全防护:防止注入攻击(如SQL注入)、跨站脚本攻击(XSS)等安全威胁,通过验证可以过滤掉非法输入,增强系统安全性。
- 性能优化:提前验证参数可以减少不必要的数据库查询或业务逻辑执行,从而提升系统整体性能。
- 用户体验:及时向用户提供清晰的错误提示,指导他们正确输入信息,避免提交表单后才告知错误,提高了用户体验。
- 代码可维护性:集中处理参数验证逻辑,使得业务逻辑代码更加清晰,易于理解和维护。
- 遵循最佳实践:参数验证是编程和Web开发中的一个基本最佳实践,遵循这些原则可以减少错误和漏洞,提升软件质量。
- 减少异常处理:通过前端和后端的双重验证,可以减少运行时异常的发生,使得程序更加稳定可靠。
- 合规性:对于涉及用户隐私或敏感信息的应用,参数验证也是遵守数据保护法规(如GDPR)的一个重要方面。
因此,参数验证是构建高质量、安全、易用的应用程序不可或缺的一环。
2、验证方式
2.1 if 语句判断
@PostMapping("/parameterCheck") @Operation(summary = "参数校验", description = "嵌套参数校验-测试") public CommonResult<TestDto> parameterCheck(@RequestBody TestDto dto) { if (dto == null){ throw new RuntimeException("参数不能为空"); } return CommonResult.SUCCESS(dto); }
缺点:
- 代码可读性和维护性降低:当if条件复杂或嵌套层次过多时,代码可读性大大降低,使得维护和理解代码变得更加困难。开发者可能需要花费更多时间去梳理逻辑关系。
- 容易出错:复杂的if条件判断容易出现逻辑错误,比如漏写某个条件分支,或条件判断逻辑失误,导致程序行为不符合预期。
- 测试难度增加:if语句尤其是嵌套和多重if的情况下,会生成多个代码路径,这意味着需要编写更多的测试用例来覆盖所有可能的执行路径,增加了测试的复杂度和成本。
- 性能影响:虽然现代编译器会对代码进行优化,但在某些情况下,特别是深度嵌套或大量if判断时,可能会对程序的执行效率产生负面影响,尤其是在循环内部或者高频调用的代码块中。
- 扩展性差:随着需求变化,频繁修改或增减if条件会使代码结构变得混乱,不利于后期的扩展和修改。
- 难以调试:当if逻辑出错时,定位问题可能比较困难,特别是在没有明确错误信息或日志记录的情况下。
因此,在设计代码时,推荐采用诸如策略模式、状态模式等设计模式来替代复杂的if判断,或者使用Switch语句(在适用的情况下)来提高代码的清晰度和可维护性。同时,也可以考虑利用函数式编程的思想,将逻辑分解为更小的、可重用的函数,以提高代码的模块化程度。
2.2 Assert
@PostMapping("/parameterCheck") @Operation(summary = "参数校验", description = "嵌套参数校验-测试") public CommonResult<TestDto> parameterCheck(@RequestBody TestDto dto) { Assert.isNull(dto.getName(), "姓名不能为空"); Assert.isNull(dto.getSex(), "性别不能为空"); return CommonResult.SUCCESS(dto); }
使用Assert
语句进行参数校验在Java等编程语言中较为常见,尤其是在单元测试中用于验证预期结果。然而,在生产代码中过度依赖Assert
进行参数校验也存在一些缺点:
- 非异常处理机制:
Assert
主要用于开发阶段的自我检查,它抛出的是AssertionError
,这是一种错误而非异常。在默认的Java虚拟机设置下,生产环境通常不启用断言(即-ea
标志未设置),这意味着断言不会执行,从而无法起到参数校验的作用。 - 用户体验不佳:即便在启用了断言的环境中,
AssertionError
通常是直接终止程序的,没有被捕获和处理的机制,这会导致程序突然崩溃,给用户带来不友好的体验。 - 缺乏灵活性:
Assert
主要用于验证程序内部不变性条件,其信息更多服务于开发者调试,而不能提供丰富的错误信息反馈或自定义错误处理逻辑。 - 不利于维护和调试:由于
Assert
在生产环境中默认不启用,可能导致某些错误在开发阶段未被发现,而在生产环境中因为不同的配置导致问题浮现,增加了问题排查的难度。 - 不适用于所有类型的应用程序:对于要求高稳定性和错误处理逻辑复杂的应用,直接使用
Assert
进行参数校验并不合适,因为它缺乏控制异常流和提供恢复机制的能力。
2.3 Validator
Validator
框架就是为了解决开发人员在开发的时候少写代码,提升开发效率;Validator专门用来进行接口参数校验,例如常见的必填校验,email格式校验,用户名必须位于6到12之间等等。
2.3.1 引入依赖
注意:如果spring-boot版本小于2.3.x,spring-boot-starter-web会自动传入hibernate-validator依赖。如果spring-boot版本大于2.3.x,则需要手动引入依赖。我这里使用的SpringBoot版本是3.0.0,因此手动引入了。
<!-- 如果spring-boot版本小于2.3.x,spring-boot-starter-web会自动传入hibernate-validator依赖。如果spring-boot版本大于2.3.x,则需要手动引入依赖 --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-validation</artifactId> </dependency>
2.3.2 定义参数实体类
常见的约束注解如下:
注解 | 功能 |
---|---|
@AssertFalse | 可以为null,如果不为null的话必须为false |
@AssertTrue | 可以为null,如果不为null的话必须为true |
@DecimalMax | 设置不能超过最大值 |
@DecimalMin | 设置不能超过最小值 |
@Digits | 设置必须是数字且数字整数的位数和小数的位数必须在指定范围内 |
@Future | 日期必须在当前日期的未来 |
@Past | 日期必须在当前日期的过去 |
@Max | 最大不得超过此最大值 |
@Min | 最大不得小于此最小值 |
@NotNull | 不能为null,可以是空 |
@Null | 必须为null |
@Pattern | 必须满足指定的正则表达式 |
@Size | 集合、数组、map等的size()值必须在指定范围内 |
必须是email格式 | |
@Length | 长度必须在指定范围内 |
@NotBlank | 字符串不能为null,字符串trim()后也不能等于"" |
@NotEmpty | 不能为null,集合、数组、map等size()不能为0;字符串trim()后可以等于"" |
@Range | 值必须在指定范围内 |
@URL | 必须是一个URL |
import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.Valid; import jakarta.validation.constraints.*; import lombok.AllArgsConstructor; import lombok.Data; import lombok.NoArgsConstructor; @Data @AllArgsConstructor @NoArgsConstructor public class TestDto { @NotBlank(message = "姓名不能为空") @Schema(description = "姓名") private String name; @NotNull(message = "年龄不能为空") @Schema(description = "年龄") @Min(value = 0, message = "年龄不能小于0") @Max(value = 200, message = "年龄不能大于200") private Integer age; //性别只允许为男或女 @NotBlank(message = "性别不能为空") @Pattern(regexp = "^(男|女)$", message = "性别必须为'男'或'女'") @Schema(description = "性别") private String sex; @Valid @Schema(description = "嵌套对象") private TestDtoObj testDtoObj; }
import com.example.demo.annotation.PhoneNumber; import com.fasterxml.jackson.annotation.JsonFormat; import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.*; import lombok.AllArgsConstructor; import lombok.Data; import lombok.NoArgsConstructor; import org.hibernate.validator.constraints.URL; import java.time.LocalDate; import java.time.LocalDateTime; @Data @AllArgsConstructor @NoArgsConstructor public class TestDtoObj { @PhoneNumber @NotBlank(message = "手机号1不能为空") @Schema(description = "手机号1") private String phone1; @Pattern(regexp = "^1[3-9]\\d{9}$", message = "无效的手机号码格式") @NotBlank(message = "手机号不能为空") @Schema(description = "手机号2") private String phone2; @NotBlank(message = "密码不能为空") @Size(min = 6, max = 16, message = "密码长度必须在6到16个字符之间") @Schema(description = "密码") private String password; @NotBlank(message = "邮箱不能为空") @Pattern(regexp = "^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$", message = "邮箱格式不正确") @Schema(description = "邮箱") private String email; @Digits(integer = 4, fraction = 2, message = "整数位数必须在4位以内小数位数必须在2位以内") @Schema(description = "小数") private Double num; @URL(message = "url格式错误") @Schema(description = "地址") private String url; @Past(message = "日期必须为过去日期") @Schema(description = "过去日期") private LocalDate pastDate; @Future(message = "日期必须为将来日期") @Schema(description = "将来日期") @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss",timezone = "GMT+8") private LocalDateTime futureDate; }
2.3.4 定义特定异常全局拦截方法
Validator
框架 抛出的特定异常为MethodArgumentNotValidException,该异常会将我们在参数校验注解自定义的message返回到e.getBindingResult().getFieldError().getDefaultMessage()中。
/** * 全局异常拦截 * * @author zyw */ @Slf4j @RestControllerAdvice public class BaseExceptionHandler { /** * 拦截参数校验异常 * @param e * @param request * @return */ @ExceptionHandler(MethodArgumentNotValidException.class) public CommonResult<?> handleGlobalException(MethodArgumentNotValidException e, HttpServletRequest request) { log.error("请求地址'{}',发生系统异常'{}'", request.getRequestURI(), e.getBindingResult().getFieldError().getDefaultMessage()); return CommonResult.ECEPTION(ResultCode.PARAMETER_EXCEPTION, e.getBindingResult().getFieldError().getDefaultMessage()); } }
2.3.5 定义校验类进行测试
import com.example.demo.config.CommonResult; import com.example.demo.model.dto.TestDto; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.enums.ParameterIn; import io.swagger.v3.oas.annotations.tags.Tag; import lombok.extern.slf4j.Slf4j; import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.*; @RestController @Slf4j @RequestMapping("knife4j") @Tag(name = "knife4j测试控制器") public class Knife4jController { @PostMapping("/parameterCheck") @Operation(summary = "参数校验", description = "嵌套参数校验-测试") public CommonResult<TestDto> parameterCheck(@Validated @RequestBody TestDto dto) { return CommonResult.SUCCESS(dto); } }
2.3.6 测试
2.4 自定义验证注解
在Java项目中,自定义注解是一种强大的功能,允许开发者创建自己的注解类型来满足特定需求,比如验证、日志记录、性能监控等。我们通过自定义注解修饰特定的接口、方法、属性、类,可以实现更加灵活的功能。
2.4.1 定义自定义注解
import com.example.demo.uitls.validator.PhoneNumberValidator; import jakarta.validation.Constraint; import jakarta.validation.Payload; import java.lang.annotation.*; /** * PhoneNumber : 手机号格式验证注解 * 用于验证电话号码格式的注解。 * 该注解可以应用于字段或参数上,以验证其是否为有效的电话号码格式。 * 默认的错误消息是“无效的手机号码格式”,但可以通过message属性自定义。 * 可以通过groups和payload属性来支持分组验证和负载信息。 * * @Documented 标记此注解将被包含在文档中。 * @Constraint 标记此注解为约束注解,并指定PhoneNumberValidator类作为验证器。 * @Target 指定此注解可以应用于字段和参数上。 * @Retention 指定此注解在运行时保留。 * @author zyw * @create 2024-05-31 15:38 */ @Documented @Constraint(validatedBy = PhoneNumberValidator.class) @Target({ElementType.FIELD, ElementType.PARAMETER}) @Retention(RetentionPolicy.RUNTIME) public @interface PhoneNumber { /** * 验证失败时的错误消息,默认为“无效的手机号码格式”。 * 可以通过将此属性设置为自定义错误消息来更改默认消息。 * * @return 验证失败时的错误消息。 */ String message() default "无效的手机号码格式"; /** * 定义验证的分组,默认为空组。 * 可以通过将此属性设置为一个或多个分组类来指定字段应在哪些分组中进行验证。 * * @return 验证的分组类数组。 */ Class<?>[] groups() default {}; /** * 定义验证的负载信息,默认为空负载。 * 可以通过将此属性设置为一个或多个负载类来携带额外的验证信息。 * * @return 验证的负载信息类数组。 */ Class<? extends Payload>[] payload() default {}; }
2.4.2 定义自定义验证器类
import com.example.demo.annotation.PhoneNumber; import jakarta.validation.ConstraintValidator; import jakarta.validation.ConstraintValidatorContext; /** * PhoneNumberValidator : 手机号验证器类 * * @author zyw * @create 2024-05-31 15:39 */ public class PhoneNumberValidator implements ConstraintValidator<PhoneNumber, String> { private static final String PHONE_PATTERN = "^1[3-9]\\d{9}$"; // 中国手机号码的简单正则表达式 @Override public boolean isValid(String phoneNumber, ConstraintValidatorContext context) { return phoneNumber != null && phoneNumber.matches(PHONE_PATTERN); } }
2.4.3 应用自定义注解
import com.example.demo.annotation.PhoneNumber; import com.fasterxml.jackson.annotation.JsonFormat; import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.*; import lombok.AllArgsConstructor; import lombok.Data; import lombok.NoArgsConstructor; import org.hibernate.validator.constraints.URL; import java.time.LocalDate; import java.time.LocalDateTime; /** * TestDtoObj : * * @author zyw * @create 2024-05-31 15:47 */ @Data @AllArgsConstructor @NoArgsConstructor public class TestDtoObj { @PhoneNumber @PhoneNumber(message = "手机号格式错误") @Schema(description = "手机号1") private String phone1; @Pattern(regexp = "^1[3-9]\\d{9}$", message = "无效的手机号码格式") @NotBlank(message = "手机号不能为空") @Schema(description = "手机号2") private String phone2; }
*/
@Data
@AllArgsConstructor
@NoArgsConstructor
public class TestDtoObj {
@PhoneNumber @PhoneNumber(message = "手机号格式错误") @Schema(description = "手机号1") private String phone1; @Pattern(regexp = "^1[3-9]\\d{9}$", message = "无效的手机号码格式") @NotBlank(message = "手机号不能为空") @Schema(description = "手机号2") private String phone2;
}