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