一、什么是统一功能处理
回忆图书管理系统,每个功能都有 3 部分业务逻辑是重复的:
- 判断是否登录,未登录则强制登录。
- Result 包装接口返回值,使接口返回值统一。
- 处理抛出的异常,返回结果为 fail。


为了统一处理重复的业务逻辑,减少无用劳动,我们需学习 Spring Boot 统一功能处理。
二、拦截器
拦截器(保安的作用),就是在目标方法前后 (比如添加图书),执行预设的业务逻辑。强制登录功能的统一处理,可以用拦截器实现。
1、拦截器的使用
两步:定义和注册拦截器。
(1)定义拦截器
实现 HandlerInterceptor 接口,定义拦截器的业务逻辑:可以重写 3 个方法。
java
package com.edu.springbookdemo.interceptor;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.servlet.HandlerInterceptor;
import org.springframework.web.servlet.ModelAndView;
@Slf4j
public class LoginInterceptor implements HandlerInterceptor {
// 目标方法执行前,执行 preHandle
// 返回 true 继续执行目标方法;返回 false 中断执行目标方法
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
log.info("目标方法执行前,执行 preHandle");
return true;
}
// 目标方法执行后,执行 postHandle
@Override
public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception {
log.info("目标方法执行后,执行 postHandle");
}
// 视图渲染完毕后,执行 afterCompletion(目前流行前后端完全分离,所以这个方法不怎么用)
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
log.info("视图渲染完毕后,执行 afterCompletion");
}
}
(2)注册拦截器
将定义好的拦截器放到 Spring Boot 项目中:重写添加拦截器方法。
java
package com.edu.springbookdemo.configuration;
import com.edu.springbookdemo.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 // 一定要加这个,Spring Boot 才能管理这个注册类
public class WebConfig implements WebMvcConfigurer {
// new 一个 LoginInterceptors 实例也可以,因为项目启动只会注册拦截器一次
@Autowired
private LoginInterceptor loginInterceptor;
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(loginInterceptor) // 添加拦截器
.addPathPatterns("/**"); // 拦截所有请求
}
}
若 preHandle 返回 true,继续执行 目标方法:

若 preHandle 返回 false,中断执行 目标方法:

2、拦截路径
注册拦截器时使用:
- addPathPatterns 添加到拦截路径。
- excludePathPatterns 排除出拦截路径。
|----------------|--------------------------------------------|
| 拦截路径 | 含义 |
| /* | 所有一级路径。如:/book |
| /** | 所有任意级路径。如:/book、/user/addUser ...... |
| /book/* | /book 下的一级路径。如:/book/addBook |
| /book/** | /book 下的任意级路径。如:/book、/book/addBook ...... |
3、使用拦截器实现强制登陆
(1)定义拦截器
删除在 Controller 中的强制登陆逻辑。
java
package com.edu.springbookdemo.interceptor;
import com.edu.springbookdemo.constant.Constants;
import com.edu.springbookdemo.entity.Result;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import jakarta.servlet.http.HttpSession;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import org.springframework.web.servlet.HandlerInterceptor;
import java.nio.charset.StandardCharsets;
@Slf4j
@Component // 目的:注册拦截器时注入使用
public class LoginInterceptor implements HandlerInterceptor {
@Autowired
private ObjectMapper mapper;
// 目标方法执行前,执行 preHandle
// 返回 true 继续执行目标方法;返回 false 中断执行目标方法
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
HttpSession session = request.getSession(false); // 如果没有会议,返回 null
// 登录状态
if (session != null && session.getAttribute(Constants.SESSION_USER_NAME) != null) {
// 继续执行目标方法
return true;
}
// 未登录,强制登录,拦截请求
Result<?> result = Result.unLogin();
// 注明返回类型为json,编码为UTF-8(含中文)
response.setContentType("application/json;charset=UTF-8");
// 对象转为 json 格式的字符串
String json = mapper.writeValueAsString(result);
// 将 json 格式的字符串写入到 response 中,以 UTF-8 编码
response.getOutputStream().write(json.getBytes(StandardCharsets.UTF_8));
// 设置状态码 401 未授权
response.setStatus(401);
// 结束请求
return false;
}
}
(2)注册拦截器
登陆界面不能拦截,拦截了就无法登录,也就无法解除拦截。静态资源也不能拦截,用户需要看到界面。
java
package com.edu.springbookdemo.configuration;
import com.edu.springbookdemo.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;
import java.util.List;
@Configuration // 一定要加这个,Spring Boot 才能管理这个注册类
public class WebConfig implements WebMvcConfigurer {
// new 一个 LoginInterceptors 实例也可以,因为项目启动只会注册拦截器一次
@Autowired
private LoginInterceptor loginInterceptor;
@Override
public void addInterceptors(InterceptorRegistry registry) {
// 登陆界面、静态资源不能拦截
List<String> excludePathPatterns = List.of("/user/login",
"/**/*.html",
"/css/**",
"/js/**",
"/pic/**");
registry.addInterceptor(loginInterceptor) // 添加拦截器
.addPathPatterns("/**") // 拦截所有请求
.excludePathPatterns(excludePathPatterns); // 排除登录页面
}
}
4、源码分析
(1)DispatcherServlet(了解)
应用启动完成后,第一次访问程序接口,会打印日志:

这个 DispatcherServlet 是什么?它本质上是 Servlet 的实现 ,从 Servlet(Tomcat 的核心类) 层层包装为 DispatcherServlet(Spring 的核心类 ),作用是总调度程序的执行顺序(让 Spring 知道该运行哪个接口)。
回顾一下,Servlet 是 Java Web 开发的规范,所以它需要被实现,可以是 JSP 实现、也可以是 Tomcat 实现,而 DispatcherServlet 包装的 Servlet就是由 Tomcat 实现的,存在于 Tomcat 核心类中:

Servlet 接口中有 3 个核心方法:管理着整个应用的生命周期。
- init :对 Servlet 容器进行初始化,在完成之前无法接收任何请求。

- service:循环处理请求。

- destroy:停止调用 service,销毁所有资源。

(2)初始化 init(了解)
主要功能:获取配置参数,初始化容器资源 。目的是建立让应用能够正常执行的上下文环境。
DispatcherServlet 的 init 方法在HttpServletBean中实现。

initServletBean 在 FrameworkServlet 中实现。打印日志、异常捕获,都不是核心。
为了避免混乱,从此时使用快捷键:crl+alt+左箭头 (上一个点击的方法);crl+alt+右箭头(下一个点击的方法)。可以从核心方法名猜测:初始化上下文环境。

initWebApplicationContext 方法中有很多内容,但是返回值是 wac ,所以可以进行debug 看看正常启动应用经过了哪些对 wac 的操作。经过的对 wac 的操作就打上断点,这些就是核心操作。





比如其中的一个文件上传资源组件初始化:

(3)处理请求 service(核心)
service 中执行了一个核心方法 doService,doService 中又执行了一个核心方法 doDispatch ,在 DispatcherServlet 类中,作用是:接收到一个请求后 ,通过处理器(Handler)和处理器的适配器(HandlerAdapter)来找到该请求由哪个接口处理。
主要看 try 里面的内容:

处理器可能是 Controller 的方法,也可能是 Servlet 的方法,通过适配器转为统一的 handle 方法:



查看应用 preHandler:

查看应用 postHandler:

总结:应用程序是怎么执行的 ?应用程序的启动,会执行 DispatcherServlet,它是 Servlet 的实现,管理着应用程序的生命周期和环境资源。生命周期主要为初始化、循环处理请求、销毁三个阶段。DispatcherServlet 中,init 方法实现初始化,在控制台打印日志,初始化环境资源;service 方法最终循环调用 doDispatch 方法,获得请求的处理器、适配器,执行拦截器、请求的目标方法逻辑。处理器就能映射获取到请求对应的接口了,为什么还需要适配器?旧接口的设计不知道以后需要统一调用,因此接口名设计得不一致(Controller 处理器和 Servlet 处理器的接口名不一样)。在 Spring 的 doDispatch 中需要统一调用 handle 接口,为了弥补旧的设计,采用适配器模式,将不同种类处理器的不同名的接口适配到 handle 接口。
(4)适配器模式
将一个接口使用对应适配器类,转换为客户端期望的接口。
比如 log4j、logback 两个不同的日志工具(先出现的),由 slf4j 门面类统一操作(后出现的)。设定 log4j 打印日志是 print 方法,logback 打印日志是 log 方法,而 slf4j 打印日志是 logger 方法,就需要用适配器转换为 logger 方法。
这种设计模型属于无奈之举 ,如果一开始就知道 slf4j 这个需求,完全可以把所有日志工具的打印设计为 logger,但是 log4j、logback 设计时并不知道未来有 slf4j ,同时也不能修改原有的代码 ,就只能使用适配器了。
日志案例实现适配器模型:
两种日志和门面:
java
package com.edu.springbookdemo.adapter;
public class Log4jApi {
public void print(String message) {
System.out.println("使用 Log4j 打印日志: " + message);
}
}
java
package com.edu.springbookdemo.adapter;
public class LogbackApi {
public void log(String message) {
System.out.println("使用 Logback 打印日志: " + message);
}
}
java
package com.edu.springbookdemo.adapter;
public interface Slf4jApi {
void logger(String message);
}
适配器,不同日志接口适配门面:
java
package com.edu.springbookdemo.adapter;
public class Log4jAdapter implements Slf4jApi{
private final Log4jApi log4jApi;
public Log4jAdapter(Log4jApi log4jApi) {
this.log4jApi = log4jApi;
}
@Override
public void logger(String message) {
log4jApi.print(message);
}
}
java
package com.edu.springbookdemo.adapter;
public class LogbackAdapter implements Slf4jApi {
private final LogbackApi logbackApi;
public LogbackAdapter(LogbackApi logbackApi) {
this.logbackApi = logbackApi;
}
@Override
public void logger(String message) {
logbackApi.log(message);
}
}
使用适配器:
java
package com.edu.springbookdemo.adapter;
public class Main {
public static void main(String[] args) {
LogbackApi logbackApi = new LogbackApi();
LogbackAdapter logbackAdapter = new LogbackAdapter(logbackApi);
logbackAdapter.logger("Hello World");
}
}
三、统一数据返回格式
在图书管理系统中,每个接口的返回值需要用 Result 统一包装,我们现在学习统一数据返回格式,不用在每个 Controller 接口中分别处理返回值。
1、使用
定义 ResponseAdvice 类,实现 ResponseBodyAdvice 接口 ,在类上使用 @ControllerAdvice 注解 。重写 supports 方法,选择哪些类、方法执行 beforeBodyWrite 方法,true 表示执行、false 表示不执行。重写 beforeBodyWrite 方法 ,将返回值 body 进行处理,统一数据返回格式。
java
@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);
}
}
2、存在的问题
(1)返回值出现多层 Result 包装
返回值包装了两次:

原因:部分接口中包装了 Result,beforeBodyWrite 中又重复包装了 Result。
解决办法:当 body 类型为 Result 时,直接返回。
(2)当 body 是 String 类型时报错
当 body 是 String 类型,http 状态码 500(程序出现异常),但数据库操作成功:

程序报错:Result 类型强制转换为 String 类型失败。

debug 错误分析:根据控制台的异常信息栈,定位最顶部的几个方法抛出异常的位置打断点。此时参数 t 的类型是 Result。

但是该方法接收的参数 s 类型要求是 String,因此报错。

解决办法:当 body 为 String 类型时,将 Result 结果转为 json 字符串返回 。为了接口的返回值在响应正文中是 json 格式 ,使用 RequestMapping 中的 produces 属性指定。
(3)修改后的代码
java
package com.edu.springbookdemo.configuration;
import com.edu.springbookdemo.entity.Result;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.SneakyThrows;
import org.springframework.beans.factory.annotation.Autowired;
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 {
@Autowired
private ObjectMapper mapper;
@Override
public boolean supports(MethodParameter returnType, Class converterType) {
return true;
}
@SneakyThrows // 自动 catch 异常(转为 Json 字符串时,可能触发异常)
@Override
public Object beforeBodyWrite(Object body, MethodParameter returnType, MediaType selectedContentType, Class selectedConverterType, ServerHttpRequest request, ServerHttpResponse response) {
if(body instanceof Result) {
return body;
}
if (body instanceof String) {
return mapper.writeValueAsString(Result.success(body));
}
return Result.success(body);
}
}
(4)源码分析
在初始化阶段,会创建很多种类的 messageConvert 对象:

追溯异常调用栈:

首先调用 AbstractMessageConverterMethoudProcessor 里的 writeWhithMessageConverters 方法。其中又调用了 write 方法:

抽象类里的 write 调用了抽象类的 addDefaultHeaders,它的参数 t 类型是泛型:


当返回值类型是 String 时,会被 StringHttpMessageConverter 类的 supports 方法识别到:

因此会使用 StringHttpMessageConverter,而它重写了 addDefaultHeaders,参数是 String 类型:

总结:不同的返回值类型,可能会使用不同类型的 messageConverter 对象。有些返回值类型调用的 messageConverter 没有重写抽象 messageConverter 的 addDefaultHeaders 方法,参数 t 是泛型,因此可以接收任意类型的统一处理后的返回值,不会报错。而String 类型的返回值,使用的 StringHttpMessageConverter,它重写了父类的 addDefaultHeaders 方法,参数 t 是 String 类型,因此只能接收 String 类型的统一处理后的返回值。
四、统一异常处理
响应状态明显是异常的,但业务逻辑的状态却是正常的,这容易导致明明程序出错前端却当作正常情况处理。正常的效果应该是,响应状态错误,那么业务状态也错误;响应状态正确,那么业务状态可能错误。

解决办法:当抛出异常时,统一处理异常为 Result.fail。
(1)实现
使用 @ControllerAdvice (通知控制器,交给 String 管理)和 @ExceptionHandler (异常处理器,定义异常的逻辑)来实现。因为返回的是数据,类需要加上 @ResponseBody 注解(不加的话,因为只有 @ControllerAdvice,返回值会被当作视图路径处理 )。
除了程序内部抛出的异常,我们还有自定义的参数校验不合法异常 、数据库操作失败异常。
java
package com.edu.springbookdemo.exception;
public class InvalidParameterException extends RuntimeException {
public InvalidParameterException(String message) {
super(message);
}
}
java
package com.edu.springbookdemo.exception;
public class DatabaseOperationException extends RuntimeException {
public DatabaseOperationException(String message) {
super(message);
}
}
java
package com.edu.springbookdemo.configuration;
import com.edu.springbookdemo.entity.Result;
import com.edu.springbookdemo.exception.DatabaseOperationException;
import com.edu.springbookdemo.exception.InvalidParameterException;
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
@ResponseBody
@Slf4j
public class ExceptionAdvice {
// 第一种注解使用方法:将异常类型放到方法参数中
@ExceptionHandler
public Result<?> handleException(Exception e) {
log.error("发生异常:" + e.getMessage());
return Result.fail(-1, "内部错误,请联系管理员");
}
@ExceptionHandler
public Result<?> handleException(InvalidParameterException e) {
log.error(e.getMessage());
return Result.fail(-2, e.getMessage());
}
// 第二种注解使用方法:将异常类型放到注解中
@ExceptionHandler(DatabaseOperationException.class)
public Result<?> handleException(Exception e) {
log.error(e.getMessage());
return Result.fail(-3, e.getMessage());
}
}

(2)源码分析
为什么 加入**@ControllerAdvice、@ExceptionHandler** 注解后,应用程序就能找到 我们定义的统一返回值处理类和统一异常处理类的匹配方法?回到 9 大组件初始化,其中处理器、适配器、异常处理器是我们最常见的:

以适配器类初始化为例,它会获取上下文中所有类型的适配器类:

其中有一个 RequestMappingHandlerAdapter 适配器类,它有一个 afterPropertiesSet 初始化方法,核心代码就在 initControllerAdviceCache 中:

找到 @ControllerAdvice 注释的类的对象,并找到统一返回值处理的类的对象:

补充看一下找到所有 ControllerAdvice 类对象的代码:

同理,有一个 ExceptionHandlerExceptionResolver 异常处理器类,它调用了 ExceptionHandlerMethodResolver 对象解析所有的异常处理对象:

为什么 当同时存在空指针异常处理方法 和 Exception 异常处理方法 时,触发的空指针异常优先匹配了空指针异常处理方法?
ExceptionHandlerMethodResolver 类中有一个核心方法:将与程序触发异常匹配的所有异常处理方法进行了排序,深度浅的优先级更高。
深度级别:空指针异常为 0,Exception 异常为 2。

