天天看點

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配置中還可以使用

and

or

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