草庐IT

手写Dubbo SPI机制和源码解析

pq217 2023-03-28 原文

版本

2.7.8

SPI机制

官方文档介绍如下

SPI 全称为 Service Provider Interface,是一种服务发现机制。SPI 的本质是将接口实现类的全限定名配置在文件中,并由服务加载器读取配置文件,加载实现类。这样可以在运行时,动态为接口替换实现类。正因此特性,我们可以很容易的通过 SPI 机制为我们的程序提供拓展功能。

说白了,SPI是一种第三方框架常用的扩展手段:第三方框架定义接口,使用者来写实现类,通过SPI机制框架运行时可以获取到这个实现类,通过反射创建对象,并使用这个对象来继续完成业务

SPI机制

所以通过SPI机制,第三方框架可以将某一段业务逻辑交由使用者自定义实现

比如Dubbo的负载均衡策略,内置提供了多种常用策略(Random/RoundRobin..),但依然无法满足所有用户的需求

通过SPI机制,可以让用户自己实现负载均衡策略,Dubbo再发送RPC调用时通过SPI获取到用户实现的策略,并使用这个策略来决策最终选择调用的服务端

Java SPI

jdk提供了简单的SPI功能,可以再运行时获取某接口的所有扩展实现类,比如,定义一个接口:Car

public interface Car {
    void run();
}

定义两个实现:Audi(奥迪)和Buick(别克)

public class Audi implements Car {
    @Override
    public void run() {
        System.out.println("Audi is running");
    }
}
public class Buick implements Car {
    @Override
    public void run() {
        System.out.println("Buick is running");
    }
}

在 META-INF/services 文件夹下创建一个文件,名称为 Car 的全限定名 com.pq.pure.spi.Car。文件内容为实现类的全限定的类名,如下:

com.pq.pure.spi.Buick
com.pq.pure.spi.Audi

Java SPI Dubbo SPI 的相关逻辑被封装在了ServiceLoader下,测试一下

public class SPITest {
    @Test
    public void run() {
        ServiceLoader<Car> serviceLoader = ServiceLoader.load(Car.class);
        serviceLoader.forEach(Car::run);
    }
}

输出如下

JAVA SPI

成功加载了两个实现类,并实例化且循环执行了run方法

Dubbo SPI

Java提供的SPI可以获取某个接口的所有实现,一般后续代码就是全部循环执行,可以新增,但不能只指定其中某一个执行,比较使用的场景比如~后置处理器

而Dubbo需要的场景一般是从接口的实现中指定某一个(比如从多个负载均衡器中选用一个)去执行,这种场景Java SPI就很难实现了,所以Dubbo自己实现了一套SPI机制:

  • 可以给每个实现取一个名字
  • 可以按名字获取对应的实现

可以理解为JAVA SPI的所有实现是一个LIST,可以循环但不能指定某一个,而Dubbo SPI所有实现是一个MAP,可以根据key获取指定的一个

Dubbo SPI

接下来就测试一下Dubbo SPI的使用,还是刚才的一个接口和两个实现,在
META-INF/dubbo目录下文件夹下以 Car 的全限定名创建文件,内容如下

Buick=com.pq.pure.spi.Buick
Audi=com.pq.pure.spi.Audi

等号前面是key后面是value

Dubbo SPI 的相关逻辑被封装在了ExtensionLoader类中,测试一下(需要给Car接口加上@SPI注解)

public class DubboSPITest {
    public static void main(String[] args) {
        // 初始化Car接口的扩展类加载器
        ExtensionLoader<Car> extensionLoader = ExtensionLoader.getExtensionLoader(Car.class);
        // 获取名为"Buick"的扩展实现
        Car buick = extensionLoader.getExtension("Buick");
        // 运行
        buick.run();
    }
}

运行结果如下

Buick

这就是Dubbo SPI的更强的地方,可以按名称获取某一个实现,这样就可以实现通过配置来切换实现,也方便自定义扩展实现

模拟实现

接下来再深度研究下Dubbo SPI如何做到的

首先如果自己去实现,如何做到?大概整理一下实现流程,其实很简单

  • 约定一个地址,让用户去里面定义文件,以key=value的形式填写实现名称和全限定名
  • 当获取某个接口的某个名称实现时,去约定地址下读取名为接口全限定名文件,扫描文件内容,获取到key为该名称的value(即实现的全限定名),通过类加载器加载这个类,然后通过反射创建实例返回

思路屡清了,很简单,接下来尝试实现一下

做一个接口扩展加载器,用泛型代表接口类型,并约定好扩展文件地址: META-INF/pq下

public class ExtensionLoader<T> {
    /**
     * 接口的类
     */
    private final Class<T> type;

    /**
     * 约定好的地址
     */
    private final static String dir = "META-INF/pq/";

    public ExtensionLoader(Class<T> type) {
        this.type = type;
    }
}

在约定路径 META-INF/pq 下创建扩展文件com.pq.pure.spi.Car,内容与之前一样

com.pq.pure.spi.Car

实现类加载功能,即把扩展文件中的内容转换为MAP内存结构

MAP内存结构

代码如下

/**
 * 获取当前接口的扩展类,即把扩展文件中的的数据转换为map结构
 *
 * @return
 */
private Map<String, Class<?>> loadExtensionClasses() {
    try {
        // 存储结果
        Map<String, Class<?>> extensionClasses = new HashMap<>();
        // 扩展文件名称
        String fileName = dir + type.getName();
        // jdk 类加载器
        ClassLoader classLoader = Thread.currentThread().getContextClassLoader();
        // 加载配置文件
        Enumeration<URL> urls = classLoader.getResources(fileName);

        if (urls != null) {
            while (urls.hasMoreElements()) {
                URL resourceURL = urls.nextElement();
                loadResource(extensionClasses, classLoader, resourceURL);
            }
        }
        return extensionClasses;
    } catch (Throwable t) {
        throw new IllegalStateException();
    }
}

/**
 * 读取扩展文件
 * @param extensionClasses
 * @param classLoader
 * @param resourceURL
 */
private void loadResource(Map<String, Class<?>> extensionClasses, ClassLoader classLoader,
                          URL resourceURL) {
    try {
        try (BufferedReader reader = new BufferedReader(new InputStreamReader(resourceURL.openStream(), StandardCharsets.UTF_8))) {
            String line;
            // 逐行读取
            while ((line = reader.readLine()) != null) {
                line = line.trim();
                if (line.length() > 0) {
                    // 找到等号
                    int i = line.indexOf('=');
                    // name
                    String name = line.substring(0, i).trim();
                    // 实现类全限定名
                    line = line.substring(i + 1).trim();
                    extensionClasses.put(name, Class.forName(line, true, classLoader));
                }
            }
        }
    } catch (Throwable t) {
       throw new IllegalStateException();
    }
}

主要用到了类加载的功能,和文件读取的一些逻辑

最后,根据名字获取某个实现的实例,很简单,从map中读取class,反射实例化即可

/**
 * 根据name获取实现实例
 * @param name
 * @return
 */
public T getExtension(String name) {
    // todo 省去了缓存逻辑
    // 获取改名字的实现类
    Class<?> clazz = loadExtensionClasses().get(name);
    try {
        // 反射实例化
        return (T) clazz.newInstance();
    } catch (Exception e) {
        throw new IllegalStateException();
    }
}

测试一下

package com.pq.pure.spi;

import com.pq.pure.spi.extension.ExtensionLoader;

public class MyDubboTest {
    public static void main(String[] args) {
        ExtensionLoader<Car> extensionLoader = new ExtensionLoader(Car.class);
        // 获取别克的实现
        Car buick = extensionLoader.getExtension("Audi");
        // 运行
        buick.run(); // 输出 Audi is running
    }
}

至此就实现了一个运行时按名称获取对应实现的SPI功能~

源码

回头看dubbo的实现源码,其实上一步的模拟实现就是从源码摘抄的主线代码,但是省略了很多功能、异常校验、线程安全、缓存等,下面来学习下Dubbo源码的优秀写法

缓存

因为读取文件,反射这些代码都是比较耗时的,而且读取一次之后完全可以缓存起来下次直接使用,所以Dubbo的ExtensionLoader源码中包含大量的缓存逻辑,比如

// 缓存每个可扩展接口的扩展加载器,是一个静态全局缓存
static ConcurrentMap<Class<?>, ExtensionLoader<?>> EXTENSION_LOADERS
// 缓存当前扩展加载器每个扩展实现类的实例
ConcurrentMap<Class<?>, Object> EXTENSION_INSTANCES
// 缓存当前扩展加载器每个实现名称对应的实现类
Holder<Map<String, Class<?>>> cachedClasses
// 缓存当前扩展加载器每个实现名称对应的实现类实例
ConcurrentMap<String, Holder<Object>> cachedInstances
配置路径

在我们的模拟实现中使用一个静态变量来约定配置的路径:

private final static String dir = "META-INF/pq/";

而dubbo的实现更具有扩展性,并且是使用Java SPI来实现这种扩展性

首先dubbo定义了一个加载策略的接口

LoadingStrategy

最重要的方法就是directory(),返回的就是配置路径
在ExtensionLoader类中存放静态的加载策略实现数组

public class ExtensionLoader<T> {
    //...
    private static volatile LoadingStrategy[] strategies = loadLoadingStrategies();
    private static LoadingStrategy[] loadLoadingStrategies() {
        // 通过JAVA SPI加载策略实现类
        return stream(ServiceLoader.load(LoadingStrategy.class).spliterator(), false)
                .sorted()
                .toArray(LoadingStrategy[]::new);
    }
    //...
}

可以到Dubbo jar包中META-INF/services下找到其实现配置文件

LoadingStrategy扩展配置文件
内容

内置三个策略,分别对应"META-INF/dubbo/internal/","META-INF/dubbo/","META-INF/services/"三个路径,而且可以继续扩展

AOP

Dubbo SPI还支持面向切面编程,回到Dubbo SPI那个例子,我们的Car接口有两种实现:别克&奥迪,如果有个需求是不管使用什么车,都要加一个行车记录仪,这时只需要加一个行车记录仪的装饰器即可

public class CarRecorderWrapper implements Car {

    private Car car;

    public CarRecorderWrapper(Car car) {
        this.car = car;
    }

    @Override
    public void run() {
        car.run();
        System.out.println("driving recorder");
    }
}

配置文件也加入

CarRecorder=com.pq.pure.spi.CarRecorderWrapper

这时再次运行原测试用例,结果如下

AOP

而其实现方式:再扫描实现时看看你的这个类是不是装饰器类,如果是,就缓存起来

loadClass

而是否是装饰器类就是看是否有以该接口为参数的构造方法

isWrapperClass

熟悉装饰器模式的人应该一看就懂
再实例化时createExtension,会使用所有装饰器对其装饰一遍,以达到代理的目的

createExtension
线程安全

这事挺不足道的,但感觉写的挺巧妙就研究一下

源码中线程安全考虑主要是对以上这些缓存,比如 cachedInstances 保存的是一个名称到实例映射的 ConcurrentMap,但是依然会出问题:

比如两个线程同时要获取相同名称的实例,发现缓存中没有,于是两个线程同时开始进行读取配置创建实例一系列操作,但最终只有先实例化完的线程成功的把实例存入缓存(使用putIfAbsent),而另一个线程就耗费了时间和资源去实例化了一个无用的对象

线程时序图1

一般自己写代码这种情况其实也可以接受,至少不会出现bug,只是有点性能浪费

如果想保证只有一个线程加载实例化,用锁即可,比如用synchronized给cachedInstances上锁就可以解决,但是锁的粒度太大,会导致其它实现的实例化过程都被阻塞

Dubbo解决问题的方法还是挺巧妙的:

如果细心看可以发现cachedInstances存储的并不是 ConcurrentMap<String, Object> 而是 ConcurrentMap<String, Holder<Object>>,使用了一个Holder来存放对象,看一下Holder类

public class Holder<T> {

    private volatile T value;
    public void set(T value) {
        this.value = value;
    }
    public T get() {
        return value;
    }
}

就是一个存放对象的类,使用volatile保证对象的可见性,初看感觉这个类莫名其妙,继续往下看getExtension

getExtension
getOrCreateHolder

以上代码逻辑:当获取某名称实例时,会走getOrCreateHolder方法在缓存中该名称位置存放一个空Holder对象,且只有第一个线程创建,其它线程只是获取,该方法返回holder对象,通过synchronized给holder上锁,然后检查是否为空,如果为空就创建,这样就保证只有一个线程会实际执行实例化的代码,而且synchronized的锁粒度只是当前名称的实现,不妨碍其它实现的实例化

线程时序图2

所以可以说Holder的存在就是为了控制synchronized的锁粒度

其它

DUBBO SPI还支持IOC,并且涉及到一个扩展点自适应机制,相对复杂一点,留下一篇研究~

有关手写Dubbo SPI机制和源码解析的更多相关文章

  1. Ruby 解析字符串 - 2

    我有一个字符串input="maybe(thisis|thatwas)some((nice|ugly)(day|night)|(strange(weather|time)))"Ruby中解析该字符串的最佳方法是什么?我的意思是脚本应该能够像这样构建句子:maybethisissomeuglynightmaybethatwassomenicenightmaybethiswassomestrangetime等等,你明白了......我应该一个字符一个字符地读取字符串并构建一个带有堆栈的状态机来存储括号值以供以后计算,还是有更好的方法?也许为此目的准备了一个开箱即用的库?

  2. ruby - 解析 RDFa、微数据等的最佳方式是什么,使用统一的模式/词汇(例如 schema.org)存储和显示信息 - 2

    我主要使用Ruby来执行此操作,但到目前为止我的攻击计划如下:使用gemsrdf、rdf-rdfa和rdf-microdata或mida来解析给定任何URI的数据。我认为最好映射到像schema.org这样的统一模式,例如使用这个yaml文件,它试图描述数据词汇表和opengraph到schema.org之间的转换:#SchemaXtoschema.orgconversion#data-vocabularyDV:name:namestreet-address:streetAddressregion:addressRegionlocality:addressLocalityphoto:i

  3. ruby - 用逗号、双引号和编码解析 csv - 2

    我正在使用ruby​​1.9解析以下带有MacRoman字符的csv文件#encoding:ISO-8859-1#csv_parse.csvName,main-dialogue"Marceu","Giveittohimóhe,hiswife."我做了以下解析。require'csv'input_string=File.read("../csv_parse.rb").force_encoding("ISO-8859-1").encode("UTF-8")#=>"Name,main-dialogue\r\n\"Marceu\",\"Giveittohim\x97he,hiswife.\"\

  4. ruby-on-rails - 我更新了 ruby​​ gems,现在到处都收到解析树错误和弃用警告! - 2

    简而言之错误:NOTE:Gem::SourceIndex#add_specisdeprecated,useSpecification.add_spec.Itwillberemovedonorafter2011-11-01.Gem::SourceIndex#add_speccalledfrom/opt/local/lib/ruby/site_ruby/1.8/rubygems/source_index.rb:91./opt/local/lib/ruby/gems/1.8/gems/rails-2.3.8/lib/rails/gem_dependency.rb:275:in`==':und

  5. UE4 源码阅读:从引擎启动到Receive Begin Play - 2

    一、引擎主循环UE版本:4.27一、引擎主循环的位置:Launch.cpp:GuardedMain函数二、、GuardedMain函数执行逻辑:1、EnginePreInit:加载大多数模块int32ErrorLevel=EnginePreInit(CmdLine);PreInit模块加载顺序:模块加载过程:(1)注册模块中定义的UObject,同时为每个类构造一个类默认对象(CDO,记录类的默认状态,作为模板用于子类实例创建)(2)调用模块的StartUpModule方法2、FEngineLoop::Init()1、检查Engine的配置文件找出使用了哪一个GameEngine类(UGame

  6. ruby - 用 YAML.load 解析 json 安全吗? - 2

    我正在使用ruby2.1.0我有一个json文件。例如:test.json{"item":[{"apple":1},{"banana":2}]}用YAML.load加载这个文件安全吗?YAML.load(File.read('test.json'))我正在尝试加载一个json或yaml格式的文件。 最佳答案 YAML可以加载JSONYAML.load('{"something":"test","other":4}')=>{"something"=>"test","other"=>4}JSON将无法加载YAML。JSON.load("

  7. ruby - 如何使用 Nokogiri 解析纯 HTML 表格? - 2

    我想用Nokogiri解析HTML页面。页面的一部分有一个表,它没有使用任何特定的ID。是否可以提取如下内容:Today,3,455,34Today,1,1300,3664Today,10,100000,3444,Yesterday,3454,5656,3Yesterday,3545,1000,10Yesterday,3411,36223,15来自这个HTML:TodayYesterdayQntySizeLengthLengthSizeQnty345534345456563113003664354510001010100000344434113622315

  8. python - 帮我找到合适的 ruby​​/python 解析器生成器 - 2

    我使用的第一个解析器生成器是Parse::RecDescent,它的指南/教程很棒,但它最有用的功能是它的调试工具,特别是tracing功能(通过将$RD_TRACE设置为1来激活)。我正在寻找可以帮助您调试其规则的解析器生成器。问题是,它必须用python或ruby​​编写,并且具有详细模式/跟踪模式或非常有用的调试技术。有人知道这样的解析器生成器吗?编辑:当我说调试时,我并不是指调试python或ruby​​。我指的是调试解析器生成器,查看它在每一步都在做什么,查看它正在读取的每个字符,它试图匹配的规则。希望你明白这一点。赏金编辑:要赢得赏金,请展示一个解析器生成器框架,并说明它的

  9. ruby - 如何用 Nokogiri 解析连续的标签? - 2

    我有这样的HTML代码:Label1Value1Label2Value2...我的代码不起作用。doc.css("first").eachdo|item|label=item.css("dt")value=item.css("dd")end显示所有首先标记,然后标记标签,我需要“标签:值” 最佳答案 首先,您的HTML应该有和中的元素:Label1Value1Label2Value2...但这不会改变您解析它的方式。你想找到s并遍历它们,然后在每个你可以使用next_element得到;像这样:doc=Nokogiri::HTML(

  10. ruby-on-rails - 如何在 Rails 3 中禁用 XML 解析 - 2

    我想禁用HTTP参数的自动XML解析。但我发现命令仅适用于Rails2.x,它们都不适用于3.0:config.action_controller.param_parsers.deleteMime::XML(application.rb)ActionController::Base.param_parsers.deleteMime::XMLRails3.0中的等价物是什么? 最佳答案 根据CVE-2013-0156的最新安全公告你可以将它用于Rails3.0。3.1和3.2ActionDispatch::ParamsParser::

随机推荐