1.jdk SPI介紹
SPI 全稱為 Service Provider Interface,是一種服務發現機制。SPI 的
這樣可以在
,我們可以很容易的通過 SPI 機制為我們的程序提供拓展功能。SPI 機制在第三方框架中也有所應用,比如 Dubbo 就是通過 SPI 機制加載所有的組件。不過,Dubbo 并未使用 Java 原生的 SPI 機制,而是對其進行了增強,使其能夠更好的滿足需求。在 Dubbo 中,SPI 是一個非常重要的模塊。基于 SPI,我們可以很容易的對 Dubbo 進行拓展。如果大家想要學習 Dubbo 的源碼,SPI 機制務必弄懂。接下來,我們先來了解一下 Java SPI 與 Dubbo SPI 的用法,然后再來分析 Dubbo SPI 的源碼。
以上來自dubbo官方文檔
我們系統里抽象的各個模塊,往往有很多不同的實現方案,比如日志模塊的方案,xml解析模塊、jdbc模塊的方案等。面向的對象的設計里,我們一般推薦模塊之間基于接口編程,模塊之間不對實現類進行硬編碼。一旦代碼里涉及具體的實現類,就違反了可拔插的原則,如果需要替換一種實現,就需要修改代碼。為了實現在模塊裝配的時候能不在程序里動態指明,這就需要一種服務發現機制。 java spi就是提供這樣的一個機制:為某個接口尋找服務實現的機制。有點類似IOC的思想,就是將裝配的控制權移到程序之外,在模塊化設計中這個機制尤其重要。
SPI的具體約定:服務的提供者,提供了服務接口的一種實現之后,在jar包的META-INF/services/目錄里同時創建一個以服務接口命名的文件。該文件里就是實現該服務接口的具體實現類。而當外部程序裝配這個模塊的時候,就能通過該jar包META-INF/services/里的配置文件找到具體的實現類名,并裝載實例化,完成模塊的注入。 基于這樣一個約定就能很好的找到服務接口的實現類,而不需要再代碼里制定。jdk提供服務實現查找的一個工具類:java.util.ServiceLoader
-
common-logging
apache最早提供的日志的門面接口。只有接口,沒有實現。具體方案由各提供商實現, 發現日志提供商是通過掃描 META-INF/services/org.apache.commons.logging.LogFactory配置文件,通過讀取該文件的內容找到日志提工商實現類。只要我們的日志實現里包含了這個文件,并在文件里制定 LogFactory工廠接口的實現類即可。
-
JDBC
jdbc4.0以前, 開發人員還需要基于Class.forName("xxx")的方式來裝載驅動,jdbc4也基于spi的機制來發現驅動提供商了,可以通過META-INF/services/java.sql.Driver文件里指定實現類的方式來暴露驅動提供者.
jdbc連接過程 賈璉預執事(加載驅動、鏈接數據庫、預執行、執行、釋放資源)
//加載JDBC驅動程序
Class.forName("com.mysql.jdbc.Driver") ;
//2、提供JDBC連接的URL
String url = jdbc:mysql://localhost:3306/test?useUnicode=true&characterEncoding=utf8
//3、創建數據庫的連接
Connection con = DriverManager.getConnection(url , username , password ) ;
//4、創建一個Statement
PreparedStatement pstmt = con.prepareStatement(sql) ;
//5、執行SQL語句
ResultSet rs = stmt.executeQuery("SELECT * FROM ...") ;
while(rs.next()){
//do something
}
//6、釋放資源
con.close()
我們都知道,也聽了無數遍,驅動的加載是由Class.forName 方法完成的。
由于JVM對類的加載有一個邏輯是:在類被需要的時候,或者首次調用的時候就會把類加載到JVM。反過來也就是:如果類沒有被需要的時候,一般是不會被加載到JVM的。
當連接數據庫的時候我們調用了Class.forName語句之后,數據庫驅動類被加載到JVM,那么靜態初始化塊就會被執行,從而完成驅動的注冊工作,也就是注冊到了JDBC的DriverManager類中。
由于是靜態初始化塊中完成的加載,所以也就不必擔心驅動被加載多次,原因可以參考單例模式相關的知識。
拋棄Class.forName()
在JDBC 4.0之后實際上我們不需要再調用Class.forName來加載驅動程序了,我們只需要把驅動的jar包放到工程的類加載路徑里,那么驅動就會被自動加載。
這個自動加載采用的技術叫做SPI,數據庫驅動廠商也都做了更新。可以看一下jar包里面的META-INF/services目錄,里面有一個java.sql.Driver的文件,文件里面包含了驅動的全路徑名。
比如mysql-connector里面的內容:
com.mysql.jdbc.Driver
com.mysql.fabric.jdbc.FabricMySQLDriver
那么SPI技術又是在什么階段加載的數據庫驅動呢?看一下JDBC的DriverManager類就知道了。
public class DriverManager {
static {
loadInitialDrivers();//......1
println("JDBC DriverManager initialized");
}
private static void loadInitialDrivers() {
String drivers;
try {
drivers = AccessController.doPrivileged(new PrivilegedAction<String>() {
public String run() {
return System.getProperty("jdbc.drivers");
}
});
} catch (Exception ex) {
drivers = null;
}
AccessController.doPrivileged(new PrivilegedAction<Void>() {
public Void run() {
ServiceLoader<Driver> loadedDrivers = ServiceLoader.load(Driver.class);//.....2
Iterator driversIterator = loadedDrivers.iterator();
//.....
}
上述代碼片段標記…1的位置是在DriverManager類加載是執行的靜態初始化塊,這里會調用loadInitialDrivers方法。
再看loadInitialDrivers方法里面標記…2的位置,這里調用的 ServiceLoader.load(Driver.class); 就會加載所有在META-INF/services/java.sql.Driver文件里邊的類到JVM內存,完成驅動的自動加載。
這就是SPI的優勢所在,能夠自動的加載類到JVM內存。這個技術在阿里的dubbo框架里面也占到了很大的分量,有興趣的朋友可以看一下dubbo的代碼,或者百度一下dubbo的擴展機制。
JDBC如何區分多個驅動?
一個項目里邊很可能會即連接MySQL,又連接Oracle,這樣在一個工程里邊就存在了多個驅動類,那么這些驅動類又是怎么區分的呢?
關鍵點就在于getConnection的步驟,DriverManager.getConnection中會遍歷所有已經加載的驅動實例去創建連接,當一個驅動創建連接成功時就會返回這個連接,同時不再調用其他的驅動實例。DriverManager關鍵代碼如下:
private static Connection getConnection(
//.....
for(DriverInfo aDriver : registeredDrivers) {
if(isDriverAllowed(aDriver.driver, callerCL)) {
try {
println(" trying " + aDriver.driver.getClass().getName());
Connection con = aDriver.driver.connect(url, info);
if (con != null) {
// Success!
println("getConnection returning " + aDriver.driver.getClass().getName());
return (con);
}
} catch (SQLException ex) {
if (reason == null) {
reason = ex;
}
}
} else {
println(" skipping: " + aDriver.getClass().getName());
}
}
是不是每個驅動實例都真真實實的要嘗試建立連接呢?不是的!
比如,MySQL驅動類中的關鍵代碼:
public boolean acceptsURL(String url) throws SQLException {
return (parseURL(url, null) != null);
}
public Properties parseURL(String url, Properties defaults)
throws java.sql.SQLException {
Properties urlProps = (defaults != null) ? new Properties(defaults)
: new Properties();
if (url == null) {
return null;
}
if (!StringUtils.startsWithIgnoreCase(url, URL_PREFIX)
&& !StringUtils.startsWithIgnoreCase(url, MXJ_URL_PREFIX)
&& !StringUtils.startsWithIgnoreCase(url,
LOADBALANCE_URL_PREFIX)
&& !StringUtils.startsWithIgnoreCase(url,
REPLICATION_URL_PREFIX)) { //$NON-NLS-1$
return null;
}
//......
2.SPI示例
3.JDK SPI缺點
- 需要遍歷所有的實現,并實例化,然后我們在循環中才能找到我們需要的實現。
- 配置文件中只是簡單的列出了所有的擴展實現,而沒有給他們命名。導致在程序中很難去準確的引用它們。
- 擴展如果依賴其他的擴展,做不到自動注入和裝配
- 不提供類似于Spring的AOP功能
- 擴展很難和其他的框架集成,比如擴展里面依賴了一個Spring bean,原生的Java SPI不支持
所以Java SPI應付一些簡單的場景是可以的,但對于Dubbo,它的功能還是比較弱的。Dubbo對原生SPI機制進行了一些擴展。接下來,我們就更深入地了解下Dubbo的SPI機制。
4.dubbo SPI
擴展點(Extension Point)
是一個Java的接口。
擴展(Extension)
擴展點的實現類。
擴展實例
擴展點實現類的實例。
擴展自適應實例(Extension Adaptive Instance)
第一次接觸這個概念時,可能不太好理解(我第一次也是這樣的...)。如果稱它為擴展代理類,可能更好理解些。擴展的自適應實例其實就是一個Extension的代理,它實現了擴展點接口。在調用擴展點的接口方法時,會根據實際的參數來決定要使用哪個擴展。比如一個IRepository的擴展點,有一個save方法。有兩個實現MysqlRepository和MongoRepository。IRepository的自適應實例在調用接口方法的時候,會根據save方法中的參數,來決定要調用哪個IRepository的實現。如果方法參數中有repository=mysql,那么就調用MysqlRepository的save方法。如果repository=mongo,就調用MongoRepository的save方法。和面向對象的延遲綁定很類似。為什么Dubbo會引入擴展自適應實例的概念呢?
Dubbo中的配置有兩種,一種是固定的系統級別的配置,在Dubbo啟動之后就不會再改了。還有一種是運行時的配置,可能對于每一次的RPC,這些配置都不同。比如在xml文件中配置了超時時間是10秒鐘,這個配置在Dubbo啟動之后,就不會改變了。但針對某一次的RPC調用,可以設置它的超時時間是30秒鐘,以覆蓋系統級別的配置。對于Dubbo而言,每一次的RPC調用的參數都是未知的。只有在運行時,根據這些參數才能做出正確的決定。
很多時候,我們的類都是一個單例的,比如Spring的bean,在Spring bean都實例化時,如果它依賴某個擴展點,但是在bean實例化時,是不知道究竟該使用哪個具體的擴展實現的。這時候就需要一個代理模式了,它實現了擴展點接口,方法內部可以根據運行時參數,動態的選擇合適的擴展實現。而這個代理就是自適應實例。 自適應擴展實例在Dubbo中的使用非常廣泛,Dubbo中,每一個擴展都會有一個自適應類,如果我們沒有提供,Dubbo會使用字節碼工具為我們自動生成一個。所以我們基本感覺不到自適應類的存在。后面會有例子說明自適應類是怎么工作的。
@SPI
@SPI注解作用于擴展點的接口上,表明該接口是一個擴展點。可以被Dubbo的ExtentionLoader加載。如果沒有此ExtensionLoader調用會異常。
@Adaptive
@Adaptive注解用在擴展接口的方法上。表示該方法是一個自適應方法。Dubbo在為擴展點生成自適應實例時,如果方法有@Adaptive注解,會為該方法生成對應的代碼。方法內部會根據方法的參數,來決定使用哪個擴展。
ExtentionLoader
類似于Java SPI的ServiceLoader,負責擴展的加載和生命周期維護。
擴展別名
和Java SPI不同,Dubbo中的擴展都有一個別名,用于在應用中引用它們。比如
random=com.alibaba.dubbo.rpc.cluster.loadbalance.RandomLoadBalance
roundrobin=com.alibaba.dubbo.rpc.cluster.loadbalance.RoundRobinLoadBalance
其中的random,roundrobin就是對應擴展的別名。這樣我們在配置文件中使用random或roundrobin就可以了。
路徑
和Java SPI從/META-INF/services目錄加載擴展配置類似,Dubbo也會從以下路徑去加載擴展配置文件:
- META-INF/dubbo/internal
- META-INF/dubbo
- META-INF/services
5.Dubbo的LoadBalance擴展點解讀
在了解了Dubbo的一些基本概念后,讓我們一起來看一個Dubbo中實際的擴展點,對這些概念有一個更直觀的認識。
我們選擇的是Dubbo中的擴展點。Dubbo中的一個服務,通常有多個Provider,consumer調用服務時,需要在多個Provider中選擇一個。這就是一個LoadBalance。我們一起來看看在Dubbo中,LoadBalance是如何成為一個擴展點的。
package com.alibaba.dubbo.rpc.cluster;
@SPI("random")
public interface LoadBalance {
@Adaptive({"loadbalance"})
<T> Invoker<T> select(List<Invoker<T>> var1, URL var2, Invocation var3) throws RpcException;
}
LoadBalance接口只有一個select方法。select方法從多個invoker中選擇其中一個。上面代碼中和Dubbo SPI相關的元素有:
- @SPI("random") @SPI作用于LoadBalance接口,表示接口LoadBalance是一個擴展點。如果沒有@SPI注解,試圖去加載擴展時,會拋出異常。@SPI注解有一個參數,該參數表示該擴展點的默認實現的別名。如果沒有顯示的指定擴展,就使用默認實現。默認實現是"random",是一個隨機負載均衡的實現。 random的定義在配置文件
META-INF/dubbo/internal/com.alibaba.dubbo.rpc.cluster.LoadBalance
中:
random=com.alibaba.dubbo.rpc.cluster.loadbalance.RandomLoadBalance
roundrobin=com.alibaba.dubbo.rpc.cluster.loadbalance.RoundRobinLoadBalance
leastactive=com.alibaba.dubbo.rpc.cluster.loadbalance.LeastActiveLoadBalance
consistenthash=com.alibaba.dubbo.rpc.cluster.loadbalance.ConsistentHashLoadBalance
可以看到文件中定義了4個LoadBalance的擴展實現。由于負載均衡的實現不是本次的內容,這里就不過多說明。只用知道Dubbo提供了4種負載均衡的實現,我們可以通過xml文件,properties文件,JVM參數顯式的指定一個實現。如果沒有,默認使用隨機。
-
@Adaptive("loadbalance")
@Adaptive注解修飾select方法,表明方法select方法是一個可自適應的方法。Dubbo會自動生成該方法對應的代碼。當調用select方法時,會根據具體的方法參數來決定調用哪個擴展實現的select方法。@Adaptive注解的參數loadbalance表示方法參數中的loadbalance的值作為實際要調用的擴展實例。 但奇怪的是,我們發現select的方法中并沒有loadbalance參數,那怎么獲取loadbalance的值呢?select方法中還有一個URL類型的參數,Dubbo就是從URL中獲取loadbalance的值的。這里涉及到Dubbo的URL總線模式,簡單說,URL中包含了RPC調用中的所有參數。URL類中有一個Map字段,parameters中就包含了loadbalance。
LoadBalance lb = ExtensionLoader.getExtensionLoader(LoadBalance.class).getExtension(loadbalanceName);
使用ExtensionLoader.getExtensionLoader(LoadBalance.class)
方法獲取一個ExtensionLoader
的實例,然后調用getExtension
,傳入一個擴展的別名來獲取對應的擴展實例。
6.自定義一個LoadBalance擴展
1.實現LoadBalance接口,首先,編寫一個自己實現的LoadBalance,因為是為了演示Dubbo的擴展機制,而不是LoadBalance的實現,所以這里LoadBalance的實現非常簡單,選擇第一個invoker,并在控制臺輸出一條日志。
public class DemoLoadBalance implements LoadBalance {
@Override
public <T> Invoker<T> select(List<Invoker<T>> invokers, URL url, Invocation invocation) throws RpcException {
System.out.println("DemoLoadBalance : Select the first invoker...");
return invokers.get(0);
}
}
2.META-INF/dubbo
下添加擴展配置文件
demo=com.dubbo.spi.demo.consumer.DemoLoadBalance
- 通過上面的兩步,已經添加了一個名字為demo的LoadBalance實現,并在配置文件中進行了相應的配置。接下來,需要顯式的告訴Dubbo使用demo的負載均衡實現。如果是通過spring的方式使用Dubbo,可以在xml文件中進行設置。
<dubbo:registry address="zookeeper://127.0.0.1:2181"/>
<dubbo:reference id="helloService" interface="com.dubbo.spi.demo.api.IHelloService" loadbalance="demo" />
啟動Dubbo
啟動Dubbo,調用一次IHelloService,可以看到控制臺會輸出一條DemoLoadBalance: Select the first invoker...
日志。說明Dubbo的確是使用了我們自定義的LoadBalance。
總結dubbo SPI
- 對Dubbo進行擴展,不需要改動Dubbo的源碼
- 自定義的Dubbo的擴展點實現,是一個普通的Java類,Dubbo沒有引入任何Dubbo特有的元素,對代碼侵入性幾乎為零。
- 將擴展注冊到Dubbo中,只需要在ClassPath中添加配置文件。使用簡單。而且不會對現有代碼造成影響。符合開閉原則。
- Dubbo的擴展機制支持IoC,AoP等高級功能
- Dubbo的擴展機制能很好的支持第三方IOC容器,默認支持Spring Bean,可自己擴展來支持其他容器,比如Google的Guice。
- 切換擴展點的實現,只需要在配置文件中修改具體的實現,不需要改代碼。使用方便。