1. 程式人生 > 程式設計 >SPI機制的原理和應用

SPI機制的原理和應用

前言

SPI ,全稱為 Service Provider Interface,是一種服務發現機制。它通過在ClassPath路徑下的META-INF/services資料夾查詢檔案,自動載入檔案裡所定義的類。

這一機制為很多框架的擴充套件提供了可能,比如在Dubbo、JDBC、SpringBoot中都使用到了SPI機制。雖然他們之間的實現方式不同,但原理都差不多。今天我們就來看看,SPI到底是何方神聖,在眾多開源框架中又扮演了什麼角色。

一、JDK中的SPI

我們先從JDK開始,通過一個很簡單的例子來看下它是怎麼用的。

1、小栗子

首先,我們需要定義一個介面,SpiService

public interface SpiService {
    void println();
}
複製程式碼

然後,定義一個實現類,沒別的意思,只做列印。

public class SpiServiceImpl implements SpiService {
    @Override
    public void println() {
        System.out.println("-------------");
    }
}
複製程式碼

最後呢,要在resources路徑下配置新增一個檔案。檔名字是介面的全限定類名,內容是實現類的全限定類名,多個實現類用換行符分隔。

檔案內容就是實現類的全限定類名:

com.youyouxunyin.service.impl.SpiServiceImpl
複製程式碼

2、測試

然後我們就可以通過ServiceLoader.load方法拿到實現類的例項,並呼叫它的方法。

public static void main(String[] args){
    ServiceLoader<SpiService> load = ServiceLoader.load(SpiService.class);
    Iterator<SpiService> iterator = load.iterator();
    while (iterator.hasNext()){
        SpiService service = iterator.next();
        service.println();
    }
}
複製程式碼

3、原始碼分析

首先,我們先來瞭解下ServiceLoader,看看它的類結構。

public final class ServiceLoader<S> implements Iterable<S>{
    //配置檔案的路徑
    private static final String PREFIX = "META-INF/services/";
    //載入的服務類或介面
    private final Class<S> service;
    //已載入的服務類集合
    private LinkedHashMap<String,S> providers = new LinkedHashMap<>();
    //類載入器
    private final ClassLoader loader;
    //內部類,真正載入服務類
    private LazyIterator lookupIterator;
}
複製程式碼

當我們呼叫load方法時,並沒有真正的去載入和查詢服務類。而是呼叫了ServiceLoader的構造方法,在這裡最重要的是例項化了內部類LazyIterator,它才是接下來的主角。

private ServiceLoader(Class<S> svc,ClassLoader cl) {
    //要載入的介面
    service = Objects.requireNonNull(svc,"Service interface cannot be null");
    //類載入器
    loader = (cl == null) ? ClassLoader.getSystemClassLoader() : cl;
    //訪問控制器
    acc = (System.getSecurityManager() != null) ? AccessController.getContext() : null;
    //先清空
    providers.clear();
    //例項化內部類 
    LazyIterator lookupIterator = new LazyIterator(service,loader);
}
複製程式碼

查詢實現類和建立實現類的過程,都在LazyIterator完成。當我們呼叫iterator.hasNext和iterator.next方法的時候,實際上呼叫的都是LazyIterator的相應方法。

public Iterator<S> iterator() {

    return new Iterator<S>() {
	
    	public boolean hasNext() {
    		return lookupIterator.hasNext();
    	}
    	public S next() {
    		return lookupIterator.next();
    	}
    	.......
    };
}
複製程式碼

所以,我們重點關注lookupIterator.hasNext()方法,它最終會呼叫到hasNextService,在這裡返回實現類名稱。

private class LazyIterator implements Iterator<S>{
    Class<S> service;
    ClassLoader loader;
    Enumeration<URL> configs = null;
    Iterator<String> pending = null;
    String nextName = null;	
    private boolean hasNextService() {
    	//第二次呼叫的時候,已經解析完成了,直接返回
    	if (nextName != null) {
    	    return true;
    	}
    	if (configs == null) {
    	    //META-INF/services/ 加上介面的全限定類名,就是檔案服務類的檔案
    	    //META-INF/services/com.viewscenes.netsupervisor.spi.SPIService
    	    String fullName = PREFIX + service.getName();
    	    //將檔案路徑轉成URL物件
    	    configs = loader.getResources(fullName);
    	}
    	while ((pending == null) || !pending.hasNext()) {
    	    //解析URL檔案物件,讀取內容,最後返回
    	    pending = parse(service,configs.nextElement());
    	}
    	//拿到第一個實現類的類名
    	nextName = pending.next();
    	return true;
    }
}
複製程式碼

然後當我們呼叫next()方法的時候,呼叫到lookupIterator.nextService。它通過反射的方式,建立實現類的例項並返回。

private S nextService() {
    //全限定類名
    String cn = nextName;
    nextName = null;
    //建立類的Class物件
    Class<?> c = Class.forName(cn,false,loader);
    //通過newInstance例項化
    S p = service.cast(c.newInstance());
    //放入集合,返回例項
    providers.put(cn,p);
    return p; 
}
複製程式碼

到這為止,已經獲取到了類的例項。

二、JDBC中的應用

我們開頭說,SPI機制為很多框架的擴充套件提供了可能,其實JDBC就應用到了這一機制。

在以前,需要先設定資料庫驅動的連線,再通過DriverManager.getConnection獲取一個Connection

String url = "jdbc:mysql:///consult?serverTimezone=UTC";
String user = "root";
String password = "root";

Class.forName("com.mysql.jdbc.Driver");
Connection connection = DriverManager.getConnection(url,user,password);
複製程式碼

而現在,設定資料庫驅動連線,這一步驟就不再需要,那麼它是怎麼分辨是哪種資料庫的呢?答案就在SPI。

1、載入

我們把目光回到DriverManager類,它在靜態程式碼塊裡面做了一件比較重要的事。很明顯,它已經通過SPI機制, 把資料庫驅動連線初始化了。

public class DriverManager {
    static {
        loadInitialDrivers();
        println("JDBC DriverManager initialized");
    }
}
複製程式碼

具體過程還得看loadInitialDrivers,它在裡面查詢的是Driver介面的服務類,所以它的檔案路徑就是:

META-INF/services/java.sql.Driver

private static void loadInitialDrivers() {
    AccessController.doPrivileged(new PrivilegedAction<Void>() {
    	public Void run() {
    		//很明顯,它要載入Driver介面的服務類,Driver介面的包為:java.sql.Driver
    		//所以它要找的就是META-INF/services/java.sql.Driver檔案
    		ServiceLoader<Driver> loadedDrivers = ServiceLoader.load(Driver.class);
    		Iterator<Driver> driversIterator = loadedDrivers.iterator();
    		try{
    		    //查到之後建立物件
    		    while(driversIterator.hasNext()) {
    		    	driversIterator.next();
    		    }
    		} catch(Throwable t) {
    		    // Do nothing
    		}
    		return null;
    	}
    });
}
複製程式碼

那麼,這個檔案哪裡有呢?我們來看MySQL的jar包,就是這個檔案,檔案內容為:com.mysql.cj.jdbc.Driver

2、建立例項

上一步已經找到了MySQL中的com.mysql.cj.jdbc.Driver全限定類名,當呼叫next方法時,就會建立這個類的例項。它就完成了一件事,向DriverManager註冊自身的例項。

public class Driver extends NonRegisteringDriver implements java.sql.Driver {
    static {
        try {
            //註冊、呼叫DriverManager類的註冊方法
            //往registeredDrivers集合中加入例項
            DriverManager.registerDriver(new Driver());
        } catch (SQLException var1) {
            throw new RuntimeException("Can't register driver!");
        }
    }
}
複製程式碼

3、建立Connection

DriverManager.getConnection()方法就是建立連線的地方,它通過迴圈已註冊的資料庫驅動程式,呼叫其connect方法,獲取連線並返回。

private static Connection getConnection(String url,Properties info,Class<?> caller) throws SQLException {	
    //registeredDrivers中就包含com.mysql.cj.jdbc.Driver例項
    for(DriverInfo aDriver : registeredDrivers) {
    	if(isDriverAllowed(aDriver.driver,callerCL)) {
    	    try {
    	    	//呼叫connect方法建立連線
    	    	Connection con = aDriver.driver.connect(url,info);
    	    	if (con != null) {
    	    	    return (con);
    	    	}
    	    }catch (SQLException ex) {
    	    	if (reason == null) {
    	    	    reason = ex;
    	    	}
    	    }
    	} else {
    	    println("skipping: " + aDriver.getClass().getName());
    	}
    }
}
複製程式碼

4、擴充套件

既然我們知道JDBC是這樣建立資料庫連線的,我們能不能再擴充套件一下呢?如果我們自己也建立一個java.sql.Driver檔案,自定義實現類MySQLDriver,那麼,在獲取連線的前後就可以動態修改一些資訊。

還是先在專案resources下建立檔案,檔案內容為自定義驅動類com.youyouxunyin.driver.MySQLDriver

我們的MySQLDriver實現類,繼承自MySQL中的NonRegisteringDriver,還要實現java.sql.Driver介面。這樣,在呼叫connect方法的時候,就會呼叫到此類,但實際建立的過程還靠MySQL完成。

public class MySQLDriver extends NonRegisteringDriver implements Driver{
    static {
        try {
            DriverManager.registerDriver(new MySQLDriver());
        } catch (SQLException e) {
            e.printStackTrace();
        }
    }
    public MySQLDriver() throws SQLException {}

    @Override
    public Connection connect(String url,Properties info) throws SQLException {
        System.out.println("準備建立資料庫連線.url:"+url);
        System.out.println("JDBC配置資訊:"+info);
        info.setProperty("user","root");
        Connection connection =  super.connect(url,info);
        System.out.println("資料庫連線建立完成!"+connection.toString());
        return connection;
    }
}
複製程式碼

這樣的話,當我們獲取資料庫連線的時候,就會呼叫到這裡。

--------------------輸出結果---------------------
準備建立資料庫連線.url:jdbc:mysql:///consult?serverTimezone=UTC
JDBC配置資訊:{user=root,password=root}
資料庫連線建立完成!com.mysql.cj.jdbc.ConnectionImpl@7cf10a6f
複製程式碼

三、SpringBoot中的應用

Spring Boot提供了一種快速的方式來建立可用於生產環境的基於Spring的應用程式。它基於Spring框架,更傾向於約定而不是配置,並且旨在使您儘快啟動並執行。

即便沒有任何配置檔案,SpringBoot的Web應用都能正常執行。這種神奇的事情,SpringBoot正是依靠自動配置來完成。

說到這,我們必須關注一個東西:SpringFactoriesLoader,自動配置就是依靠它來載入的。

1、配置檔案

SpringFactoriesLoader來負責載入配置。我們開啟這個類,看到它載入檔案的路徑是:META-INF/spring.factories

筆者在專案中搜索這個檔案,發現有4個Jar包都包含它:

  • spring-boot-2.1.9.RELEASE.jar
  • spring-beans-5.1.10.RELEASE.jar
  • spring-boot-autoconfigure-2.1.9.RELEASE.jar
  • mybatis-spring-boot-autoconfigure-2.1.0.jar

那麼它們裡面都是些啥內容呢?其實就是一個個介面和類的對映。在這裡筆者就不貼了,有興趣的小夥伴自己去看看。

比如在SpringBoot啟動的時候,要載入所有的ApplicationContextInitializer,那麼就可以這樣做:

SpringFactoriesLoader.loadFactoryNames(ApplicationContextInitializer.class,classLoader)

2、載入檔案

loadSpringFactories就負責讀取所有的spring.factories檔案內容。

private static Map<String,List<String>> loadSpringFactories(@Nullable ClassLoader classLoader) {

    MultiValueMap<String,String> result = cache.get(classLoader);
    if (result != null) {
    	return result;
    }
    try {
    	//獲取所有spring.factories檔案的路徑
    	Enumeration<URL> urls = lassLoader.getResources("META-INF/spring.factories");
    	result = new LinkedMultiValueMap<>();
    	while (urls.hasMoreElements()) {
    	    URL url = urls.nextElement();
    	    //載入檔案並解析檔案內容
    	    UrlResource resource = new UrlResource(url);
    	    Properties properties = PropertiesLoaderUtils.loadProperties(resource);
    	    for (Map.Entry<?,?> entry : properties.entrySet()) {
    	    	String factoryClassName = ((String) entry.getKey()).trim();
    	    	for (String factoryName : StringUtils.commaDelimitedListToStringArray((String) entry.getValue())) {
    	    	    result.add(factoryClassName,factoryName.trim());
    	    	}
    	    }
    	}
    	cache.put(classLoader,result);
    	return result;
    }
    catch (IOException ex) {
    	throw new IllegalArgumentException("Unable to load factories from location [" +
    		FACTORIES_RESOURCE_LOCATION + "]",ex);
    }
}
複製程式碼

可以看到,它並沒有採用JDK中的SPI機制來載入這些類,不過原理差不多。都是通過一個配置檔案,載入並解析檔案內容,然後通過反射建立例項。

3、參與其中

假如你希望參與到SpringBoot初始化的過程中,現在我們又多了一種方式。

我們也建立一個spring.factories檔案,自定義一個初始化器。

org.springframework.context.ApplicationContextInitializer=com.youyouxunyin.config.context.MyContextInitializer

然後定義一個MyContextInitializer類

public class MyContextInitializer implements ApplicationContextInitializer {
    @Override
    public void initialize(ConfigurableApplicationContext configurableApplicationContext) {
        System.out.println(configurableApplicationContext);
    }
}
複製程式碼

四、Dubbo中的應用

我們熟悉的Dubbo也不例外,它也是通過 SPI 機制載入所有的元件。同樣的,Dubbo 並未使用 Java 原生的 SPI 機制,而是對其進行了增強,使其能夠更好的滿足需求。在 Dubbo 中,SPI 是一個非常重要的模組。基於 SPI,我們可以很容易的對 Dubbo 進行拓展。

關於原理,如果有小夥伴不熟悉,可以參閱筆者文章:

Dubbo中的SPI和自適應擴充套件機制

它的使用方式同樣是在META-INF/services建立檔案並寫入相關類名。

關於使用場景,可以參考: SpringBoot+Dubbo整合ELK實戰