Spring 面向切面編程

AOP,也就是面向方面編程或者說面向面編程,是一種很重要的思想。在企業級系統中經常需要打印日志、事務管理這樣針對某一方面的需求,但是傳統的面向對象編程無法很好的滿足這些需求。因此催生了面向切面編程這樣的思想。面向切面編程,通過動態代理這樣的功能,向要執行的方法添加鉤子,能夠在不改動原方法的情況下,動態添加新功能。所以在現代系統中算是一項必需的功能了。Spring框架也很好的支持了AOP。

AOP的幾個術語如下,詳細的使用方法會在具體使用的時候說明。

  • 切面(Aspect),官方的抽象定義為“一個關注點的模塊化,這個關注點可能會橫切多個對象”,上面所說的打印日志、事務管理這樣的需求,就是切面。
  • 連接點(JoinPoint),程序執行過程中的某一行為。比如說我們計劃在某個方法執行的時候打印日志,那么這個方法就是連接點。
  • 通知(Advice),切面對于某個連接點產生的動作就是通知。比如說我們上面計劃在某個方法執行的時候打印日志,那么打印日志這件事情就是通知。通知按照執行時機可以分為前置通知、后置通知等五種通知。
  • 切入點(Pointcut),可以簡單地理解為正則表達式之類的東西。我們想要在哪些方法上應用打印日志的通知,就需要一個切入點來匹配。
  • 目標對象(Target Object),被切面通知的對象就是目標對象。

環境配置

Spring核心的依賴注入功能不需要AOP等其他組件的支持即可使用。不過反過來AOP卻需要依賴注入的支持。因此我們需要添加比較多的依賴。以下是Gradle的依賴配置,為了運行后面的Hibernate例子,需要Hibernate等幾個額外的包。

dependencies {
    testCompile group: 'junit', name: 'junit', version: '4.12'
    compile group: 'org.springframework', name: 'spring-core', version: springVersion
    compile group: 'org.springframework', name: 'spring-context', version: springVersion
    compile group: 'org.springframework', name: 'spring-aop', version: springVersion
    compile group: 'org.springframework', name: 'spring-test', version: springVersion
    compile group: 'org.springframework', name: 'spring-orm', version: springVersion
    compile group: 'org.projectlombok', name: 'lombok', version: '1.16.12'
    compile group: 'org.hibernate', name: 'hibernate-core', version: '5.2.6.Final'
    compile group: 'mysql', name: 'mysql-connector-java', version: '5.1.40'
    compile group: 'org.apache.commons', name: 'commons-dbcp2', version: '2.1.1'

}

定義服務

要使用AOP,我們首先需要確定要把AOP用在什么地方。這里我定義了一個小服務,執行幾個方法。這幾個方法列舉了最常用的幾種使用情景,無參方法、有參方法、有返回值方法。

public class MyService {
    public void doSomething() {
        System.out.println("做一些事情...");
    }

    public void printSomething(String msg) {
        System.out.println("信息室:" + msg);
    }

    public int calculateSomething(int a, int b) {
        return a + b;
    }

    public void throwSomething() {
        throw new RuntimeException("一個異常");
    }

    public void longWork() {
        int N = 10000;
        int sum = 0;
        for (int i = 0; i < N; ++i) {
            sum += i;
        }
    }
}

然后將這個服務注冊為Spring Bean。

<bean id="myService" class="yitian.learn.aop.MyService"/>

XML方式配置AOP

定義切面

我們在這里定義一個切面,這個切面包含幾個方法,將會在我們的服務執行前、執行后輸出信息,追蹤服務的參數、返回值和異常信息等。在編程實踐中,一個切面一般是一個類,其中包含若干方法將會代理到服務方法上。

public class MyAspect {
    public void before() {
        System.out.println("在方法之前");
    }

    public void after() {
        System.out.println("在方法之后");
    }

    public void printDataFlow(int input1, int input2, int output) {
        System.out.println(
                String.format("程序輸入是:%d,%d,輸出是:%d", input1, input2, output));
    }

    public void afterThrow(Exception e) {
        System.out.println("方法拋出了" + e);
    }
}

定義好日志切面之后,我們同樣需要將其配置為一個Bean。

<bean id="myAspect" class="yitian.learn.aop.MyAspect"/>

要將某個Bean配置為切面還需要一步,也就是在XML配置文件中beans根節點添加如下一行,引用AOP的相關規則。

xmlns:aop="http://www.springframework.org/schema/aop"

然后在配置文件中添加如下一節。將Bean聲明為切面。所有的AOP相關配置,都只能編寫在<aop:config>節點中,而且順序必須按照切入點、通知和切面的順序聲明。

<aop:config>
    <aop:aspect id="logAspect" ref="logAspect">

    </aop:aspect>
</aop:config>

定義切入點

切入點可以理解為正則表達式,簡單地說,切入點和目標方法之間的關系就像正則表達式和要匹配的字符串的關系一樣。切入點定義了一個模式,可以匹配一個或多個目標方法。Spring的切入點表達式使用的是AspectJ的切入點表達式語法,詳細信息可以參考Spring AspectJ文檔。Spring沒有支持所有的AspectJ語法,只支持了一部分。

Spring AOP支持以下幾種指示符:

  • execute,匹配指定方法執行的連接點,這是我們最常用的一種。
  • within,匹配指定類型內的連接點。
  • this,匹配bean引用(AOP代理)是指定類型的連接點。
  • target,匹配目標對象(被代理的對象)是指定類型的連接點。
  • args,匹配方法參數是指定類型的連接點。
  • @target,匹配目標對象的類被指定注解標記的連接點。
  • @args,匹配方法參數標記有指定注解的連接點。
  • @within,匹配被指定注解標記的類型的連接點。
  • @annotation,匹配執行方法含有指定注解的連接點。
  • bean,Spring AOP特有的,匹配指定id或名稱的Spring Bean的連接點。

在指示符后面,需要一組括號,括號內容是方法的匹配,語法如下:

指示符(返回類型 包名.類名.方法名(參數列表) )

下面這個切入點表示的是yitian.learn.aop.MyService類下的返回任意值的任意名稱和任意個參數的方法執行時。這樣這個切入點代表的就是MyService類的所有方法。id屬性指定切入點標識符,expression指定切入點表達式。切入點既可以定義在切面內部,也可以定義在切面外。如果定義在切面外,就可以被多個切面所共享。但是必須定義在所有切面之前,順序上面已經說了。

這里使用到了兩個通配符。星號*代表單個的任意類型和名稱,兩個點..表示任意多個名稱或參數。此外還有一個通配符+,用在某個類型之后,表示該類型的子類或者實現了該接口的某個類。

<aop:pointcut id="myService"
              expression="execution(* yitian.learn.aop.MyService.*(..))"/>

再來幾個例子。匹配任意公有方法。

execution(public * *(..))

匹配com.xyz.someapp.trading及其子包下所有方法執行。

within(com.xyz.someapp.trading..*)

匹配以set開頭的所有方法執行。

execution(* set*(..))

匹配com.xyz.service包下的任意類的任意方法。

execution(* com.xyz.service.*.*(..))

匹配任何實現了com.xyz.service.AccountService接口目標對象的切入點。

target(com.xyz.service.AccountService)

切入點還可以疊加,使用&&||!表示切入點的與或非。由于在XML配置文件中存在字符轉義現象,所以在XML配置中還可以使用andor、not來替代上面的關系運算符。

定義通知

切面對于某個連接點所執行的動作就是通知。通知有以下幾種:

  • 前置通知(before),在目標方法執行前執行、
  • 返回后通知(after-returning),在目標方法正常返回之后執行。
  • 異常后通知(after-throwing),在目標方法拋出異常之后執行。
  • 后置通知(after),在目標方法結束(包括正常返回和拋出異常)之后執行。
  • 環繞通知(around),將目標方法包裹到切面方法中執行。

通知將切面和目標方法之間聯系起來。pointcut-ref屬性指定命名切入點的引用,如果不想使用命名切入點也可以使用pointcut指定切入點表達式;method指定切面中當連接點執行時所執行的方法。通知需要定義在切面之中。下面定義了前置通知和后置通知。其他通知的定義類似,寫在上面通知的括號中了。

<aop:aspect id="aspect" ref="myAspect">
    <aop:before method="before" pointcut-ref="something"/>
    <aop:after method="after" pointcut-ref="something"/>
</aop:aspect>

這樣定義之后,每當連接點執行的時候,通知隨之執行。如果AOP的功能僅僅是這樣的話顯然沒什么作用。在通知中,我們還可以獲取目標方法的參數和返回值。下面定義了一個通知,切入點是當calculateSomething方法執行的時候;返回值使用returning屬性指明;參數在切入點表達式中使用args指明;最后指定了這幾個參數在切面方法中的順序。這樣,連接的參數和返回值就可以正確的綁定到切面方法上了。

<aop:after-returning method="printDataFlow"
                     pointcut="execution(int yitian.learn.aop.MyService.calculateSomething(int,int)) and args(input1,input2)"
                     returning="output"
                     arg-names="input1,input2,output"/>

如果要獲取方法拋出的異常,需要throwing屬性,這樣切面方法就可以順利獲取到異常對象了。

<aop:after-throwing method="afterThrow"
                    pointcut="execution(* yitian..MyService.throwSomething())"
                    throwing="e"/>

最后來說說環繞通知。相比而言環繞通知應該是最復雜的通知了。連接點會被包裹在環繞通知方法內執行。如何來處理連接點的執行和返回值呢?這需要環繞通知的方法具有一些特征:

  • 必須有一個org.aspectj.lang.ProceedingJoinPoint類型的參數作為方法的第一個參數,否則無法執行方法。
  • 環繞通知方法最好有返回值,如果沒有返回值,連接點方法的返回值將會丟失。

下面我們在MyAspect類中新建一個方法,用于測試連接點方法執行時間,因為只是測試執行時間,因此這里沒有為方法添加返回值。

public void around(ProceedingJoinPoint pjp) {
    StopWatch watch = new StopWatch();
    watch.start();
    try {
        pjp.proceed();
    } catch (Throwable throwable) {
        throwable.printStackTrace();
    }
    watch.stop();
    System.out.println(watch.shortSummary());
}

然后,我們定義一個環繞通知。

<aop:around method="around"
            pointcut="execution(void yitian..MyService.longWork())"/>

這樣的話,在執行longWork方法的時候就會自動包裹在around方法中執行。環繞通知主要用于事務處理等必須包裹的情形當中。使用前面幾種通知可以實現功能的話就不要使用環繞通知。

定義引入

引入(Introduction)是AOP的一項功能,可以在不改變源代碼的情況下,動態的讓某個對象實現某個接口。

首先我們需要一個接口和一個默認實現。

public interface Service {
    void doService();
}
public class ServiceImpl implements Service {
    @Override
    public void doService() {
        System.out.println("實現了Service接口");
    }
}

然后在<aop:aspect>中添加如下一節。<aop:declare-parents>來指定一個引入。types-matching屬性指定要匹配的類;implement-interface屬性指定要實現的接口;default-impl屬性指定該接口的默認實現。

<aop:declare-parents types-matching="yitian.learn.aop.MyService"
                     implement-interface="yitian.learn.aop.Service"
                     default-impl="yitian.learn.aop.ServiceImpl"/>

然后我們就可以將MyService轉換成Service接口了。

Service s = context.getBean("myService", Service.class);
s.doService();

@AspectJ配置

前面用的是XML方式配置的AOP,由于Spring AOP的很多概念和類直接來自于AspectJ開源項目。當然也支持AspectJ形式的注解配置。要啟用AspectJ注解形式的配置,需要在Java配置類上添加@EnableAspectJAutoProxy注解。

@Configuration
@EnableAspectJAutoProxy
public class AppConfig {

}

如果使用XML配置Spring而使用注解配置Spring AOP,需要在配置文件中添加下面一行。

<aop:aspectj-autoproxy/>

定義切面

定義切面很簡單,在切面類上應用@Aspect即可。

@Aspect
public class MyAspect {
...
}

定義切入點

定義切入點需要在切面類中定義一個空方法,方法名會作為切入點的名稱,切入點表達式使用注解聲明。這里這個方法的作用就是充當一個占位符,所以方法體為空,這個方法返回類型必須是void。

@Pointcut(value = "execution(* yitian..MyService.doSomething())")
private void something() {
}

定義通知

定義通知和配置XML文件類似。這里不說了。直接上代碼。

@Aspect
public class MyAspect {
    //定義切入點
    @Pointcut("execution(* yitian..MyService.doSomething())")
    private void something() {
    }

    //定義通知
    @Before("something()")
    public void before() {
        System.out.println("在方法之前");
    }

    @After("something()")
    public void after() {
        System.out.println("在方法之后");
    }

    @AfterReturning(pointcut = "execution(* yitian..MyService.calculateSomething(..)) && args(input1,input2)",
            returning = "output", argNames = "input1,input2,output")
    public void printDataFlow(int input1, int input2, int output) {
        System.out.println(
                String.format("程序輸入是:%d,%d,輸出是:%d", input1, input2, output));
    }

    @AfterThrowing(pointcut = "execution(* yitian..MyService.throwSomething())",
            throwing = "e")
    public void afterThrow(Exception e) {
        System.out.println("方法拋出了" + e);
    }

    @Around("execution(* yitian..MyService.longWork())")
    public void around(ProceedingJoinPoint pjp) {
        System.out.println("開始計時");
        StopWatch watch = new StopWatch();
        watch.start();
        try {
            pjp.proceed();
        } catch (Throwable throwable) {
            throwable.printStackTrace();
        }
        watch.stop();
        System.out.println(watch.shortSummary());
    }

}

可以看到使用注解配置的優勢就是配置文件和切面類在一起,閱讀方便。如果使用XML配置的話,要查看切面應用了什么方法,需要同時查看XML和Java代碼,比較麻煩。

此外通知還有一個順序的問題,在前面沒有說明。如果有兩個切面的相同通知(比如都是前置通知)要應用到某個連接點上,我們就可以定義它們之間的順序。有兩種方法,第一種是讓通知所在的切面類實現org.springframework.core.Ordered接口,這個接口有一個getValue()方法,我們可以實現這個方法來確定順序。第二種方法就是在切面類上應用Order注解,并給定一個值。不論用哪種方法,值較小的通知會先執行。同一切面中的通知,執行順序是未定義的,也就是不確定的,我們無法指定它們的執行順序。

定義引入

在切面類中定義一個接口類型的字段,然后應用DeclareParents注解并定義要引入的類和該接口的默認實現。

//定義引入
@DeclareParents(value = "yitian..MyService", defaultImpl = ServiceImpl.class)
private Service service;

理解Spring AOP

Spring AOP是一個基于代理實現的框架,因此有一些事情需要我們注意。舉個例子,我們定義如下一個類。

public class SimplePojo {

    public void foo() {
        System.out.println("調用了foo");
        bar();
    }

    public void bar() {
        System.out.println("調用了bar");
    }
}

然后定義一個切面和兩個通知,在目標方法之后執行。

@Aspect
public class PojoAspect {


    @AfterReturning(pointcut = "execution(* yitian..SimplePojo.foo())")
    public void afterFoo() {
        System.out.println("代理了foo");
    }

    @AfterReturning(pointcut = "execution(* yitian..SimplePojo.bar())")
    public void afterBar() {
        System.out.println("代理了bar");
    }
}

然后我們運行一下foo方法,看看會出現什么情況。

@Test
public void testProxy() {
    ApplicationContext context = new AnnotationConfigApplicationContext(AOPConfig.class);
    SimplePojo pojo = context.getBean("simplePojo", SimplePojo.class);
    pojo.foo();
}

結果如下:

調用了foo
調用了bar
代理了foo

我們注意到一個事實,在foo方法中調用bar方法并沒有相應的通知執行。由于Spring AOP是一個基于代理的框架,因此我們從ApplicationContext中獲取到的Bean其實是一個代理,因此foo方法會執行相應的通知。但是,foo方法調用自己類中的bar方法,使用的是this引用,沒有經過代理,因此無法觸發AOP的通知執行。這一點需要注意。如果我們希望編寫一個目標類型,讓其能夠使用Spring AOP,那么盡量不要出現調用自己類中的方法的情況。由于AspectJ不是基于代理的框架,因此如果你使用AspectJ,就不會出現上面的問題。

小例子

我們來使用環繞通知配置一下Hibernate的事務管理。

首先需要定義一個實體類。

@Entity
@Data
@NoArgsConstructor
@AllArgsConstructor
public class User {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private int id;
    @NaturalId
    private String username;
    @Column(nullable = false)
    private String password;
    @Column
    private String nickname;
    @Column
    private LocalDate birthday;
}

然后添加一個用戶服務,向數據庫中添加用戶。這里為了使用環繞通知來進行事務管理,故意將Session寫在參數中,方便環繞通知獲取Session。

public class UserService {

    public void add(Session session, User user) {
        session.save(user);

    }
}

然后我們需要一個切面和一個環繞通知,環繞通知將連接點的代碼用事務處理語句環繞。

@Aspect
public class TransactionAspect {
    @Pointcut("execution(* yitian..UserService.add(..))&&args(session,user)")
    private void addUser(Session session, User user) {
    }

    @Around(value = "addUser(session,user)", argNames = "pjp,session,user")
    public void manageTransaction(ProceedingJoinPoint pjp, Session session, User user) {
        Transaction transaction = session.beginTransaction();
        try {
            pjp.proceed(new Object[]{session, user});
            transaction.commit();
        } catch (Throwable e) {
            transaction.rollback();
        }
    }
}

當然上面這幾個類應該注冊為Spring Bean。

@Configuration
@EnableAspectJAutoProxy
public class HibernateConfig {
    @Autowired
    private SessionFactory sessionFactory;

    @Bean
    public SessionFactory sessionFactory() {
        final StandardServiceRegistry registry = new StandardServiceRegistryBuilder()
                .configure()
                .build();
        try {
            SessionFactory sessionFactory = new MetadataSources(registry).buildMetadata().buildSessionFactory();
            return sessionFactory;
        } catch (Exception e) {
            StandardServiceRegistryBuilder.destroy(registry);
            throw new RuntimeException(e);
        }
    }

    @Bean
    public Session session() {
        return sessionFactory.openSession();
    }

    @Bean
    public UserService userService() {
        return new UserService();
    }
}

最后來測試一下,我們運行測試方法,然后查看一下數據庫,看是否成功插入了。

@ContextConfiguration(classes = {HibernateConfig.class})
@RunWith(SpringRunner.class)
public class HibernateTest {

    @Autowired
    private UserService userService;

    @Autowired
    private Session session;


    @Test
    public void testTransactionAspect() {
        User user = new User();
        user.setUsername("yitian");
        user.setPassword("123456");
        user.setNickname("易天");
        user.setBirthday(LocalDate.now());
        userService.add(session, user);
    }
}

參考資料

https://my.oschina.net/sniperLi/blog/491854
http://blog.csdn.net/wangpeng047/article/details/8556800

項目代碼

項目在csdn代碼庫中,見下。
https://code.csdn.net/u011054333/spring-core-sample/tree/master

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 230,527評論 6 544
  • 序言:濱河連續發生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發現死者居然都...
    沈念sama閱讀 99,687評論 3 429
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 178,640評論 0 383
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 63,957評論 1 318
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 72,682評論 6 413
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發上,一...
    開封第一講書人閱讀 56,011評論 1 329
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 44,009評論 3 449
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 43,183評論 0 290
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發現了一具尸體,經...
    沈念sama閱讀 49,714評論 1 336
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 41,435評論 3 359
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發現自己被綠了。 大學時的朋友給我發了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 43,665評論 1 374
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 39,148評論 5 365
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發生泄漏。R本人自食惡果不足惜,卻給世界環境...
    茶點故事閱讀 44,838評論 3 350
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 35,251評論 0 28
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 36,588評論 1 295
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 52,379評論 3 400
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 48,627評論 2 380

推薦閱讀更多精彩內容

  • Spring Cloud為開發人員提供了快速構建分布式系統中一些常見模式的工具(例如配置管理,服務發現,斷路器,智...
    卡卡羅2017閱讀 134,830評論 18 139
  • 解釋AOP AOP(Aspect-OrientedProgramming,面向方面編程),可以說是OOP(Obje...
    jiangmo閱讀 925評論 0 2
  • 本章內容: 面向切面編程的基本原理 通過POJO創建切面 使用@AspectJ注解 為AspectJ切面注入依賴 ...
    謝隨安閱讀 3,180評論 0 9
  • Spring提供了4種類型的AOP支持: 基于代理的經典Spring AOP; 純POJO切面; @AspectJ...
    我弟是個程序員閱讀 213評論 0 0
  • 好多天沒去逛空間了,滑到最后看見他發了動態,下面是一群留言的人。 99,難得秀一回恩愛,樂色,最近流行和男的...
    施與樹閱讀 172評論 0 0