用SpringBoot写接口时,参数校验的5种实用写法
一个深夜同事老张在生产环境盯着告警群脸色铁青。他的接口收到一个age-1的请求数据库里的积分字段被扣成了负数。事后问原因他说“我明明在实体类写了Min(0)但那个接口没生效啊。” 这类问题在SpringBoot开发中太常见了——不是校验不强大而是大多数人只用了参数校验的“默认姿势”却不知道它还有“隐藏技能”。今天不谈理论直接盘一盘我在实际项目中反复打磨出的5种实用写法每一条都会告诉你适用场景和容易踩的坑。注解驱动的基础校验——Bean Validation的默认姿势最简单也最常用的写法就是用javax.validation包下的注解比如NotNull、Size、Min直接标在实体类字段上然后在Controller方法参数前加Valid或Validated。这条套路处理“单个对象”的场景游刃有余。一旦你开始依赖它就必须明白Valid和Validated并不完全等价——前者来自标准JSR-303后者是Spring的封装多了分组和能力。比如你在Controller写public String save(Valid RequestBody User user)如果User里的嵌套对象没有级联校验Spring只会校验当前对象嵌套对象里的规则默认为空。真正的坑在于很多人以为写了Valid就万事大吉结果遇到ListUser参数时校验规则全部失效——除非在集合字段上再标一个Valid否则Spring不会递归校验子元素。这套写法的最大优点是零成本团队协作时一眼就能看懂。但它有个致命伤业务校验逻辑和实体类强耦合。比如创建用户时password不能为空更新用户时password可以为空你总不能为了这个需求把字段的NotNull去掉吧于是分组校验应运而生。分组校验——不同场景不同规则分组校验是Validated的独家本领Valid不支持。你只需要定义两个空接口比如CreateGroup和UpdateGroup然后在字段注解上写NotNull(groups CreateGroup.class)在Controller方法参数上用Validated(CreateGroup.class)指定当前要启用哪个分组。这就解决了“同一个实体在不同接口里规则不同”的难题。但分组校验有个极易被忽视的副作用当你指定了分组未标注groups的字段默认属于Default组就不会被校验。很多新手在这里翻车因为你在UpdateGroup里想校验id非空结果发现NotNull忘了加groups于是id居然可以为空。解决方法是要么把所有字段的groups都写清楚要么在分组接口上继承Default例如public interface UpdateGroup extends javax.validation.groups.Default {}这样未分组的字段依然走默认校验逻辑。分组校验的另一个实用场景是“复用一个实体做新增和修改”。我曾经维护过一个老系统用户DTO有十多个字段新增时必须全部填修改时只需填部分。如果不分组就得写两个DTO或者用null判断——狼狈至极。分组校验干净利落唯一的代价是你的实体类注解会变得很热闹每个字段少则一个注解多则三四个。阅读体验会下降但换来的是极高的可维护性。自定义校验注解——当内置约束不够用假设你要校验“手机号必须是合法大陆号码”或者“订单状态必须符合特定枚举”内置注解显然不够。这时自定义注解就是最佳解法。步骤是先定义一个注解比如Phone用Constraint(validatedBy PhoneValidator.class)指认校验器再写一个类实现ConstraintValidatorPhone, String在isValid里写你的业务逻辑。这是参数校验的“升降级”机制——从声明式校验提升到可编码的领域校验。需要注意的是自定义注解里的message属性最好用国际化key别写死中文否则将来多语言适配时你会想哭。自定义注解最大的价值不是让你炫技而是把“业务规则”沉淀为“可复用的元数据”比如CheckOrderStatus就能在三个接口里反复使用而不是每个方法都写一遍 if-else。自定义校验器还支持注入Spring Bean比如你可以在Validator里调用UserService查库。但这里有个性能陷阱如果isValid方法里做了耗时的数据库操作而你把这个注解标在了一个批处理接口上每个元素都会触发一次查询。我的建议是自定义校验器只做纯内存逻辑正则、枚举、集合判断涉及数据库的规则移步到服务层做手动校验。如果你非要在校验器里查库至少加个缓存或者明确告诉调用方——这个接口的校验代价很高。编程式校验——把主动权握在手里前三种写法都是“声明式”适合规则固定的场景。但当校验逻辑需要依赖多个字段的相互关系例如“当typeA时code不能为空否则code必须为空”声明式注解就变得非常笨拙你可能会写出AssertTrue配合一个布尔方法但那种写法可读性极差。这时候就该编程式校验上场了——你在Service层注入javax.validation.Validator手动调用validator.validate(obj)返回一组ConstraintViolation然后遍历抛出业务异常。编程式校验的真正优势在于你可以精确控制校验的时机、中断逻辑和错误收集方式。比如一个批量导入接口你希望校验所有行收集所有错误一次性返回给前端而不是遇到第一行错误就中断。用声明式注解做不到但编程式校验轻松应对循环importRows每行都validate将错误信息拼接成Map。另一种编程式写法是使用BindingResult在Controller方法参数里紧挨着Valid对象后面放一个BindingResultSpring就不会自动抛异常而是把错误塞进这个对象里。你可以手动判断hasErrors()然后做自定义响应。这招在处理“校验错误提示需要按字段分组显示”时特别好用。但要注意BindingResult必须紧跟在校验对象后面中间不能有其他参数否则Spring会报“Parameter with that position [1] not found”之类的错误这是很多人踩烂的坑。使用Spring的Validator接口——老派但稳固的选择除了标准的Bean ValidationSpring还提供了org.springframework.validation.Validator接口。你可以实现它重写supports和validate方法然后在Controller或Service里调用。这种写法在SpringBoot早期版本中很常见现在被Bean Validation的光芒掩盖了但它依然有价值——比如当你需要一个“横切多个实体”的校验器时比如校验“用户订单地址”组合规则写在某个实体的注解里都不合适写一个独立的OrderValidator反而干净。不过更常见的用法是配合InitBinder在Controller层面注入自定义校验器让某个页面独享特殊的校验逻辑。这里不得不提Validated在类级别上的妙用——你可以把Validated标在Controller类上然后在方法上直接用Min、NotNull校验单个普通参数比如PathVariable或RequestParam。很多人不知道非对象参数也能用注解校验但必须开启类级别的Validated否则Spring不会解析这些注解。这也是容易被忽视的“第5种写法”的变种。但注意它不会校验嵌套对象只针对简单参数。所以你的接口如果既有对象又有简单参数最好混用两种方式但别在同一个方法里用两套异常处理逻辑避免错误响应格式不统一。再谈异常处理——让校验失败不再是灾难以上所有写法都绕不开一个问题校验失败后返回给前端什么默认情况下SpringBoot会抛MethodArgumentNotValidException或ConstraintViolationException返回的JSON是400状态加一段诡异的错误消息前端根本看不懂。真正成熟的工程一定会在全局异常处理器里统一拦截这些异常输出{field: message}结构。这既是工程规范也是参数校验的一部分。建议用RestControllerAdvice写一个GlobalExceptionHandler捕获取MethodArgumentNotValidException遍历FieldError组装成Map捕获取ConstraintViolationException遍历getConstraintViolations()组装成自定义错误码。这里的关键是不要只返回第一个错误要把所有字段错误一次给全这样前端可以一次渲染多个提示而不是让用户反复提交。另外有些团队喜欢在异常里塞入“错误码”与“错误消息”分离的结构这更专业。但别过度设计——如果只是内部项目直接返回消息也行。建议在后端把校验错误信息定义成常量或者枚举不要散落在自定义注解的message里否则将来改提示语你要搜遍整个项目找字符串。我在审计别人代码时经常看到一个Pattern的message直接写“格式错误”到了用户那里根本不知道哪个字段错了。与其写“格式错误”不如写“手机号格式错误”——优秀的错误信息能省去你大量答疑时间。实战组合拳5种写法如何搭配实际项目中我不会只用某一种写法而是根据接口的复杂程度分层使用。简单查询接口用RequestParam加类级别Validated的注解就够了基础新增/修改接口用分组校验实体类注解复杂业务接口比如“根据用户类型、订单金额、优惠券状态等多个条件决定是否允许操作”我会直接在Service层用编程式校验。自定义注解只用于那些“绝对可复用、且不依赖外部状态”的规则比如身份证号、邮箱、枚举值。至于Spring的Validator接口一般在整合旧系统或与Spring MVC的数据绑定混用时才想起它但它的validate方法可以同时校验多个对象这种场景在报表导入中特别实用。参数校验不是点缀而是接口安全的第一道防线。别把校验都压在数据库约束上也别指望前端传的数据永远合法。我见过太多漏洞就是因为程序员只校验了“必须非空”和“长度”却没校验“值域范围”和“字符串合规”。比如一个优惠券接口用户传入couponId从0到999999你只做了NotNull结果被恶意遍历瞬间刷走所有优惠券。校验也要考虑攻击面比如对分页参数加Min(1)对排序字段加Pattern白名单这些细节才算真正的实用。回到老张的场景。他的接口用了Min(0)却没生效原因无非是三种要么忘了加Valid要么在嵌套对象上没级联校验要么用的Validated指定了分组而Min没有归属那个分组。参数校验的坑往往比你想的多一个层次。但如果你掌握了上面那五种写法并且理解它们各自的适用边界就基本不会遇到“校验不生效”这种玄学问题。规则是死的组合是活的——当你能够针对不同接口自由选择声明式、分组式、自定义或编程式校验时你才真正把SpringBoot的参数校验玩明白了。记住校验的终极目标不是让代码看起来严谨而是让业务数据永远处于合法状态。