1. 程式人生 > >關於 spring aop (aspectj) 你該知曉的一切

關於 spring aop (aspectj) 你該知曉的一切

本篇是年後第一篇博文,由於博主用了不少時間在構思這篇博文,加上最近比較忙,所以這篇檔案寫得比較久,也分了不同的時間段在寫,已盡最大能力去連貫博文中的內容,盡力呈現出簡單易懂的文字含義,如文中有錯誤請留言,謝謝。

    • 神一樣的AspectJ-AOP的領跑者
    • AspectJ的織入方式及其原理概要
  • 基於Aspect Spring AOP 開發
  • 基於註解的Spring AOP開發
  • Spring AOP的實現原理概要

OOP的新生機

OOP新生機前夕

OOP即面向物件的程式設計,談起了OOP,我們就不得不瞭解一下POP即面向過程程式設計,它是以功能為中心來進行思考和組織的一種程式設計方式,強調的是系統的資料被加工和處理的過程,說白了就是注重功能性的實現,效果達到就好了,而OOP則注重封裝,強調整體性的概念,以物件為中心,將物件的內部組織與外部環境區分開來。之前看到過一個很貼切的解釋,博主把它們畫成一幅圖如下:

在這裡我們暫且把程式設計比喻為房子的佈置,一間房子的佈局中,需要各種功能的傢俱和潔具(類似方法),如馬桶、浴缸、天然氣灶,床、桌子等,對於面向過程的程式設計更注重的是功能的實現(即功能方法的實現),效果符合預期就好,因此面向過程的程式設計會更傾向圖1設定結構,各種功能都已實現,房子也就可以正常居住了。但對於面向物件的程式設計則是無法忍受的,這樣的設定使房子內的各種傢俱和潔具間擺放散亂並且相互暴露的機率大大增加,各種氣味相互參雜,顯然是很糟糕的,於是為了更優雅地設定房屋的佈局,面向物件的程式設計便採用了圖2的佈局,對於面向物件程式設計來說這樣設定好處是顯而易見的,房子中的每個房間都有各自的名稱和相應功能(在

Java程式設計中一般把類似這樣的房間稱為類,每個類代表著一種房間的抽象體),如衛生間是大小解和洗澡梳妝用的,臥室是休息用的,廚房則是做飯用的,每個小房間都各司其職並且無需時刻向外界暴露內部的結構,整個房間結構清晰,外界只需要知道這個房間並使用房間內提供的各項功能即可(方法呼叫),同時也更有利於後期的拓展了,畢竟哪個房間需要新增那些功能,其範圍也有了限制,也就使職責更加明確了(單一責任原則)。OOP的出現對POP確實存在很多顛覆性的,但並不能說POP已沒有價值了,畢竟只是不同時代的產物,從方法論來講,更喜歡將面向過程與面向物件看做是事物的兩個方面–區域性與整體(你必須要注意到區域性與整體是相對的),因此在實際應用中,兩者方法都同樣重要。瞭解完OOP和POP各自的特點,接著看java程式設計過程中OOP應用,在java程式設計過程中,我們幾乎享盡了OOP設計思想帶來的甜頭,以至於在這個一切皆物件,眾生平等的世界裡,狂歡不已,而OOP確實也遵循自身的宗旨即將資料及對資料的操作行為放在一起,作為一個相互依存、不可分割的整體,這個整體美其名曰:物件,利用該定義對於相同型別的物件進行分類、抽象後,得出共同的特徵,從而形成了類,在java程式設計中這些類就是class,由於類(物件)基本都是現實世界存在的事物概念(如前面的不同的小房間)因此更接近人們對客觀事物的認識,同時把資料和方法(
演算法
)封裝在一個類(物件)中,這樣更有利於資料的安全,一般情況下屬性和演算法只單獨屬於某個類,從而使程式設計更簡單,也更易於維護。基於這套理論思想,在實際的軟體開發中,整個軟體系統事實也是由系列相互依賴的物件所組成,而這些物件也是被抽象出來的類。相信大家在實際開發中是有所體驗的(本篇檔案假定讀者已具備面向物件的開發思想包括封裝、繼承、多型的知識點)。但隨著軟體規模的增大,應用的逐漸升級,慢慢地,OOP也開始暴露出一些問題,現在不需要急於知道它們,通過案例,我們慢慢感受:

A類:

public class A {
    public void executeA(){
        //其他業務操作省略......
        recordLog();
    }

    public void recordLog(){
        //....記錄日誌並上報日誌系統
    }
}

B類:

public class B {
    public void executeB(){
        //其他業務操作省略......
        recordLog();
    }

    public void recordLog(){
        //....記錄日誌並上報日誌系統
    }
}

C類:

public class C {
    public void executeC(){
        //其他業務操作省略......
        recordLog();
    }

    public void recordLog(){
        //....記錄日誌並上報日誌系統
    }
}

假設存在A、B、C三個類,需要對它們的方法訪問進行日誌記錄,在程式碼中各種存在recordLog方法進行日誌記錄並上報,或許對現在的工程師來說幾乎不可能寫出如此糟糕的程式碼,但在OOP這樣的寫法是允許的,而且在OOP開始階段這樣的程式碼確實並大量存在著,直到工程師實在忍受不了一次修改,到處挖墳時(修改recordLog內容),才下定決心解決該問題,為了解決程式間過多冗餘程式碼的問題,工程師便開始使用下面的編碼方式

//A類
public class A {
    public void executeA(){
        //其他業務操作省略...... args 引數,一般會傳遞類名,方法名稱 或資訊(這樣的資訊一般不輕易改動)
        Report.recordLog(args ...);
    }
}

//B類
public class B {
    public void executeB(){
        //其他業務操作省略......
        Report.recordLog(args ...);
    }
}

//C類
public class C {
    public void executeC(){
        //其他業務操作省略......
        Report.recordLog(args ...);
    }
}

//record
public class Report {
    public static void recordLog(args ...){
        //....記錄日誌並上報日誌系統
    }
}

這樣操作後,我們欣喜地發現問題似乎得到了解決,當上報資訊內部方法需要調整時,只需調整Report類中recordLog方法體,也就避免了隨處挖墳的問題,大大降低了軟體後期維護的複雜度。確實如此,而且除了上述的解決方案,還存在一種通過繼承來解決的方式,採用這種方式,只需把相通的程式碼放到一個類(一般是其他類的父類)中,其他的類(子類)通過繼承父類獲取相通的程式碼,如下:

//通用父類
public class Dparent {
    public void commond(){
        //通用程式碼
    }
}
//A 繼承 Dparent 
public class A extends Dparent {
    public void executeA(){
        //其他業務操作省略......
        commond();
    }
}
//B 繼承 Dparent 
public class B extends Dparent{
    public void executeB(){
        //其他業務操作省略......
        commond();
    }
}
//C 繼承 Dparent 
public class C extends Dparent{
    public void executeC(){
        //其他業務操作省略......
        commond();
    }
}

顯然程式碼冗餘也得到了解決,這種通過繼承抽取通用程式碼的方式也稱為縱向拓展,與之對應的還有橫向拓展(現在不需急於明白,後面的分析中它將隨處可見)。事實上有了上述兩種解決方案後,在大部分業務場景的程式碼冗餘問題也得到了實實在在的解決,原理如下圖

但是隨著軟體開發的系統越來越複雜,工程師認識到,傳統的OOP程式經常表現出一些不自然的現象,核心業務中總摻雜著一些不相關聯的特殊業務,如日誌記錄,許可權驗證,事務控制,效能檢測,錯誤資訊檢測等等,這些特殊業務可以說和核心業務沒有根本上的關聯而且核心業務也不關心它們,比如在使用者管理模組中,該模組本身只關心與使用者相關的業務資訊處理,至於其他的業務完全可以不理會,我們看一個簡單例子協助理解這個問題

/**
 * Created by zejian on 2017/2/15.
 * Blog : http://blog.csdn.net/javazejian [原文地址,請尊重原創]
 */
public interface IUserService {

    void saveUser();

    void deleteUser();

    void findAllUser();
}
//實現類
public class UserServiceImpl implements IUserService {

    //核心資料成員

    //日誌操作物件

    //許可權管理物件

    //事務控制物件

    @Override
    public void saveUser() {

        //許可權驗證(假設許可權驗證丟在這裡)

        //事務控制

        //日誌操作

        //進行Dao層操作
        userDao.saveUser();

    }

    @Override
    public void deleteUser() {

    }

    @Override
    public void findAllUser() {

    }
}

上述程式碼中我們注意到一些問題,許可權,日誌,事務都不是使用者管理的核心業務,也就是說使用者管理模組除了要處理自身的核心業務外,還需要處理許可權,日誌,事務等待這些雜七雜八的不相干業務的外圍操作,而且這些外圍操作同樣會在其他業務模組中出現,這樣就會造成如下問題

  • 程式碼混亂:核心業務模組可能需要兼顧處理其他不相干的業務外圍操作,這些外圍操作可能會混亂核心操作的程式碼,而且當外圍模組有重大修改時也會影響到核心模組,這顯然是不合理的。

  • 程式碼分散和冗餘:同樣的功能程式碼,在其他的模組幾乎隨處可見,導致程式碼分散並且冗餘度高。

  • 程式碼質量低擴充套件難:由於不太相關的業務程式碼混雜在一起,無法專注核心業務程式碼,當進行類似無關業務擴充套件時又會直接涉及到核心業務的程式碼,導致拓展性低。

顯然前面分析的兩種解決方案已束手無策了,那麼該如何解決呢?事實上我們知道諸如日誌,許可權,事務,效能監測等業務幾乎涉及到了所有的核心模組,如果把這些特殊的業務程式碼直接到核心業務模組的程式碼中就會造成上述的問題,而工程師更希望的是這些模組可以實現熱插拔特性而且無需把外圍的程式碼入侵到核心模組中,這樣在日後的維護和擴充套件也將會有更佳的表現,假設現在我們把日誌、許可權、事務、效能監測等外圍業務看作單獨的關注點(也可以理解為單獨的模組),每個關注點都可以在需要它們的時刻及時被運用而且無需提前整合到核心模組中,這種形式相當下圖:

從圖可以看出,每個關注點與核心業務模組分離,作為單獨的功能,橫切幾個核心業務模組,這樣的做的好處是顯而易見的,每份功能程式碼不再單獨入侵到核心業務類的程式碼中,即核心模組只需關注自己相關的業務,當需要外圍業務(日誌,許可權,效能監測、事務控制)時,這些外圍業務會通過一種特殊的技術自動應用到核心模組中,這些關注點有個特殊的名稱,叫做“橫切關注點”,上圖也很好的表現出這個概念,另外這種抽象級別的技術也叫AOP(面向切面程式設計),正如上圖所展示的橫切核心模組的整面,因此AOP的概念就出現了,而所謂的特殊技術也就面向切面程式設計的實現技術,AOP的實現技術有多種,其中與Java無縫對接的是一種稱為AspectJ的技術。那麼這種切面技術(AspectJ)是如何在Java中的應用呢?不必擔心,也不必全面瞭解AspectJ,本篇博文也不會這樣進行,對於AspectJ,我們只會進行簡單的瞭解,從而為理解spring中的AOP打下良好的基礎(Spring AOP 與AspectJ 實現原理上並不完全一致,但功能上是相似的,這點後面會分析),畢竟Spring中已實現AOP主要功能,開發中直接使用Spring中提供的AOP功能即可,除非我們想單獨使用AspectJ的其他功能。這裡還需要注意的是,AOP的出現確實解決外圍業務程式碼與核心業務程式碼分離的問題,但它並不會替代OOP,如果說OOP的出現是把編碼問題進行模組化,那麼AOP就是把涉及到眾多模組的某一類問題進行統一管理,因此在實際開發中AOP和OOP同時存在並不奇怪,後面將會慢慢體會帶這點,好的,已迫不及待了,讓我們開始瞭解神一樣的AspectJ吧。

神一樣的AspectJ-AOP的領跑者

這裡先進行一個簡單案例的演示,然後引出AOP中一些晦澀難懂的抽象概念,放心,通過本篇部落格,我們將會非常輕鬆地理解並掌握它們。編寫一個HelloWord的類,然後利用AspectJ技術切入該類的執行過程。

/**
 * Created by zejian on 2017/2/15.
 * Blog : http://blog.csdn.net/javazejian [原文地址,請尊重原創]
 */
public class HelloWord {

    public void sayHello(){
        System.out.println("hello world !");
    }
    public static void main(String args[]){
        HelloWord helloWord =new HelloWord();
        helloWord.sayHello();
    }
}

編寫AspectJ類,注意關鍵字為aspect(MyAspectJDemo.aj,其中aj為AspectJ的字尾),含義與class相同,即定義一個AspectJ的類

/**
 * Created by zejian on 2017/2/15.
 * Blog : http://blog.csdn.net/javazejian [原文地址,請尊重原創]
 * 切面類
 */
public aspect MyAspectJDemo {
    /**
     * 定義切點,日誌記錄切點
     */
    pointcut recordLog():call(* HelloWord.sayHello(..));

    /**
     * 定義切點,許可權驗證(實際開發中日誌和許可權一般會放在不同的切面中,這裡僅為方便演示)
     */
    pointcut authCheck():call(* HelloWord.sayHello(..));

    /**
     * 定義前置通知!
     */
    before():authCheck(){
        System.out.println("sayHello方法執行前驗證許可權");
    }

    /**
     * 定義後置通知
     */
    after():recordLog(){
        System.out.println("sayHello方法執行後記錄日誌");
    }
}

ok~,執行helloworld的main函式:

對於結果不必太驚訝,完全是意料之中。我們發現,明明只運行了main函式,卻在sayHello函式執行前後分別進行了許可權驗證和日誌記錄,事實上這就是AspectJ的功勞了。對aspectJ有了感性的認識後,再來聊聊aspectJ到底是什麼?AspectJ是一個java實現的AOP框架,它能夠對java程式碼進行AOP編譯(一般在編譯期進行),讓java程式碼具有AspectJ的AOP功能(當然需要特殊的編譯器),可以這樣說AspectJ是目前實現AOP框架中最成熟,功能最豐富的語言,更幸運的是,AspectJ與java程式完全相容,幾乎是無縫關聯,因此對於有java程式設計基礎的工程師,上手和使用都非常容易。在案例中,我們使用aspect關鍵字定義了一個類,這個類就是一個切面,它可以是單獨的日誌切面(功能),也可以是許可權切面或者其他,在切面內部使用了pointcut定義了兩個切點,一個用於許可權驗證,一個用於日誌記錄,而所謂的切點就是那些需要應用切面的方法,如需要在sayHello方法執行前後進行許可權驗證和日誌記錄,那麼就需要捕捉該方法,而pointcut就是定義這些需要捕捉的方法(常常是不止一個方法的),這些方法也稱為目標方法,最後還定義了兩個通知,通知就是那些需要在目標方法前後執行的函式,如before()即前置通知在目標方法之前執行,即在sayHello()方法執行前進行許可權驗證,另一個是after()即後置通知,在sayHello()之後執行,如進行日誌記錄。到這裡也就可以確定,切面就是切點和通知的組合體,組成一個單獨的結構供後續使用,下圖協助理解。

這裡簡單說明一下切點的定義語法:關鍵字為pointcut,定義切點,後面跟著函式名稱,最後編寫匹配表示式,此時函式一般使用call()或者execution()進行匹配,這裡我們統一使用call()

pointcut 函式名 : 匹配表示式

案例:recordLog()是函式名稱,自定義的,* 表示任意返回值,接著就是需要攔截的目標函式,sayHello(..)的..,表示任意引數型別。這裡理解即可,後面Spring AOP會有關於切點表示式的分析,整行程式碼的意思是使用pointcut定義一個名為recordLog的切點函式,其需要攔截的(切入)的目標方法是HelloWord類下的sayHello方法,引數不限。

pointcut recordLog():call(* HelloWord.sayHello(..));

關於定義通知的語法:首先通知有5種類型分別如下:

  • before 目標方法執行前執行,前置通知
  • after 目標方法執行後執行,後置通知
  • after returning 目標方法返回時執行 ,後置返回通知
  • after throwing 目標方法丟擲異常時執行 異常通知
  • around 在目標函式執行中執行,可控制目標函式是否執行,環繞通知

語法:

[返回值型別] 通知函式名稱(引數) [returning/throwing 表示式]:連線點函式(切點函式){

函式體

}

案例如下,其中要注意around通知即環繞通知,可以通過proceed()方法控制目標函式是否執行。

/**
  * 定義前置通知
  *
  * before(引數):連線點函式{
  *     函式體
  * }
  */
 before():authCheck(){
     System.out.println("sayHello方法執行前驗證許可權");
 }

 /**
  * 定義後置通知
  * after(引數):連線點函式{
  *     函式體
  * }
  */
 after():recordLog(){
     System.out.println("sayHello方法執行後記錄日誌");
 }


 /**
  * 定義後置通知帶返回值
  * after(引數)returning(返回值型別):連線點函式{
  *     函式體
  * }
  */
 after()returning(int x): get(){
     System.out.println("返回值為:"+x);
 }

 /**
  * 異常通知
  * after(引數) throwing(返回值型別):連線點函式{
  *     函式體
  * }
  */
 after() throwing(Exception e):sayHello2(){
     System.out.println("丟擲異常:"+e.toString());
 }



 /**
  * 環繞通知 可通過proceed()控制目標函式是否執行
  * Object around(引數):連線點函式{
  *     函式體
  *     Object result=proceed();//執行目標函式
  *     return result;
  * }
  */
 Object around():aroundAdvice(){
     System.out.println("sayAround 執行前執行");
     Object result=proceed();//執行目標函式
     System.out.println("sayAround 執行後執行");
     return result;
 }

切入點(pointcut)和通知(advice)的概念已比較清晰,而切面則是定義切入點和通知的組合如上述使用aspect關鍵字定義的MyAspectJDemo,把切面應用到目標函式的過程稱為織入(weaving)。在前面定義的HelloWord類中除了sayHello函式外,還有main函式,以後可能還會定義其他函式,而這些函式都可以稱為目標函式,也就是說這些函式執行前後也都可以切入通知的程式碼,這些目標函式統稱為連線點,切入點(pointcut)的定義正是從這些連線點中過濾出來的,下圖協助理解。

AspectJ的織入方式及其原理概要

經過前面的簡單介紹,我們已初步掌握了AspectJ的一些語法和概念,但這樣仍然是不夠的,我們仍需要了解AspectJ應用到java程式碼的過程(這個過程稱為織入),對於織入這個概念,可以簡單理解為aspect(切面)應用到目標函式(類)的過程。對於這個過程,一般分為動態織入和靜態織入,動態織入的方式是在執行時動態將要增強的程式碼織入到目標類中,這樣往往是通過動態代理技術完成的,如Java JDK的動態代理(Proxy,底層通過反射實現)或者CGLIB的動態代理(底層通過繼承實現),Spring AOP採用的就是基於執行時增強的代理技術,這點後面會分析,這裡主要重點分析一下靜態織入,ApectJ採用的就是靜態織入的方式。ApectJ主要採用的是編譯期織入,在這個期間使用AspectJ的acj編譯器(類似javac)把aspect類編譯成class位元組碼後,在java目標類編譯時織入,即先編譯aspect類再編譯目標類。

關於ajc編譯器,是一種能夠識別aspect語法的編譯器,它是採用java語言編寫的,由於javac並不能識別aspect語法,便有了ajc編譯器,注意ajc編譯器也可編譯java檔案。為了更直觀瞭解aspect的織入方式,我們開啟前面案例中已編譯完成的HelloWord.class檔案,反編譯後的java程式碼如下:

//
// Source code recreated from a .class file by IntelliJ IDEA
// (powered by Fernflower decompiler)
//

package com.zejian.demo;

import com.zejian.demo.MyAspectJDemo;
//編譯後織入aspect類的HelloWord位元組碼反編譯類
public class HelloWord {
    public HelloWord() {
    }

    public void sayHello() {
        System.out.println("hello world !");
    }

    public static void main(String[] args) {
        HelloWord helloWord = new HelloWord();
        HelloWord var10000 = helloWord;

   try {
        //MyAspectJDemo 切面類的前置通知織入
        MyAspectJDemo.aspectOf().ajc$before$com_zejian_demo_MyAspectJDemo$1$22c5541();
        //目標類函式的呼叫
           var10000.sayHello();
        } catch (Throwable var3) {
        MyAspectJDemo.aspectOf().ajc$after$com_zejian_demo_MyAspectJDemo$2$4d789574();
            throw var3;
        }

        //MyAspectJDemo 切面類的後置通知織入 
        MyAspectJDemo.aspectOf().ajc$after$com_zejian_demo_MyAspectJDemo$2$4d789574();
    }
}

顯然AspectJ的織入原理已很明朗了,當然除了編譯期織入,還存在連結期(編譯後)織入,即將aspect類和java目標類同時編譯成位元組碼檔案後,再進行織入處理,這種方式比較有助於已編譯好的第三方jar和Class檔案進行織入操作,由於這不是本篇的重點,暫且不過多分析,掌握以上AspectJ知識點就足以協助理解Spring AOP了。有些同學可能想自己編寫aspect程式進行測試練習,博主在這簡單介紹執行環境的搭建,首先博主使用的idea的IDE,因此只對idea進行介紹(eclipse,呵呵)。首先通過maven倉庫下載工具包aspectjtools-1.8.9.jar,該工具包包含ajc核心編譯器,然後開啟idea檢查是否已安裝aspectJ的外掛:

配置專案使用ajc編譯器(替換javac)如下圖:

如果使用maven開發(否則在libs目錄自行引入jar)則在pom檔案中新增aspectJ的核心依賴包,包含了AspectJ執行時的核心庫檔案:

<dependency>
    <groupId>aspectj</groupId>
    <artifactId>aspectjrt</artifactId>
    <version>1.5.4</version>
</dependency>

新建檔案處建立aspectJ檔案,然後就可以像執行java檔案一樣,操作aspect檔案了。