Java SPI机制及Sentinel中的应用:揭秘框架的可扩展设计

图片[1]-Java SPI机制及Sentinel中的应用:揭秘框架的可扩展设计 - 速优课-速优课

Java SPI机制及Sentinel中的应用:揭秘框架的可扩展设计

前言

在学习开源框架的过程中,你可能经常听到”SPI”这个词。Dubbo用了SPI,RocketMQ用了SPI,Sentinel也用了SPI。

那SPI到底是什么?它为什么这么受开源框架的青睐?Sentinel又是怎么利用SPI实现灵活扩展的?

这篇文章,我们就从Java SPI的基本概念讲起,再看看Sentinel是怎么用SPI的,最后手把手教你自定义SlotChainBuilder。

一、什么是SPI

SPI全称是Service Provider Interface,直译就是”服务提供者接口”。它是Java内置的一种服务发现机制。

简单说,SPI的本质是:

将接口实现类的全限定名写在配置文件里,由服务加载器读取配置文件,动态加载实现类。这样就能在运行时,根据配置动态替换接口的实现。

听起来有点绕,我们用一个通俗的例子来理解:

想象你买了一台台式机,主板上有各种PCIe插槽(这就是”接口”)。你可以插显卡、可以插声卡、可以插网卡(这些就是”实现类”)。你不需要改主板,只要换插槽上的卡,就能改变电脑的功能。

SPI就是这样一种”插槽机制”——框架定义好接口(插槽),你可以提供自己的实现(插卡),框架通过配置文件发现并加载你的实现。

SPI的典型应用

SPI在很多阿里开源项目中都有广泛应用:

  • Dubbo:自己实现了一套SPI(比Java原生更强大,支持key-value配置和自适应扩展)
  • RocketMQ:使用Java原生SPI
  • Sentinel:使用Java原生SPI

二、Java SPI快速入门

光说不练假把式,我们用一个”Hello World”级别的例子,快速上手Java SPI。

第一步:定义接口

假设我们有一个登录服务接口:

public interface LoginService {
    void login(String username, String password);
}

第二步:编写实现类

我们提供两种登录实现:一个基于Shiro,一个基于Spring。

public class ShiroLoginService implements LoginService {
    @Override
    public void login(String username, String password) {
        System.out.println("使用Shiro登录:" + username);
    }
}
public class SpringLoginService implements LoginService {
    @Override
    public void login(String username, String password) {
        System.out.println("使用Spring登录:" + username);
    }
}

第三步:创建配置文件

这一步是关键。在resources/META-INF/services目录下,创建一个以接口全限定名命名的文件。

文件名:com.wujiuye.spi.LoginService

文件内容:写上实现类的全限定名(多个实现类用换行分隔)

com.wujiuye.spi.ShiroLoginService
com.wujiuye.spi.SpringLoginService

小技巧:在IDEA中,只要目录和文件名正确,编写内容时IDEA会自动提示有哪些实现类,非常方便。

第四步:测试使用

public class JavaSPITest {
    public static void main(String[] args) {
        ServiceLoader<LoginService> serviceLoader = ServiceLoader.load(LoginService.class);
        for (LoginService service : serviceLoader) {
            service.login("wujiuye", "123456");
        }
    }
}

运行结果:

使用Shiro登录:wujiuye
使用Spring登录:wujiuye

ServiceLoader的工作原理

ServiceLoader是Java提供的SPI核心类。它的工作原理并不复杂:

  1. 接收一个接口类型
  2. 拼接配置文件路径:META-INF/services/ + 接口全类名
  3. 读取配置文件,解析出所有实现类的全限定名
  4. 遍历的时候,才会用Class.forName()加载类,并用反射创建实例

注意:ServiceLoader是懒加载的——调用load()方法时并不会立即加载和实例化,只有在遍历迭代器的时候才会真正加载。

SPI的适用场景

什么时候适合用SPI?

  • 责任链/拦截器/过滤器模式:需要可插拔地添加或移除处理器
  • 策略模式:多种算法实现,根据配置选择使用哪一种
  • 插件化架构:框架提供扩展点,第三方可以编写插件

举个例子:一个画图程序,定义一个Shape接口。一开始只有矩形、三角形,后来想加个圆形,只需要新增一个实现类,在配置文件里加一行,完全不用改原有代码。这就是开闭原则的完美体现。

三、SPI在Sentinel中的应用

了解了Java SPI的基本用法,我们来看看Sentinel是怎么用SPI的。

Sentinel中的两个核心SPI

sentinel-core模块的resources/META-INF/services目录下,有两个配置文件:

配置文件接口作用
com.alibaba.csp.sentinel.slotchain.SlotChainBuilderSlotChainBuilder构造Slot责任链
com.alibaba.csp.sentinel.init.InitFuncInitFunc初始化函数

两个文件的默认配置:

SlotChainBuilder的默认配置:

# Default slot chain builder
com.alibaba.csp.sentinel.slots.DefaultSlotChainBuilder

InitFunc的默认配置:

com.alibaba.csp.sentinel.metric.extension.MetricCallbackInit

两种SPI的加载策略

虽然都用了ServiceLoader加载,但Sentinel对这两个接口的加载策略不一样:

接口加载策略说明
InitFunc加载所有实现类允许多个初始化函数并存
SlotChainBuilder只加载第一个非默认实现只能有一个Slot链构建器

SlotChainBuilder的加载逻辑

SlotChainBuilder的加载逻辑在SpiLoader.loadFirstInstanceOrDefault方法中:

public final class SpiLoader {
    public static <T> T loadFirstInstanceOrDefault(Class<T> clazz, 
                                                    Class<? extends T> defaultClass) {
        try {
            // 遍历所有实现类
            for (T instance : serviceLoader) {
                // 返回第一个非默认类的实例
                if (instance.getClass() != defaultClass) {
                    return instance;
                }
            }
            // 没有自定义实现,就用默认的
            return defaultClass.newInstance();
        } catch (Throwable t) {
            return null;
        }
    }
}

逻辑很清晰:

  1. 遍历所有实现类
  2. 找到第一个不是默认实现类的,直接返回
  3. 如果遍历完都没有找到,就返回默认实现类的实例

所以,如果你写了自己的SlotChainBuilder,只要配置上了,就会优先用你的。

InitFunc的加载逻辑

InitFunc的加载逻辑在InitExecutor.doInit方法中:

public final class InitExecutor {
    public static void doInit() {
        try {
            // 加载所有InitFunc实现
            ServiceLoader<InitFunc> loader = ServiceLoaderUtil.getServiceLoader(InitFunc.class);
            List<OrderWrapper> initList = new ArrayList<OrderWrapper>();
            for (InitFunc initFunc : loader) {
                // 插入并排序
                insertSorted(initList, initFunc);
            }
            // 按顺序调用初始化方法
            for (OrderWrapper w : initList) {
                w.func.init();
            }
        } catch (Exception ex) {
            ex.printStackTrace();
        }
    }
}

InitFunc允许多个实现类并存,而且还支持排序(通过@Order注解)。

不过在实际的Web项目中,InitFunc用得不多。通常我们会监听Spring容器刷新完成事件来初始化Sentinel规则,如果用了动态数据源,还可以在配置变更时重新加载规则。

一个重要的细节:配置文件合并

有个细节需要注意:你不需要修改Sentinel源码里的配置文件。

你只需要在自己项目的resources/META-INF/services目录下,创建同名的配置文件,添加你自己的实现类就行。

项目编译后,Sentinel自带的配置文件和你项目里的配置文件会合并成一个,而不是覆盖。这样就能实现”不修改框架源码,扩展框架功能”。

四、实战:自定义SlotChainBuilder

说了这么多,我们来动手实践一下——自定义一个SlotChainBuilder,去掉不需要的Slot,优化性能。

为什么要自定义?

Sentinel默认的SlotChainBuilder会把所有Slot都加上:

  • NodeSelectorSlot
  • ClusterBuilderSlot
  • LogSlot
  • StatisticSlot
  • AuthoritySlot
  • SystemSlot
  • FlowSlot
  • DegradeSlot

但不是每个项目都需要所有功能。比如:

  • 不需要黑白名单?可以去掉AuthoritySlot
  • 不需要系统自适应限流?可以去掉SystemSlot
  • 不需要日志?可以去掉LogSlot

少一个Slot就少一次方法调用,性能就能好一点。对于性能敏感的场景,自定义SlotChainBuilder是很有价值的优化。

第一步:编写自定义Builder

假设我们只需要核心的限流和熔断功能,去掉LogSlot、AuthoritySlot、SystemSlot:

public class MySlotChainBuilder implements SlotChainBuilder {

    @Override
    public ProcessorSlotChain build() {
        ProcessorSlotChain chain = new DefaultProcessorSlotChain();
        // 节点选择(必须有)
        chain.addLast(new NodeSelectorSlot());
        // 集群节点构建(必须有)
        chain.addLast(new ClusterBuilderSlot());
        // 统计(必须有)
        chain.addLast(new StatisticSlot());
        // 限流
        chain.addLast(new FlowSlot());
        // 熔断
        chain.addLast(new DegradeSlot());
        return chain;
    }
}

第二步:配置SPI

在项目的resources/META-INF/services目录下,创建文件:

文件名:com.alibaba.csp.sentinel.slotchain.SlotChainBuilder

文件内容:

com.wujiuye.sck.provider.config.MySlotChainBuilder

第三步:注意事项

自定义SlotChainBuilder时,有些Slot的顺序是不能乱的

  • NodeSelectorSlot 必须在 ClusterBuilderSlot 前面
  • ClusterBuilderSlot 必须在 StatisticSlot 前面
  • StatisticSlot 必须在各种功能Slot前面

为什么?因为后面的Slot依赖前面构建出来的Node。顺序乱了,就会出现空指针等各种问题。

但功能类Slot之间的顺序是可以调整的,比如你可以把DegradeSlot放在FlowSlot前面,意思是”先检查熔断,再检查限流”。这个就看业务需求了。

五、Sentinel 1.7.2+的新特性

在Sentinel 1.7.2版本之后,支持了更灵活的扩展方式:直接用SPI注册ProcessorSlot,并且支持排序。

也就是说,你不需要自定义整个SlotChainBuilder了,可以单独添加一个自定义的ProcessorSlot,Sentinel会自动帮你加到链里。

这对于只想加一个自定义功能Slot的场景来说,方便太多了。

总结与思考

这篇文章我们从Java SPI的基本概念讲起,到Sentinel中SPI的应用,再到自定义SlotChainBuilder的实战。

几个关键要点回顾:

  1. SPI是一种服务发现机制:通过配置文件动态加载实现类,遵循开闭原则
  2. ServiceLoader是懒加载的:只有遍历的时候才会加载和实例化
  3. Sentinel有两个核心SPI:SlotChainBuilder(单实现)和InitFunc(多实现)
  4. 配置文件会合并:在自己项目里加配置就行,不用改框架源码
  5. 自定义SlotChainBuilder可以优化性能:去掉不需要的Slot,但要注意顺序不能乱

思考一下:

  • SPI和Spring的IoC有什么区别?(SPI是服务发现,IoC是依赖注入,两者解决的问题不同,但可以配合使用)
  • 为什么Dubbo要自己实现一套SPI,而不用Java原生的?(原生SPI功能太弱,不支持key-value、不支持自适应扩展、不支持按需加载等)
  • 除了SlotChainBuilder,你还能想到Sentinel的哪些地方可以用SPI扩展?(动态数据源、集群限流TokenServer等都用到了SPI)

SPI是一个非常实用的设计思想,掌握它不仅能帮助你更好地理解Sentinel,也能让你在自己的项目设计中,写出更具扩展性的代码。

下一篇,我们将深入Sentinel的资源指标统计实现,看看StatisticSlot是怎么工作的。

© 版权声明
THE END
喜欢就支持一下吧
点赞7
相关推荐
评论 抢沙发

请登录后发表评论

    请登录后查看评论内容

温馨提示:
1、本内容转载于网络,版权归原作者所有!
2、本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。
3、本内容若侵犯到你的版权利益,请联系我们,会尽快给予删除处理!