Spring Boot 参数校验实战:@Valid、分组校验与统一错误响应

📅 2026/7/16 10:13:48
Spring Boot 参数校验实战:@Valid、分组校验与统一错误响应
Spring Boot 参数校验实战:Valid、分组校验与统一错误响应写接口时最烦的一件事,就是在 Controller 里手写一堆if (dto.getName() null || dto.getName().isEmpty())。校验逻辑和业务逻辑搅在一起,同一个字段的规则在新增和更新接口里还要各写一遍。Bean Validation(JSR 380)就是来解决这个问题的,但很多人只会用最基础的NotNull,一到「新增必填、更新可选」这种场景就抓瞎。这篇把参数校验从入门用到能落地。朴素写法的问题先看一个典型的「手写校验」Controller:PostMapping(/users)publicResultcreateUser(RequestBodyUserDTOdto){if(dto.getUsername()null||dto.getUsername().trim().isEmpty()){returnResult.fail(用户名不能为空);}if(dto.getAge()!null(dto.getAge()0||dto.getAge()150)){returnResult.fail(年龄不合法);}if(dto.getEmail()!null!dto.getEmail().contains()){returnResult.fail(邮箱格式错误);}// 真正的业务逻辑被埋在下面returnuserService.create(dto);}问题很明显:校验代码比业务代码还长;换个接口这套还得重写;contains()这种手搓的规则也不靠谱。用注解声明规则把规则搬到 DTO 字段上,Controller 只留一个Valid:publicclassUserDTO{NotBlank(message用户名不能为空)// 非 null 且去空格后非空Size(min2,max20,message用户名长度需 2-20)privateStringusername;Min(value0,message年龄不能为负)Max(value150,message年龄不合法)privateIntegerage;// 包装类型,不填就是 null,跳过校验Email(message邮箱格式错误)privateStringemail;// getter/setter 省略}Controller 加Valid触发校验:PostMapping(/users)publicResultcreateUser(ValidRequestBodyUserDTOdto){returnuserService.create(dto);// 校验不过根本进不到这里}注意NotBlank、NotEmpty、NotNull的区别:NotNull只管非 null;NotEmpty用于集合/字符串要求非空;NotBlank只用于字符串,还会去掉首尾空格再判断,所以填一堆空格也过不了。别忘了引依赖(Spring Boot 2.3 之后 web starter 不再自带):dependencygroupIdorg.springframework.boot/groupIdartifactIdspring-boot-starter-validation/artifactId/dependency分组校验:同一个 DTO,新增和更新规则不同这是最容易踩坑的场景。新增用户时id必须为空、username必填;更新时id必填、其他字段可选(允许只改一个字段)。用两个 DTO 太重复,正解是校验分组。先定义两个空接口当分组标记:publicinterfaceCreateGroup{}publicinterfaceUpdateGroup{}在字段上用groups指定规则属于哪个分组:publicclassUserDTO{Null(groupsCreateGroup.class,message新增时 id 必须为空)NotNull(groupsUpdateGroup.class,message更新时 id 不能为空)privateLongid;NotBlank(groupsCreateGroup.class,message用户名不能为空)Size(min2,max20,groups{CreateGroup.class,UpdateGroup.class})privateStringusername;}Controller 里用Validated指定这次走哪个分组(注意是 Spring 的Validated,不是Valid,只有前者支持分组):PostMapping(/users)publicResultcreate(Validated(CreateGroup.class)RequestBodyUserDTOdto){returnuserService.create(dto);}PutMapping(/users)publicResultupdate(Validated(UpdateGroup.class)RequestBodyUserDTOdto){returnuserService.update(dto);}同一个字段,新增走「必须为空」,更新走「必须非空」,一份 DTO 搞定。统一错误响应:别让异常直接抛给前端校验失败时 Spring 抛MethodArgumentNotValidException,不处理的话前端收到的是一大坨堆栈。用RestControllerAdvice全局兜住,拼成干净的 JSON:RestControllerAdvicepublicclassValidationExceptionHandler{ExceptionHandler(MethodArgumentNotValidException.class)ResponseStatus(HttpStatus.BAD_REQUEST)publicResulthandleValidation(MethodArgumentNotValidExceptione){// 把所有字段错误收集成 field - message 的 mapMapString,StringerrorsnewHashMap();for(FieldErrorfe:e.getBindingResult().getFieldErrors()){errors.put(fe.getField(),fe.getDefaultMessage());}returnResult.fail(参数校验失败,errors);}}前端拿到的就是{code:400,msg:参数校验失败,data:{username:用户名不能为空}},能直接定位到哪个字段。一个进阶坑:嵌套对象和集合要手动加 ValidDTO 里套了另一个对象,或者是一个 List,默认不会级联校验。必须在字段上再加Valid:publicclassOrderDTO{NotNullValid// 少了这个,address 内部的注解全部失效privateAddressDTOaddress;NotEmptyValid// 校验 list 里每个元素privateListValidItemDTOitems;}这个坑很隐蔽——外层字段校验正常,内层规则却静默失效,提交脏数据都不报错。凡是嵌套结构,记得逐层补Valid。小结Controller 只留Valid/Validated,规则全部下沉到 DTO 注解,校验与业务解耦。NotNull(非 null)、NotEmpty(集合/串非空)、NotBlank(串去空格非空)三者别混用。「新增/更新规则不同」用分组校验,配合Validated(分组.class),一份 DTO 复用。RestControllerAdvice全局兜住MethodArgumentNotValidException,返回字段级错误 map。嵌套对象和集合元素要逐层加Valid才会级联,否则内层规则静默失效。一句话记忆点:校验规则写在数据上,不写在流程里;分组解决「同一份数据不同场景」,全局处理器解决「错误怎么返回」。