文章目录
优雅的Controller层
参考:https://mp.weixin.qq.com/s/h6X3BB-0KYFWooZRB5oLeg
一个优秀的Controller需要具备的:
- 接收请求并解析参数
- 调用 Service 执行具体的业务代码(可能包含参数校验)
- 捕获业务逻辑异常做出反馈
- 业务逻辑执行成功做出响应
搭建案例环境
新建一个springboot的项目 controller-test
导入依赖 pom.xml
先只给必要的 web 和 lombok依赖
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.6.11</version>
<relativePath/> <!-- lookup parent from repository -->
</parent>
<groupId>com.ung</groupId>
<artifactId>controller-test</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>controller-test</name>
<description>Demo project for Spring Boot</description>
<properties>
<java.version>1.8</java.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>
application.yml
server:
port: 8001
新建实体类 UserDto
package com.ung.controllertest.pojo.dto;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.io.Serializable;
/**
* @author: wenyi
* @create: 2022/9/21
* @Description:
*/
@Data
@AllArgsConstructor
@NoArgsConstructor
public class UserDto implements Serializable {
private Long uid;
private String username;
}
给实体类写service层
UserService
package com.ung.controllertest.service;
import com.ung.controllertest.pojo.dto.UserDto;
import java.util.List;
/**
* @author: wenyi
* @create: 2022/9/21
* @Description:
*/
public interface UserService {
UserDto getById(Long id);
String getUserName(Long id);
boolean addUser(UserDto userDto);
List<UserDto> getList();
}
service的实现类,给接口方法实现,并且根据业务进行处理,校验
package com.ung.controllertest.service.impl;
import com.ung.controllertest.pojo.dto.UserDto;
import com.ung.controllertest.service.UserService;
import org.springframework.stereotype.Service;
import java.util.ArrayList;
import java.util.List;
/**
* @author: wenyi
* @create: 2022/9/21
* @Description:
*/
@Service
public class UserServiceImpl implements UserService {
@Override
public UserDto getById(Long id) {
if (id != null && id > 0) {
return new UserDto(id, "默认名字");
}
throw new RuntimeException("参数异常");
}
@Override
public String getUserName(Long id) {
if (id != null && id > 0) {
return "默认名字";
}
throw new RuntimeException("参数异常");
}
@Override
public boolean addUser(UserDto userDto) {
if (userDto != null) {
if (userDto.getUsername() != null) {
return true;
}
}
throw new RuntimeException("参数异常");
}
@Override
public List<UserDto> getList() {
ArrayList<UserDto> list = new ArrayList<>();
list.add(new UserDto(1L, "1默认名字"));
list.add(new UserDto(2L, "2默认名字"));
list.add(new UserDto(3L, "3默认名字"));
return list;
}
}
controller 层的 UserController
package com.ung.controllertest.controller;
import com.ung.controllertest.pojo.dto.UserDto;
import com.ung.controllertest.service.UserService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;
import java.util.List;
/**
* @author: wenyi
* @create: 2022/9/21
* @Description:
*/
@RestController
public class UserController {
private UserService userService;
@Autowired
public UserController(UserService userService) {
this.userService = userService;
}
@GetMapping("/user/getById/{id}")
public UserDto getById(@PathVariable("id") Long id) {
return userService.getById(id);
}
@GetMapping("/user/getList")
public List<UserDto> getList() {
return userService.getList();
}
@PostMapping("/user/addUser")
public boolean addUser(@RequestBody UserDto userDto) {
return userService.addUser(userDto);
}
@GetMapping("/user/getUserName/{id}")
public String getUserName(@PathVariable("id") Long id) {
return userService.getUserName(id);
}
}
启动服务,正常访问没问题 http://localhost:8001/user/getList
上面的实现有些许问题
- 参数校验过多地耦合了业务代码,违背单一职责原则
- 可能在多个业务中都抛出同一个异常,导致代码重复
- 各种异常反馈和成功响应格式不统一,接口对接不友好
改造Controller层的逻辑
统一返回结构
统一返回值类型无论项目前后端是否分离都是非常必要的,
方便对接接口的开发人员更加清晰地知道这个接口的调用是否成功
(不能仅仅简单地看返回值是否为 ``null就判断成功与否,因为有些接口的设计就是如此),使用一个状态码、状态信息就能清楚地了解接口调用情况
定义统一返回格式
在com.ung.controllertest.pojo.dto.common 包下创建 IResult 接口 ,定义返回的数据格式
package com.ung.controllertest.pojo.dto.common;
/**
* @author: wenyi
* @create: 2022/9/21
* @Description: 定义返回数据结构
*/
public interface IResult {
Integer getCode();
String getMessage();
}
在 com.ung.controllertest.enums 包新建一个枚举 ResultEnum,用来定义常用的返回结果
package com.ung.controllertest.enums;
import com.ung.controllertest.pojo.dto.common.IResult;
import lombok.Getter;
/**
* 定义枚举 ,常用返回结果
*/
@Getter
public enum ResultEnum implements IResult {
SUCCESS(2001, "接口调用成功"),
VALIDATE_FAILED(2002, "参数校验失败"),
COMMON_FAILED(2003, "接口调用失败"),
FORBIDDEN(2004, "没有权限访问资源");
private Integer code;
private String message;
ResultEnum(Integer code, String message) {
this.code = code;
this.message = message;
}
}
最后在 com.ung.controllertest.pojo.dto.common 包下新建一个统一返回的类 Result
package com.ung.controllertest.pojo.dto.common;
import com.ung.controllertest.enums.ResultEnum;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.io.Serializable;
/**
* @author: wenyi
* @create: 2022/9/21
* @Description: 统一返回类型
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
public class Result<T> implements Serializable {
private Integer code;
private String message;
private T data;
public static <T> Result<T> success(T data) {
return new Result<>(ResultEnum.SUCCESS.getCode(), ResultEnum.SUCCESS.getMessage(), data);
}
public static <T> Result<T> success(String message, T data) {
return new Result<>(ResultEnum.SUCCESS.getCode(), message, data);
}
public static Result<?> failed() {
return new Result<>(ResultEnum.COMMON_FAILED.getCode(), ResultEnum.COMMON_FAILED.getMessage(), null);
}
public static Result<?> failed(String message) {
return new Result<>(ResultEnum.COMMON_FAILED.getCode(), message, null);
}
public static Result<?> failed(IResult errorResult) {
return new Result<>(errorResult.getCode(), errorResult.getMessage(), null);
}
public static <T> Result<T> instance(Integer code, String message, T data) {
Result<T> result = new Result<>();
result.setCode(code);
result.setMessage(message);
result.setData(data);
return result;
}
}
统一返回结构后,在 Controller 中就可以使用了,直接在Controller里面的结果返回给封装上;
但是每一个 Controller 都写这么一段最终封装的逻辑,这些都是很重复的工作,所以还要继续想办法进一步处理统一返回结构
返回值统一包装处理
Spring 中提供了一个类 ResponseBodyAdvice
对 Controller 返回的内容在 HttpMessageConverter
进行类型转换之前拦截,进行相应的处理操作后,再将结果返回给客户端。可以把统一包装的工作放到这个类里面。
新建一个 ResponseAdvice ,用来进行统一返回包装处理;
package com.ung.controllertest.handler;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.ung.controllertest.annotation.NotControllerResponseAdvice;
import com.ung.controllertest.pojo.common.Result;
import org.springframework.core.MethodParameter;
import org.springframework.http.MediaType;
import org.springframework.http.converter.HttpMessageConverter;
import org.springframework.http.server.ServerHttpRequest;
import org.springframework.http.server.ServerHttpResponse;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.servlet.mvc.method.annotation.ResponseBodyAdvice;
/**
* @author: wenyi
* @create: 2022/9/21
* @Description: 进行 统一包装的工作
* Spring 中提供了一个类 ResponseBodyAdvice
* 对 Controller 返回的内容在 HttpMessageConverter 进行类型转换之前拦截,
* 进行相应的处理操作后,再将结果返回给客户端
*/
// 如果引入了swagger或knife4j的文档生成组件,这里需要仅扫描自己项目的包,否则文档无法正常生成
@RestControllerAdvice(basePackages = "com.ung.controllertest")
public class ResponseAdvice implements ResponseBodyAdvice<Object> {
/**
* 判断是否要交给 beforeBodyWrite 方法执行,ture:需要;false:不需要
*/
@Override
public boolean supports(MethodParameter returnType, Class<? extends HttpMessageConverter<?>> converterType) {
// 如果不需要进行封装的,可以添加一些校验手段,比如添加标记排除的注解
// return true;
//response是ResultVo类型,
return !(returnType.getParameterType().isAssignableFrom(Result.class)
//或者注释了NotControllerResponseAdvice都不进行包装
|| returnType.hasMethodAnnotation(NotControllerResponseAdvice.class));
}
/**
* 对 response 进行具体的处理
*/
@Override
public Object beforeBodyWrite(Object body, MethodParameter returnType, MediaType selectedContentType, Class<? extends HttpMessageConverter<?>> selectedConverterType, ServerHttpRequest request, ServerHttpResponse response) {
//如果body已经被包装了,就不进行包装
if (body instanceof Result) {
return body;
}
// //是String类型的就特殊处理,手动将Result对象转化为Json字符串
// if (body instanceof String) {
// try {
// return JSONObject.toJSONString(Result.success(body));
// } catch (Exception e) {
// throw new RuntimeException(e);
// }
// }
return Result.success(body);
}
}
继续启动服务,发起请求 http://localhost:8001/user/getList
可以看到controller没有做改动,一样可以进行返回值包装;
经过这样改造,既能实现对 Controller 返回的数据进行统一包装,又不需要对原有代码进行大量的改动;
但是请求返回string类型的时候,会报错哦!!!
http://localhost:8001/user/getUserName/1
后台的报错信息: java.lang.ClassCastException: com.ung.controllertest.pojo.dto.common.Result cannot be cast to java.lang.String
处理 cannot be cast to java.lang.String 问题
如果直接使用ResponseBodyAdvice
,对于一般的类型都没有问题,当处理字符串类型时,会抛出 xxx.包装类 cannot be cast to java.lang.String
的类型转换的异常
在ResponseBodyAdvice
实现类中 debug 发现,只有 String 类型的 selectedConverterType
参数值是 org.springframework.http.converter.StringHttpMessageConverter
,
而其他数据类型的值是 org.springframework.http.converter.json.MappingJackson2HttpMessageConverter
- String 类型
- 其他类型 (如 Integer 类型)
所以,要返回Result
对象,所以是 MappingJackson2HttpMessageConverter
类型可以转化,
但是使用 StringHttpMessageConverter
字符串转换器会导致类型转换失败
解决
方法1
在 beforeBodyWrite
方法处进行判断,如果返回值是 String 类型就对 Result
对象手动进行转换成 JSON 字符串,另外方便前端使用,最好在 @RequestMapping
中指定 ContentType
导入json依赖
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>fastjson</artifactId>
<version>1.2.68</version>
</dependency>
修改ResponseAdvice 的 beforeBodyWrite 方法
@Override
public Object beforeBodyWrite(Object body, MethodParameter returnType, MediaType selectedContentType, Class<? extends HttpMessageConverter<?>> selectedConverterType, ServerHttpRequest request, ServerHttpResponse response) {
//如果body已经被包装了,就不进行包装
if (body instanceof Result) {
return body;
}
//是String类型的就特殊处理,手动将Result对象转化为Json字符串
if (body instanceof String) {
try {
return JSONObject.toJSONString(Result.success(body));
} catch (Exception e) {
throw new RuntimeException(e);
}
}
return Result.success(body);
}
测试访问请求 http://localhost:8001/user/getUserName/1
结果没有报错,按照统一格式正常返回
方法2
修改 HttpMessageConverter
实例集合中MappingJackson2HttpMessageConverter
的顺序。
因为发生上述问题的根源所在是集合中 StringHttpMessageConverter
的顺序先于 MappingJackson2HttpMessageConverter
的,调整顺序后即可从根源上解决这个问题
新建config包下的 WebMvcConfiguration 配置类
package com.ung.controllertest.config;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.converter.HttpMessageConverter;
import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
import java.util.List;
/**
* @author: wenyi
* @create: 2022/9/21
* @Description:
*/
@Configuration
public class WebMvcConfiguration implements WebMvcConfigurer {
/**
* 交换MappingJackson2HttpMessageConverter与第一位元素
* 让返回值类型为String的接口能正常返回包装结果
*
* @param converters initially an empty list of converters
*/
@Override
public void configureMessageConverters(List<HttpMessageConverter<?>> converters) {
for (int i = 0; i < converters.size(); i++) {
if (converters.get(i) instanceof MappingJackson2HttpMessageConverter) {
MappingJackson2HttpMessageConverter mappingJackson2HttpMessageConverter = (MappingJackson2HttpMessageConverter) converters.get(i);
// 第一位的放在这一位
converters.set(i, converters.get(0));
//放到第一位,互换位置
converters.set(0, mappingJackson2HttpMessageConverter);
break;
}
}
}
}
重启服务,发起请求 http://localhost:8001/user/getUserName/1
可以看到结果也是正常返回的,说明问题解决了;
针对需要特殊处理的,不返回统一的格式,就是单纯想返回个success
通过定义注解,来标记需要不进行统一包装
package com.ung.controllertest.annotation;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
/**
* 不进行统一格式封装返回 的注解
*/
@Target({ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
public @interface NotControllerResponseAdvice {
}
控制层接口
package com.ung.controllertest.controller;
import com.ung.controllertest.annotation.NotControllerResponseAdvice;
import com.ung.controllertest.pojo.common.Result;
import org.springframework.web.bind.annotation.*;
/**
* @author: wenyi
* @create: 2022/9/21
* @Description: 特别的controller
*/
@RestController
public class SpecialController {
/**
* 就想只返回个success 不需要包装成统一格式
*/
@NotControllerResponseAdvice
@GetMapping("/isLive")
public String special() {
return "success";
}
/**
* response是ResultVo类型
*/
@NotControllerResponseAdvice
@GetMapping("/result")
public Result result() {
return Result.success();
}
}
在ResponseAdvice 的supports 方法控制是否要包装来进行判断
@Override
public boolean supports(MethodParameter returnType, Class<? extends HttpMessageConverter<?>> converterType) {
// 如果不需要进行封装的,可以添加一些校验手段,比如添加标记排除的注解
// return true;
//response是ResultVo类型,
return !(returnType.getParameterType().isAssignableFrom(Result.class)
//或者注释了NotControllerResponseAdvice都不进行包装
|| returnType.hasMethodAnnotation(NotControllerResponseAdvice.class));
}
参数校验
Java API 的规范 JSR303
定义了校验的标准 validation-api
,其中一个比较出名的实现是hibernate validation,spring validation
是对其的二次封装,常用于 SpringMVC 的参数自动校验,参数校验的代码就不需要再与业务逻辑代码进行耦合了
引入依賴
spring boot项目
低版本spring-boot-starter-web
模块中包含了hibernate-validator,因此不需要重新再次引入,
版本高于2.3.x需要手动引入依赖
<dependency>
<groupId>org.hibernate</groupId>
<artifactId>hibernate-validator</artifactId>
<version>6.0.1.Final</version>
</dependency>
验证注解 | 验证的数据类型 | 说明 |
---|---|---|
@AssertFalse | Boolean,boolean | 验证注解的元素值是false |
@AssertTrue | Boolean,boolean | 验证注解的元素值是true |
@NotNull | 任意类型 | 验证注解的元素值不是null |
@Null | 任意类型 | 验证注解的元素值是null |
@Min(value=值) | BigDecimal,BigInteger, byte,short, int, long,等任何Number或CharSequence(存储的是数字)子类型 | 验证注解的元素值大于等于@Min指定的value值 |
@Max(value=值) | 和@Min要求一样 | 验证注解的元素值小于等于@Max指定的value值 |
@DecimalMin(value=值) | 和@Min要求一样 | 验证注解的元素值大于等于@ DecimalMin指定的value值 |
@DecimalMax(value=值) | 和@Min要求一样 | 验证注解的元素值小于等于@ DecimalMax指定的value值 |
@Digits(integer=整数位数, fraction=小数位数) | 和@Min要求一样 | 验证注解的元素值的整数位数和小数位数上限 |
@Size(min=下限, max=上限) | 字符串、Collection、Map、数组等 | 验证注解的元素值的在min和max(包含)指定区间之内,如字符长度、集合大小 |
@Past | java.util.Date,java.util.Calendar;Joda Time类库的日期类型 | 验证注解的元素值(日期类型)比当前时间早 |
@Future | 与@Past要求一样 | 验证注解的元素值(日期类型)比当前时间晚 |
@NotBlank | CharSequence子类型 | 验证注解的元素值不为空(不为null、去除首位空格后长度为0),不同于@NotEmpty,@NotBlank只应用于字符串且在比较时会去除字符串的首位空格 |
@Length(min=下限, max=上限) | CharSequence子类型 | 验证注解的元素值长度在min和max区间内 |
@NotEmpty | CharSequence子类型、Collection、Map、数组 | 验证注解的元素值不为null且不为空(字符串长度不为0、集合大小不为0) |
@Range(min=最小值, max=最大值) | BigDecimal,BigInteger,CharSequence, byte, short, int, long等原子类型和包装类型 | 验证注解的元素值在最小值和最大值之间 |
@Email(regexp=正则表达式,flag=标志的模式) | CharSequence子类型(如String) | 验证注解的元素值是Email,也可以通过regexp和flag指定自定义的email格式 |
@Pattern(regexp=正则表达式,flag=标志的模式) | String,任何CharSequence的子类型 | 验证注解的元素值与指定的正则表达式匹配 |
@Valid | 任何非原子类型 | 指定递归验证关联的对象;如用户对象中有个地址对象属性,如果想在验证用户对象时一起验证地址对象的话,在地址对象上加@Valid注解即可级联验证 |
@Validated和@Valid区别
@Validated对@Valid进行了二次封装,但是二者有以下的区别:
@Validated提供分组功能,可以在参数验证时,根据不同的分组采用不同的验证机制。@Valid没有分组功能
@Validated用在类型、方法和方法参数上。但不能用于成员属性(field),@Valid可以用在方法、构造函数、方法参数和成员属性(field)上
一个待验证的pojo类,其中还包含了待验证的对象属性,需要在待验证对象上注解@Valid,才能验证待验证对象中的成员属性,这里不能使用@Validated
一般会都会在Controller层进行参数校验,常见的方式主要包含了两种:
- get、delete等请求,参数形式为
RequestParam/PathVariable
- post、put等请求,参数形式为
RequestBoty
@PathVariable 和 @RequestParam 参数校验
Get 请求的参数接收一般依赖这两个注解,但是处于 url 有长度限制和代码的可维护性,超过 5 个参数尽量用实体来传参
对 @PathVariable 和 @RequestParam 参数进行校验需要在入参声明约束的注解
如果校验失败,会抛出ConstraintViolationException
异常
使用
- 必须在
Controller
类上标注@Validated
注解 - 在接口参数前声明约束注解(如
@NotBlank
等)
创建一个新的实体类 CheckUserDto
package com.ung.controllertest.pojo.dto;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.hibernate.validator.constraints.Length;
import javax.validation.constraints.Email;
import javax.validation.constraints.NotBlank;
import java.io.Serializable;
/**
* @author: wenyi
* @create: 2022/9/21
* @Description:
*/
@Data
@AllArgsConstructor
@NoArgsConstructor
public class CheckUserDto implements Serializable {
private Long uid;
private String username;
private String password;
private String email;
}
创建一个新的校验的接口
package com.ung.controllertest.controller;
import com.ung.controllertest.pojo.dto.CheckUserDto;
import com.ung.controllertest.pojo.dto.UserDto;
import com.ung.controllertest.service.UserService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import javax.validation.constraints.Email;
import javax.validation.constraints.Max;
import javax.validation.constraints.Min;
import javax.validation.constraints.NotBlank;
import java.util.List;
/**
* @author: wenyi
* @create: 2022/9/21
* @Description:
*/
@RestController
@Validated
public class CheckController {
@GetMapping("/checkUser/getById/{id}")
public CheckUserDto getById(@PathVariable("id") @Min(1) @Max(20) Long id) {
return new CheckUserDto(id,"username","123456","123@qq.com");
}
@GetMapping("/checkUser/getByEmail")
public CheckUserDto getByEmail(@NotBlank(message = "邮箱不能为空") @Email String email) {
return null;
}
}
重启服务,请求链接 http://localhost:8001/checkUser/getById/22
输入不合法的参数,会报错500;
可以看到服务器后台报错信息 ConstraintViolationException ,打印的是自定义的message的信息; 最大不能超过20,请求其他链接也会一样的校验错误,会报错;邮箱不合法,邮箱不为空等;
说明校验有效;
如果被校验的类 的成员变量有对象属性,在属性字段上加注解**@Valid**
@Data
@AllArgsConstructor
@NoArgsConstructor
public class CheckUserDto implements Serializable {
private Long uid;
@NotBlank(message = "username不能为空")
@Length(min = 6, max = 10)
private String username;
@NotBlank(message = "password不能为空")
@Length(min = 6, max = 12)
private String password;
@NotBlank(message = "email不能为空")
@Email(message = "email的格式不对")
private String email;
@Valid
@NotEmpty(message = "任课老师不能为空")
@Size(min = 1, message = "至少有一个老师")
private List<Teacher> teachers;
}
@Data
public class Teacher {
@NotBlank(message = "老师姓名不能为空")
private String teacherName;
@Min(value = 1, message = "学科类型从1开始计算")
private Integer type;
}
校验原理
在 SpringMVC 中,有一个类是 ``RequestResponseBodyMethodProcessor,这个类有两个作用
- 用于解析 @RequestBody 标注的参数
- 处理 @ResponseBody 标注方法的返回值
解析 @RequestBoyd 标注参数的方法是resolveArgument
public class RequestResponseBodyMethodProcessor extends AbstractMessageConverterMethodProcessor {
/**
* Throws MethodArgumentNotValidException if validation fails.
* @throws HttpMessageNotReadableException if {@link RequestBody#required()}
* is {@code true} and there is no body content or if there is no suitable
* converter to read the content with.
*/
@Override
public Object resolveArgument(MethodParameter parameter, @Nullable ModelAndViewContainer mavContainer,
NativeWebRequest webRequest, @Nullable WebDataBinderFactory binderFactory) throws Exception {
parameter = parameter.nestedIfOptional();
//把请求数据封装成标注的DTO对象
Object arg = readWithMessageConverters(webRequest, parameter, parameter.getNestedGenericParameterType());
String name = Conventions.getVariableNameForParameter(parameter);
if (binderFactory != null) {
WebDataBinder binder = binderFactory.createBinder(webRequest, arg, name);
if (arg != null) {
//执行数据校验
validateIfApplicable(binder, parameter);
//如果校验不通过,就抛出MethodArgumentNotValidException异常
//如果我们不自己捕获,那么最终会由DefaultHandlerExceptionResolver捕获处理
if (binder.getBindingResult().hasErrors() && isBindExceptionRequired(binder, parameter)) {
throw new MethodArgumentNotValidException(parameter, binder.getBindingResult());
}
}
if (mavContainer != null) {
mavContainer.addAttribute(BindingResult.MODEL_KEY_PREFIX + name, binder.getBindingResult());
}
}
return adaptArgumentIfNecessary(arg, parameter);
}
}
public abstract class AbstractMessageConverterMethodArgumentResolver implements HandlerMethodArgumentResolver {
/**
* Validate the binding target if applicable.
* <p>The default implementation checks for {@code @javax.validation.Valid},
* Spring's {@link org.springframework.validation.annotation.Validated},
* and custom annotations whose name starts with "Valid".
* @param binder the DataBinder to be used
* @param parameter the method parameter descriptor
* @since 4.1.5
* @see #isBindExceptionRequired
*/
protected void validateIfApplicable(WebDataBinder binder, MethodParameter parameter) {
//获取参数上的所有注解
Annotation[] annotations = parameter.getParameterAnnotations();
for (Annotation ann : annotations) {
//如果注解中包含了@Valid、@Validated或者是名字以Valid开头的注解就进行参数校验
Object[] validationHints = ValidationAnnotationUtils.determineValidationHints(ann);
if (validationHints != null) {
//实际校验逻辑,最终会调用Hibernate Validator执行真正的校验
//所以Spring Validation是对Hibernate Validation的二次封装
binder.validate(validationHints);
break;
}
}
}
}
@RequestBody 参数校验
Post、Put 请求的参数推荐使用 @RequestBody 请求体参数
这种情况下,在入参对象上添加@Validated
注解就能实现自动参数校验
在实体类 CheckUserDto 上面加注解校验信息
package com.ung.controllertest.pojo.dto;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.hibernate.validator.constraints.Length;
import javax.validation.constraints.Email;
import javax.validation.constraints.NotBlank;
import java.io.Serializable;
/**
* @author: wenyi
* @create: 2022/9/21
* @Description:
*/
@Data
@AllArgsConstructor
@NoArgsConstructor
public class CheckUserDto implements Serializable {
private Long uid;
@NotBlank(message = "username不能为空")
@Length(min = 6, max = 10)
private String username;
@NotBlank(message = "password不能为空")
@Length(min = 6, max = 12)
private String password;
@NotBlank(message = "email不能为空")
@Email(message = "email的格式不对")
private String email;
}
在方法的入参加注解 @Validated
package com.ung.controllertest.controller;
import com.ung.controllertest.pojo.dto.CheckUserDto;
import com.ung.controllertest.pojo.dto.UserDto;
import com.ung.controllertest.service.UserService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import javax.validation.constraints.Email;
import javax.validation.constraints.Max;
import javax.validation.constraints.Min;
import javax.validation.constraints.NotBlank;
import java.util.List;
/**
* @author: wenyi
* @create: 2022/9/21
* @Description:
*/
@RestController
@Validated
public class CheckController {
@GetMapping("/checkUser/getById/{id}")
public CheckUserDto getById(@PathVariable("id") @Min(1) @Max(20) Long id) {
return new CheckUserDto(id,"username","123456","123@qq.com");
}
@GetMapping("/checkUser/getByEmail")
public CheckUserDto getByEmail(@NotBlank(message = "邮箱不能为空") @Email String email) {
return null;
}
@PostMapping("/checkUser/addUser")
public boolean addUser(@Validated @RequestBody CheckUserDto checkUserDto) {
return true;
}
}
发送post 请求 http://localhost:8001/checkUser/addUser
传递错误的参数
{
"username": "user",
"password": "123456",
"email": "123@qq.com"
}
可以看到报400请求错误
服务器后台可以看到错误信息 MethodArgumentNotValidException 也会打印自定义的message
说明校验有效;
校验原理
声明约束的方式,注解加到了参数上面,可以比较容易猜测到是使用了 AOP 对方法进行增强
而实际上 Spring 也是通过 MethodValidationPostProcessor
动态注册 AOP 切面,然后使用MethodValidationInterceptor
对切点方法进行织入增强
public class MethodValidationPostProcessor extends AbstractBeanFactoryAwareAdvisingPostProcessor implements InitializingBean {
//指定了创建切面的Bean的注解
private Class<? extends Annotation> validatedAnnotationType = Validated.class;
@Override
public void afterPropertiesSet() {
//为所有@Validated标注的Bean创建切面
Pointcut pointcut = new AnnotationMatchingPointcut(this.validatedAnnotationType, true);
//创建Advisor进行增强
this.advisor = new DefaultPointcutAdvisor(pointcut, createMethodValidationAdvice(this.validator));
}
//创建Advice,本质就是一个方法拦截器
protected Advice createMethodValidationAdvice(@Nullable Validator validator) {
return (validator != null ? new MethodValidationInterceptor(validator) : new MethodValidationInterceptor());
}
}
public class MethodValidationInterceptor implements MethodInterceptor {
@Override
public Object invoke(MethodInvocation invocation) throws Throwable {
//无需增强的方法,直接跳过
if (isFactoryBeanMetadataMethod(invocation.getMethod())) {
return invocation.proceed();
}
Class<?>[] groups = determineValidationGroups(invocation);
ExecutableValidator execVal = this.validator.forExecutables();
Method methodToValidate = invocation.getMethod();
Set<ConstraintViolation<Object>> result;
try {
//方法入参校验,最终还是委托给Hibernate Validator来校验
//所以Spring Validation是对Hibernate Validation的二次封装
result = execVal.validateParameters(
invocation.getThis(), methodToValidate, invocation.getArguments(), groups);
}
catch (IllegalArgumentException ex) {
...
}
//校验不通过抛出ConstraintViolationException异常
if (!result.isEmpty()) {
throw new ConstraintViolationException(result);
}
//Controller方法调用
Object returnValue = invocation.proceed();
//下面是对返回值做校验,流程和上面大概一样
result = execVal.validateReturnValue(invocation.getThis(), methodToValidate, returnValue, groups);
if (!result.isEmpty()) {
throw new ConstraintViolationException(result);
}
return returnValue;
}
}
自定义注解校验
- 自定义注解类,定义错误信息和一些其他需要的内容
- 注解校验器,定义判定规则
自定义注解类:CheckMobile
- 与普通注解相比,这种自定义注解需要增加元注解
@Constraint
,并通过validatedBy
参数指定验证器。 - 依据JSR规范,定义三个通用参数:
message
(校验失败保存信息)、groups
(分组)和payload
(负载)。 - 自定义额外所需配置参数
package com.ung.controllertest.annotation;
import javax.validation.Constraint;
import javax.validation.Payload;
import java.lang.annotation.*;
/**
* 自定义校验注解
*/
@Target({ElementType.METHOD, ElementType.FIELD, ElementType.ANNOTATION_TYPE, ElementType.CONSTRUCTOR, ElementType.PARAMETER})
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Constraint(validatedBy = MobileValidator.class)//指定校验器
public @interface CheckMobile {
/**
* 是否允许为空
*/
boolean required() default true;
/**
* 校验不通过返回的提示信息
*/
String message() default "不是一个手机号码格式";
/**
* Constraint要求的属性,用于分组校验和扩展,留空就好
*/
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
}
注解校验器 MobileValidator
- 该验证器需要实现ConstraintValidator接口,ConstraintValidator接口包含两个类型参数,第一个指定验证器要校验的注解,第二个参数指定要验证的数据类型。
- 实现initialize方法,通常在该注解中拿到注解的参数值。
- 实现isValid方法,方法第一个参数是要校验的属性值;校验逻辑写在该方法内;校验通过返回true,校验失败返回false。
package com.ung.controllertest.annotation;
import org.springframework.util.StringUtils;
import javax.validation.ConstraintValidator;
import javax.validation.ConstraintValidatorContext;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* @author: wenyi
* @create: 2022/9/21
* @Description: 注解校验器
*/
public class MobileValidator implements ConstraintValidator<CheckMobile, CharSequence> {
private boolean required = false;
private static final Pattern pattern = Pattern.compile("^1[34578][0-9]{9}$"); // 验证手机号
/**
* 在验证开始前调用注解里的方法,从而获取到一些注解里的参数
*
* @param constraintAnnotation annotation instance for a given constraint declaration
*/
@Override
public void initialize(CheckMobile constraintAnnotation) {
this.required = constraintAnnotation.required();
}
/**
* 判断参数是否合法
*
* @param value object to validate
* @param context context in which the constraint is evaluated
*/
@Override
public boolean isValid(CharSequence value, ConstraintValidatorContext context) {
if (value == null) {
return false;
}
if (this.required) {
// 验证
return isMobile(value);
}
if (StringUtils.hasText(value)) {
// 验证
return isMobile(value);
}
return true;
}
private boolean isMobile(final CharSequence str) {
if (str == null) {
return false;
}
Matcher m = pattern.matcher(str);
return m.matches();
}
}
最后在实体类的字段上使用即可
package com.ung.controllertest.pojo.dto;
import com.ung.controllertest.annotation.CheckMobile;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.hibernate.validator.constraints.Length;
import javax.validation.constraints.Email;
import javax.validation.constraints.NotBlank;
import java.io.Serializable;
/**
* @author: wenyi
* @create: 2022/9/21
* @Description:
*/
@Data
@AllArgsConstructor
@NoArgsConstructor
public class CheckUserDto implements Serializable {
private Long uid;
@NotBlank(message = "username不能为空")
@Length(min = 6, max = 10)
private String username;
@NotBlank(message = "password不能为空")
@Length(min = 6, max = 12)
private String password;
@NotBlank(message = "email不能为空")
@Email(message = "email的格式不对")
private String email;
@CheckMobile( message = "手机号不对!!!")
private String phone;
}
POST请求链接 http://localhost:8001/checkUser/addUser
{
"username": "username",
"password": "123456",
"email": "123@qq.com",
"phone": "13900442200"
}
这是正确参数请求;
当输入错误的电话,会报400错误,
后台也会报错 MethodArgumentNotValidException ,说明校验有效
注意:@Valid
和@Validated
注解
在Spring中,我们使用@Valid
注解进行方法级别验证,同时还能用它来标记成员属性以进行验证。
但是,此注释不支持分组验证。@Validated
则支持分组验证。
分组校验
在执行保存和更新操作的时候,校验的参数可能存在差异,比如保存的时候不需要校验Id
,而更新的时候就需要校验id
(主键),写两个实体类复用率会很低。所以这个时候分组校验就很有必要
定义分组
CheckUserDto中定义一个Update分组,主要用于校验更新时的操作,一般情况下需要实现Default分组。
定义好分组后,需要在对应的字段上添加上需要校验的分组。
只有在更新的时候需要校验id是否为空,我们在这个加上对应的分组即可。
并且插入的时候需要校验id必须为空 ,也给一个Add的分组
package com.ung.controllertest.pojo.dto;
import com.ung.controllertest.annotation.CheckMobile;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.hibernate.validator.constraints.Length;
import javax.validation.constraints.Email;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.NotNull;
import javax.validation.constraints.Null;
import javax.validation.groups.Default;
import java.io.Serializable;
/**
* @author: wenyi
* @create: 2022/9/21
* @Description:
*/
@Data
@AllArgsConstructor
@NoArgsConstructor
public class CheckUserDto implements Serializable {
/**
* 在更新时校验id
*/
@NotNull(message = "修改的id不能为空", groups = Update.class)
@Null(message = "新增的id必须为空", groups = Add.class)
private Long uid;
/**
* 更新分组
*/
public interface Update extends Default {
}
/**
* 插入分组
*/
public interface Add extends Default {
}
@NotBlank(message = "username不能为空")
@Length(min = 6, max = 10)
private String username;
@NotBlank(message = "password不能为空")
@Length(min = 6, max = 12)
private String password;
@NotBlank(message = "email不能为空")
@Email(message = "email的格式不对")
private String email;
@CheckMobile( message = "手机号不对!!!")
private String phone;
}
controller的方法
在@Validated 注解上指定 CheckUserDto.Update.class 是修改的分组;
指定Add方法给定Add分组
package com.ung.controllertest.controller;
import com.ung.controllertest.pojo.dto.CheckUserDto;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import javax.validation.constraints.Email;
import javax.validation.constraints.Max;
import javax.validation.constraints.Min;
import javax.validation.constraints.NotBlank;
/**
* @author: wenyi
* @create: 2022/9/21
* @Description:
*/
@RestController
@Validated
public class CheckController {
@GetMapping("/checkUser/getById/{id}")
public CheckUserDto getById(@PathVariable("id") @Min(1) @Max(20) Long id) {
return new CheckUserDto(id, "username", "123456", "123@qq.com", "13812341234");
}
@GetMapping("/checkUser/getByEmail")
public CheckUserDto getByEmail(@NotBlank(message = "邮箱不能为空") @Email String email) {
return null;
}
@PostMapping("/checkUser/addUser")
public boolean addUser(@Validated(CheckUserDto.Add.class) @RequestBody CheckUserDto checkUserDto) {
return true;
}
/**
* 【分组校验】@Validated注解实现
*
* @param checkUserDto 入参
* @return true/false 成功或失败
*/
@PostMapping("checkUser/updateUser")
public boolean updateUser(@Validated(CheckUserDto.Update.class) @RequestBody CheckUserDto checkUserDto) {
// 具体业务逻辑调用
return true;
}
}
POST请求
http://localhost:8001/checkUser/updateUser
{
"uid": 2,
"username": "username",
"password": "123456",
"email": "123@qq.com",
"phone": "13812341234"
}
这样正常请求正常返回调用成功,去掉uid,可以看到给出自定义的message返回
分组校验有效;
POST请求
http://localhost:8001/checkUser/addUser
{
"uid": 1,
"username": "username",
"password": "123456",
"email": "123@qq.com",
"phone": "13900445200"
}
新插入的参数携带有id,参数校验不通过
自定义异常与统一拦截异常
之前还存在的问题:
- 抛出的异常不够具体,只是简单地把错误信息放到了 Exception 中
- 抛出异常后,Controller 不能具体地根据异常做出反馈
- 虽然做了参数自动校验,但是异常返回结构和正常返回结构不一致
自定义异常是为了后面统一拦截异常时,对业务中的异常有更加细颗粒度的区分,拦截时针对不同的异常作出不同的响应
而统一拦截异常的目的一个是为了可以与前面定义下来的统一包装返回结构能对应上,另一个是我们希望无论系统发生什么异常,Http 的状态码都要是 200 ,尽可能由业务来区分系统的异常
定义自定义异常类
package com.ung.controllertest.exception;
/**
* @author: wenyi
* @create: 2022/9/21
* @Description: 自定义异常
*/
public class BusinessException extends RuntimeException {
public BusinessException(String message) {
super(message);
}
}
自定义异常统一处理,处理参数校验异常和自定义异常,给正常格式返回
package com.ung.controllertest.handler;
import com.ung.controllertest.enums.ResultEnum;
import com.ung.controllertest.exception.BusinessException;
import org.springframework.util.StringUtils;
import org.springframework.validation.BindingResult;
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 com.ung.controllertest.pojo.common.Result;
import javax.validation.ConstraintViolationException;
//统一拦截异常
@RestControllerAdvice(basePackages = "com.ung.controllertest")
public class ExceptionAdvice {
/**
* 捕获 {@code BusinessException} 异常
*/
@ExceptionHandler({BusinessException.class})
public Result<?> handleBusinessException(BusinessException ex) {
return Result.failed(ex.getMessage());
}
/**
* {@code @RequestBody} 参数校验不通过时抛出的异常处理
*/
@ExceptionHandler({MethodArgumentNotValidException.class})
public Result<?> handleMethodArgumentNotValidException(MethodArgumentNotValidException ex) {
BindingResult bindingResult = ex.getBindingResult();
StringBuilder sb = new StringBuilder("校验失败:");
for (FieldError fieldError : bindingResult.getFieldErrors()) {
sb.append(fieldError.getField()).append(":").append(fieldError.getDefaultMessage()).append(", ");
}
String msg = sb.toString();
if (StringUtils.hasText(msg)) {
return Result.failed(ResultEnum.VALIDATE_FAILED.getCode(), msg);
}
return Result.failed(ResultEnum.VALIDATE_FAILED);
}
/**
* {@code @PathVariable} 和 {@code @RequestParam} 参数校验不通过时抛出的异常处理
*/
@ExceptionHandler({ConstraintViolationException.class})
public Result<?> handleConstraintViolationException(ConstraintViolationException ex) {
if (StringUtils.hasText(ex.getMessage())) {
return Result.failed(ResultEnum.VALIDATE_FAILED.getCode(), ex.getMessage());
}
return Result.failed(ResultEnum.VALIDATE_FAILED);
}
/**
* 顶级异常捕获并统一处理,当其他异常无法处理时候选择使用
*/
@ExceptionHandler({Exception.class})
public Result<?> handle(Exception ex) {
return Result.failed(ex.getMessage());
}
}
http://localhost:8001/checkUser/addUser
{
"username": "username",
"password": "123456",
"email": "123@qq.com",
"phone": "1390044200"
}
在请求错误的POST字段校验请求时,有正常格式返回
http://localhost:8001/checkUser/getById/211
方法入参校验也会正常格式返回;
总结
做了统一返回格式,参数校验,自定义异常和统一异常拦截返回;
可以发现 Controller 的代码变得非常简洁,可以很清楚地知道每一个参数、每一个 DTO 的校验规则,可以很明确地看到每一个 Controller 方法返回的是什么数据,也可以方便每一个异常应该如何进行反馈
最后代码上传gitee
https://gitee.com/wenyi49/controller-test.git