后端接口没做参数检验导致服务雪崩,被批评代码健壮性太差......

1,153 阅读7分钟

1.背景

首先,我这里想先说下心里活动,后端接口参数检验这个知识点比较基础,是每个Java后端开发必须掌握的“入坑”技能,哦不,是入门技能,所以我之前都有点不太愿意花时间精力去总结。但是无奈前不久发生了一个血淋淋的事故,情况大概是这样,前端在某种情况下会触发某个参数传空的情况,然后后端是用这参数去数据库查询数据的,如果这个参数为空,就会查出全表数据百万级别加载到内存中导致频繁fullgc,最终内存OOM服务不可用。

复盘原因的时候被领导无情地吐槽我们后端团队写的代码健壮性不咋地~~~只能默默抗下了所有。其实刚刚工作的时候,当初的部门leader就给我说过一句话,后端写接口要按照是对外提供的open接口级别进行开发,要做到严谨、安全、可靠,因为外部一切资源都是不值得信任的。但大部分的开发随着时光荏苒,岁月蹉跎,团队换了一茬又一茬,项目做了一个又一个,当年的初心可能早已不再,大家都随波逐流了。所以今天痛定思痛,总结下后端接口参数校验的相关知识点和小伙伴们分享下。因为参数没传后端没检验的报错比比皆是,什么空指针、写入数据库报错等等,参数没检验后端接口报错后端就得背锅,这跑不脱的。

由此看来,在现代Web应用开发中,参数校验是保证系统健壮性和安全性的重要环节。Spring Boot提供了强大的参数校验机制,可以帮助开发者轻松实现各种校验需求。本文将全面介绍Spring Boot中的参数校验,从基础使用到实践掌握。

2.Spring Boot接口参数校验

2.1 手动检验

在使用框架组件进行参数检验之前,先来看看在代码中手动校验数据,直接手写 if-else 来做这些基础校验:

@RestController
@RequestMapping("/user")
public class UserController {
    @Resource
    private UserService userService;
​
    @PostMapping
    public void addUser(@RequestBody UserParam userParam) {
        if (StringUtils.isBlank(userParam.getUserNo())) {
            throw new BizException("用户名不能为空");
        }
        if (StringUtils.isBlank(userParam.getName())) {
            throw new BizException("姓名不能为空");
        }
        if (StringUtils.isBlank(userParam.getPhone())) {
            throw new BizException("手机号不能为空");
        }
        if (StringUtils.isBlank(userParam.getUserNo())) {
            throw new BizException("账号不能为空");
        }
        if (Objects.isNull(userParam.getGender())) {
            throw new BizException("性别不能为空");
        }
    }
}

可以看出手动检验参数少一点还好,多一点就会带来代码冗余、错误处理的一致性以及业务规则的维护等问题。通过引入 Spring Validator,我们能够有效解决这些痛点,提高代码的可读性、可维护性,并确保校验逻辑的一致性。

2.2 整合Spring Validator组件检验参数

在项目中添加校验相关的依赖:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-validation</artifactId>
</dependency>

直接来看检验接口参数的相关注解信息如下:

分类注解说明适用类型
空值检查@NotNull验证对象是否为null任意类型
@Null验证对象必须为null任意类型
@NotBlank验证字符串不为空且长度>0(至少一个非空格字符)String
@NotEmpty验证对象不为null且不为空(集合/数组长度>0,字符串长度>0)String, Collection, Map, Array
布尔检查@AssertTrue验证布尔值为trueboolean, Boolean
@AssertFalse验证布尔值为falseboolean, Boolean
长度检查@Size(min, max)验证对象长度在范围内(字符串/集合/数组)String, Collection, Map, Array
@Length(min, max)Hibernate扩展,验证字符串长度String
数值检查@Min(value)验证数字最小值(含边界)数字类型(int, long等)
@Max(value)验证数字最大值(含边界)数字类型
@DecimalMin(value)验证小数值最小值(含边界,可配置是否包含)BigDecimal, BigInteger, String
@DecimalMax(value)验证小数值最大值(含边界,可配置是否包含)BigDecimal, BigInteger, String
@Digits(integer, fraction)验证数字整数位和小数位长度数字类型
@Positive验证数字为正数(不包括0)数字类型
@PositiveOrZero验证数字为正数或0数字类型
@Negative验证数字为负数(不包括0)数字类型
@NegativeOrZero验证数字为负数或0数字类型
日期检查@Past验证日期是否在当前时间之前Date, LocalDate等
@PastOrPresent验证日期是否在当前时间或之前Date, LocalDate等
@Future验证日期是否在当前时间之后Date, LocalDate等
@FutureOrPresent验证日期是否在当前时间或之后Date, LocalDate等
格式检查@Email验证字符串是否符合邮箱格式String
@Pattern(regex)验证字符串是否符合正则表达式String
@URL验证字符串是否符合URL格式String
@CreditCardNumber验证字符串是否符合信用卡格式(Luhn算法)String
集合检查@Valid对集合/数组中的每个元素进行验证Collection, Array
级联检查@Valid对对象属性进行级联验证自定义对象
特殊检查@Range(min, max)Hibernate扩展,验证数值在范围内数字类型
@SafeHtmlHibernate扩展,验证字符串不包含恶意HTMLString
@ScriptAssert类级别验证,通过脚本表达式验证

2.2.1 常规校验

接下来就来看看使用注解校验参数有多简单,我们可以根据上面列出来的注解做对应的参数校验

@Data
public class UserParam {
    private Long id;
    @NotBlank(message = "用户名不能为空")
    @Size(min = 8, max = 16, message = "长度必须在8~16个字符之间")
    private String userNo;
    @NotBlank(message = "姓名不能为空")
    @Size(max = 32, message = "姓名不能超过32个字符")
    private String name;
    @NotNull(message = "性别不能为空")
    private Integer gender;
    @Past(message = "出身日期必须在当前日期之前")
    private Date birthday;
    @Email(message = "邮箱格式不对")
    private String email;
    @Pattern(regexp = "^(13[0-9]|14[01456879]|15[0-35-9]|16[2567]|17[0-8]|18[0-9]|19[0-35-9])\d{8}$", message = "手机号格式不对")
    private String phone;
}
​

接口调整为:在参数对象加上@Validated注解就能实现自动参数校验,当然了使用@Valid也是可以的

@PostMapping
public void addUser(@RequestBody @Validated UserParam userParam) {
    System.out.println(userParam);
}

在postman调接口http://127.0.0.1:18000/user, body传参如下:

{
    "name":"张三",
    "gender":0
}

日志报错如下:

[common-demo] [] [2025-05-07 16:38:47.139] [WARN] [http-nio-18000-exec-2@2165] org.springframework.web.servlet.mvc.support.DefaultHandlerExceptionResolver logException: Resolved [org.springframework.web.bind.MethodArgumentNotValidException: Validation failed for argument [0] in public void com.shepherd.basedemo.controller.UserController.addUser(com.shepherd.basedemo.param.UserParam): [Field error in object 'userParam' on field 'userNo': rejected value [null]; codes [NotBlank.userParam.userNo,NotBlank.userNo,NotBlank.java.lang.String,NotBlank]; arguments [org.springframework.context.support.DefaultMessageSourceResolvable: codes [userParam.userNo,userNo]; arguments []; default message [userNo]]; default message [用户名不能为空]] ]
​

接口返回:

{
    "timestamp": "2025-05-07 16:38:47",
    "status": 400,
    "error": "Bad Request",
    "path": "/user"
}

如果校验失败,会抛出MethodArgumentNotValidException或者ConstraintViolationException异常。在实际项目开发中,通常会用统一异常处理来返回一个更友好的提示,关于统一结果格式封装和全局异常统一处理,请看之前我们总结的:

Spring Boot如何优雅实现结果统一封装和异常统一处理

这里简单看看统一处理参数校验异常:

@ControllerAdvice
@Slf4j
public class GlobalExceptionHandler {
​
    /**
     * 全局异常处理
     * @param e
     * @return
     */
    @ResponseBody
    @ResponseStatus(HttpStatus.BAD_REQUEST)
    @ExceptionHandler(Exception.class)
    public ResponseVO exceptionHandler(Exception e){
        // 处理业务异常
        if (e instanceof BizException) {
            BizException bizException = (BizException) e;
            if (bizException.getCode() == null) {
                bizException.setCode(ResponseStatusEnum.BAD_REQUEST.getCode());
            }
            return ResponseVO.failure(bizException.getCode(), bizException.getMessage());
        } else if (e instanceof MethodArgumentNotValidException) {
            // 参数检验异常
            MethodArgumentNotValidException methodArgumentNotValidException = (MethodArgumentNotValidException) e;
            Map<String, String> map = new HashMap<>();
            BindingResult result = methodArgumentNotValidException.getBindingResult();
            result.getFieldErrors().forEach((item)->{
                String message = item.getDefaultMessage();
                String field = item.getField();
                map.put(field, message);
            });
            log.error("数据校验出现错误:", e);
            return ResponseVO.failure(ResponseStatusEnum.BAD_REQUEST, map);
        }else if (e instanceof ConstraintViolationException) {
            log.error("数据校验出现错误:", e);
            return ResponseVO.failure(ResponseStatusEnum.BAD_REQUEST, e.getMessage());
        }else if (e instanceof HttpRequestMethodNotSupportedException) {
            log.error("请求方法错误:", e);
            return ResponseVO.failure(ResponseStatusEnum.BAD_REQUEST.getCode(), "请求方法不正确");
        } else if (e instanceof MissingServletRequestParameterException) {
            log.error("请求参数缺失:", e);
            MissingServletRequestParameterException ex = (MissingServletRequestParameterException) e;
            return ResponseVO.failure(ResponseStatusEnum.BAD_REQUEST.getCode(), "请求参数缺少: " + ex.getParameterName());
        } else if (e instanceof MethodArgumentTypeMismatchException) {
            log.error("请求参数类型错误:", e);
            MethodArgumentTypeMismatchException ex = (MethodArgumentTypeMismatchException) e;
            return ResponseVO.failure(ResponseStatusEnum.BAD_REQUEST.getCode(), "请求参数类型不正确:" + ex.getName());
        } else if (e instanceof NoHandlerFoundException) {
            NoHandlerFoundException ex = (NoHandlerFoundException) e;
            log.error("请求地址不存在:", e);
            return ResponseVO.failure(ResponseStatusEnum.NOT_EXIST, ex.getRequestURL());
        } else {
            //如果是系统的异常,比如空指针这些异常
            log.error("【系统异常】", e);
            return ResponseVO.failure(ResponseStatusEnum.SYSTEM_ERROR.getCode(), ResponseStatusEnum.SYSTEM_ERROR.getMsg());
        }
    }
}
​

添加全局异常统一处理之后再次调接口,返回结果如下:

{
    "code": 400,
    "msg": "Bad Request",
    "data": {
        "userNo": "用户名不能为空"
    }
}

是不是直接明了多了,关于参数的其他的验证,你可以一一输入参数慢慢去验证,这里碍于篇幅问题,就不赘述了。

2.2.2 requestParam/PathVariable参数校验

GET请求一般会使用requestParam/PathVariable传参,在这种情况下,必须在Controller类上标注@Validated注解,并在入参上声明约束注解(如@Min等) 。如果校验失败,会抛出ConstraintViolationException异常。代码示例如下:

    @GetMapping("/{userId}")
    public void detail(@PathVariable("userId") @Min(value = 1L, message = "userId必须大于0") Long userId) {
        System.out.println(userId);
    }
​
    @GetMapping("/info")
    public void getUserInfo(@RequestParam("userId") @Max(value = 10L, message = "userId必须不超过10") Long userId) {
        System.out.println(userId);
    }

2.2.3 嵌套检验

上面的示例中,入参都比较简单,但是当入参中包括其他对象参数,我们要对此对象进行参数检验,也就是嵌套检验,这时候就只能使用@Valid进行级联校验了,注意,并不能使用@Validated,这是这两个注解的一大区别:

**@Valid@Validated**区别

区别@Valid@Validated
提供者JSR-303规范Spring
是否支持分组不支持支持
标注位置METHOD, FIELD, CONSTRUCTOR, PARAMETER, TYPE_USETYPE, METHOD, PARAMETER
嵌套校验支持不支持

我们在上面的userParam里面增加了一个地址集合字段:

@NotEmpty(message = "地址不能为空")
@Valid
private List<Address> addressList;

地址对象address要求省份和城市不能为空:

@Data
public class Address {
    @NotBlank(message = "省份不能为空")
    private String province;
    @NotBlank(message = "城市不能为空")
    private String city;
    private String region;
    private String address;
}
​

再次调用上面新增用户的接口,入参如下:

{
    "userNo":"zfj-001000",
    "name":"张三",
    "gender":0,
    "addressList":[
        {
            "province":"浙江省"
        }
​
    ]
}

返回结果如下:

{
    "code": 400,
    "msg": "Bad Request",
    "data": {
        "addressList[0].city": "城市不能为空"
    }
}

2.2.4 分组检验

在实际项目开发中,可能有多个接口方法使用同一个类来接收参数,比如说新增用户、更新用户、删除用户等操作,每个操作对入参字段的要求都不一样,比如在用户创建时可能强调用户名和密码的合法性,而在用户更新时可能更关心其他信息的完整性。如果我们针对每个操作单独新建一个入参类来接收参数单独检验这是没问题的,但是随着业务场景的多变性,会造成类的膨胀,业务的重复实现,难以维护。这也是很多后端开发不想做接口检验的主要原因,不做检验一个公共入参类随便你拿去接收相关接口的参数。为了解决这个不同操作分组问题,spring-validation提供的@Validated支持了分组校验的功能,专门用来解决这类问题,这里就不能用@Valid,这也是两者的一大区别。

比如上面的userParam,在新增用户的时候用户名userNo不能为空,在更新用户时id不能为空,如下所示:

@Data
public class UserParam {
    @NotNull(message = "id不能为空", groups = {Update.class})
    private Long id;
    @NotBlank(message = "用户名不能为空", groups = {Insert.class})
    @Size(min = 8, max = 16, message = "长度必须在8~16个字符之间")
    private String userNo;
    @NotBlank(message = "姓名不能为空")
    @Size(max = 32, message = "姓名不能超过32个字符")
    private String name;
    @NotNull(message = "性别不能为空")
    private Integer gender;
    @Past(message = "出身日期必须在当前日期之前")
    private Date birthday;
    @Email(message = "邮箱格式不对")
    private String email;
    @Pattern(regexp = "^(13[0-9]|14[01456879]|15[0-35-9]|16[2567]|17[0-8]|18[0-9]|19[0-35-9])\d{8}$", message = "手机号格式不对")
    private String phone;
​
    @NotEmpty(message = "地址不能为空")
    @Valid
    private List<Address> addressList;
​
​
    public interface Insert{};
​
    public interface  Update{};
​
}

接口调整为:

  @PostMapping
  public void addUser(@RequestBody @Validated({UserParam.Insert.class}) UserParam userParam) {
      System.out.println(userParam);
  }
​
  @PutMapping
  public void updateUser(@RequestBody @Validated(UserParam.Update.class) UserParam userParam) {
      System.out.println(userParam);
  }

调用接口发现userNoid是能分别在新增,更新两个场景下单独检验了,但是姓名、性别字段竟然不校验了,我的本意是这些字段不论是新增还是编辑都要检验,这可咋整?其实问题就出在我们的分组定义上。

    public interface Insert extends Default {};
​
    public interface  Update extends Default {};

我们需要在定义分组的时候继承默认分组,这样就能实现指定了分组的校验规则,分别在对应的分组校验中生效,没有指定分组使用默认分组Default,即对所有的校验都生效。也就是使用@Validated({UserParam.Insert.class}) UserParam userParam代表着Insert分组包括了Default分组了

3.总结

至此,Spring Boot如何优雅实现后端接口参数检验实践教程已经讲述完了,Spring Boot提供了强大而灵活的参数校验机制,从简单的字段校验到复杂的自定义校验都能很好支持。合理使用参数校验可以:

  • 提高代码的健壮性
  • 减少大量的样板代码
  • 统一校验逻辑和错误处理
  • 提高API文档的准确性

在实际项目中,建议将校验逻辑集中在参数传输DTO层,保持业务逻辑的纯净性,同时结合全局异常处理提供友好的错误信息。

最后的最后,这里放个彩蛋,这里的总结对参数校验完全够用了,但是我们一般都要知其然知其所以然,肯定要知道其实现原理、高级自定义实现,多语言环境整合等等,我会在下一篇文章中详细奉上,敬请期待~~~