1. 程式人生 > 其它 >自定義零侵入的springboot-starter

自定義零侵入的springboot-starter

背景:

突發奇想,有沒有什麼辦法可以不需要在 springboot 的啟動類上新增類似 @EnableEurekaClient、@EnableFeignClients、@EnableXXXXXXX 這樣的註解,也不需要在程式碼裡新增 @Configuration 類似的配置類,更不需要修改原有的程式碼, 僅需在 pom 中引入一個 jar 包,然後什麼都不用做就能對專案的執行產生影響,或者隨意支配。

想了下,要不就拿所有的 controller 方法執行前後列印一下 log 的功能來寫一個 demo 實現一下吧。

列印 log ???這裡為什麼不用 aspect 寫一個 aop 的切面來實現呢?因為這樣你就要在 springboot 啟動類上新增 @EnableAspectJAutoProxy 註解,在專案中申明切面,在每個 controller 中加上切面的註解,那這樣不就產生了程式碼侵入了嘛。

分析

既然要在所有的 controller 方法被呼叫的前後列印 log,那麼我們就需要對這些 controller 進行增強,既然要增強,那麼就需要用到代理,既然要使用代理,就需要知道在什麼時候能對 controller 進行代理物件的包裝,就需要對這些 controller 的建立過程瞭解,需要知道 spring 的 bean 在什麼時候例項化完成,在什麼時候扔進單例池,這其中哪個階段,是音樂家(聽說spring的作者是音樂家)留給開發者的勾子方法。

這裡我們用到的是 BeanPostProcessor ,因為在 spring 中,所有的單例 bean 在例項化完成,丟進單例池之前的這個狀態裡,都會呼叫所有實現了 BeanPostProcessor 介面的 #postProcessAfterInitialization 方法對 bean 做相關的操作,我們利用 bean 生命週期中的這個時間點,對所有 bean 中凡是 controller 的 bean 進行增強,參考spring的aop、事務等實現原理生成代理物件

(###不過我不用啟動類上加註解,以及搭配什麼 @Import SelectImport Registry 等操作來實現。)



梳理了一下實現的方案,大致分為三個步驟:

  • 第一步:我們需要在 controller 這個 bean 丟進單例池之前前新增攔截,需要用到 BeanPostProcessor 後置處理器來實現。
  • 第二步:我們給所有攔截到的 controller 包裝一層自定義的代理,方便在所有 controller 的方法在呼叫前後做一些自己的操作,此處用到的是 cglib 實現。
  • 第三步:我們需要將我們攔截 controller 用到的 BeanPostProcessor 後置處理器被 spring 框架載入並呼叫,這裡用到了 SPI 設計模式,使用 spring.factories 協助來實現。
  1. 第一步:
    為什麼要在 controller 這個 bean 丟進單例池之前前新增攔截,是因為 springMVC 開始維護 controller 的 handler、method、url 關係對映的時候,都是建立在所有的 bean 已經例項化完成之後,在單例池中獲取 bean 的資訊,參考[AbstractHandlerMethodMapping->#afterPropertiesSet],所以,我們需要在 bean 例項化完成之前,就對 bean 進行代理物件的生成,將生成好的代理物件丟進單例池中,而不影響其他業務邏輯,所以我們藉助 bean 生命週期中的最會一環-BeanPostProcessor#postProcessAfterInitialization 來實現。

  2. 第二步:
    這裡偷個懶,直接用 cglib 生成了 controller bean 的代理物件,因為 jdk 代理生成後的動態物件在 springMVC 維護 controller、method、url 對映關係的時候,無法識別當前 jdk 生成的 jdk 動態代理物件是否是 controller 物件,因為框架沒有獲取到代理物件的真實物件型別,不過感覺理論上是有辦法解決的。

  3. 第三步:
    藉助 spring 啟動流程中較為早期的環節,載入 ApplicationContextInitializer 實現類的環節,我們把我們的物件交給 spring 容器去管理,此時我們通過 spring.factories 來配置我們的實現類,以此達到了程式碼無侵入的目的。

具體實現:

打算弄兩個專案,一個是 starter 專案,一個是 springboot 專案,然後 springboot 專案中引用 starter 專案,寫在一個專案裡面也行。

首先,我們先新建一個空的 maven 專案,作為 starter 功能編寫的專案,專案的 group、artifactId 等資訊如下:

    groupId = com.summer
    artifactId = my-spring-starter
    version = 1.0-SNAPSHOT

在該專案的 pom 中新增相關依賴:


    <dependency>
        <groupId>org.springframework</groupId>
        <artifactId>spring-context</artifactId>
        <version>5.3.8</version>
    </dependency>

    
    <dependency>
        <groupId>org.springframework</groupId>
        <artifactId>spring-web</artifactId>
        <version>5.2.8.RELEASE</version>
    </dependency>


依賴中使用到了 spring-context ,我們要用的相關擴充套件點基本上全都在 spring-context 中,但是我們還引入了 spring-web 依賴,因為 @RestController、@Mapping 和 @RequestMapping 三個註解都在 spring-web 依賴中,而我們想要確定一個 bean 是否是 controller,我們需要用到四個註解分別是 @Controller、@RestController、@Mapping 和 @RequestMapping, spring-context 只有 @Controller 註解,滿足不了需求。

然後在主目錄下的src/main/java路徑下,新建一個 java POJO,叫做 MySpringStarterApplicationContextInitializer,全路徑為

com.summer.starter.initializer.MySpringStarterApplicationContextInitializer

在該類中,我們實現了 ApplicationContextInitializer ,重寫 initialize 方法,在方法中註冊了一個 BeanDefinitionRegistryPostProcessor 的實現類 MyBeanDefinitionRegistryPostProcessor。之所以實現 ApplicationContextInitializer 一是為了無侵入做鋪墊,我們通過springboot啟動全週期的spring.factories配置我們的MySpringStarterApplicationContextInitializer類,就能在springboot啟動流程中,較為前期的準備上下文的階段載入我們的類檔案到系統中,以此達到無侵入的目的,二是因為通過該類,可以將我們後期想要做相關邏輯處理的一些物件註冊到spring容器中,去實現更多的想要做的事情。

然後再新建一個 MyBeanDefinitionRegistryPostProcessor 實現類,或者就寫在當前類中都可以。

在 MyBeanDefinitionRegistryPostProcessor 類中,我們實現了 BeanDefinitionRegistryPostProcessor 和 Ordered,重寫 BeanDefinitionRegistryPostProcessor 的 postProcessBeanDefinitionRegistry 方法,註冊一個 ControllerEnhanceBeanPostProcessor 物件,該物件中包含了最核心的邏輯,同時,實現了 Ordered 介面,設定了該 BeanFactoryPostProcessor 實現類的執行順序為最晚執行。

其中 ControllerEnhanceBeanPostProcessor 是一個 BeanPostProcessor 介面的實現類, BeanPostProcessor 介面的兩個方法分別作用於 bean 的 IOC 階段完成,例項化操作開始之前的階段,以及例項化已經完成,放進單例池之前的階段。我們實現 BeanPostProcessor 介面,目的是為了利用例項化已經完成,放進單例池之前的這個階段,在這個期間,spring框架會將對 bean 傳到這個方法中,此時可以做隨意的修改,並將修改後的 bean 還給 spring 框架,我們對 controller 物件做一層代理的封裝,就在這個例項化完成,放進單例池之前的這個階段,以此達到前期的設想。

ControllerEnhanceBeanPostProcessor 的全部程式碼如下:

package com.summer.starter.processor;


import com.summer.starter.proxy.ControllerEnhanceInterceptor;
import com.summer.starter.proxy.ControllerEnhanceInvocationHandler;
import org.springframework.aop.framework.ProxyFactory;
import org.springframework.beans.BeansException;
import org.springframework.beans.factory.FactoryBean;
import org.springframework.beans.factory.InitializingBean;
import org.springframework.beans.factory.config.BeanPostProcessor;
import org.springframework.context.EnvironmentAware;
import org.springframework.core.env.Environment;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.Mapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import java.lang.annotation.Annotation;
import java.lang.reflect.Proxy;
import java.util.concurrent.ConcurrentHashMap;

/**
 * 控制器增強後置處理
 */

public class ControllerEnhanceBeanPostProcessor implements BeanPostProcessor, EnvironmentAware {

    /**
     * 增強log是否開啟
     */
    public static enum EnhanceLogEnum {

        LOG_ON,
        LOG_OFF;

        private EnhanceLogEnum() {
        }
    }

    /**
     * 記錄已經建立過代理物件的 bean
     */
    private ConcurrentHashMap<String, Object> beanCache = new ConcurrentHashMap<>();

    //增強 log 配置 key
    private static final String enhanceLogOpenEnv = "spring.controller.enhance.log.open";

    //是否開啟增強log
    private boolean enhanceLogOpen = true;

    //可以拿到 application.yml 的配置資訊
    @Override
    public void setEnvironment(Environment environment) {
        //讀取配置中的設定
        String openLogSetting = environment.getProperty(enhanceLogOpenEnv);
        if (EnhanceLogEnum.LOG_OFF.name().toLowerCase().equals(openLogSetting)) {
            enhanceLogOpen = false;
        }
    }


    /**
     * 例項化完成,放進單例池之前的階段
     */
    @Override
    public Object postProcessAfterInitialization(Object bean, String beanName) throws BeansException {
        //是否是 controller 物件
        boolean hasControllerAnnotation = false;

        Class<?>[] interfaces = bean.getClass().getInterfaces();

        if (interfaces.length <= 0) {
            //檢驗是否是 controller bean  普通物件 bean.getClass() 就可以獲取到 class 的 Annotation 資訊
            hasControllerAnnotation = matchController(bean.getClass());
        } else { 
            //被springboot處理過的代理物件需要獲取 super class 才能拿到真實的 class 的 Annotation 資訊,否則拿不到註解資訊
            //檢驗是否是 controller bean
            hasControllerAnnotation = matchController(bean.getClass().getSuperclass());
        }
        //如果是 controller bean 建立代理物件      //如果是 controller bean 建立代理物件
        if (hasControllerAnnotation) {
            return this.creatCglibProxy(bean, beanName, enhanceLogOpen);
        }
        //返回預設 bean
        return bean;
    }


    /**
     * 遞迴獲取包含 base 中是否帶有四個標籤的註解來判斷是否是 controller
     *
     * @param clazz
     * @return
     */
    private boolean matchController(Class<?> clazz) {
        for (Annotation annotation : clazz.getAnnotations()) {
            if (annotation instanceof Controller
                    || annotation instanceof RestController
                    || annotation instanceof Mapping
                    || annotation instanceof RequestMapping) {
                return true;
            }
        }
        if (clazz.getSuperclass() != null) {
            matchController(clazz.getSuperclass());
        }
        return false;
    }


    /**
     * 建立代理物件
     *
     * @param bean
     * @param beanName
     * @param enhanceLogOpen
     * @return
     */
    private Object creatJdkProxy(Object bean, String beanName, boolean enhanceLogOpen) {
        Object beanCache = this.beanCache.get(beanName);
        if (beanCache != null) {
            return beanCache;
        }

        //ControllerEnhanceInvocationHandler  jdk代理物件
        ControllerEnhanceInvocationHandler invocationHandler = new ControllerEnhanceInvocationHandler(bean, enhanceLogOpen);
        Object proxyBean = Proxy.newProxyInstance(bean.getClass().getClassLoader(), bean.getClass().getInterfaces(), invocationHandler);

        this.beanCache.put(beanName, proxyBean);
        return proxyBean;
    }

    /**
     * 建立代理物件
     *
     * @param bean
     * @param beanName
     * @param enhanceLogOpen
     * @return
     */
    private Object creatCglibProxy(Object bean, String beanName, boolean enhanceLogOpen) {
        Object beanCache = this.beanCache.get(beanName);
        if (beanCache != null) {
            return beanCache;
        }

        ProxyFactory proxyFactory = new ProxyFactory();
        proxyFactory.setTarget(bean);
        proxyFactory.addAdvice(new ControllerEnhanceInterceptor(enhanceLogOpen));
        Object proxyBean = proxyFactory.getProxy();

        this.beanCache.put(beanName, proxyBean);
        return proxyBean;
    }


}

ControllerEnhanceBeanPostProcessor 物件實現了 BeanPostProcessor介面 與 EnvironmentAware 介面,我們需要的例項化完成,放進單例池之前的階段是在 BeanPostProcessor 介面的 postProcessAfterInitialization 方法中,對於 controller 做一層代理封裝的操作,也是從這個方法開始。而 EnvironmentAware 介面則是為我們提供專案的配置檔案資訊,在 setEnvironment 方法中,配置檔案可有可無,此處做功能測試,以獲取配置控制 log 開關為實驗。




該類檔案最上方是 EnhanceLogEnum 列舉物件,其實可有可無,就是拿來配置所有 controller 中的方法執行前後是否開啟 log 列印的功能而已,直接在 application.yml 中使用 1、2數值或者 true/false 的布林值都能實現。


[上圖序號1處] beanCache 是為了解決物件重複建立的問題,理論上是不存在的,因為每個 bean 只會經過該方法一次的呼叫。

[上圖序號2處] enhanceLogOpenEnv 是 application.yml 檔案中的配置 key。

[上圖序號3處] enhanceLogOpen 代表是否開啟所有 controller 中的方法執行前後的 log 列印的功能,預設開啟,如果 application.yml 配置了 enhanceLogOpenEnv,以配置為主。

[上圖序號4處] setEnvironment 方法會將目前最新的專案配置檔案資訊暴露出來,此時也可以往裡面新增一些新的配置,但是目前只是為了使用它獲取我們需要的 enhanceLogOpenEnv 配置來判斷是否需要關閉所有 controller 中的方法執行前後 log 列印的功能。



postProcessAfterInitialization 方法中的邏輯是判斷當前的 bean 是否是 controller 物件,是的話,則為 controller 物件建立 cglib 的代理物件,jdk代理物件的方式,這裡省略了,否則什麼也不操作,直接返回當前的物件。

判斷是否為 controller 呼叫的是 matchController 方法,通過四個註解( Controller、RestController、Mapping 、RequestMapping)判斷一個 bean 是否為 controller,如果沒找到的話,遞迴查詢父類是否為 controller。

如果是 controller 則呼叫 creatCglibProxy 方法,建立 cglib 的代理物件,物件用到了 ControllerEnhanceInterceptor 物件,在 ControllerEnhanceInterceptor 中實現了對當前 controller 中的所有方法做增強的邏輯。

ControllerEnhanceInterceptor 物件實現了 MethodInterceptor,其實就是實現了 Advice 介面,主要的目的就是做增強,在 invoke 方法中,對 controller 方法 (Object proceed = invocation.proceed()) 呼叫的前後做增強。


# Application Context Initializers
org.springframework.context.ApplicationContextInitializer=\
com.summer.starter.initializer.MySpringStarterApplicationContextInitializer

到這裡程式碼部分已經都完成了,接下來,要配置 spring.factories ,我們在專案的 resource 資料夾下新建一個 META-INF 資料夾,在 META-INF 資料夾中新建一個 spring.factories 的檔案,在檔案中填入我們的 ApplicationContextInitializer 實現類的全包路徑。

!!! 至此,starter 就已經寫好了,install 一下,將依賴打包到本地maven倉庫中。

此時新建一個 springboot 專案,專案中引入剛剛的 starter 測試一下效果。

通過測試,發現結果和預期的效果一致,springboot 中僅僅引入了 jar 包,就能實現相關的控制,零業務程式碼侵入,有了 spring-context 中的這些擴充套件點,對整個框架的功能可以做很多很多的擴充套件。

github地址 https://github.com/GITHUBFORSUMMER/spring-starter

我的個人網站 https://www.huangyingsheng.com/2021/07/10/6b23595c-5c45-e08c-d80a-7fc2582df0ba/