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 實現統一攔截的難點主要有以下幾個方面:

爲了解決 Spring AOP 的這些問題,Spring 提供了攔截器~

1.2 使用 Spring 攔截器實現統一用戶登錄驗證

Spring 攔截器是 Spring 框架提供的一個功能強大的組件,用於在請求到達控制器之前或之後進行攔截和處理。攔截器可以用於實現各種功能,如身份驗證、日誌記錄、性能監測等。

要使用 Spring 攔截器,需要創建一個實現了 HandlerInterceptor 接口的攔截器類。該接口定義了三個方法:preHandle、postHandle 和 afterCompletion。

攔截器的實現可以分爲以下兩個步驟:

具體實現如下:

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 來實現。

而所有的方法都會執行 DispatcherServlet 中的 doDispatch 調度方法,doDispatch 源碼如下:

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 方法,這與之前我們實現攔截器的步驟對應,如下圖所示:

此時,相應的 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 來實現的:

以上兩個註解組合使用,表示當出現異常的時候執行某個通知,即執行某個方法事件,具體實現代碼如下:

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 的方式實現,具體步驟如下:

示例代碼如下:

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