動態代理在Java中有着廣泛的應用,比如Spring AOP、Hibernate數據查詢、測試框架的后端mock、RPC遠程調用、Java注解對象獲取、日志、用戶鑒權、全局性異常處理、性能監控,甚至事務處理等。
本文主要介紹Java中兩種常見的動態代理方式:JDK原生動態代理和CGLIB動態代理。
由於Java動態代理與java反射機制關系緊密,請讀者確保已經了解了Java反射機制,可參考上一篇文章《Java反射機制詳解》
代理模式
本文將介紹的Java動態代理與設計模式中的代理模式有關,什么是代理模式呢?
代理模式:給某一個對象提供一個代理,並由代理對象來控制對真實對象的訪問。代理模式是一種結構型設計模式。
代理模式角色分為 3 種:
Subject(抽象主題角色):定義代理類和真實主題的公共對外方法,也是代理類代理真實主題的方法;
RealSubject(真實主題角色):真正實現業務邏輯的類;
Proxy(代理主題角色):用來代理和封裝真實主題;
代理模式的結構比較簡單,其核心是代理類,為了讓客戶端能夠一致性地對待真實對象和代理對象,在代理模式中引入了抽象層
代理模式按照職責(使用場景)來分類,至少可以分為以下幾類:1、遠程代理。 2、虛擬代理。 3、Copy-on-Write 代理。 4、保護(Protect or Access)代理。 5、Cache代理。 6、防火牆(Firewall)代理。 7、同步化(Synchronization)代理。 8、智能引用(Smart Reference)代理等等。
如果根據字節碼的創建時機來分類,可以分為靜態代理和動態代理:
- 所謂靜態也就是在程序運行前就已經存在代理類的字節碼文件,代理類和真實主題角色的關系在運行前就確定了。
- 而動態代理的源碼是在程序運行期間由JVM根據反射等機制動態的生成,所以在運行前並不存在代理類的字節碼文件
靜態代理
我們先通過實例來學習靜態代理,然后理解靜態代理的缺點,再來學習本文的主角:動態代理
編寫一個接口 UserService ,以及該接口的一個實現類 UserServiceImpl
public interface UserService {
public void select();
public void update();
}
public class UserServiceImpl implements UserService {
public void select() {
System.out.println("查詢 selectById");
}
public void update() {
System.out.println("更新 update");
}
}
我們將通過靜態代理對 UserServiceImpl 進行功能增強,在調用 select
和 update
之前記錄一些日志。寫一個代理類 UserServiceProxy,代理類需要實現 UserService
public class UserServiceProxy implements UserService {
private UserService target; // 被代理的對象
public UserServiceProxy(UserService target) {
this.target = target;
}
public void select() {
before();
target.select(); // 這里才實際調用真實主題角色的方法
after();
}
public void update() {
before();
target.update(); // 這里才實際調用真實主題角色的方法
after();
}
private void before() { // 在執行方法之前執行
System.out.println(String.format("log start time [%s] ", new Date()));
}
private void after() { // 在執行方法之后執行
System.out.println(String.format("log end time [%s] ", new Date()));
}
}
客戶端測試
public class Client1 {
public static void main(String[] args) {
UserService userServiceImpl = new UserServiceImpl();
UserService proxy = new UserServiceProxy(userServiceImpl);
proxy.select();
proxy.update();
}
}
輸出
log start time [Thu Dec 20 14:13:25 CST 2018]
查詢 selectById
log end time [Thu Dec 20 14:13:25 CST 2018]
log start time [Thu Dec 20 14:13:25 CST 2018]
更新 update
log end time [Thu Dec 20 14:13:25 CST 2018]
通過靜態代理,我們達到了功能增強的目的,而且沒有侵入原代碼,這是靜態代理的一個優點。
靜態代理的缺點
雖然靜態代理實現簡單,且不侵入原代碼,但是,當場景稍微復雜一些的時候,靜態代理的缺點也會暴露出來。
1、 當需要代理多個類的時候,由於代理對象要實現與目標對象一致的接口,有兩種方式:
- 只維護一個代理類,由這個代理類實現多個接口,但是這樣就導致代理類過於龐大
- 新建多個代理類,每個目標對象對應一個代理類,但是這樣會產生過多的代理類
2、 當接口需要增加、刪除、修改方法的時候,目標對象與代理類都要同時修改,不易維護。
如何改進?
當然是讓代理類動態的生成啦,也就是動態代理。
為什么類可以動態的生成?
這就涉及到Java虛擬機的類加載機制了,推薦翻看《深入理解Java虛擬機》7.3節 類加載的過程。
Java虛擬機類加載過程主要分為五個階段:加載、驗證、准備、解析、初始化。其中加載階段需要完成以下3件事情:
- 通過一個類的全限定名來獲取定義此類的二進制字節流
- 將這個字節流所代表的靜態存儲結構轉化為方法區的運行時數據結構
- 在內存中生成一個代表這個類的
java.lang.Class
對象,作為方法區這個類的各種數據訪問入口
由於虛擬機規范對這3點要求並不具體,所以實際的實現是非常靈活的,關於第1點,獲取類的二進制字節流(class字節碼)就有很多途徑:
- 從ZIP包獲取,這是JAR、EAR、WAR等格式的基礎
- 從網絡中獲取,典型的應用是 Applet
- 運行時計算生成,這種場景使用最多的是動態代理技術,在 java.lang.reflect.Proxy 類中,就是用了 ProxyGenerator.generateProxyClass 來為特定接口生成形式為
*$Proxy
的代理類的二進制字節流 - 由其它文件生成,典型應用是JSP,即由JSP文件生成對應的Class類
- 從數據庫中獲取等等
所以,動態代理就是想辦法,根據接口或目標對象,計算出代理類的字節碼,然后再加載到JVM中使用。但是如何計算?如何生成?情況也許比想象的復雜得多,我們需要借助現有的方案。
常見的字節碼操作類庫
這里有一些介紹:https://java-source.net/open-source/bytecode-libraries
- Apache BCEL (Byte Code Engineering Library):是Java classworking廣泛使用的一種框架,它可以深入到JVM匯編語言進行類操作的細節。
- ObjectWeb ASM:是一個Java字節碼操作框架。它可以用於直接以二進制形式動態生成stub根類或其他代理類,或者在加載時動態修改類。
- CGLIB(Code Generation Library):是一個功能強大,高性能和高質量的代碼生成庫,用於擴展JAVA類並在運行時實現接口。
- Javassist:是Java的加載時反射系統,它是一個用於在Java中編輯字節碼的類庫; 它使Java程序能夠在運行時定義新類,並在JVM加載之前修改類文件。
- ...
實現動態代理的思考方向
為了讓生成的代理類與目標對象(真實主題角色)保持一致性,從現在開始將介紹以下兩種最常見的方式:
- 通過實現接口的方式 -> JDK動態代理
- 通過繼承類的方式 -> CGLIB動態代理
注:使用ASM對使用者要求比較高,使用Javassist會比較麻煩
JDK動態代理
JDK動態代理主要涉及兩個類:java.lang.reflect.Proxy
和 java.lang.reflect.InvocationHandler
,我們仍然通過案例來學習
編寫一個調用邏輯處理器 LogHandler 類,提供日志增強功能,並實現 InvocationHandler 接口;在 LogHandler 中維護一個目標對象,這個對象是被代理的對象(真實主題角色);在 invoke
方法中編寫方法調用的邏輯處理
import java.lang.reflect.InvocationHandler;
import java.lang.reflect.Method;
import java.util.Date;
public class LogHandler implements InvocationHandler {
Object target; // 被代理的對象,實際的方法執行者
public LogHandler(Object target) {
this.target = target;
}
@Override
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
before();
Object result = method.invoke(target, args); // 調用 target 的 method 方法
after();
return result; // 返回方法的執行結果
}
// 調用invoke方法之前執行
private void before() {
System.out.println(String.format("log start time [%s] ", new Date()));
}
// 調用invoke方法之后執行
private void after() {
System.out.println(String.format("log end time [%s] ", new Date()));
}
}
編寫客戶端,獲取動態生成的代理類的對象須借助 Proxy 類的 newProxyInstance 方法,具體步驟可見代碼和注釋
import proxy.UserService;
import proxy.UserServiceImpl;
import java.lang.reflect.InvocationHandler;
import java.lang.reflect.Proxy;
public class Client2 {
public static void main(String[] args) throws IllegalAccessException, InstantiationException {
// 設置變量可以保存動態代理類,默認名稱以 $Proxy0 格式命名
// System.getProperties().setProperty("sun.misc.ProxyGenerator.saveGeneratedFiles", "true");
// 1. 創建被代理的對象,UserService接口的實現類
UserServiceImpl userServiceImpl = new UserServiceImpl();
// 2. 獲取對應的 ClassLoader
ClassLoader classLoader = userServiceImpl.getClass().getClassLoader();
// 3. 獲取所有接口的Class,這里的UserServiceImpl只實現了一個接口UserService,
Class[] interfaces = userServiceImpl.getClass().getInterfaces();
// 4. 創建一個將傳給代理類的調用請求處理器,處理所有的代理對象上的方法調用
// 這里創建的是一個自定義的日志處理器,須傳入實際的執行對象 userServiceImpl
InvocationHandler logHandler = new LogHandler(userServiceImpl);
/*
5.根據上面提供的信息,創建代理對象 在這個過程中,
a.JDK會通過根據傳入的參數信息動態地在內存中創建和.class 文件等同的字節碼
b.然后根據相應的字節碼轉換成對應的class,
c.然后調用newInstance()創建代理實例
*/
UserService proxy = (UserService) Proxy.newProxyInstance(classLoader, interfaces, logHandler);
// 調用代理的方法
proxy.select();
proxy.update();
// 保存JDK動態代理生成的代理類,類名保存為 UserServiceProxy
// ProxyUtils.generateClassFile(userServiceImpl.getClass(), "UserServiceProxy");
}
}
運行結果
log start time [Thu Dec 20 16:55:19 CST 2018]
查詢 selectById
log end time [Thu Dec 20 16:55:19 CST 2018]
log start time [Thu Dec 20 16:55:19 CST 2018]
更新 update
log end time [Thu Dec 20 16:55:19 CST 2018]
InvocationHandler 和 Proxy 的主要方法介紹如下:
java.lang.reflect.InvocationHandler
Object invoke(Object proxy, Method method, Object[] args)
定義了代理對象調用方法時希望執行的動作,用於集中處理在動態代理類對象上的方法調用
java.lang.reflect.Proxy
static InvocationHandler getInvocationHandler(Object proxy)
用於獲取指定代理對象所關聯的調用處理器
static Class<?> getProxyClass(ClassLoader loader, Class<?>... interfaces)
返回指定接口的代理類
static Object newProxyInstance(ClassLoader loader, Class<?>[] interfaces, InvocationHandler h)
構造實現指定接口的代理類的一個新實例,所有方法會調用給定處理器對象的 invoke 方法
static boolean isProxyClass(Class<?> cl)
返回 cl 是否為一個代理類
代理類的調用過程
生成的代理類到底長什么樣子呢?借助下面的工具類,把代理類保存下來再探個究竟
(通過設置環境變量sun.misc.ProxyGenerator.saveGeneratedFiles=true也可以保存代理類)
import sun.misc.ProxyGenerator;
import java.io.FileOutputStream;
import java.io.IOException;
public class ProxyUtils {
/**
* 將根據類信息動態生成的二進制字節碼保存到硬盤中,默認的是clazz目錄下
* params: clazz 需要生成動態代理類的類
* proxyName: 為動態生成的代理類的名稱
*/
public static void generateClassFile(Class clazz, String proxyName) {
// 根據類信息和提供的代理類名稱,生成字節碼
byte[] classFile = ProxyGenerator.generateProxyClass(proxyName, clazz.getInterfaces());
String paths = clazz.getResource(".").getPath();
System.out.println(paths);
FileOutputStream out = null;
try {
//保留到硬盤中
out = new FileOutputStream(paths + proxyName + ".class");
out.write(classFile);
out.flush();
} catch (Exception e) {
e.printStackTrace();
} finally {
try {
out.close();
} catch (IOException e) {
e.printStackTrace();
}
}
}
}
然后在 Client2 測試類的main的最后面加入一行代碼
// 保存JDK動態代理生成的代理類,類名保存為 UserServiceProxy
ProxyUtils.generateClassFile(userServiceImpl.getClass(), "UserServiceProxy");
IDEA 再次運行之后就可以在 target 的類路徑下找到 UserServiceProxy.class,雙擊后IDEA的反編譯插件會將該二進制class文件
UserServiceProxy 的代碼如下所示:
import java.lang.reflect.InvocationHandler;
import java.lang.reflect.Method;
import java.lang.reflect.Proxy;
import java.lang.reflect.UndeclaredThrowableException;
import proxy.UserService;
public final class UserServiceProxy extends Proxy implements UserService {
private static Method m1;
private static Method m2;
private static Method m4;
private static Method m0;
private static Method m3;
public UserServiceProxy(InvocationHandler var1) throws {
super(var1);
}
public final boolean equals(Object var1) throws {
// 省略...
}
public final String toString() throws {
// 省略...
}
public final void select() throws {
try {
super.h.invoke(this, m4, (Object[])null);
} catch (RuntimeException | Error var2) {
throw var2;
} catch (Throwable var3) {
throw new UndeclaredThrowableException(var3);
}
}
public final int hashCode() throws {
// 省略...
}
public final void update() throws {
try {
super.h.invoke(this, m3, (Object[])null);
} catch (RuntimeException | Error var2) {
throw var2;
} catch (Throwable var3) {
throw new UndeclaredThrowableException(var3);
}
}
static {
try {
m1 = Class.forName("java.lang.Object").getMethod("equals", Class.forName("java.lang.Object"));
m2 = Class.forName("java.lang.Object").getMethod("toString");
m4 = Class.forName("proxy.UserService").getMethod("select");
m0 = Class.forName("java.lang.Object").getMethod("hashCode");
m3 = Class.forName("proxy.UserService").getMethod("update");
} catch (NoSuchMethodException var2) {
throw new NoSuchMethodError(var2.getMessage());
} catch (ClassNotFoundException var3) {
throw new NoClassDefFoundError(var3.getMessage());
}
}
}
從 UserServiceProxy 的代碼中我們可以發現:
- UserServiceProxy 繼承了 Proxy 類,並且實現了被代理的所有接口,以及equals、hashCode、toString等方法
- 由於 UserServiceProxy 繼承了 Proxy 類,所以每個代理類都會關聯一個 InvocationHandler 方法調用處理器
- 類和所有方法都被
public final
修飾,所以代理類只可被使用,不可以再被繼承 - 每個方法都有一個 Method 對象來描述,Method 對象在static靜態代碼塊中創建,以
m + 數字
的格式命名 - 調用方法的時候通過
super.h.invoke(this, m1, (Object[])null);
調用,其中的super.h.invoke
實際上是在創建代理的時候傳遞給Proxy.newProxyInstance
的 LogHandler 對象,它繼承 InvocationHandler 類,負責實際的調用處理邏輯
而 LogHandler 的 invoke 方法接收到 method、args 等參數后,進行一些處理,然后通過反射讓被代理的對象 target 執行方法
@Override
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
before();
Object result = method.invoke(target, args); // 調用 target 的 method 方法
after();
return result; // 返回方法的執行結果
}
JDK動態代理執行方法調用的過程簡圖如下:
代理類的調用過程相信大家都明了了,而關於Proxy的源碼解析,還請大家另外查閱其他文章或者直接看源碼
CGLIB動態代理
maven引入CGLIB包,然后編寫一個UserDao類,它沒有接口,只有兩個方法,select() 和 update()
public class UserDao {
public void select() {
System.out.println("UserDao 查詢 selectById");
}
public void update() {
System.out.println("UserDao 更新 update");
}
}
編寫一個 LogInterceptor ,繼承了 MethodInterceptor,用於方法的攔截回調
import java.lang.reflect.Method;
import java.util.Date;
public class LogInterceptor implements MethodInterceptor {
/**
* @param object 表示要進行增強的對象
* @param method 表示攔截的方法
* @param objects 數組表示參數列表,基本數據類型需要傳入其包裝類型,如int-->Integer、long-Long、double-->Double
* @param methodProxy 表示對方法的代理,invokeSuper方法表示對被代理對象方法的調用
* @return 執行結果
* @throws Throwable
*/
@Override
public Object intercept(Object object, Method method, Object[] objects, MethodProxy methodProxy) throws Throwable {
before();
Object result = methodProxy.invokeSuper(object, objects); // 注意這里是調用 invokeSuper 而不是 invoke,否則死循環,methodProxy.invokesuper執行的是原始類的方法,method.invoke執行的是子類的方法
after();
return result;
}
private void before() {
System.out.println(String.format("log start time [%s] ", new Date()));
}
private void after() {
System.out.println(String.format("log end time [%s] ", new Date()));
}
}
測試
import net.sf.cglib.proxy.Enhancer;
public class CglibTest {
public static void main(String[] args) {
DaoProxy daoProxy = new DaoProxy();
Enhancer enhancer = new Enhancer();
enhancer.setSuperclass(Dao.class); // 設置超類,cglib是通過繼承來實現的
enhancer.setCallback(daoProxy);
Dao dao = (Dao)enhancer.create(); // 創建代理類
dao.update();
dao.select();
}
}
運行結果
log start time [Fri Dec 21 00:06:40 CST 2018]
UserDao 查詢 selectById
log end time [Fri Dec 21 00:06:40 CST 2018]
log start time [Fri Dec 21 00:06:40 CST 2018]
UserDao 更新 update
log end time [Fri Dec 21 00:06:40 CST 2018]
還可以進一步多個 MethodInterceptor 進行過濾篩選
public class LogInterceptor2 implements MethodInterceptor {
@Override
public Object intercept(Object object, Method method, Object[] objects, MethodProxy methodProxy) throws Throwable {
before();
Object result = methodProxy.invokeSuper(object, objects);
after();
return result;
}
private void before() {
System.out.println(String.format("log2 start time [%s] ", new Date()));
}
private void after() {
System.out.println(String.format("log2 end time [%s] ", new Date()));
}
}
// 回調過濾器: 在CGLib回調時可以設置對不同方法執行不同的回調邏輯,或者根本不執行回調。
public class DaoFilter implements CallbackFilter {
@Override
public int accept(Method method) {
if ("select".equals(method.getName())) {
return 0; // Callback 列表第1個攔截器
}
return 1; // Callback 列表第2個攔截器,return 2 則為第3個,以此類推
}
}
再次測試
public class CglibTest2 {
public static void main(String[] args) {
LogInterceptor logInterceptor = new LogInterceptor();
LogInterceptor2 logInterceptor2 = new LogInterceptor2();
Enhancer enhancer = new Enhancer();
enhancer.setSuperclass(UserDao.class); // 設置超類,cglib是通過繼承來實現的
enhancer.setCallbacks(new Callback[]{logInterceptor, logInterceptor2, NoOp.INSTANCE}); // 設置多個攔截器,NoOp.INSTANCE是一個空攔截器,不做任何處理
enhancer.setCallbackFilter(new DaoFilter());
UserDao proxy = (UserDao) enhancer.create(); // 創建代理類
proxy.select();
proxy.update();
}
}
運行結果
log start time [Fri Dec 21 00:22:39 CST 2018]
UserDao 查詢 selectById
log end time [Fri Dec 21 00:22:39 CST 2018]
log2 start time [Fri Dec 21 00:22:39 CST 2018]
UserDao 更新 update
log2 end time [Fri Dec 21 00:22:39 CST 2018]
CGLIB 創建動態代理類的模式是:
- 查找目標類上的所有非final 的public類型的方法定義;
- 將這些方法的定義轉換成字節碼;
- 將組成的字節碼轉換成相應的代理的class對象;
- 實現 MethodInterceptor接口,用來處理對代理類上所有方法的請求
JDK動態代理與CGLIB動態代理對比
JDK動態代理:基於Java反射機制實現,必須要實現了接口的業務類才能用這種辦法生成代理對象。
cglib動態代理:基於ASM機制實現,通過生成業務類的子類作為代理類。
JDK Proxy 的優勢:
- 最小化依賴關系,減少依賴意味着簡化開發和維護,JDK 本身的支持,可能比 cglib 更加可靠。
- 平滑進行 JDK 版本升級,而字節碼類庫通常需要進行更新以保證在新版 Java 上能夠使用。
- 代碼實現簡單。
基於類似 cglib 框架的優勢:
- 無需實現接口,達到代理類無侵入
- 只操作我們關心的類,而不必為其他相關類增加工作量。
- 高性能
面試題
來源於網上,用於幫助理解和掌握,歡迎補充
描述動態代理的幾種實現方式?分別說出相應的優缺點
代理可以分為 "靜態代理" 和 "動態代理",動態代理又分為 "JDK動態代理" 和 "CGLIB動態代理" 實現。
靜態代理:代理對象和實際對象都繼承了同一個接口,在代理對象中指向的是實際對象的實例,這樣對外暴露的是代理對象而真正調用的是 Real Object
- 優點:可以很好的保護實際對象的業務邏輯對外暴露,從而提高安全性。
- 缺點:不同的接口要有不同的代理類實現,會很冗余
JDK 動態代理:
-
為了解決靜態代理中,生成大量的代理類造成的冗余;
-
JDK 動態代理只需要實現 InvocationHandler 接口,重寫 invoke 方法便可以完成代理的實現,
-
jdk的代理是利用反射生成代理類 Proxyxx.class 代理類字節碼,並生成對象
-
jdk動態代理之所以只能代理接口是因為代理類本身已經extends了Proxy,而java是不允許多重繼承的,但是允許實現多個接口
-
優點:解決了靜態代理中冗余的代理實現類問題。
-
缺點:JDK 動態代理是基於接口設計實現的,如果沒有接口,會拋異常。
CGLIB 代理:
-
由於 JDK 動態代理限制了只能基於接口設計,而對於沒有接口的情況,JDK方式解決不了;
-
CGLib 采用了非常底層的字節碼技術,其原理是通過字節碼技術為一個類創建子類,並在子類中采用方法攔截的技術攔截所有父類方法的調用,順勢織入橫切邏輯,來完成動態代理的實現。
-
實現方式實現 MethodInterceptor 接口,重寫 intercept 方法,通過 Enhancer 類的回調方法來實現。
-
但是CGLib在創建代理對象時所花費的時間卻比JDK多得多,所以對於單例的對象,因為無需頻繁創建對象,用CGLib合適,反之,使用JDK方式要更為合適一些。
-
同時,由於CGLib由於是采用動態創建子類的方法,對於final方法,無法進行代理。
-
優點:沒有接口也能實現動態代理,而且采用字節碼增強技術,性能也不錯。
-
缺點:技術實現相對難理解些。
CGlib 對接口實現代理?
import net.sf.cglib.proxy.Enhancer;
import net.sf.cglib.proxy.MethodInterceptor;
import net.sf.cglib.proxy.MethodProxy;
import proxy.UserService;
import java.lang.reflect.Method;
/**
* 創建代理類的工廠 該類要實現 MethodInterceptor 接口。
* 該類中完成三樣工作:
* (1)聲明目標類的成員變量,並創建以目標類對象為參數的構造器。用於接收目標對象
* (2)定義代理的生成方法,用於創建代理對象。方法名是任意的。代理對象即目標類的子類
* (3)定義回調接口方法。對目標類的增強這在這里完成
*/
public class CGLibFactory implements MethodInterceptor {
// 聲明目標類的成員變量
private UserService target;
public CGLibFactory(UserService target) {
this.target = target;
}
// 定義代理的生成方法,用於創建代理對象
public UserService myCGLibCreator() {
Enhancer enhancer = new Enhancer();
// 為代理對象設置父類,即指定目標類
enhancer.setSuperclass(UserService.class);
/**
* 設置回調接口對象 注意,只所以在setCallback()方法中可以寫上this,
* 是因為MethodIntecepter接口繼承自Callback,是其子接口
*/
enhancer.setCallback(this);
return (UserService) enhancer.create();// create用以生成CGLib代理對象
}
@Override
public Object intercept(Object obj, Method method, Object[] args, MethodProxy proxy) throws Throwable {
System.out.println("start invoke " + method.getName());
Object result = method.invoke(target, args);
System.out.println("end invoke " + method.getName());
return result;
}
}
參考:
《Java核心技術》卷1
《深入理解Java虛擬機》7.3
java docs: https://docs.oracle.com/javase/8/docs/api/java/lang/reflect/Proxy.html
Java三種代理模式:靜態代理、動態代理和cglib代理
描述動態代理的幾種實現方式 分別說出相應的優缺點
JDK動態代理詳解
Java動態代理機制詳解(JDK 和CGLIB,Javassist,ASM)
靜態代理和動態代理的理解
后記
歡迎評論、轉發、分享,您的支持是我最大的動力
更多內容可訪問我的個人博客:http://laijianfeng.org
關注【小旋鋒】微信公眾號,及時接收博文推送