從零開始實現簡單 RPC 框架 2:擴充套件利器 SPI

小新是也發表於2021-08-15

RPC 框架有很多可擴充套件的地方,如:序列化型別、壓縮型別、負載均衡型別、註冊中心型別等等。
假設框架提供的註冊中心只有zookeeper,但是使用者想用Eureka,修改框架以支援使用者的需求顯然不是好的做法。
最好的做法就是留下擴充套件點,讓使用者可以不需要修改框架,就能自己去實現擴充套件。
JDK 原生已經為我們提供了 SPI 機制,ccx-rpc 在此基礎上,進行了效能優化和功能增強。
在講解 ccx-rpc 的增強 SPI 之前,先來了解一下 JDK SPI 吧。

講解的 RPC 框架叫 ccx-rpc,程式碼已經開源。
Github:https://github.com/chenchuxin/ccx-rpc
Gitee:https://gitee.com/imccx/ccx-rpc

JDK SPI

下面我們來看一下 JDK SPI 是如何使用的。
我們先來定義一個序列化介面和 JSONProtostuff 兩種實現:

public interface Serializer { 
    byte[] serialize(Object object);
}
public class JSONSerializer implements Serializer {
    @Override 
    public byte[] serialize(Object object) {
        return JSONUtil.toJsonStr(object).getBytes();
    } 
}

public class ProtostuffSerializer implements Serializer {
    private static final LinkedBuffer BUFFER = LinkedBuffer.allocate(LinkedBuffer.DEFAULT_BUFFER_SIZE);
    @Override
    public byte[] serialize(Object object) {
        Schema schema = RuntimeSchema.getSchema(object.getClass());
        return ProtostuffIOUtil.toByteArray(object, schema, BUFFER);
    }
}

resources/META-INF/services 目錄下新增一個 com.xxx.Serializer 的檔案,這是 JDK SPI 的配置檔案:

com.xxx.JSONSerializer
com.xxx.ProtostuffSerializer

如何使用 SPI 將實現類載入出來呢?

public static void main(String[] args) {
    ServiceLoader<Serializer> serviceLoader = ServiceLoader.load(Serializer.class);
    Iterator<Serializer> iterator = serviceLoader.iterator();
    while (iterator.hasNext()) {
        Serializer serializer= iterator.next();
        System.out.println(serializer.getClass().getName());
    }
}

輸出如下:

com.xxx.JSONSerializer
com.xxx.ProtostuffSerializer

通過上面的例子,我們可以瞭解到 SPI 的簡單用法。接下來,我們就來看增強版的 SPI 是如何實現的,又增強在哪裡。

增強版 SPI

我們先來看看增強版 SPI 是如何使用的吧,還是拿序列化來舉例。

  1. 定義介面,介面加上 @SPI 註解
@SPI
public interface Serializer { 
    byte[] serialize(Object object);
}
  1. 實現類,這個程式碼跟上面的一模一樣,就不重複貼程式碼了
  2. 配置檔案
json=com.ccx.rpc.demo.client.spi.JSONSerializer
protostuff=com.ccx.rpc.demo.client.spi.ProtostuffSerializer
  1. 獲取擴充套件類
    我們可以只例項化想要的實現類
public static void main(String[] args) {
    ExtensionLoader<Serializer> loader = ExtensionLoader.getLoader(Serializer.class);
    Serializer serializer = loader.getExtension("protostuff");
    System.out.println(serializer.getClass().getName());
}

上面是增強版 SPI 的基礎用法,還是相當簡單的。下面我們就要開始講解程式碼實現了,準備好,要發車了。

增強版 SPI 的邏輯位於 ccx-rpc-commoncom.ccx.rpc.common.extension.ExtensionLoader 中。
以下貼的程式碼,為了突出重點,會進行刪減,想看完整版,請到 github 或者 gitee看。

懶惰載入

JDK SPI 在查詢實現類的時候,需要遍歷配置檔案中定義的所有實現類,而這個過程會把所有實現類都例項化。一個介面如果有很多實現類,而我們只需要其中一個的時候,就會產生其他不必要的實現類。 例如 Dubbo 的序列化介面,實現類就有 fastjsongsonhession2jdkkryoprotobuf 等等,通常我們只需要選擇一種序列化方式。如果用 JDK SPI,那其他沒用的序列化實現類都會例項化,例項化所有實現類明顯是資源浪費!

ccx-rpc 的擴充套件載入器就對此進行了優化,只會對需要例項化的實現類進行例項化,也就是俗稱的"懶惰載入"。

獲取擴充套件類例項的實現如下:

public T getExtension(String name) {
    T extension = extensionsCache.get(name);
    if (extension == null) {
        synchronized (lock) {
            extension = extensionsCache.get(name);
            if (extension == null) {
                extension = createExtension(name);
                extensionsCache.put(name, extension);
            }
        }
    }
    return extension;
}

這是一個典型的 double-check 懶漢單例實現,當程式需要某個實現類的時候,才會去真正初始化它。

配置檔案

配置檔案採用的格式參考 dubbo,示例:

json=com.ccx.rpc.demo.client.spi.JSONSerializer
protostuff=com.ccx.rpc.demo.client.spi.ProtostuffSerializer

採用 key-value 的配置格式有個好處就是,要獲取某個型別的擴充套件,可以直接使用名字來獲取,可以大大提高可讀性。

載入解析配置檔案的程式碼也比較簡單:

/**
 * 從資原始檔中載入所有擴充套件類
 */
private Map<String, Class<?>> loadClassesFromResources() {
    // ... 省略非關鍵程式碼
    Enumeration<URL> resources = classLoader.getResources(fileName);
    while (resources.hasMoreElements()) {
        URL url = resources.nextElement();
        try (BufferedReader reader = new BufferedReader(url...) {
            // 開始讀檔案
            while (true) {
                String line = reader.readLine();
                parseLine(line, extensionClasses);
            }
        }
    }
}

 /**
  * 解析行,並且把解析到的類,放到 extensionClasses 中
  */
 private void parseLine(String line, Map<String, Class<?>> extensionClasses) {
     // 用等號將行分割開,kv[0]就是名字,kv[1]就是類名
     String[] kv = line.split("=");
     Class<?> clazz = ExtensionLoader.class.getClassLoader().loadClass(kv[1]);
     extensionClasses.put(kv[0], clazz);
 }

擴充套件類的建立

當獲取擴充套件類不存在時,會加鎖例項化擴充套件類。例項化的流程如下:

  1. 從配置檔案中,載入該介面所有的實現類的 Class 物件,並放到快取中。
  2. 根據要獲取的副檔名字,找到對應的 Class 物件。
  3. 呼叫 clazz.newInstance() 例項化。(Class 需要有無參建構函式)

目前例項化的方式是最簡單的方式,當然後面如果需要,也可以再擴充套件成可以注入的。
程式碼在自己手上,擴充套件就相對於 JDK SPI 容易很多。

private T createExtension(String name) {
    // 獲取當前型別所有擴充套件類
    Map<String, Class<?>> extensionClasses = getAllExtensionClasses();
    // 再根據名字找到對應的擴充套件類
    Class<?> clazz = extensionClasses.get(name);
    return (T) clazz.newInstance();
}

載入器快取

載入器指的就是 ExtensionLoader<T>,為了減少物件的開銷,ccx-rpc 遮蔽了載入器的建構函式,提供了一個靜態方法來獲取載入器。

/**
 * 擴充套件載入器例項快取 {型別:載入器例項}
 */
private static final Map<Class<?>, ExtensionLoader<?>> extensionLoaderCache = new ConcurrentHashMap<>();

public static <S> ExtensionLoader<S> getLoader(Class<S> type) {
    // ... 忽略部分程式碼
    SPI annotation = type.getAnnotation(SPI.class);
    ExtensionLoader<?> extensionLoader = extensionLoaderCache.get(type);
    if (extensionLoader != null) {
        return (ExtensionLoader<S>) extensionLoader;
    }
    extensionLoader = new ExtensionLoader<>(type);
    extensionLoaderCache.putIfAbsent(type, extensionLoader);
    return (ExtensionLoader<S>) extensionLoader;
}

extensionLoaderCache 是一個 Map,快取了各種型別的載入器。獲取的時候先從快取獲取,快取不存在則去例項化,然後放到快取中。這是一個很常見的快取技巧。

預設擴充套件

ccx-rpc 還提供了預設擴充套件的功能,介面在使用 @SPI 的時候可以指定一個預設的實現類名,例如 @SPI("netty")
這樣當獲取副檔名留空沒有配置的時候,就會直接獲取預設擴充套件,減少了配置的量。

在獲取擴充套件類的時候,會從 @SPI 中獲取 value(),把預設副檔名快取起來。

private static String defaultNameCache;

public static <S> ExtensionLoader<S> getLoader(Class<S> type) {
    // ... 省略
    SPI annotation = type.getAnnotation(SPI.class);
    defaultNameCache = annotation.value();
    // ... 省略
}

獲取預設擴充套件的程式碼就很簡單了,直接使用了 defaultNameCache 去獲取擴充套件。

public T getDefaultExtension() {
    return getExtension(defaultNameCache);
}

適配擴充套件

獲取擴充套件類的時候,需要輸入副檔名,這樣就需要先從配置裡面讀到響應的副檔名,才能根據副檔名獲取擴充套件類。這個過程稍顯麻煩,ccx-rpc 還提供了一種適配擴充套件,可以動態從 URL 中讀取對應的配置並自動獲取擴充套件類。
下面我們來看一下用法:

@SPI
public interface RegistryFactory {

    /**
     * 獲取註冊中心
     *
     * @param url 註冊中心的配置,例如註冊中心的地址。會自動根據協議獲取註冊中心例項
     * @return 如果協議型別跟註冊中心匹配上了,返回對應的配置中心例項
     */
    @Adaptive("protocol")
    Registry getRegistry(URL url);
}
public static void main(String[] args) {
    // 獲取適配擴充套件
    RegistryFactory zkRegistryFactory = ExtensionLoader.getLoader(RegistryFactory.class).getAdaptiveExtension();
    URL url = URLParser.toURL("zk://localhost:2181");
    // 適配擴充套件自動從 ur 中解析出副檔名,然後返回對應的擴充套件類
    Registry registry = zkRegistryFactory.getRegistry(url);
}

從例項程式碼,可以看到,有一個@Adaptive("protocol") 註解,方法中有 URL 引數。其邏輯就是,SPI 從傳進來的 URL 的協議中欄位中,獲取到副檔名 zk

下面我們來看看獲取適配擴充套件的程式碼是怎麼實現的吧。

public T getAdaptiveExtension() {
    InvocationHandler handler = new AdaptiveInvocationHandler<T>(type);
    return (T) Proxy.newProxyInstance(ExtensionLoader.class.getClassLoader(),
            new Class<?>[]{type}, handler);
}

適配擴充套件類其實是一個代理類,接下來來看看這個代理類 AdaptiveInvocationHandler

public class AdaptiveInvocationHandler<T> implements InvocationHandler {

    private final Class<T> clazz;

    public AdaptiveInvocationHandler(Class<T> tClass) {
        clazz = tClass;
    }

    @Override
    public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
        if (args.length == 0) {
            return method.invoke(proxy, args);
        }
        // 找 URL 引數
        URL url = null;
        for (Object arg : args) {
            if (arg instanceof URL) {
                url = (URL) arg;
                break;
            }
        }
        // 找不到 URL 引數,直接執行方法
        if (url == null) {
            return method.invoke(proxy, args);
        }

        Adaptive adaptive = method.getAnnotation(Adaptive.class);
        // 如果不包含 @Adaptive,直接執行方法即可
        if (adaptive == null) {
            return method.invoke(proxy, args);
        }

        // 從 @Adaptive#value() 中拿到副檔名的 key
        String extendNameKey = adaptive.value();
        String extendName;
        // 如果這個 key 是協議,從協議拿。其他的就直接從 URL 引數拿
        if (URLKeyConst.PROTOCOL.equals(extendNameKey)) {
            extendName = url.getProtocol();
        } else {
            extendName = url.getParam(extendNameKey, method.getDeclaringClass() + "." + method.getName());
        }
        // 拿到副檔名之後,就直接從 ExtensionLoader 拿就行了
        ExtensionLoader<T> extensionLoader = ExtensionLoader.getLoader(clazz);
        T extension = extensionLoader.getExtension(extendName);
        return method.invoke(extension, args);
    }
}

從配置中獲取擴充套件的程式碼註釋都有,我們在梳理一下流程:

  1. 從方法引數中拿到 URL 引數,拿不到就直接執行方法
  2. 獲取配置 Key。從 @Adaptive#value() 拿副檔名的配置 key,如果拿不到就直接執行方法
  3. 獲取副檔名。判斷配置 key 是不是協議,如果是就拿協議型別,否則拿 URL 後面的引數。
    例如 URL 是:zk://localhost:2181?type=eureka
    • 如果 @Adaptive("protocol"),那麼副檔名就是協議型別:zk
    • 如果 @Adaptive("type"),那麼副檔名就是type 引數:eureka
  4. 最後根據副檔名獲取擴充套件 extensionLoader.getExtension(extendName)

總結

RPC 框架擴充套件很重要,SPI 是一個很好的機制。
JDK SPI 獲取擴充套件的時候,會例項化所有的擴充套件,造成資源的浪費。
ccx-rpc 自己實現了一套增強版的 SPI,有如下特點:

  • 懶惰載入
  • key-value 結構的配置檔案
  • 載入器快取
  • 預設擴充套件
  • 適配擴充套件

ccx-rpcSPI 機制參考 Dubbo SPI,在它的基礎上進行了精簡和修改,在此對 Dubbo 表示感謝。

相關文章