前言
在日常使用 Spring 框架进行开发的时候,对于一些板块来说,可能需要实现一个相同的功能,这个功能可以是验证你的登录信息,也可以是其他的,但是由于各个板块实现这个功能的代码逻辑都是相同的,如果一个板块一个板块进行添加的话,开发效率就会很低,所以 Spring 也想到了这点,为我们程序员提供了 SpringBoot 统一功能处理的方法实现,我们是可以直接使用的。这篇文章我将带大家一起学习 SpringBoot 统一功能的处理。
1. 拦截器
正常的判断用户是否登录的逻辑就是通过 session 来判断,对于一些网站来说,很多的功能都是需要用户进行登录之后才可以使用的,如果此时通过 session 判断出来用户处于未登录状态的话,咱们的服务器会强制用户进行登录,通过代码来显示就是这样的:
//检验用户是否登录 HttpSession session = request.getSession(false); if (session == null || session.getAttribute("userInfo") == null) { return "您未登录,请登录后再试试该功能吧"; }
我们想要在哪个功能前判断用户的登录信息,就需要在该功能的模块中添加上面这些代码,如果需要添加的功能较少还好,如果很多,那么就需要花费很多的时间,那么有人就说了:我是否可以将这些代码封装成函数,然后哪个模块需要使用只需要调用这些函数就可以了呢?可以是可以,但是使用函数封装代码还是需要在原代码的基础上调用这个函数,并且显得也不是那么优雅。那么是否有方法既不需要更改原代码,也可以使得我们写的代码很优雅呢?SpringBoot 为我们提供了一种功能——拦截器。
1.1 什么是拦截器
在 Spring Boot 中,拦截器是一种用于在处理请求之前或之后执行特定操作的组件。拦截器通常用于实现一些通用的功能,比如权限验证、日志记录等。拦截器可以拦截通过Controller的请求,并在请求处理前后执行特定的操作。
拦截器的思想正好符合我们执行其他功能之前进行身份验证验证,并且不仅在方法执行之前拦截器可以起作用,方法执行之后。我们的拦截器也可以起到作用。
1.2 拦截器的使用
拦截器的使用分为两个步骤:
- 定义拦截器
- 注册配置拦截器
1.2.1 自定义拦截器
我们自定义的拦截器需要实现 HandlerInterceptor 接口,并且重写这个接口中的方法。
package com.example.springbootbook2.interceptor; import lombok.extern.slf4j.Slf4j; import org.springframework.web.servlet.HandlerInterceptor; import org.springframework.web.servlet.ModelAndView; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import javax.servlet.http.HttpSession; @Component @Slf4j public class LoginInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { log.info("目标方法执行前执行..."); HttpSession session = request.getSession(false); if (session == null || session.getAttribute("userInfo") == null) { response.setStatus(401); return false; } return true; } @Override public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception { log.info("目标方法执行后执行..."); } @Override public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception { log.info("视图渲染完毕之后执行,最后执行..."); } }
preHandle() 方法是在目标方法执行之前执行的,返回 true,继续执行后面的代码;返回 false,中断后续的操作
postHandle() 方法是在目标方法执行之后执行的
afterCompletion() 方法是在视图渲染之后才执行的,它还在 postHandle() 方法之后执行,并且现在因为前后端分离,我们后端基本上接触不到视图的渲染,所以这个方法使用的较少
1.2.2 注册配置拦截器
注册配置拦截器需要实现 WebMvcConfiguer 接口,并实现 addInterceptors 方法。
package com.example.springbootbook2.config; import com.example.springbootbook2.interceptor.LoginInterceptor; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.context.annotation.Configuration; import org.springframework.web.servlet.config.annotation.InterceptorRegistry; import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; @Configuration public class WebConfig implements WebMvcConfigurer { @Autowired private LoginInterceptor loginInterceptor; @Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(loginInterceptor) .addPathPatterns("/**") //指定路径配置拦截器 .excludePathPatterns("/user/login"); //指定路径不配置拦截器 } }
在添加拦截器进行身份校验之前,我们可以直接通过对应的 url 访问到指定功能页面。
而在我们添加拦截器之后,再看是否能直接访问某些功能。
我们启动添加了拦截器之后的代码之后,点击刷新就发现不能够直接访问到某些功能了,再搭配着前端我们就可以实现强制登录的功能了。
1.3 拦截器详解
拦截器的⼊⻔程序完成之后,接下来我们来介绍拦截器的使⽤细节。拦截器的使⽤细节我们主要介绍两个部分:
- 拦截器的拦截路径配置
- 拦截器实现原理
1.3.1 拦截路径
拦截路径是指我们定义的这个拦截器,对哪些请求生效,我们在注册配置拦截器的时候,通过 addPathPatterns() 方法指定要拦截哪些 HTTP 请求,也可以通过 excludePathPatterns() 方法指定不拦截哪些请求,上面我们的 /** 表示拦截所有的 HTTP 请求,除了可以设置拦截所有请求外,还有一些其他的拦截设置:
拦截路径 | 含义 | 举例 |
/* | ⼀级路径 | 能匹配/user,/book,/login,不能匹配 /user/login |
/** | 任意级路径 | 能匹配/user,/user/login,/user/reg |
/book/* | /book下的⼀级路径 | 能匹配/book/addBook,不能匹配/book/addBook/1,/book |
/book/** | /book下的任意级路径 | 能匹配/book,/book/addBook,/book/addBook/2,不能匹配/user/login |
这些拦截规则可以拦截此项⽬中的使⽤ URL,包括静态⽂件(图⽚⽂件、JS、和 CSS 等⽂件)。
知道了如何配置拦截路径之后,我们就可以解决网页上身份验证的问题了。因为 /**
会拦截所有的请求,包括前端页面请求,所以我们需要将前端页面请求排除在拦截之外。
如果我们不讲前端页面请求在外的话机会出现这种情况:
然后我们将前端页面请求不添加拦截器的话,就可以实现完整的身份校验强制登录功能了。
@Configuration public class WebConfig implements WebMvcConfigurer { @Autowired private LoginInterceptor loginInterceptor; private List<String> excludePath = Arrays.asList("/user/login", "/css/**", "/js/**", "/pic/**", "/**/*.html"); // /**/*html中的/**表示所有路径下的所有以html结尾的文件 * 表示通配符 @Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(loginInterceptor) .addPathPatterns("/**") .excludePathPatterns(excludePath); } }
当我们未登录,然后访问其他功能的话,就会强制跳转到登录页面:
1.3.2 拦截器执行流程
正常的调用顺序是这样的:
有了拦截器之后,会在调⽤ Controller 之前进⾏相应的业务处理,执⾏的流程如下图:
当我们添加拦截器之后,在执行 Controller 方法之前,请求会先被拦截器拦截,执行 preHandle() 方法,这个方法会返回一个布尔类型的值,如果返回的是 true,那么会继续执行后面的操作;如果返回的是 false,则不会执行后面的操作
Controller 当中的方法执行完毕之后,会继续执行 postHandle() 方法以及 afterHandle() 方法,执行完毕之后,返回给浏览器响应数据。
在源码中的 DispatcherServlet 类中的 doDispatch 方法中可以看到这三个方法的执行流程:
1.3.3 适配器模式
在拦截器执行的过程中还使用了适配器模式:
适配器模式(Adapter Pattern)在计算机编程中是一种常用的设计模式,主要用于解决两个不兼容的类之间的接口匹配问题。通过将一个类的接口转换成客户端所期望的另一种接口,原本因为接口不匹配而无法一起工作的两个类现在可以一起工作。
适配器模式的优点在于它能够使原本由于接口不兼容而无法一起工作的类一起工作,提高了系统的灵活性和可扩展性。同时,它也能够减少代码的重复性,因为多个源可以共享同一个适配器。
HandlerAdapter 主要⽤于⽀持不同类型的处理器(如 Controller、HttpRequestHandler 或者
Servlet 等),让它们能够适配统⼀的请求处理流程。这样,SpringMVC可以通过⼀个统⼀的接⼝
来处理来⾃各种处理器的请求。
本来 target 和 adaptee 是两个无法正常对接的事物,但是通过适配器 adapter,这两者之间就可以进行对接,也就类似于下面这个情况:
适配器模式⻆⾊:
Target:⽬标接口(可以是抽象类或接口)客户希望直接⽤的接口
Adaptee:适配者,但是与Target不兼容
Adapter:适配器类,此模式的核⼼。通过继承或者引⽤适配者的对象,把适配者转为⽬标接⼝
client:需要使⽤适配器的对象
前⾯学习的 slf4j 就使⽤了适配器模式,slf4j 提供了⼀系列打印⽇志的 api,底层调⽤的是 log4j 或者logback 来打⽇志,我们作为调⽤者,只需要调⽤ slf4j 的 api 就⾏了。
/** * slf4j接⼝ */ interface Slf4jApi{ void log(String message); } /** * log4j 接⼝ */ class Log4j{ void log4jLog(String message){ System.out.println("Log4j打印:"+message); } } /** * slf4j和log4j适配器 */ class Slf4jLog4JAdapter implements Slf4jApi{ private Log4j log4j; public Slf4jLog4JAdapter(Log4j log4j) { this.log4j = log4j; } @Override public void log(String message) { log4j.log4jLog(message); } } /** * 客⼾端调⽤ */ public class Slf4jDemo { public static void main(String[] args) { Slf4jApi slf4jApi = new Slf4jLog4JAdapter(new Log4j()); slf4jApi.log("使⽤slf4j打印⽇志"); } }
可以看出,我们不需要改变 log4j 的api,只需要通过适配器转换下,就可以更换⽇志框架,保障系统的平稳运行。
⼀般来说,适配器模式可以看作⼀种"补偿模式",⽤来补救设计上的缺陷。应⽤这种模式算是"⽆奈之举",如果在设计初期,我们就能协调规避接⼝不兼容的问题,就不需要使⽤适配器模式了。
所以适配器模式更多的应⽤场景主要是对正在运⾏的代码进⾏改造,并且希望可以复⽤原有代码实现新的功能.。⽐如版本升级等。
2. 统一数据返回格式
如果我们完成了一个项目的开发,但是这时候,我们突然觉得后端的各个功能的返回值返回的信息不够全面,我们需要补充一些返回信息,那么这时候就意味着所有方法的返回值都需要做出修改,这也是一个不小的工作量。这时 SpringBot 又为我们提供了解决方法——统一数据返回格式。
SpringBoot 统一数据返回格式会在各个方法进行 return 返回值之前插入一些代码逻辑,从而达到改变返回值的功能。
统一的数据返回格式使用 @ControllerAdvice 和 ResponseBodyAdvice 的方式实现。ControllerAdvice 表示控制器通知类
public enum ResultCode { SUCCESS(0), FAIL(-1), UNLOGIN(-2); //0-成功 -1 失败 -2 未登录 private int code; ResultCode(int code) { this.code = code; } public int getCode() { return code; } public void setCode(int code) { this.code = code; } }
@Data public class Result<T> { /** * 业务状态码 */ private ResultCode code; //0-成功 -1 失败 -2 未登录 /** * 错误信息 */ private String errMsg; /** * 数据 */ private T data; public static <T> Result<T> success(T data){ Result result = new Result(); result.setCode(ResultCode.SUCCESS); result.setErrMsg(""); result.setData(data); return result; } public static <T> Result<T> fail(String errMsg){ Result result = new Result(); result.setCode(ResultCode.FAIL); result.setErrMsg(errMsg); result.setData(null); return result; } public static <T> Result<T> fail(String errMsg,Object data){ Result result = new Result(); result.setCode(ResultCode.FAIL); result.setErrMsg(errMsg); result.setData(data); return result; } public static <T> Result<T> unlogin(){ Result result = new Result(); result.setCode(ResultCode.UNLOGIN); result.setErrMsg("用户未登录"); result.setData(null); return result; } }
package com.example.springbootbook2.config; import com.example.springbootbook2.model.Result; import org.springframework.core.MethodParameter; import org.springframework.http.MediaType; import org.springframework.http.server.ServerHttpRequest; import org.springframework.http.server.ServerHttpResponse; import org.springframework.web.bind.annotation.ControllerAdvice; import org.springframework.web.servlet.mvc.method.annotation.ResponseBodyAdvice; @ControllerAdvice public class ResponseAdvice implements ResponseBodyAdvice { @Override public boolean supports(MethodParameter returnType, Class converterType) { return true; } @Override public Object beforeBodyWrite(Object body, MethodParameter returnType, MediaType selectedContentType, Class selectedConverterType, ServerHttpRequest request, ServerHttpResponse response) { return Result.success(body); } }
继承 ResponseBodyAdvice 接口后,需要实现该接口下的 supports 方法和 beforeBodyWrite 方法,supports 方法只需要更改返回值为 true 就可以了,表示是否要执行 beforeBodyWrite 方法,返回 true 表示执行,false 表示不执行,beforeBodyWrite 方法中的 body 参数就是我们原方法的返回值。
当我们关闭拦截器,访问图书列表页之后,就发现我们的返回类型发生了变化:
但是这个统一数据返回格式也存在问题,当我们的返回值为 String 类型的话机会出现错误:
这是为什么呢?SpringMVC默认会注册⼀些⾃带的 HttpMessageConverter (从先后顺序排列分别为
ByteArrayHttpMessageConverter ,StringHttpMessageConverter , SourceHttpMessageConverter ,AllEncompassingFormHttpMessageConverter )
其中 AllEncompassingFormHttpMessageConverter 会根据项⽬依赖情况添加对应的
HttpMessageConverter
在依赖中引⼊jackson包后,容器会把 MappingJackson2HttpMessageConverter ⾃动注册到
messageConverters 链的末尾。Spring会根据返回的数据类型,从 messageConverters 链选择合适的 HttpMessageConverter。当返回的数据是⾮字符串时,使⽤的MappingJackson2HttpMessageConverter 写⼊返回对象。当返回的数据是字符串时, StringHttpMessageConverter 会先被遍历到,这时会认为
StringHttpMessageConverter 可以使用。
然⽽⼦类 StringHttpMessageConverter 的addDefaultHeaders⽅法定义接收参数为String,此
时t为Result类型,所以出现类型不匹配"Result cannot be cast to java.lang.String"的异常。
那么如何解决返回类型为 String 类型的问题呢?如果返回结果为String类型, 使⽤SpringBoot内置提供的Jackson来实现信息的序列化。
@ControllerAdvice public class ResponseAdvice implements ResponseBodyAdvice { @Autowired private ObjectMapper objectMapper; @Override public boolean supports(MethodParameter returnType, Class converterType) { return true; } @SneakyThrows @Override public Object beforeBodyWrite(Object body, MethodParameter returnType, MediaType selectedContentType, Class selectedConverterType, ServerHttpRequest request, ServerHttpResponse response) { if (body instanceof String) { return objectMapper.writeValueAsString(Result.success(body)); }else if (body instanceof Result) { return body; }else { return Result.success(body); } } }
@SneakyThrows 的主要目的是解决 Java 的异常处理问题。当我们在代码中抛出一个异常时,如果这个异常被包裹在一个方法中,并且这个方法没有 throws 关键字来声明会抛出这个异常,那么编译器会报错。通过使用 @SneakyThrows,你可以告诉编译器:“我知道这个方法可能会抛出异常,但我保证在 catch 块中处理它。” 这样编译器就不会报错了。
通过这个处理,当返回的数据类型为 String 的时候就不会出现错误了。
统一数据返回格式的优点:
- ⽅便前端程序员更好的接收和解析后端数据接口返回的数据
- 降低前端程序员和后端程序员的沟通成本,按照某个格式实现就可以了,因为所有接口都是这样返回的
- 有利于项目统⼀数据的维护和修改
- 有利于后端技术部⻔的统⼀规范的标准制定,不会出现稀奇古怪的返回内容
3. 统一异常处理
当我们的程序中出现异常的时候,我们需要解决这些异常,因为前面做了统一数据返回格式的处理,所以这里的异常也可以进行统一的处理。
统⼀异常处理使⽤的是 @ControllerAdvice + @ExceptionHandler 来实现的,@ControllerAdvice 表⽰控制器通知类, @ExceptionHandler 是异常处理器,两个结合表示当出现异常的时候执行某个通知,也就是执⾏某个⽅法事件。
package com.example.springbootbook2.config; import com.example.springbootbook2.model.Result; import lombok.extern.slf4j.Slf4j; import org.springframework.web.bind.annotation.ControllerAdvice; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.ResponseBody; @ControllerAdvice @Slf4j @ResponseBody //因为返回的数据都不是视图类型,所以加上这个注解防止出现问题 public class ErrorHandler { @ExceptionHandler public Result<String> exception(Exception e) { log.error("发生异常:e{}",e); return Result.fail("内部异常"); } @ExceptionHandler public Result<String> exception(NullPointerException e) { log.error("发生异常:e{}",e); return Result.fail("NullPointerException异常,请联系管理员"); } @ExceptionHandler public Result<String> exception(ArithmeticException e) { log.error("发生异常:e{}",e); return Result.fail("ArithmeticException异常,请联系管理员"); } }
@Controller @RequestMapping("/test") public class TestController { @RequestMapping("/t1") public Integer test1() { return 10/0; } }
这个 Exception 和 ArithmeticException 的先后顺序可以不考虑,这个不会因为 Exception 在前面捕获就报的 Exception 异常,而是会根据自己出现的最接近的异常来捕获。