目录
[1.1 Spring AOP 用户统一登录验证的问题](#1.1 Spring AOP 用户统一登录验证的问题)
[1.2 Spring 拦截器](#1.2 Spring 拦截器)
[1.2.1 自定义拦截器](#1.2.1 自定义拦截器)
[1.2.2 将自定义拦截器加入到系统配置](#1.2.2 将自定义拦截器加入到系统配置)
[1.3 拦截器实现原理](#1.3 拦截器实现原理)
[1.3.1 实现原理源码分析](#1.3.1 实现原理源码分析)
[2. 统一异常处理](#2. 统一异常处理)
[2.1 创建一个异常处理类](#2.1 创建一个异常处理类)
[2.2 创建异常检测的类和处理业务方法](#2.2 创建异常检测的类和处理业务方法)
[3. 统一数据返回格式](#3. 统一数据返回格式)
[3.1 统一数据返回的实现](#3.1 统一数据返回的实现)
[3.2 返回String报错问题](#3.2 返回String报错问题)
1.用户登录权限效验
1.1 Spring AOP 用户统一登录验证的问题
说到统一的用户登录验证,我们想到的第一个实现方案是 Spring AOP 前置通知或环绕通知来实现,具体实现代码如下:
java
@Aspect
@Component
public class UserAspect {
// 定义切点方法 controller 包下、子孙包下所有类的所有方法
@Pointcut("execution(* com.example.demo.controller..*.*(..))")
public void pointcut() {
}
// 前置方法
@Before("pointcut()")
public void doBefore() {
}
// 环绕方法
@Around("pointcut()")
public Object doAround(ProceedingJoinPoint joinPoint) {
System.out.println("Around 方法开始执行");
Object obj = joinPoint.proceed();
System.out.println("Around 方法结束执行");
return obj;
}
}
如果要在以上 Spring AOP 的切面中实现用户登录权限效验的功能,有以下两个问题:
- 没办法获取到
HttpSession
对象。 - 我们要对一部分方法进行拦截,而另一部分方法不拦截,如注册方法和登录方法是不拦截的,这样的话排除方法的规则很难定义,甚至没办法定义。
那这样如何解决呢?
1.2 Spring 拦截器
对于以上问题 Spring 中提供了具体的实现拦截器: Handlerinterceptor
,拦截器的实现分为以下两个步骤:
- 创建自定义拦截器 ,实现
Handlerlnterceptor
接口的preHandle
(执行具体方法之前的预处理)方法。 - 将自定义拦截器配置到系统配置项, 并且设置合理的拦截规则, 也就是将自定义拦截器加入
WebMvcConfigurer
的addlnterceptors
方法中。具体实现如下.
Spring拦截器能够拿到参数并方便设置拦截规则, 也不需要AspectJ表达式.
1.2.1 自定义拦截器
新建一个普通的Spring Boot项目.
接下来使用代码来实现一个用户登录的权限效验,自定义拦截器是一个普通类,具体实现代码如下:
java
@Component
public class LoginInterceptor implements HandlerInterceptor {
// 调用目标方法之前执行的方法
// 此方法返回 boolean 类型的值,
// 如果返回 true , 表示(拦截器)验证成功, 继续走后续的流程, 执行目标方法;
// 如果返回 false , 这表示拦截器执行失败, 验证未通过, 后续的流程和目标方法不要执行了.
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
// 用户登录判断业务
HttpSession session = request.getSession(false);
if (session != null && session.getAttribute("session_userinfo") != null) {
// 用户已经登录
return true;
}
// response.sendRedirect("http://www.baidu.com");
// response.setStatus(401);
return false;
}
}
1.2.2 将自定义拦截器加入到系统配置
要实现接口WebMvcConfigurer
, 它里面有大量的方法, 其中addInterceptors
方法, 需要我们实现.
也就是说当我们实现WebMvcConfigurer
这个类, 那么这个类里面它内置了一个API, 那么我们去重写这个API就可以实现将我们自定义的拦截器写在项目当中.
可以看到, 这个是将registry注册器
交给框架, 那么我们在重写的时候就拿这个registry
去设置相应的规则即可.
具体实现代码如下:
java
@Configuration
public class MyConfig implements WebMvcConfigurer {
@Autowired
private LoginInterceptor loginInterceptor;
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(loginInterceptor) // 自定义的拦截器添加到系统配置项中
.addPathPatterns("/**") // 拦截所有URL
.excludePathPatterns("/user/login") // 排除 url /user/login 不拦截
.excludePathPatterns("/user/reg")
.excludePathPatterns("/image/**") // 排除 image 文件夹下的所有文件
;
}
}
其中:
addPathPatterns
: 表示需要拦截的 URL,"**
"表示拦截任意方法 (多级的全部方法), "*
"则表示一级目录的所有.excludePathPatterns
: 表示需要排除的 URL。
说明:以上拦截规则可以拦截此项目中的使用 URL,包括静态文件 (图片文件、JS 和 CSS 等文件)排除所有的静态资源
我们来看下代码是否能够实现拦截的目标.
java
@RestController
@RequestMapping("/user")
public class UserController {
@RequestMapping("/login")
public String login() {
return "login";
}
@RequestMapping("/index")
public String index() {
return "index";
}
@RequestMapping("/reg")
public String reg() {
return "reg";
}
}
启动项目后访问验证一下我们这个拦截器:
访问reg, reg是被排除故可访问.
访问index, 被拦截, 无响应信息.
通过F12可以看到index被拦截器拦截了, 这个目标方法没有被调用, 所以没有响应信息.
经过自定义拦截器中的设置可以看到响应:
如果拦截器执行失败了false, 那么后面的代码也不会走, 这个时候当我们返回false的时候, 前端人员如何拿到相关信息以知道是拦截器出错还是代码出错, 还是其他的问题出错?
在LoginInterceptor
中的return false
前添加相关代码, 那么我们使用Servlet的方式打印给前端就可以解决.:
java
response.setContentType("application/json;charset=utf8");
response.getWriter().println("{\"code\":-1,\"msg\":\"登录失败\",\"data\":\"\"}");
启动项目, 访问index, 可以看到通过response打印了相关错误信息.
1.3 拦截器实现原理
对于一个标准的后端程序来说, 正常情况下的调用顺序:
用户访问后端程序, 那么访问的时候无论是用户还是前端程序员, 都是会把请求发送给控制器, 控制器进行参数的校验, 如果校验没问题之后会把请求发送给服务层(也就是调用服务层), 然后服务层再去决定要调用几个Mapper, 然后Mapper会去调用数据库, 数据库会把结果返回给Mapper, 然后按着 来时的路 回去给前端用户.
然而有了拦截器之后,会在调用 Controller 之前先进行相应的业务处理,执行的流程如下图所示:
1.3.1 实现原理源码分析
所有的 Controller 执行都会通过一个调度器 DispatcherServlet
来实现,这一点可以从 Spring Boot 控制台的打印信息看出,如下图所示:
而所有方法都会执行 DispatcherServlet
中的 doDispatch
调度方法。doDispatch源码如下:
java
protected void doDispatch(HttpServletRequest request, HttpServletResponse response) throws Exception {
HttpServletRequest processedRequest = request;
HandlerExecutionChain mappedHandler = null;
boolean multipartRequestParsed = false;
WebAsyncManager asyncManager = WebAsyncUtils.getAsyncManager(request);
try {
try {
ModelAndView mv = null;
Object dispatchException = null;
try {
processedRequest = this.checkMultipart(request);
multipartRequestParsed = processedRequest != request;
mappedHandler = this.getHandler(processedRequest);
if (mappedHandler == null) {
this.noHandlerFound(processedRequest, response);
return;
}
HandlerAdapter ha = this.getHandlerAdapter(mappedHandler.getHandler());
String method = request.getMethod();
boolean isGet = HttpMethod.GET.matches(method);
if (isGet || HttpMethod.HEAD.matches(method)) {
long lastModified = ha.getLastModified(request, mappedHandler.getHandler());
if ((new ServletWebRequest(request, response)).checkNotModified(lastModified) && isGet) {
return;
}
}
// 调用预处理(重点) [执行我们拦截器的代码; 拦截器方法为false就直接返回否则调用Controller]
if (!mappedHandler.applyPreHandle(processedRequest, response)) {
return;
}
// 执行 Controller 中的业务 [执行我们自己方法的代码, 过了拦截器之后的方法]
mv = ha.handle(processedRequest, response, mappedHandler.getHandler());
if (asyncManager.isConcurrentHandlingStarted()) {
return;
}
this.applyDefaultViewName(processedRequest, mv);
mappedHandler.applyPostHandle(processedRequest, response, mv);
} catch (Exception var20) {
dispatchException = var20;
} catch (Throwable var21) {
dispatchException = new NestedServletException("Handler dispatch failed", var21);
}
this.processDispatchResult(processedRequest, response, mappedHandler, mv, (Exception)dispatchException);
} catch (Exception var22) {
this.triggerAfterCompletion(processedRequest, response, mappedHandler, var22);
} catch (Throwable var23) {
this.triggerAfterCompletion(processedRequest, response, mappedHandler, new NestedServletException("Handler processing failed", var23));
}
} finally {
if (asyncManager.isConcurrentHandlingStarted()) {
if (mappedHandler != null) {
mappedHandler.applyAfterConcurrentHandlingStarted(processedRequest, response);
}
} else if (multipartRequestParsed) {
this.cleanupMultipart(processedRequest);
}
}
}
从上述源码可以看出在开始执行 Controller 之前,会先调用预处理方法 applyPreHandle
,而applyPreHandle 方法的实现源码如下:
java
boolean applyPreHandle(HttpServletRequest request, HttpServletResponse response) throws Exception {
for(int i = 0; i < this.interceptorList.size(); this.interceptorIndex = i++) {
// 获取项⽬中使⽤的拦截器 HandlerInterceptor
HandlerInterceptor interceptor = (HandlerInterceptor)this.interceptorList.get(i);
if (!interceptor.preHandle(request, response, this.handler)) {
this.triggerAfterCompletion(request, response, (Exception)null);
return false;
}
}
return true;
}
从上述源码可以看出,在 applyPreHandle 中会获取所有的拦截器 Handlerlinterceptor
并执行拦截器中的 preHandle
方法,这样就会咱们前面定义的拦截器对应上了,如下图所示:
2. 统一异常处理
统一异常处理使用的是 @ControllerAdvice
+@ExceptionHandler
来实现的,@ControllerAdvice
表示控制器通知类,@ExceptionHandler
是异常处理器,两个结合表示当出现异常的时候执行某个通知也就是执行某个方法事件
2.1 创建一个异常处理类
java
@ControllerAdvice
public class MyExceptionAdvice {
}
@ControllerAdvice
是针对于controller
的通知, 是针对于controller的增强方法.
当加了这个注解之后, 它会去监测控制器的异常, 如果控制器发生异常了, 那么底下的类就能感知的到, 感知到之后就能根据写的业务代码将相应的代码返回给前端.
2.2 创建异常检测的类和处理业务方法
拦截方法可以针对不同的拦截去写相应的处理代码.
java
@ControllerAdvice
@ResponseBody // 加在类上表示类中所有方法都可以返回一个 JSON 的数据
public class MyExceptionAdvice {
// 处理空指针异常
// 如果出现了异常就返回给前端一个 HashMap 的对象
@ExceptionHandler(NullPointerException.class)
public HashMap<String, Object> doNullPointerException(NullPointerException e) {
HashMap<String, Object> result = new HashMap<>();
result.put("code", -1);
result.put("msg", "空指针: " + e.getMessage());
result.put("data", null);
return result;
}
// 默认的异常处理(当具体的异常匹配不到时, 会执行此方法)
@ExceptionHandler(Exception.class)
public HashMap<String, Object> doException(Exception e) {
HashMap<String, Object> result = new HashMap<>();
result.put("code", -300);
result.put("msg", "Exception: " + e.getMessage());
result.put("data", null);
return result;
}
}
PS: 方法名和返回值可以自定义,其中最重要的是
@ExceptionHandler(Exception.class)
注解。
java
@RestController
@RequestMapping("/user")
public class UserController {
@RequestMapping("/login")
public int login() {
Object obj = null;
System.out.println(obj.hashCode());
return 1;
}
}
启动以访问:
可以看到, 前端没有报错, 说明通知到了前端.
改为:
java
@RequestMapping("/login")
public int login() {
// Object obj = null;
// System.out.println(obj.hashCode());
int num = 10 / 0;
return 1;
}
再次访问user/login,
说明上面的Advice只是处理了空指针异常.
解决: 在MyExceptionAdvice
中加入默认异常处理:
java
// 默认的异常处理(当具体的异常匹配不到时, 会执行此方法)
@ExceptionHandler(Exception.class)
public HashMap<String, Object> doException(Exception e) {
HashMap<String, Object> result = new HashMap<>();
result.put("code", -300);
result.put("msg", "Exception: " + e.getMessage());
result.put("data", null);
return result;
}
再次访问user/login之后, 可以看到返回了算数异常. 所以说明当子类没有找到相应的异常处理之后, 就会找父类的Exception.
异常较多的时候, 交给前端后, 前端如何处理?
如果异常会走到
MyExceptionAdvice
,就说明这个异常是我们后端程序员不可知的, 所以这个异常检测类是用于意外异常的拦截. 因为正常的业务异常在业务代码中会直接报出, 而意外的异常里面, 重要的是以正常的格式返回给前端的状态码, 至于具体的内容是什么, 大概率前端用不到.
3. 统一数据返回格式
统一数据返回格式的优点有很多,比如以下几个:
- 方便前端程序员更好的接收和解析后端数据接口返回的数据
- 降低前端程序员和后端程序员的沟通成本,任何时候返回的都是状态, 状态描述符, 数据, 按照某个格式实现就行了,因为所有接口都是这样返回的。
- 有利于项目统一数据的维护和修改
- 有利于后端技术部门的统一规范的标准制定,不会出现稀奇古怪的返回内容
3.1 统一数据返回的实现
统一的数据返回格式可以使用 @ControllerAdvice
+ ResponseBodyAdvice
的方式实现,当写下面这些代码的时候, 就相当于是在返回之前做了一个拦截操作, 所有的返回之前都会走这里面的两个重写的方法(即在返回之前进行数据重写), 具体实现代码如下:
java
@ControllerAdvice // 第一步
public class ResponseAdvice implements ResponseBodyAdvice {
// 第二步, 实现ResponseBodyAdvice接口并重写supports()与beforeBodyWrite()
// 是否执行 beforeBodyWrite 方法, true=执行, 重写返回结果
@Override
public boolean supports(MethodParameter returnType, Class converterType) {
return true;
}
// 返回数据之前进行数据重写, body是业务代码的返回结果, 即原始返回值
@SneakyThrows
@Override
public Object beforeBodyWrite(Object body, MethodParameter returnType, MediaType selectedContentType, Class selectedConverterType, ServerHttpRequest request, ServerHttpResponse response) {
if (body instanceof String){
// 返回一个 将对象转换成 JSON String 字符串
// objectMapper.writeValueAsString(request);
return "{\"code\":200,\"msg\":\"\",\"data\":\"" + body +"\"}";
}
// 假定标准的返回值为 HashMap<String,Object>
// 相关属性为 code,msg,data
// 判断返回类型是否符合假定的标准返回值
if (body instanceof HashMap) {
return body; // 符合假定的标准返回值则直接返回body
}
// 不符合假定的标准返回值则 重写返回结果, 让其返回一个统一的数据格式
HashMap<String, Object> result = new HashMap<>();
result.put("code", 200);
result.put("data", body);
result.put("msg", "");
return result;
}
}
java
// 返回 int 类型
@RequestMapping("/login")
public int login() {
return 1;
}
// 返回假定的标准数据格式
@RequestMapping("/reg")
public HashMap<String, Object> reg() {
HashMap<String, Object> result = new HashMap<>();
result.put("code", 200);
result.put("data", 1);
result.put("msg", "");
return result;
}
可以看到, 返回的都是标准的数据格式.
3.2 返回String报错问题
但是, 统一异常处理在遇到String的时候返回会报错.
java
@RequestMapping("/sayHi")
public String sayHi() {
return "say hi";
}
异常日志:
2023-08-19 17:17:19.398 WARN 9316 --- [nio-8080-exec-8] .m.m.a.ExceptionHandlerExceptionResolver : Resolved [java.lang.ClassCastException: java.util.HashMap cannot be cast to java.lang.String]
注: 这里是由于之前设置了拦截器所以报了异常状态码是-300, 如果没有拦截器会直接报错500, 报错如下图.
没有拦截器的500报错图:
在异常状态码为-300的日志中可以看到, 显示了HashMap不能转换为String.
那么为什么我们明明在重写beforeBodyWrite()
的时候已经是将String转换成HashMap了, 为什么又报错HashMap不能转换为String?
首先, String转换的执行流程是分为以下三步:
- 方法返回了String
- 统一数据返回之前的处理是: 将String转换成HashMap.
- 最终将HashMap转换成application/json字符串返回给前端(接口).
所以HashMap转换成String出错是在第三步发生的, 也就是异常日志的内容.
那么出错的原因就是在第三步时程序会去判断原body的类型是什么, 根据body的类型来选择相应的消息转换器进行转换. 也就是下面两种情况:
- 如果是String, 那么它就会使用一个叫做
StringHttpMessageConverter
的转换器进行类型的转换. - 如果不是String, 那么它就会使用
HttpMessageConverter
的转换器进行类型转换.
正是因为上面选择转换器进行类型转换的动作, 所以就会触发bug, 在判断的时候是使用原类型进行判断的, 但是在转换的时候是拿HashMap进行转换的, 这个时候使用StringHttpMessageConverter去转换HashMap的时候这个转换器试图将HashMap转换成String JSON字符串, 但会发现无法转换, 此时就会直接报错. (可以认为这是Spring MVC在设计上的问题)
问题解决
- 将StringHttpMessageConverter这个转换器从项目中去掉.
我们可以通过修改当前项目的配置文件, 然后把StringHttpMessageConverter去掉, 这个时候就只能使用HttpMessageConverter来进行转换, 这个时候它就不会出错.
新建MyConfig类, 进行相关配置.
java
// 移除StringHttpMessageConverter
@Configuration // 第一步, 加入 Spring 中
public class MyConfig implements WebMvcConfigurer { // 实现 WebMvcConfigurer, 这样当前类才是一个系统配置项
@Override
public void configureMessageConverters(List<HttpMessageConverter<?>> converters) {
converters.removeIf(converter -> converter instanceof StringHttpMessageConverter);
}
}
- 在统一数据返回格式代码重写时, 单独处理String类型, 让其返回一个String字符串,而非HashMap.
java
@ControllerAdvice
public class ResponseAdvice implements ResponseBodyAdvice {
@Autowired
private ObjectMapper objectMapper;
// ..此处省略 supports重写的代码, 节省篇幅, 具体代码同前文
@SneakyThrows
@Override
public Object beforeBodyWrite(Object body, MethodParameter returnType, MediaType selectedContentType, Class selectedConverterType, ServerHttpRequest request, ServerHttpResponse response) {
// ..此处省略 body类型判断 以及 重写HashMap 代码, 见前文
if (body instanceof String){
// 返回一个 将对象转换成 JSON String 字符串
return objectMapper.writeValueAsString(result);
// return "{\"code\":200,\"msg\":\"\",\"data\":\"" + body +"\"}";
}
return result;
}
}