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 錶示感謝。

從零開始實現簡單 RPC 框架 2:擴展利器 SPI的更多相關文章

  1. RPC筆記之初探RPC:DIY簡單RPC框架

    一.什麼是RPC RPC(Remote Procedure Call)即遠程過程調用,簡單的說就是在A機器上去調用B機器上的某個方法,在分布式系統中極其常用. rpc原理其實很簡單,比較容易理解,在r ...

  2. Java實現簡單RPC框架(轉)

    一.RPC簡介 RPC,全稱Remote Procedure Call, 即遠程過程調用,它是一個計算機通信協議.它允許像本地服務一樣調用遠程服務.它可以有不同的實現方式.如RMI(遠程方法調用).H ...

  3. 簡單RPC框架-基於Consul的服務注册與發現

    *:first-child { margin-top: 0 !important; } body>*:last-child { margin-bottom: 0 !important; } /* ...

  4. 一個簡單RPC框架是怎樣煉成的(V)——引入傳輸層

    開局篇我們說了,RPC框架的四個核心內容 RPC數據的傳輸. RPC消息 協議 RPC服務注册 RPC消息處理    接下來處理傳輸數據.實際應用場景一般都是基於socket.socket代碼比較多, ...

  5. 一個簡單RPC框架是怎樣煉成的(II)——制定RPC消息

    開局篇我們說了,RPC框架的四個核心內容 RPC數據的傳輸. RPC消息 協議 RPC服務注册 RPC消息處理 以下,我們先看一個普通的過程調用 class Client(object): def _ ...

  6. 一個簡單RPC框架是怎樣煉成的(VI)——引入服務注册機制

    開局篇我們說了.RPC框架的四個核心內容 RPC數據的傳輸. RPC消息 協議 RPC服務注册 RPC消息處理 接下來處理RPC服務的注册機制.所謂注册機制,就是Server須要聲明支持哪些rpc方法 ...

  7. 一個簡單RPC框架是怎樣煉成的(I)——開局篇

    開場白,這是一個關於RPC的相關概念的普及篇系列,主要是通過一步步的調整,提煉出一個相對完整的RPC框架. RPC(Remote Procedure Call Protocol)--遠程過程調用協議, ...

  8. 簡單RPC框架-業務線程池

    *:first-child { margin-top: 0 !important; } body>*:last-child { margin-bottom: 0 !important; } /* ...

  9. 一個簡單RPC框架是怎樣煉成的(IV)——實現RPC消息的編解碼

    之前我們制定了一個非常easy的RPC消息 的格式,可是還遺留了兩個問題,上一篇解决掉了一個.還留下一個 我們並沒有實現對應的encode和decode方法,沒有基於能够跨設備的字符串傳輸,而是直接的 ...

  10. Java實現簡單的RPC框架(美團面試)

    一.RPC簡介 RPC,全稱為Remote Procedure Call,即遠程過程調用,它是一個計算機通信協議.它允許像調用本地服務一樣調用遠程服務.它可以有不同的實現方式.如RMI(遠程方法調用) ...

隨機推薦

  1. Intellij筆記

    環境 官網: http://www.jetbrains.com/idea/download/ 需要Java的JDK,需要安裝 JDK,而不是 JRE! http://www.oracle.com/te ...

  2. ELK——Logstash 2.2 mutate 插件【翻譯+實踐】

    官網地址 本文內容 語法 測試數據 可選配置項 mutate 插件可以在字段上執行變換,包括重命名.删除.替換和修改.這個插件相當常用. 比如: 你已經根據 Grok 錶達式將 Tomcat 日志的內 ...

  3. 【筆記】UML核心元素

    1.參與者 定義:在系統之外與系統交互的某人或某物. 特點:1.可以非人:2.與系統直接交互:3.主動發出動作並獲得反饋:4.涉眾(stakerholder)的代錶 具有兩個版型: 1.業務主角(bu ...

  4. IDEA 中使用Maven Compile 找不到本地 Jar

    本文地址:http://www.cnblogs.com/duwei/p/4656410.html 在IDEA 的子 Maven Module 中使用 compile 進行編譯, 一開始提示從私有遠程倉 ...

  5. iOS集成ApplePay

    Apple Pay正式在國內上線的那天,一起工作的小夥伴就走進了Starbucks,7-11等帶有銀聯閃付的店進行了嘗鮮.不管是否要再次輸入一次密碼,但是它的出現確實給我們帶來了極大的便捷.下面就嘗試 ...

  6. java設計模式之 裝飾器模式

    裝飾器模式 裝飾器模式(Decorator Pattern)允許向一個現有的對象添加新的功能,同時又不改變其結構. 這種類型的設計模式屬於結構型模式,它是作為現有的類的一個包裝. 這種模式創建了一個裝 ...

  7. [轉]如何用adb控制MTKLogger

    [DESCRIPTION] 如何通過adb command去控制MTKLogger 開關,設定log路徑等等. [KEYWORD] MTKLogger adb控制 路徑 大小 [SOLUTION] 1 ...

  8. Hadoop Bloom Filter 使用

    1.Bloom Filter  默認的 BloomFilter filter =new BloomFilter(10,2,1); // 過濾器長度為10 ,用2哈希函數,MURMUR_HASH (1) ...

  9. caffe安裝教程(Ubuntu14+GPU+pycaffe+anaconda2)

    caffe安裝教程 本文所使用的底層環境配置:cuda8.cudnn6.OpenCV2.4.5.anaconda2(Python2.7).如使用其他版本的環境,如cuda,可安裝自己的版本,但須在相應 ...

  10. three.js:使用createMultiMaterialObject創建的多材質對象無法使用光線跟踪Raycaster選中

    創建多材質對象: var loader = new THREE.DDSLoader(); var map = loader.load('../assets/textures/Mountains_arg ...