0.代碼概述 代碼說明:第一章中的代碼為了突出模塊化拆分的必要性,所以db採用了真實操作。下麵代碼中dao層使用了列印日誌模擬插入db的方法,方便所有人運行demo。 1.項目代碼地址:https://github.com/kingszelda/SpringAopPractice 2.結構化拆分,代 ...
0.代碼概述
代碼說明:第一章中的代碼為了突出模塊化拆分的必要性,所以db採用了真實操作。下麵代碼中dao層使用了列印日誌模擬插入db的方法,方便所有人運行demo。
1.項目代碼地址:https://github.com/kingszelda/SpringAopPractice
2.結構化拆分,代碼包結構:org.kingszelda.version1
3.Spring AOP,代碼包結構:org.kingszelda.version2
4.AspectJ AOP,代碼包結構:org.kingszelda.version3
1.為什麼會出現AOP
相信很多人和我一樣,編程入門從c語言開始,之後接觸的java等其他面向對象語言。剛接觸編程語言時編寫的代碼以實現功能為首要目標,因此很少考慮模塊化、封裝等因素,以一個計算功能的web項目為例。該web項目有如下功能:
- 通過http介面提供加法計算功能
- 計算請求與結果需要留存
- 需統計介面調用次數,列印請求響應日誌,列印方法運行時間
就算基礎java語言而言,這個功能也比較簡單。如果不考慮http協議的問題,單獨編寫demo功能只需要一部分。
import java.sql.Connection; import java.sql.DriverManager; import java.sql.PreparedStatement; import java.util.HashMap; import java.util.Map; import org.slf4j.Logger; import org.slf4j.LoggerFactory; /** * 計算器 */ public class CalculateService { private static final Logger logger = LoggerFactory.getLogger(CalculateService.class); //1.構造介面調用次數計數Map public Map<String, Integer> countMap = new HashMap<String, Integer>(); public int add(int first, int second) throws Exception { //2.獲得計算開始時間 long start = System.currentTimeMillis(); //3.列印入口參數 logger.info("方法入參為:{}{}", first, second); //4.將該方法調用次數+1後放入map countMap.put("calcAdd", count); //5.計算加法 int result = first + second; //6.載入mysql驅動 Class.forName("com.mysql.jdbc.Driver"); //7.配置mysql連接屬性,地址、用戶名、密碼 String url = "jdbc:mysql://localhost:3306/samp_db"; String userName = "root"; String passWord = "123456"; //8.獲得mysql連接 Connection conn = DriverManager.getConnection(url, userName, passWord); //9.生成插入sql String sql = "INSERT INTO calcAdd (`first`,`second`,`result`) VALUES(?,?,?)"; //10.使用preparedStatement防止sql註入 PreparedStatement ps = conn.prepareStatement(sql); ps.setString(1, String.valueOf(first)); ps.setString(2, String.valueOf(second)); ps.setString(3, String.valueOf(result)); //11.執行sql ps.execute(); //12.釋放sql與con連接 ps.close(); conn.close(); //13.列印返回參數 logger.info("方法結果為:{}", result); //14.列印方法總耗時 logger.info("運行時間:{}ms", System.currentTimeMillis() - start); //13.返回計算結果 return result; } }
1.1 模塊化
上述代碼完全可以滿足要求,只是看起來有點長,當新增減法計算器等其他計算功能的時候,新增的代碼重覆的很多,比如計算的“+”號換成“-”號,存入資料庫表從加法表換到減法表,然後計算介面調用次數。從模塊化的角度來看,上面的加法計算器代碼就可以做如下拆分:
import java.sql.Connection; import java.sql.DriverManager; import java.sql.PreparedStatement; import java.sql.Statement; /** * 資料庫工具類 */ public class DbUtil { public static Connection getConnection() throws Exception { //1.載入mysql驅動 Class.forName("com.mysql.jdbc.Driver"); //2.配置mysql連接屬性,地址、用戶名、密碼 String url = "jdbc:mysql://localhost:3306/samp_db"; String userName = "root"; String passWord = "123456"; //3.獲得mysql連接 return DriverManager.getConnection(url, userName, passWord); } public static void closeCon(Connection connection, Statement statement) throws Exception { //1.先關閉sql連接 statement.close(); //2.再關閉資料庫連接 connection.close(); } public static Statement getInsertAddStatement(int first, int second, int result, Connection connection) throws Exception { //7.生成插入sql String sql = "INSERT INTO calcAdd (`first`,`second`,`result`) VALUES(?,?,?)"; //8.使用preparedStatement防止sql註入 PreparedStatement ps = connection.prepareStatement(sql); ps.setString(1, String.valueOf(first)); ps.setString(2, String.valueOf(second)); ps.setString(3, String.valueOf(result)); return ps; } }
import java.util.HashMap; import java.util.Map; /** * 方法調用次數計數器 */ public class CountUtil { public static Map<String, Integer> countMap = new HashMap<>(); public static void countMethod(String methodName) { Integer count = countMap.get(methodName); count = (count != null) ? new Integer(count + 1) : new Integer(1); countMap.put(methodName, count); } }
import java.sql.Connection; import java.sql.DriverManager; import java.sql.PreparedStatement; import java.util.HashMap; import java.util.Map; import org.slf4j.Logger; import org.slf4j.LoggerFactory; /** * 計算器 */ public class CalculateService { private static final Logger logger = LoggerFactory.getLogger(CalculateService.class); //1.構造介面調用次數計數Map public Map<String, Integer> countMap = new HashMap<String, Integer>(); public int add(int first, int second) throws Exception { //2.獲得計算開始時間 long start = System.currentTimeMillis(); //3.列印入口參數 logger.info("方法入參為:{}{}", first, second); //4.將該方法調用次數+1後放入map CountUtil.countMethod("calcAdd"); //5.計算加法 int result = first + second; //6.資料庫操作 Connection conn = DbUtil.getConnection(); Statement ps = DbUtil.getInsertAddStatement(first, second, result, conn); DbUtil.closeCon(conn, ps); //7.列印返回參數 logger.info("方法結果為:{}", result); //8.列印方法總耗時 logger.info("運行時間:{}ms", System.currentTimeMillis() - start); //9.返回計算結果 return result; } }
經過上述拆分,功能由原來的一塊代碼變為2大塊與3小塊,大塊分別是:
- 資料庫相關處理
- 方法調用計數處理
小塊則是:
- 列印請求
- 列印方法耗時
- 列印響應
- 統計方法調用次數
經過拆分以後,代碼的復用性增強了很多,模塊之間的邊界也變得很清晰。並且隨著設計模式的發展,上面的2大塊可以進行進一步抽象,可以抽象出一個統一的主邏輯,然後加法計算進一步抽象為運算模塊,這樣就可以通過派生支持減法乘法等其他方法運算。這是數據設計模式的內容,這裡不做贅述。此時代碼拆分邏輯顯然是垂直拆分,如圖:
如圖所示,模塊化之前,代碼是一整塊,當功能越來越餓複雜之後,這塊代碼將無法區分邊界,變得不好維護。模塊化之後,代碼分模塊獨立,邊界清晰、好維護。由於代碼總是一行一行的從上到下執行,所以很自然的拆分邏輯就是從上到下縱向拆分。
但是當我們仔細分析不同模塊之間的區別之後,現有的縱向拆分並非達到了最佳狀態。因為大塊之間雖然清晰了,但是小塊之間還是散落在各處,並且都是簡單的兩行,無法進行進一步封裝。並且代碼模塊的業務也不相同。比如上述的存入資料庫與計算方法調用次數之間就有區別。資料庫模塊關心具體業務,比如是哪個資料庫,那張表,插入語句的具體內容。但是計算方法調用次數模塊是不關心業務的,只是對調用次數進行統計,這種模塊的應用有很多,比如列印日誌,計算qps,計算運行時間等,不論是什麼業務,這種運算總是相同的。所以,當業務變複雜之後,這些代碼可以進行橫向拆分。
至此,我們可以先給出模塊化拆分之後的代碼:
此時的代碼結構是:
此時的代碼為:
package org.kingszelda.version1.service; import org.kingszelda.common.dao.AddDao; import org.kingszelda.common.dao.SubDao; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.stereotype.Service; import org.springframework.web.bind.annotation.RequestMapping; import javax.annotation.Resource; /** * Created by shining.cui on 2017/7/15. */ @Service @RequestMapping("version1") public class CalculateService { private static final Logger logger = LoggerFactory.getLogger(CalculateService.class); @Resource private AddDao addDao; @Resource private SubDao subDao; @Resource private MethodCounter methodCounter; public int add(int first, int second) { //1.獲得計算開始時間 long start = System.currentTimeMillis(); //2.列印入口參數 logger.info("方法入參為:{}{}", first, second); //3.計算調用次數 methodCounter.count("sub"); //4.計算加法 int result = first + second; //5.插入資料庫 addDao.insert(first, second, result); //6.列印返回參數 logger.info("方法結果為:{}", result); //7.列印方法總耗時 logger.info("運行時間:{}ms", System.currentTimeMillis() - start); //8.返回結果 return result; } public int sub(int first, int second) { //1.獲得計算開始時間 long start = System.currentTimeMillis(); //2.列印入口參數 logger.info("方法入參為:{}{}", first, second); //3.計算調用次數 methodCounter.count("sub"); //4.計算加法 int result = first - second; //5.插入資料庫 subDao.insert(first, second, result); //6.列印返回參數 logger.info("sub 方法結果為:{}", result); //7.列印方法總耗時 logger.info("運行時間:{}ms", System.currentTimeMillis() - start); //8.返回結果 return result; } }
package org.kingszelda.version1.service; import com.google.common.collect.Maps; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.stereotype.Component; import java.util.Map; /** * Created by shining.cui on 2017/7/15. */ @Component public class MethodCounter { private static final Logger logger = LoggerFactory.getLogger(MethodCounter.class); //防止併發 private static final Map<String, Integer> methodCountMap = Maps.newConcurrentMap(); /** * 根據方法名進行調用次數計數 */ public void count(String methodName) { Integer methodCount = methodCountMap.get(methodName); methodCount = (methodCount != null) ? new Integer(methodCount + 1) : new Integer(1); logger.info("對方法{}進行次數加1,當前次數為:{}", methodName, methodCount); methodCountMap.put(methodName, methodCount); } public Map<String, Integer> getMethodCountMap() { return methodCountMap; } }
1.2 切麵織入
正如上面的代碼一樣,我們的項目開始支持加法與減法兩種計算,此時的縱向拆分的代碼是這樣的結構:
這樣的結構也很清晰,沒有任何問題,但是存在了一些小瑕疵,那就是違反了DRY法則(Don't Repeat Yourself):計算次數、列印求響應、列印方法耗時出現在不同的程式的相同位置。雖然模塊化之後的這兩個功能調用都只需要一行,但是依然是發生了重覆,這時候就是Aop登場的最佳時刻。
當時用了Aop橫向拆分之後,業務模塊就只關心業務(加法計算器只關心加法計算與存入加法表),不用再關心一些通用的處理功能——日誌與qps。這時候的代碼發生了本質上的改變,首先需要一個Aop模塊功能,然後通過"配置"的方式橫向“織入”想要的代碼模塊中。這時候就算新增了乘法計算器,也只需要編寫業務功能——“乘法計算與入庫”,然後配置之前的Aop模塊即可生效。
從圖中我們可以看到,進行切麵編程有三個步驟:
- 定義切麵功能,Advice即通知,比如列印請求,計算調用次數的功能。
- 定義切點,Pointcut即切點,比如開始的時候列印請求,結束的時候列印響應。對應功能1的調用時間定義。
- 組織功能,即Advisor通知器,將切麵功能織入切點上。
是時候引出Aop的定義了,以下定義引自維基百科:
面向側面的程式設計(aspect-oriented programming,AOP,又譯作面向方面的程式設計、觀點導向編程、剖面導向程式設計)是電腦科學中的一個術語,指一種程式設計範型。該範型以一種稱為側面(aspect,又譯作方面)的語言構造為基礎,側面是一種新的模塊化機制,用來描述分散在對象、類或函數中的橫切關註點(crosscutting concern)。
2.AOP與Spring AOP
AOP的出現使得代碼的整體設計括了一個維度,豎向寫業務邏輯,橫向切麵寫公共邏輯。如同其他概念一樣,這項技術有著各種各樣的實現,比較著名的有AspectJ,Javassist等。為了制定統一規範,於是出現了AOP聯盟來起引導與約束的作用。
正如上圖所示,切麵拆分場景一般分為4種情況:
- 進入業務之前,比如統計qps,列印請求參數
- 完成業務之後,比如列印響應結果
- 環繞業務,比如計算方法耗時,需要在業務前計時,業務後取時間差
- 業務拋異常後,比如統一的異常處理。這一點上面的代碼沒有體現。
上面4種情況的的間隔其實比較模糊,比如環繞業務其實可以包含前、後、異常這三種情況,因為本質上都是在業務運行前後加通用邏輯,其實Spring AOP的AfterReturningAdvice,MethodBeforeAdvice,ThrowsAdvice也是基於MethodInterceptor的環繞業務實現的。
對於Spring來說,其核心模塊是IoC與AOP,其AOP是與IoC結合使用的。Spring不僅支持本身的Aop實現,同時也支持AspectJ的AOP功能。因此我們說:
- AOP是一種技術規範,本身與Spring無關
- Spring 實現了自身的AOP,即Spring AOP
- Spring 同時支持AspectJ的AOP功能
3.使用Spring AOP架構的代碼
使用Spring AOP調整後的業務代碼得到了一定的精簡,整體代碼結構如圖:
首先是定義三個切麵業務Advice,進行列印日誌、運行時間與統計qps功能。
package org.kingszelda.version2.aop; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.aop.AfterReturningAdvice; import org.springframework.stereotype.Component; import java.lang.reflect.Method; /** * 方法後切麵,列印響應結果 * Created by shining.cui on 2017/7/15. */ @Component public class CalculateAfterAdvice implements AfterReturningAdvice { private static final Logger logger = LoggerFactory.getLogger(CalculateAfterAdvice.class); public void afterReturning(Object returnObject, Method method, Object[] args, Object target) throws Throwable { String methodName = method.getDeclaringClass().getSimpleName() + "." + method.getName(); String returnValue = String.valueOf(returnObject); logger.info("方法{}的響應結果為{}", methodName, returnValue); } }
package org.kingszelda.version2.aop; import org.kingszelda.version2.service.MethodCounter; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.aop.MethodBeforeAdvice; import org.springframework.stereotype.Component; import java.lang.reflect.Method; import java.util.Arrays; /** * 方法前切麵,列印請求參數,統計調用次數 * Created by shining.cui on 2017/7/15. */ @Component public class CalculateBeforeAdvice extends MethodCounter implements MethodBeforeAdvice { private static final Logger logger = LoggerFactory.getLogger(CalculateBeforeAdvice.class); public void before(Method method, Object[] args, Object target) throws Throwable { String methodName = method.getDeclaringClass().getSimpleName() + "." + method.getName(); String argStr = Arrays.toString(args); logger.info("方法{}的請求參數為{}", methodName, argStr); count(methodName); } }
package org.kingszelda.version2.aop; import org.aopalliance.intercept.MethodInvocation; import org.kingszelda.version2.service.CalculateService; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.aop.IntroductionInterceptor; import org.springframework.stereotype.Component; /** * 方法後切麵,列印響應結果 * Created by shining.cui on 2017/7/15. */ @Component public class CalculateMethodInterceptor implements IntroductionInterceptor { private static final Logger logger = LoggerFactory.getLogger(CalculateMethodInterceptor.class); public Object invoke(MethodInvocation methodInvocation) throws Throwable { //1.獲得計算開始時間 long start = System.currentTimeMillis(); String methodName = methodInvocation.getMethod().getDeclaringClass().getSimpleName() + "." + methodInvocation.getMethod().getName(); //2.運行程式 Object proceed = methodInvocation.proceed(); //3.列印間隔時間 logger.info("方法{}運行時間:{}ms", methodName, System.currentTimeMillis() - start); return proceed; } public boolean implementsInterface(Class<?> aClass) { //滿足CalculateService介面的方法都進行攔截 return aClass.isAssignableFrom(CalculateService.class); } }
此時的業務代碼精簡為如下:
package org.kingszelda.version2.service.impl; import org.kingszelda.common.dao.AddDao; import org.kingszelda.common.dao.SubDao; import org.kingszelda.version2.service.CalculateService; import javax.annotation.Resource; /** * Created by shining.cui on 2017/7/15. */ public class CalculateServiceImpl implements CalculateService { @Resource private AddDao addDao; @Resource private SubDao subDao; @Override public int add(int first, int second) { //1.計算加法 int result = first + second; //2.插入資料庫 addDao.insert(first, second, result); //3.返回結果 return result; } @Override public int sub(int first, int second) { //1.計算加法 int result = first - second; //2.插入資料庫 subDao.insert(first, second, result); //3.返回結果 return result; } }
到目前為止,切麵邏輯已經與業務邏輯分離,接下來需要做的就是定義切點PointCut與通知器Advisor,即將業務與切麵在合適的時候組合起來。這也是最容易出錯的地方。
<?xml version="1.0" encoding="UTF-8"?> <beans xmlns="http://www.springframework.org/schema/beans" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:mvc="http://www.springframework.org/schema/mvc" xmlns:context="http://www.springframework.org/schema/context" xmlns:aop="http://www.springframework.org/schema/aop" xsi:schemaLocation="http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans.xsd http://www.springframework.org/schema/mvc http://www.springframework.org/schema/mvc/spring-mvc.xsd http://www.springframework.org/schema/context http://www.springframework.org/schema/context/spring-context.xsd http://www.springframework.org/schema/aop http://www.springframework.org/schema/aop/spring-aop.xsd"> <!--spring mvc註解預設配置--> <context:component-scan base-package="org.kingszelda"/> <mvc:annotation-driven/> <!--下麵是version2的配置,二選一均可--> <!-- version2 配置方案1 --> <bean id="calculateServiceV2" class="org.springframework.aop.framework.ProxyFactoryBean"> <!-- 需要代理的介面 --> <property name="proxyInterfaces" value="org.kingszelda.version2.service.CalculateService"/> <!-- 被代理的實體,一定要是上面介面的實現類 --> <property name="target"> <bean class="org.kingszelda.version2.service.impl.CalculateServiceImpl"/> </property> <!-- 調用代理對象方法時的額外操作 --> <property name="interceptorNames"> <!--這裡的順序對責任鏈順序有直接影響,所以能合併的都可以合併在一個過濾器中,本例都可以合併到middleInterceptor中--> <list> <value>calculateMethodInterceptor</value> <value>calculateBeforeAdvice</value> <value>calculateAfterAdvice</value> </list> </property> </bean> <!-- version2 配置方案2 --> <!-- 業務前切點 --> <bean id="beforeAdvisor" class="org.springframework.aop.support.DefaultPointcutAdvisor"> <constructor-arg ref="calculateBeforeAdvice"/> <constructor-arg ref="pointcut"/> </bean> <!-- 返回前切點 --> <bean id="afterAdvisor" class="org.springframework.aop.support.DefaultPointcutAdvisor"> <constructor-arg ref="calculateAfterAdvice"/> <constructor-arg ref="pointcut"/> </bean> <!-- 環繞式切點,相對於before after throw,這是屬於高一層設計,因為before等也是通過Interceptor實現的 --> <bean id="middleInterceptor" class="org.springframework.aop.support.DefaultPointcutAdvisor"> <constructor-arg ref="calculateMethodInterceptor"/> <constructor-arg ref="pointcut"/> </bean> <!-- 定義切點位置,只有滿足正則的才攔截,如果不配置切點,則攔截所有介面方法 --> <bean id="pointcut" class="org.springframework.aop.support.JdkRegexpMethodPointcut"> <property name="pattern" value=".*version2.*add|.*version2.*sub"/> </bean> </beans>
可以看到,Spring AOPde 配置方式可以分為兩種:一種是手動配置被代理對象,只對配置對象織入。另外一種是只聲明Advisor通知器,由Spring 根據定義的切點PointCut進行織入。兩種方法都可以,第一種更貼近於源碼的實現。
至此,基於Spring AOP的整合已經結束,運行代碼後發現雖然業務沒有關係日誌等操作,但切麵邏輯已經完全織入業務中,業務代碼整潔了好多。
這裡需要註意一點,我們已經不能像之前使用calculateService那樣直接註入使用了,因為我們需要使用的是calculateService的代理類,這個代理類在調用真正的業務實現之前根據配置依次調用切麵邏輯。上面的配置很明朗,我們使用的calculateServiceV2是由ProxyFactoryBean生成的對於介面proxyInterfaces的Proxy實現,真正被代理的對象對象是target,在調用target之前會依次執行interceptorNames配置的攔截器責任鏈。與此相關的代碼解釋的文章很多,這裡就扒源碼了。
4.Spring集成AspectJ的AOP功能
在我看來Spring的AOP功能做的不夠有決心,要麼就定義好各種Spring AOP且切麵場景,要麼則都使用Intercepter由用戶統一管理。兩者都兼顧的Spring AOP在讓用戶使用的時候會產生一定的困惑,究竟使用那種方式更加好一些。並且Spring的老毛病也凸現出來,那就是xml如何妥善配置。
而Spring對AspectJ的集成則方便了很多。項目結構如圖:
相對於Spring AOP時的代碼,變動只有一個切麵類與xml配置
package org.kingszelda.version3.aop; import org.aspectj.lang.ProceedingJoinPoint; import org.aspectj.lang.annotation.Around; import org.aspectj.lang.annotation.Aspect; import org.aspectj.lang.annotation.Pointcut; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.stereotype.Component; import java.util.Arrays; /** * 基於AspectJ的切麵功能 * * @author shining.cui * @since 2017-07-16 */ @Aspect @Component public class ControllerAop extends MethodCounter { private static final Logger logger = LoggerFactory.getLogger(ControllerAop.class); // 攔截version3包中的所有service @Pointcut("(execution(* org.kingszelda.version3.service.*.*(..)))") public void pointcut() { } @Around("pointcut()") public Object controllerHandler(ProceedingJoinPoint joinPoint) { String signature = joinPoint.getSignature().toShortString(); //1.統計調用次數 count(signature); String argStr = Arrays.toString(joinPoint.getArgs()); //2.列印請求 logger.info("方法{}的請求參數為{}", signature, argStr); Object result = null; //3.獲得計算開始時間 long start = System.currentTimeMillis(); try { //4.運行業務 result = joinPoint.proceed(); } catch (Throwable e) { logger.error("web 應用發生異常", e); } //5.列印運行時間 logger.info("方法{}運行時間:{}ms", signature, System.currentTimeMillis() - start); String returnValue = String.valueOf(result); //6.列印響應 logger.info("方法{}的響應結果為{}", signature, returnValue); return result; } }
配置也簡單了很多:
<!-- 集成AspectJ的AOP功能 --> <aop:aspectj-autoproxy />
程式運行結果與上一章相同。
5.總結
Spring AOP是AOP聯盟規範中Spring的一種實現形式,同時Spring也支持了AspectJ的AOP實現。相對於應用來說,AspectJ的AOP結構更加清晰,配置也更加簡單。Spring中的AOP實現都是通過代理完成的,在預設的情況下,如果代理類是介面,則使用jdk動態代理,如果不是則使用CGLIB進行代理。
通過AOP功能可以很好的將通用邏輯與業務邏輯分離,使得結構化更明朗。對於列印日誌,統計qps,統計運行時間,發送消息,寫入請求記錄表等操作均可以很好的支持。Spring框架本身的一些功能也是基於AOP實現的,比如Spring的事務管理。因此研究Spring AOP總是會給我們的學習與工作帶來好處。
最後,以上代碼均可運行,地址參見第0章。