仿寫一個簡陋的 IOC/AOP 框架 mini-spring

czwbig發表於2019-07-09

講道理,感覺自己有點菜。Spring 原始碼看不懂,不想強行解釋,等多積累些專案經驗之後再看吧,但是 Spring 中的控制反轉(IOC)和麵向切面程式設計(AOP)思想很重要,為了更好的使用 Spring 框架,有必要理解這兩個點,為此,我使用 JDK API 實現了一個玩具級的簡陋 IOC/AOP 框架 mini-spring,話不多說,直接開幹。

環境搭建&快速使用

全部程式碼已上傳 GitHub:https://github.com/czwbig/mini-spring

  1. 將程式碼弄到本地並使用 IDE 開啟,這裡我們用 IDEA;
  2. 使用 Gradle 構建專案,可以使用 IDEA 提供的 GUI 操作,也可以直接使用 gradle build 命令;

仿寫一個簡陋的 IOC/AOP 框架 mini-spring

  1. 如下圖,右擊 mini-spring\framework_use_test\build\libs\framework_use_test-1.0-SNAPSHOT.jar ,點選 Run,當然也可以直接使用 java -jar jarPath.jar 命令來執行此 jar 包;

仿寫一個簡陋的 IOC/AOP 框架 mini-spring

  1. 瀏覽器開啟 localhost:8080/rap 即可觀察到顯示 CXK 字母,同時 IDE 控制檯會輸出:
first,singing <chicken is too beautiful>.
and the chicken monster is dancing now.
CXK rapping...
oh! Don't forget my favorite basketball.

下面開始框架的講解。

簡介

本專案使用 Java API 以及內嵌 Tomcat 伺服器寫了一個玩具級 IOC/AOP web 框架。實現了 @Controller@AutoWired@Component@Pointcut@Aspect@Before@After 等 Spring 常用註解。可實現簡單的訪問 uri 對映,控制反轉以及不侵入原始碼的面向切面程式設計。

講解程式碼實現之前,假設讀者已經掌握了基礎的專案構建、反射、註解,以及 JDK 動態代理知識,專案精簡,註釋詳細,並且總程式碼 + 註釋不足 1000 行,適合用來學習。其中構建工具 Gradle 沒用過也不要緊,我也是第一次使用,當成沒有 xml 的 Maven 來看就行,下面我會詳細解讀其構建配置檔案。

模組組成

專案由兩個模組組成,一個是框架本身的模組,實現了框架的 IOC/AOP 等功能,如下圖:

仿寫一個簡陋的 IOC/AOP 框架 mini-spring

類比較多,但是大部分都是程式碼很少的,特別是註解定義介面,不要怕。

  • aop 包中是 After 等註解的定義介面,以及動態代理輔助類;
  • bean 包中是兩個註解定義,以及 BeanFactory 這個 Bean 工廠,其中包含了類掃描和 Bean 的初始化的程式碼;
  • core 包是一個 ClassScanner 類掃描工具類;
  • starter 包是一個框架的啟動與初始化類;
  • web/handler 包中是 uri 請求的處理器的收集與管理,如查詢 @Controller 註解修飾的類中的 @RequestMapping 註解修飾的方法,用來響應對應 uri 請求。
  • web/mvc 包定義了與 webMVC 有關的三個註解;
  • web/server 包中是一個嵌入式 Tomcat 伺服器的初始化類;
  • web/servlet 包中是一個請求分發器,重寫的 service() 方法定義使用哪個請求處理器來響應瀏覽器請求;

另一個模組是用來測試(使用)框架的模組,如下圖:

仿寫一個簡陋的 IOC/AOP 框架 mini-spring

就像我們使用 Spring 框架一樣,定義 Controller 等來響應請求,程式碼很簡單,就不解釋了。

專案構建

根目錄下有 setting.gradlebuild.gradle 專案構建檔案,其中 setting.gradle 指定了專案名以及模組名。

rootProject.name = 'mini-spring'
include 'framework'
include 'framework_use_test'

build.gradle 是專案構建設定,主要程式碼如下:

plugins {
    id 'java'
}

group 'com.caozhihu.spring'
version '1.0-SNAPSHOT'

sourceCompatibility = 1.8

repositories {
    repositories { maven { url 'http://maven.aliyun.com/nexus/content/groups/public/' } }
//    mavenCentral()
}

dependencies {
    testCompile group: 'junit', name: 'junit', version: '4.12'
}

引入了 gradle 的 java 外掛,因為 gradle 不僅僅可以用於 java 專案,也可以用於其他專案,引入了 java 外掛定義了專案的檔案目錄結構等。

然後就是專案的版本以及 java 原始碼適配級別,這裡是 JDK 1.8,在後面是指定了依賴倉庫,gradle 可以直接使用 maven 倉庫。

最後就是引入專案具體依賴,這裡和 maven 一樣。

每個模組也有單獨的 build.gradle 檔案來指定模組的構建設定,這裡以 framework_use_test 模組的 build.gradle 檔案來說明:

dependencies {
    // 只在單元測試時候引入此依賴
    testCompile group: 'junit', name: 'junit', version: '4.12'
    // 專案依賴
    compile(project(':framework'))
}

jar {
    manifest {
        attributes "Main-Class": "com.caozhihu.spring.Application"
    }
    // 固定打包句式
    from {
        configurations.runtime.asFileTree.files.collect { zipTree(it) }
    }
}

除去和專案根目錄下構建檔案相同部分,其他的構建程式碼如上,這裡的 dependencies 除了新增 Junit 單元測試依賴之外,還指定了 framework 模組。

下面指定了 jar 包的打包設定,首先使用 manifest 設定主類,否則生成的 jar 包找不到主類清單,會無法執行。還使用了 from 語句來設定打包範圍,這是固定句式,用來收集所有的 java 類檔案。

framework 實現流程

如下圖:

仿寫一個簡陋的 IOC/AOP 框架 mini-spring

啟動 tomcat 服務

public void startServer() throws LifecycleException {
        tomcat = new Tomcat();
        tomcat.setPort(8080);
        tomcat.start();

        // new 一個標準的 context 容器並設定訪問路徑;
        // 同時為 context 設定生命週期監聽器。
        Context context = new StandardContext();
        context.setPath("");
        context.addLifecycleListener(new Tomcat.FixContextListener());
        // 新建一個 DispatcherServlet 物件,這個是我們自己寫的 Servlet 介面的實現類,
        // 然後使用 `Tomcat.addServlet()` 方法為 context 設定指定名字的 Servlet 物件,
        // 並設定為支援非同步。
        DispatcherServlet servlet = new DispatcherServlet();
        Tomcat.addServlet(context, "dispatcherServlet", servlet)
                .setAsyncSupported(true);

        // Tomcat 所有的執行緒都是守護執行緒,
        // 如果某一時刻所有的執行緒都是守護執行緒,那 JVM 會退出,
        // 因此,需要為 tomcat 新建一個非守護執行緒來保持存活,
        // 避免服務到這就 shutdown 了
        context.addServletMappingDecoded("/", "dispatcherServlet");
        tomcat.getHost().addChild(context);

        Thread tomcatAwaitThread = new Thread("tomcat_await_thread") {
            @Override
            public void run() {
                TomcatServer.this.tomcat.getServer().await();
            }
        };

        tomcatAwaitThread.setDaemon(false);
        tomcatAwaitThread.start();
    }

這裡看程式碼註釋,結合下面這張 tomcat 架構圖就可以理解了。

仿寫一個簡陋的 IOC/AOP 框架 mini-spring

圖片來自 http://click.aliyun.com/m/1000014411/

如果暫時不理解也沒關係,不影響框架學習,我只是為了玩一玩內嵌 tomcat,完全可以自己實現一個乞丐版的網路伺服器的。

這裡使用的是我們自定義的 Servlet 子類 DispatcherServlet 物件,該類重寫了 service() 方法,程式碼如下:

@Override
    public void service(ServletRequest req, ServletResponse res) throws IOException {
        for (MappingHandler mappingHandler : HandlerManager.mappingHandlerList) {
            // 從所有的 MappingHandler 中逐一嘗試處理請求,
            // 如果某個 handler 可以處理(返回true),則返回即可
            try {
                if (mappingHandler.handle(req, res)) {
                    return;
                }
            } catch (IllegalAccessException e) {
                e.printStackTrace();
            } catch (InvocationTargetException e) {
                e.printStackTrace();
            }
        }
        res.getWriter().println("failed!");
    }

HandlerManager 和 MappingHandler 處理器後面會講,這裡先不展開。至此,tomcat 伺服器啟動完成;

掃描類

掃描類是通過這句程式碼完成的:

// 掃描類
List<Class<?>> classList = ClassScanner.scannerCLasses(cls.getPackage().getName());

ClassScanner.scannerCLasses 方法實現如下:

public static List<Class<?>> scannerCLasses(String packageName)
            throws IOException, ClassNotFoundException {
        List<Class<?>> classList = new ArrayList<>();
        String path = packageName.replace(".", "/");
        // 執行緒上下文類載入器預設是應用類載入器,即 ClassLoader.getSystemClassLoader();
        ClassLoader classLoader = Thread.currentThread().getContextClassLoader();

        // 使用類載入器物件的 getResources(ResourceName) 方法獲取資源集
        // Enumeration 是古老的迭代器版本,可當成 Iterator 使用
        Enumeration<URL> resources = classLoader.getResources(path);
        while (resources.hasMoreElements()) {
            URL url = resources.nextElement();
            // 獲取協議型別,判斷是否為 jar 包
            if (url.getProtocol().contains("jar")) {
                // 將開啟的 url 返回的 URLConnection 轉換成其子類 JarURLConnection 包連線
                JarURLConnection jarURLConnection = (JarURLConnection) url.openConnection();
                String jarFilePath = jarURLConnection.getJarFile().getName();
                // getClassesFromJar 工具類獲取指定 Jar 包中指定資源名的類;
                classList.addAll(getClassesFromJar(jarFilePath, path));
            } else {
                // 簡單起見,我們暫時僅實現掃描 jar 包中的類
                // todo
            }
        }
        return classList;
    }

    private static List<Class<?>> getClassesFromJar(String jarFilePath, String path) throws IOException, ClassNotFoundException {
         // 為減少篇幅,這裡完整程式碼就不放出來了
    }

註釋很詳細,就不多廢話了。

初始化Bean工廠

這部分是最重要的,IOC 和 AOP 都在這裡實現。

程式碼請到在 BeanFactory 類中檢視,GitHub 線上檢視 BeanFactory

註釋已經寫的非常詳細。這裡簡單說下處理邏輯。

首先通過遍歷上一步類掃描獲得類的 Class 物件集合,將被 @Aspect 註解的類儲存起來,然後初始化其他被 @Component@Controller 註解的類,並處理類中被 @AutoWired 註解的屬性,將目標引用物件注入(設定屬性的值)到類中,然後將初始化好的物件儲存到 Bean 工廠。到這裡,控制反轉就實現好了。

接下來是處理被 @Aspect 註解的類,並解析他們中被 @Pointcut@Before@After 註解的方法,使用 JDK 動態代理生成代理物件,並更新 Bean 工廠。

注意,在處理被 @Aspect 註解的類之前,Bean 工廠中的物件依賴已經設定過了就舊的 Bean,更新了 Bean 工廠中的物件後,需要通知依賴了被更新物件的物件重新初始化。

例如物件 A 依賴物件 B,即 A 的類中有一句

@AutoWired
B b;

同時,一個切面類中的切點 @Pointcut 的值指向了 B 類物件,然後他像 Bean 工廠更新了 B 物件,但這時 A 中引用的 B 物件,還是之前的舊 B 物件。

這裡我的解決方式是,將帶有 @AutoWired 屬性的類儲存起來,處理好 AOP 關係之後,再次初始化這些類,這樣他們就能從 Bean 工廠獲得新的已經被代理過的物件了。

至於如何使用 JDK 動態代理處理 AOP 關係的,請參考 GitHub ProxyDyna 類
中程式碼,總的來說是,定義一個 ProxyDyna 類實現 InvocationHandler 介面,然後實現 invoke() 方法即可,在 invoke() 方法中處理代理增強邏輯。

然後獲取物件的時候,使用 Proxy.newProxyInstance() 方法而不是直接 new,如下:

Proxy.newProxyInstance(target.getClass().getClassLoader(),
                target.getClass().getInterfaces(), this);

初始化Handler

HandlerManager 類中呼叫 parseHandlerFromController() 方法來遍歷處理所有的已掃描到的類,來初始化 MappingHandler 物件,方法程式碼如下:

private static void parseHandlerFromController(Class<?> aClass) {
        Method[] methods = aClass.getDeclaredMethods();
        // 只處理包含了 @RequestMapping 註解的方法
        for (Method method : methods) {
            if (method.isAnnotationPresent(RequestMapping.class)) {
                // 獲取賦值 @RequestMapping 註解的值,也就是客戶端請求的路徑,注意,不包括協議名和主機名
                String uri = method.getDeclaredAnnotation(RequestMapping.class).value();
                List<String> params = new ArrayList<>();
                for (Parameter parameter : method.getParameters()) {
                    if (parameter.isAnnotationPresent(RequestParam.class)) {
                        params.add(parameter.getAnnotation(RequestParam.class).value());
                    }
                }

                // List.toArray() 方法傳入與 List.size() 恰好一樣大的陣列,可以提高效率
                String[] paramsStr = params.toArray(new String[params.size()]);
                MappingHandler mappingHandler = new MappingHandler(uri, aClass, method, paramsStr);
                HandlerManager.mappingHandlerList.add(mappingHandler);
            }
        }
    }

MappingHandler 物件表示如何處理一次請求,包括請求 uri,應該呼叫的類,應該呼叫的方法以及方法引數。

如此,在 MappingHandler 的 handle() 方法中處理請求,直接從 Bean 工廠獲取指定類物件,從 response 物件中獲取請求引數值,使用反射呼叫對應方法,並接收方法返回值輸出給瀏覽器即可。

再回顧我們啟動 tomcat 伺服器時指定執行的 servlet:

@Override
    public void service(ServletRequest req, ServletResponse res) throws IOException {
        for (MappingHandler mappingHandler : HandlerManager.mappingHandlerList) {
            // 從所有的 MappingHandler 中逐一嘗試處理請求,
            // 如果某個 handler 可以處理(返回true),則返回即可
            try {
                if (mappingHandler.handle(req, res)) {
                    return;
                }
            } catch (IllegalAccessException e) {
                e.printStackTrace();
            } catch (InvocationTargetException e) {
                e.printStackTrace();
            }
        }
        res.getWriter().println("failed!");
    }

一目瞭然,其 service() 方法只是遍歷所有的 MappingHandler 物件來處理請求而已。

框架使用

測試使用 IOC 和 AOP 功能。這裡以定義一個 /rap 路徑舉例,

1. 定義Controller

@Controller
public class RapController {
    @AutoWired
    private Rap rapper;

    @RequestMapping("/rap")
    public String rap() {
        rapper.rap();
        return "CXK";
    }
}

RapController 從 Bean 工廠獲取一個 Rap 物件,訪問 /rap 路徑是,會先執行該物件的 rap() 方法,然後返回 "CXK" 給瀏覽器。

2. 定義 Rap 介面及其實現類

public interface Rap {
    void rap();
}
// ----another file----
@Component
public class Rapper implements Rap {
    public void rap() {
        System.out.println("CXK rapping...");
    }
}

介面一定要定義,否則無法使用 AOP,因為我們使用的是 JDK 動態代理,只能代理實現了介面的類(原理是生成一個該介面的增強帶向)。Spring 使用的是 JDK 動態代理和 CGLIB 兩種方式,CGLIB 可以直接使用 ASM 等位元組碼生成框架,來生成一個被代理物件的增強子類。

使用瀏覽器訪問 http://localhost:8080/rap ,即可看到 IDE 控制檯輸出 CXK rapping...,可以看到,@AutoWired 註解成功注入了物件。

但如果我們想在 rap 前面先 唱、跳,並且在 rap 後面打籃球,那麼就需要定義織面類來面向切面程式設計。

定義一個 RapAspect 類如下:

@Aspect
@Component
public class RapAspect {

    // 定義切點,spring的實現中,
    // 此註解可以使用表示式 execution() 萬用字元匹配切點,
    // 簡單起見,我們先實現明確到方法的切點
    @Pointcut("com.caozhihu.spring.service.serviceImpl.Rapper.rap()")
    public void rapPoint() {
    }

    @Before("rapPoint()")
    public void singAndDance() {
        // 在 rap 之前要先唱、跳
        System.out.println("first,singing <chicken is too beautiful>.");
        System.out.println("and the chicken monster is dancing now.");
    }

    @After("rapPoint()")
    public void basketball() {
        // 在 rap 之後別忘記了籃球
        System.out.println("oh! Don't forget my favorite basketball.");
    }
}

織面類 RapAspect 定義了切入點以及前置後置通知等,這樣 RapController 中使用 @AutoWired 註解引入的 Rap 物件,會被替換為增強的 Rap 代理物件,如此,我們無需改動 RapController 中任何一處程式碼,就實現了在 rap() 方法前後執行額外的程式碼(通知)。

增加 RapAspect 後,再次訪問會在 IDE 控制檯輸出:

first,singing <chicken is too beautiful>.
and the chicken monster is dancing now.
CXK rapping...
oh! Don't forget my favorite basketball.

總結與參考

沒啥好說的,該說的,都說了,你懂得,就夠了,怎麼有某一種悲哀.... 哈哈哈哈

仿寫一個簡陋的 IOC/AOP 框架 mini-spring

參考

tomcat 使用與框架圖:手寫一個簡化版Tomcat
gradle 配置與 DI 部分實現:慕課網
Spring 常用註解 how2j SPRING系列教材

相關文章