SpringBoot 攔截器與統一功能處理
前言
Spring AOP 是一個基於面向切面編程的框架,用於將橫切性關注點(如日誌記錄、事務管理)與業務邏輯分離,通過代理對象將這些關注點織入到目標對象的方法執行前後、拋出異常或返回結果時等特定位置執行,從而提高程序的可複用性、可維護性和靈活性。
但使用原生 Spring AOP 實現統一的攔截是非常繁瑣、困難的。而在本節,我們將使用一種簡單的方式進行統一功能處理,這也是 AOP 的一次實戰,具體如下:
-
統一用戶登錄權限驗證
-
統一數據格式返回
-
統一異常處理
爲什麼需要統一功能處理?
統一功能處理是爲了提高代碼的可維護性、可重用性和可擴展性而進行的一種設計思想。在應用程序中,可能存在一些通用的功能需求,例如身份驗證、日誌記錄、異常處理等。
這些功能需要在多個地方進行調用和處理,如果每個地方都單獨實現這些功能,會導致代碼冗餘、難以維護和重複勞動。通過統一功能處理的方式,可以將這些通用功能抽取出來,以統一的方式進行處理。這樣做有以下幾個好處:
-
「代碼複用」:將通用功能抽取成獨立的模塊或組件,可以在多個地方共享使用,減少重複編寫代碼的工作量。
-
「可維護性」:將通用功能集中處理,可以方便地對其進行修改、優化或擴展,而不需要在多個地方進行修改。
-
「代碼整潔性」:通過統一功能處理,可以使代碼更加清晰、簡潔,減少了冗餘的代碼。
-
「可擴展性」:當需要添加新的功能時,只需要在統一功能處理的地方進行修改或擴展,而不需要在多個地方進行修改,降低了代碼的耦合度。
1、統一用戶登錄權限驗證
1.1 使用原生 Spring AOP 實現統一攔截的難點
以使用原生 Spring AOP 來實現⽤戶統⼀登錄驗證爲例,主要是使用前置通知和環繞通知實現的,具體實現如下
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.*;
import org.springframework.stereotype.Component;
@Aspect // 表明此類爲一個切面
@Component // 隨着框架的啓動而啓動
public class UserAspect {
// 定義切點, 這裏使用 Aspect 表達式語法
@Pointcut("execution(* com.hxh.demo.controller.UserController.*(..))")
public void pointcut(){ }
// 前置通知
@Before("pointcut()")
public void beforeAdvice() {
System.out.println("執行了前置通知~");
}
// 環繞通知
@Around("pointcut()")
public Object aroundAdvice(ProceedingJoinPoint joinPoint) {
System.out.println("進入環繞通知~");
Object obj = null;
// 執行目標方法
try {
obj = joinPoint.proceed();
} catch (Throwable e) {
e.printStackTrace();
}
System.out.println("退出環繞通知~");
return obj;
}
}
從上述的代碼示例可以看出,使用原生的 Spring AOP 實現統一攔截的難點主要有以下幾個方面:
-
定義攔截規則非常困難。如註冊⽅法和登錄⽅法是不攔截的,這樣的話排除⽅法的規則很難定義,甚⾄沒辦法定義。
-
在切面類中拿到 HttpSession 比較難。
爲了解決 Spring AOP 的這些問題,Spring 提供了攔截器~
1.2 使用 Spring 攔截器實現統一用戶登錄驗證
Spring 攔截器是 Spring 框架提供的一個功能強大的組件,用於在請求到達控制器之前或之後進行攔截和處理。攔截器可以用於實現各種功能,如身份驗證、日誌記錄、性能監測等。
要使用 Spring 攔截器,需要創建一個實現了 HandlerInterceptor 接口的攔截器類。該接口定義了三個方法:preHandle、postHandle 和 afterCompletion。
-
preHandle 方法在請求到達控制器之前執行,可以用於進行身份驗證、參數校驗等;
-
postHandle 方法在控制器處理完請求後執行,可以對模型和視圖進行操作;
-
afterCompletion 方法在視圖渲染完成後執行,用於清理資源或記錄日誌。
攔截器的實現可以分爲以下兩個步驟:
-
創建自定義攔截器,實現 HandlerInterceptor 接口的 preHandle(執行具體方法之前的預處理)方法。
-
將自定義攔截器加入 WebMvcConfigurer 的 addInterceptors 方法中,並且設置攔截規則。
具體實現如下:
step1. 創建自定義攔截器,自定義攔截器是一個普通類,代碼如下:
import org.springframework.web.servlet.HandlerInterceptor;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.servlet.http.HttpSession;
/**
* 統一用戶登錄權限驗證 —— 登錄攔截器
*/
public class LoginInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
// 用戶登錄業務判斷
HttpSession session = request.getSession(false);
if (session != null && session.getAttribute("userinfo") != null) {
return true; // 驗證成功, 繼續controller的流程
}
// 可以跳轉登錄界面或者返回 401/403 沒有權限碼
response.sendRedirect("/login.html"); // 跳轉到登錄頁面
return false; // 驗證失敗
}
}
step2. 配置攔截器並設置攔截規則,代碼如下:
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 AppConfig implements WebMvcConfigurer {
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(new LoginInterceptor())
.addPathPatterns("/**") // 攔截所有請求
.excludePathPatterns("/user/login") // 不攔截的 url 地址
.excludePathPatterns("/user/reg")
.excludePathPatterns("/**/*.html"); // 不攔截所有頁面
}
}
1.3 攔截器的實現原理及源碼分析
當有了攔截器後,會在調用 Controller 之前進行相應的業務處理,執行的流程如下圖所示:
從上述案例實現結果的控制檯的日誌信息可以看出,所有的 Controller 執⾏都會通過⼀個調度器 DispatcherServlet 來實現。
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;
}
}
// 調用預處理
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,該方法源碼如下:
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 中會獲取所有攔截器 HandlerInterceptor 並執行攔截器中的 preHandle 方法,這與之前我們實現攔截器的步驟對應,如下圖所示:
1.4 統一訪問前綴添加
統一訪問前綴的添加與登錄攔截器實現類似,即給所有請求地址添加 /hxh
前綴,示例代碼如下:
@Configuration
public class AppConfig implements WebMvcConfigurer {
// 給所有接口添加 /hxh 前綴
@Override
public void configurePathMatch(PathMatchConfigurer configurer) {
configurer.addPathPrefix("/hxh", c -> true);
}
}
另一種方式是在 application 配置文件中配置:
server.servlet.context-path=/hxh
2、統一異常處理
統一異常處理是指 在應用程序中定義一個公共的異常處理機制,用來處理所有的異常情況。這樣可以避免在應用程序中分散地處理異常,降低代碼的複雜度和重複度,提高代碼的可維護性和可擴展性。
需要考慮以下幾點:
-
異常處理的層次結構:定義異常處理的層次結構,確定哪些異常需要統一處理,哪些異常需要交給上層處理。
-
異常處理的方式:確定如何處理異常,比如打印日誌、返回錯誤碼等。
-
異常處理的細節:處理異常時需要注意的一些細節,比如是否需要事務回滾、是否需要釋放資源等
本文講述的統一異常處理使用的是 @ControllerAdvice + @ExceptionHandler
來實現的:
-
@ControllerAdvice 表示控制器通知類。
-
@ExceptionHandler 異常處理器。
以上兩個註解組合使用,表示當出現異常的時候執行某個通知,即執行某個方法事件,具體實現代碼如下:
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.ResponseBody;
import java.util.HashMap;
/**
* 統一異常處理
*/
@ControllerAdvice // 聲明是一個異常處理器
public class MyExHandler {
// 攔截所有的空指針異常, 進行統一的數據返回
@ExceptionHandler(NullPointerException.class) // 統一處理空指針異常
@ResponseBody // 返回數據
public HashMap<String, Object> nullException(NullPointerException e) {
HashMap<String, Object> result = new HashMap<>();
result.put("code", "-1"); // 與前端定義好的異常狀態碼
result.put("msg", "空指針異常: " + e.getMessage()); // 錯誤碼的描述信息
result.put("data", null); // 返回的數據
return result;
}
}
上述代碼中,實現了對所有空指針異常的攔截並進行統一的數據返回。
在實際中,常常設置一個保底,比如發生的非空指針異常,也會有保底措施進行處理,類似於 try-catch 塊中使用 Exception 進行捕獲,代碼示例如下:
@ExceptionHandler(Exception.class)
@ResponseBody
public HashMap<String, Object> exception(Exception e) {
HashMap<String, Object> result = new HashMap<>();
result.put("code", "-1"); // 與前端定義好的異常狀態碼
result.put("msg", "異常: " + e.getMessage()); // 錯誤碼的描述信息
result.put("data", null); // 返回的數據
return result;
}
3、統一數據返回格式
爲了保持 API 的一致性和易用性,通常需要使用統一的數據返回格式。一般而言,一個標準的數據返回格式應該包括以下幾個元素:
-
狀態碼:用於標誌請求成功失敗的狀態信息;
-
消息:用來描述請求狀態的具體信息;
-
數據:包含請求的數據信息;
-
時間戳:可以記錄請求的時間信息,便於調試和監控。
實現統一的數據返回格式可以使用 @ControllerAdvice + ResponseBodyAdvice
的方式實現,具體步驟如下:
-
創建一個類,並添加 @ControllerAdvice 註解;
-
實現 ResponseBodyAdvice 接口,並重寫 supports 和 beforeBodyWrite 方法。
示例代碼如下:
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;
import java.util.HashMap;
/**
* 統一數據返回格式
*/
@ControllerAdvice
public class ResponseAdvice implements ResponseBodyAdvice {
/**
* 此方法返回 true 則執行下面的 beforeBodyWrite 方法, 反之則不執行
*/
@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("code", 200);
result.put("msg", "");
result.put("data", body);
return null;
}
}
但是,如果返回的 body 原始數據類型是 String ,則會出現類型轉化異常,即 ClassCastException。
因此,如果原始返回數據類型爲 String ,則需要使用 jackson 進行單獨處理,實現代碼如下:
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
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;
import java.util.HashMap;
/**
* 統一數據返回格式
*/
@ControllerAdvice
public class ResponseAdvice implements ResponseBodyAdvice {
@Autowired
private ObjectMapper objectMapper;
/**
* 此方法返回 true 則執行下面的 beforeBodyWrite 方法, 反之則不執行
*/
@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("code", 200);
result.put("msg", "");
result.put("data", body);
if (body instanceof String) {
// 需要對 String 特殊處理
try {
return objectMapper.writeValueAsString(result);
} catch (JsonProcessingException e) {
e.printStackTrace();
}
}
return result;
}
}
但是,在實際業務中,上述代碼只是作爲保底使用,因爲狀態碼始終返回的是 200,過於死板,還需要具體問題具體分析。
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/DkMFDYGVyN-A-yFgVlrqpw