本篇将要學習 Spring Boot 統一功能處理子產品,這也是 AOP 的實戰環節
- 使用者登入權限的校驗實作接口 HandlerInterceptor + WebMvcConfigurer
- 異常處理使用注解 @RestControllerAdvice + @ExceptionHandler
- 資料格式傳回使用注解 @ControllerAdvice 并且實作接口 @ResponseBodyAdvice
1. 統一使用者登入權限效驗
使用者登入權限的發展完善過程
- 最初使用者登入效驗: 在每個方法中擷取 Session 和 Session 中的使用者資訊,如果存在使用者,那麼就認為登入成功了,否則就登入失敗了
- 第二版使用者登入校驗: 提供統一的方法,在每個需要驗證的方法中調用統一的使用者登入身份效驗方法來判斷
- 第三版使用者登入效驗: 使用 Spring AOP 來統一進行使用者登入效驗
- 第四版使用者登入效驗: 使用 Spring 攔截器來實作使用者的統一登入驗證
1.1 最初使用者登入權限效驗
@RestController
@RequestMapping("/user")
public class UserController {
@RequestMapping("/a1")
public Boolean login (HttpServletRequest request) {
// 有 Session 就擷取,沒有就不建立
HttpSession session = request.getSession(false);
if (session != null && session.getAttribute("userinfo") != null) {
// 說明已經登入,進行業務處理
return true;
} else {
// 未登入
return false;
}
}
@RequestMapping("/a2")
public Boolean login2 (HttpServletRequest request) {
// 有 Session 就擷取,沒有就不建立
HttpSession session = request.getSession(false);
if (session != null && session.getAttribute("userinfo") != null) {
// 說明已經登入,進行業務處理
return true;
} else {
// 未登入
return false;
}
}
}
這種方式寫的代碼,每個方法中都有相同的使用者登入驗證權限,缺點是:
- 每個方法中都要單獨寫使用者登入驗證的方法,即使封裝成公共方法,也一樣要傳參調用和在方法中進行判斷
- 添加控制器越多,調用使用者登入驗證的方法也越多,這樣就增加了後期的修改成功和維護成功
- 這些使用者登入驗證的方法和現在要實作的業務幾乎沒有任何關聯,但還是要在每個方法中都要寫一遍,是以提供一個公共的 AOP 方法來進行統一的使用者登入權限驗證是非常好的解決辦法。
1.2 Spring AOP 統一使用者登入驗證
統一使用者登入驗證,首先想到的實作方法是使用 Spring AOP 前置通知或環繞通知來實作
@Aspect // 目前類是一個切面
@Component
public class UserAspect {
// 定義切點方法 Controller 包下、子孫包下所有類的所有方法
@Pointcut("execution(* com.example.springaop.controller..*.*(..))")
public void pointcut(){}
// 前置通知
@Before("pointcut()")
public void doBefore() {}
// 環繞通知
@Around("pointcut()")
public Object doAround(ProceedingJoinPoint joinPoint) {
Object obj = null;
System.out.println("Around 方法開始執行");
try {
obj = joinPoint.proceed();
} catch (Throwable e) {
e.printStackTrace();
}
System.out.println("Around 方法結束執行");
return obj;
}
}
但如果隻在以上代碼 Spring AOP 的切面中實作使用者登入權限效驗的功能,有這樣兩個問題:
- 沒有辦法得到 HttpSession 和 Request 對象
- 我們要對一部分方法進行攔截,而另一部分方法不攔截,比如注冊方法和登入方法是不攔截的,也就是實際的攔截規則很複雜,使用簡單的 aspectJ 表達式無法滿足攔截的需求
1.3 Spring 攔截器
針對上面代碼 Spring AOP 的問題,Spring 中提供了具體的實作攔截器:HandlerInterceptor,攔截器的實作有兩步:
1.建立自定義攔截器,實作 Spring 中的 HandlerInterceptor 接口中的 preHandle方法
2.将自定義攔截器加入到架構的配置中,并且設定攔截規則
- 給目前的類添加 @Configuration 注解
- 實作 WebMvcConfigurer 接口
- 重寫 addInterceptors 方法
注意:一個項目中可以同時配置多個攔截器
(1)建立自定義攔截器
/**
* @Description: 自定義使用者登入的攔截器
* @Date 2023/2/13 13:06
*/
@Component
public class LoginIntercept implements HandlerInterceptor {
// 傳回 true 表示攔截判斷通過,可以通路後面的接口
// 傳回 false 表示攔截未通過,直接傳回結果給前端
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response,
Object handler) throws Exception {
// 1.得到 HttpSession 對象
HttpSession session = request.getSession(false);
if (session != null && session.getAttribute("userinfo") != null) {
// 表示已經登入
return true;
}
// 執行到此代碼表示未登入,未登入就跳轉到登入頁面
response.sendRedirect("/login.html");
return false;
}
}
(2)将自定義攔截器添加到系統配置中,并設定攔截的規則
- addPathPatterns:表示需要攔截的 URL,**表示攔截所有⽅法
- excludePathPatterns:表示需要排除的 URL
說明:攔截規則可以攔截此項⽬中的使⽤ URL,包括靜态⽂件(圖⽚⽂件、JS 和 CSS 等⽂件)。
/**
* @Description: 将自定義攔截器添加到系統配置中,并設定攔截的規則
* @Date 2023/2/13 13:13
*/
@Configuration
public class AppConfig implements WebMvcConfigurer {
@Resource
private LoginIntercept loginIntercept;
@Override
public void addInterceptors(InterceptorRegistry registry) {
// registry.addInterceptor(new LoginIntercept());//可以直接new 也可以屬性注入
registry.addInterceptor(loginIntercept).
addPathPatterns("/**"). // 攔截所有 url
excludePathPatterns("/user/login"). //不攔截登入注冊接口
excludePathPatterns("/user/reg").
excludePathPatterns("/login.html").
excludePathPatterns("/reg.html").
excludePathPatterns("/**/*.js").
excludePathPatterns("/**/*.css").
excludePathPatterns("/**/*.png").
excludePathPatterns("/**/*.jpg");
}
}
1.4 練習:登入攔截器
要求
- 登入、注冊頁面不攔截,其他頁面都攔截
- 當登入成功寫入 session 之後,攔截的頁面可正常通路
在 1.3 中已經建立了自定義攔截器 和 将自定義攔截器添加到系統配置中,并設定攔截的規則
(1)下面建立登入和首頁的 html
(2)建立 controller 包,在包中建立 UserController,寫登入頁面和首頁的業務代碼
@RestController
@RequestMapping("/user")
public class UserController {
@RequestMapping("/login")
public boolean login(HttpServletRequest request,String username, String password) {
boolean result = false;
if (StringUtils.hasLength(username) && StringUtils.hasLength(password)) {
if(username.equals("admin") && password.equals("admin")) {
HttpSession session = request.getSession();
session.setAttribute("userinfo","userinfo");
return true;
}
}
return result;
}
@RequestMapping("/index")
public String index() {
return "Hello Index";
}
}
(3)運作程式,通路頁面,對比登入前和登入後的效果
1.5 攔截器實作原理
有了攔截器之後,會在調⽤ Controller 之前進⾏相應的業務處理,執⾏的流程如下圖所示
實作原理源碼分析
所有的 Controller 執行都會通過一個排程器 DispatcherServlet 來實作
而所有方法都會執行 DispatcherServlet 中的 doDispatch 排程⽅法,doDispatch 源碼分析如下:
通過源碼分析,可以看出,Sping 中的攔截器也是通過動态代理和環繞通知的思想實作的
1.6 統一通路字首添加
所有請求位址添加 api 字首,c 表示所有
@Configuration
public class AppConfig implements WebMvcConfigurer {
// 所有的接口添加 api 字首
@Override
public void configurePathMatch(PathMatchConfigurer configurer) {
configurer.addPathPrefix("api", c -> true);
}
}
2. 統一異常處理
給目前的類上加 @ControllerAdvice 表示控制器通知類
給方法上添加 @ExceptionHandler(xxx.class),表示異常處理器,添加異常傳回的業務代碼
@RestController
@RequestMapping("/user")
public class UserController {
@RequestMapping("/index")
public String index() {
int num = 10/0;
return "Hello Index";
}
}
在 config 包中,建立 MyExceptionAdvice 類
@RestControllerAdvice // 目前是針對 Controller 的通知類(增強類)
public class MyExceptionAdvice {
@ExceptionHandler(ArithmeticException.class)
public HashMap<String,Object> arithmeticExceptionAdvice(ArithmeticException e) {
HashMap<String, Object> result = new HashMap<>();
result.put("state",-1);
result.put("data",null);
result.put("msg" , "算出異常:"+ e.getMessage());
return result;
}
}
也可以這樣寫,效果是一樣的
@ControllerAdvice
public class MyExceptionAdvice {
@ExceptionHandler(ArithmeticException.class)
@ResponseBody
public HashMap<String,Object> arithmeticExceptionAdvice(ArithmeticException e) {
HashMap<String, Object> result = new HashMap<>();
result.put("state",-1);
result.put("data",null);
result.put("msg" , "算數異常:"+ e.getMessage());
return result;
}
}
如果再有一個空指針異常,那麼上面的代碼是不行的,還要寫一個針對空指針異常處理器
@ExceptionHandler(NullPointerException.class)
public HashMap<String,Object> nullPointerExceptionAdvice(NullPointerException e) {
HashMap<String, Object> result = new HashMap<>();
result.put("state",-1);
result.put("data",null);
result.put("msg" , "空指針異常異常:"+ e.getMessage());
return result;
}
@RequestMapping("/index")
public String index(HttpServletRequest request,String username, String password) {
Object obj = null;
System.out.println(obj.hashCode());
return "Hello Index";
}
但是需要考慮的一點是,如果每個異常都這樣寫,那麼工作量是非常大的,并且還有自定義異常,是以上面這樣寫肯定是不好的,既然是異常直接寫 Exception 就好了,它是所有異常的父類,如果遇到不是前面寫的兩種異常,那麼就會直接比對到 Exception
當有多個異常通知時,比對順序為目前類及其⼦類向上依次比對
@ExceptionHandler(Exception.class)
public HashMap<String,Object> exceptionAdvice(Exception e) {
HashMap<String, Object> result = new HashMap<>();
result.put("state",-1);
result.put("data",null);
result.put("msg" , "異常:"+ e.getMessage());
return result;
}
可以看到優先比對的還是前面寫的 空指針異常
3. 統一資料格式傳回
3.1 統一資料格式傳回的實作
1.給目前類添加 @ControllerAdvice
2.實作 ResponseBodyAdvice 重寫其方法
- supports 方法,此方法表示内容是否需要重寫(通過此⽅法可以選擇性部分控制器和方法進行重寫),如果要重寫傳回 true
- beforeBodyWrite 方法,方法傳回之前調用此方法
@ControllerAdvice
public class MyResponseAdvice implements ResponseBodyAdvice {
// 傳回一個 boolean 值,true 表示傳回資料之前對資料進行重寫,也就是會進入 beforeBodyWrite 方法
// 傳回 false 表示對結果不進行任何處理,直接傳回
@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) {
HashMap<String,Object> result = new HashMap<>();
result.put("state",1);
result.put("data",body);
result.put("msg","");
return result;
}
}
@RestController
@RequestMapping("/user")
public class UserController {
@RequestMapping("/login")
public boolean login(HttpServletRequest request,String username, String password) {
boolean result = false;
if (StringUtils.hasLength(username) && StringUtils.hasLength(password)) {
if(username.equals("admin") && password.equals("admin")) {
HttpSession session = request.getSession();
session.setAttribute("userinfo","userinfo");
return true;
}
}
return result;
}
@RequestMapping("/reg")
public int reg() {
return 1;
}
}
3.2 @ControllerAdvice 源碼分析
通過對 @ControllerAdvice 源碼的分析我們可以知道上面統一異常和統一資料傳回的執行流程
(1)先看 @ControllerAdvice 源碼
可以看到 @ControllerAdvice 派生于 @Component 元件而所有元件初始化都會調用 InitializingBean 接口
(2)下面檢視 initializingBean 有哪些實作類
在查詢過程中發現,其中 Spring MVC 中的實作子類是 RequestMappingHandlerAdapter,它裡面有一個方法 afterPropertiesSet()方法,表示所有的參數設定完成之後執行的方法
(3)而這個方法中有一個 initControllerAdviceCache 方法,查詢此方法
發現這個方法在執行時會查找使用所有的 @ControllerAdvice 類,發送某個事件時,調用相應的 Advice 方法,比如傳回資料前調用統一資料封裝,比如發生異常是調用異常的 Advice 方法實作的