07 Java SPI 及 SPI 在 Sentinel 中的应用 SPI 全称是 Service Provider Interface,直译就是服务提供者接口,是一种服务发现机制,是 Java 的一个内置标准,允许不同的开发者去实现某个特定的服务。SPI 的本质是将接口实现类的全限定名配置在文件中,由服务加载器读取配置文件,加载实现类,实现在运行时动态替换接口的实现类。

使用 SPI 机制能够实现按配置加载接口的实现类,SPI 机制在阿里开源的项目中被广泛使用,例如 Dubbo、RocketMQ、以及本文介绍的 Sentinel。RocketMQ 与 Sentinel 使用的都是 Java 提供的 SPI 机制,而 Dubbo 则是使用自实现的一套 SPI,与 Java SPI 的配置方式不同,Dubbo SPI 使用 Key-Value 方式配置,目的是实现自适应扩展机制。

Java SPI 简介

我们先来个“Hello Word”级别的 Demo 学习一下 Java SPI 怎么使用,通过这个例子认识 Java SPI。

第一步:定义接口

假设我们有多种登录方式,则创建一个 LoginService 接口。 public interface LoginService{ void login(String username,String password); }

第二步:编写接口实现类

假设一开始我们使用 Shiro 框架实现用户鉴权,提供了一个 ShiroLoginService。 public class ShiroLoginService implements LoginService{ public void login(String username,String password){ // 实现登陆 } }

现在我们不想搞那么麻烦,比如我们可以直接使用 Spring MVC 的拦截器实现用户鉴权,那么可以提供一个 SpringLoginService。

public class SpringLoginService implements LoginService{ public void login(String username,String password){ // 实现登陆 } }

第三步:通过配置使用 SpringLoginService 或 ShiroLoginService。

当我们想通过修改配置文件的方式而不修改代码实现权限验证框架的切换,就可以使用 Java 的 SPI。通过运行时从配置文件中读取实现类,加载使用配置的实现类。

需要在 resources 目录下新建一个目录 META-INF,并在 META-INF 目录下创建 services 目录,用来存放接口配置文件。

配置文件名为接口 LoginService 全类名,文件中写入使用的实现类的全类名。只要是在 META-INF/services 目录下,只要文件名是接口的全类名,那么编写配置文件内容的时候,IDEA 就会自动提示有哪些实现类。

文件:/resources/META-INF/services/接口名称,填写的内容为接口的实现类,多个实现类使用换行分开。 com.wujiuye.spi.ShiroLoginService

第四步:编写 main 方法测试使用 Java SPI 加载 LoginService。

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

ServiceLoader(服务加载器)是 Java 提供的 SPI 机制的实现,调用 load 方法传入接口名就能获取到一个 ServiceLoader 实例,此时配置文件中注册的实现类是还没有加载到 JVM 的,只有通过 Iterator 遍历获取的时候,才会去加载实现类与实例化实现类。

需要说明的是,例子中配置文件只配置了一个实现类,但其实我们是可以配置 N 多个的,并且 iterator 遍历的顺序就是配置文件中注册实现类的顺序。如果非要想一个注册多实现类的适用场景的话,责任链(拦截器、过滤器)模式这种可插拔的设计模式最适合不过。又或者一个画图程序,定义一个形状接口,实现类可以有矩形、三角形等,如果后期添加了圆形,只需要在形状接口的配置文件中注册圆形就能支持画圆形,完全不用修改任何代码。

ServiceLoader 源码很容易理解,就是根据传入的接口获取接口的全类名,将前缀“/META-INF/services”与接口的全类名拼接定位到配置文件,读取配置文件中的字符串、解析字符串,将解析出来的实现类全类名添加到一个数组,返回一个 ServiceLoader 实例。只有在遍历迭代器的时候 ServiceLoader 才通过调用 Class/#forName 方法加载类并且通过反射创建实例,如果不指定类加载器,就使用当前线程的上下文类加载器加载类。

Java SPI 在 Sentinel 中的应用

在 sentinel-core 模块的 resources 资源目录下,有一个 META-INF/services 目录,该目录下有两个以接口全名命名的文件,其中 com.alibaba.csp.sentinel.slotchain.SlotChainBuilder 文件用于配置 SlotChainBuilder 接口的实现类,com.alibaba.csp.sentinel.init.InitFunc 文件用于配置 InitFunc 接口的实现类,并且这两个配置文件中都配置了接口的默认实现类,如果我们不添加新的配置,Sentinel 将使用默认配置的接口实现类。

com.alibaba.csp.sentinel.init.InitFunc 文件的默认配置如下: com.alibaba.csp.sentinel.metric.extension.MetricCallbackInit

com.alibaba.csp.sentinel.slotchain.SlotChainBuilder 文件的默认配置如下:

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

ServiceLoader 可加载接口配置文件中配置的所有实现类并且使用反射创建对象,但是否全部加载以及实例化还是由使用者自己决定。Sentinel 的 core 模块使用 Java SPI 机制加载 InitFunc 与 SlotChainBuilder 的实现上稍有不同,如果 InitFunc 接口的配置文件注册了多个实现类,那么这些注册的 InitFunc 实现类都会被 Sentinel 加载、实例化,且都会被使用,但 SlotChainBuilder 不同,如果注册多个实现类,Sentinel 只会加载和使用第一个。

调用 ServiceLoader/#load 方法传入接口可获取到一个 ServiceLoader 实例,ServiceLoader 实现了 Iterable 接口,所以可以使用 forEach 语法遍历,ServiceLoader 使用 layz 方式实现迭代器(Iterator),只有被迭代器的 next 方法遍历到的类才会被加载和实例化。如果只想使用接口配置文件中注册的第一个实现类,那么可在使用迭代器遍历时,使用 break 跳出循环。

Sentinel 在加载 SlotChainBuilder 时,只会获取第一个非默认(非 DefaultSlotChainBuilder)实现类的实例,如果接口配置文件中除了默认实现类没有注册别的实现类,则 Sentinel 会使用这个默认的 SlotChainBuilder。其实现源码在 SpiLoader 的 loadFirstInstanceOrDefault 方法中,代码如下。 public final class SpiLoader { public static T loadFirstInstanceOrDefault(Class clazz, Class<? extends T> defaultClass) { try { // 缓存的实现省略... // 返回第一个类型不等于 defaultClass 的实例 // ServiceLoader 实现了 Iterable 接口 for (T instance : serviceLoader) { // 获取第一个非默认类的实例 if (instance.getClass() != defaultClass) { return instance; } } // 没有则使用默认类的实例,反射创建对象 return defaultClass.newInstance(); } catch (Throwable t) { return null; } } }

Sentinel 加载 InitFunc 则不同,因为 Sentinel 允许存在多个初始化方法。InitFunc 可用于初始化配置限流、熔断规则,但在 Web 项目中我们基本不会使用它,更多的是通过监听 Spring 容器刷新完成事件再去初始化为 Sentinel 配置规则,如果使用动态数据源还可在监听到动态配置改变事件时重新加载规则,所以 InitFunc 我们基本使用不上。

Sentinel 使用 ServiceLoader 加载注册的 InitFunc 实现代码如下: public final class InitExecutor { public static void doInit() { try { // 加载配置 ServiceLoader loader = ServiceLoaderUtil.getServiceLoader(InitFunc.class); List initList = new ArrayList(); for (InitFunc initFunc : loader) { // 插入数组并排序,同时将 InitFunc 包装为 OrderWrapper insertSorted(initList, initFunc); } // 遍历调用 InitFunc 的初始化方法 for (OrderWrapper w : initList) { w.func.init(); } } catch (Exception ex) { ex.printStackTrace(); } catch (Error error) { error.printStackTrace(); } } }

虽然 InitFunc 接口与 SlotChainBuilder 接口的配置文件在 sentinel-core 模块下,但我们不需要去修改 Sentinel 的源码,不需要修改 sentinel-core 模块下的接口配置文件,而只需要在当前项目的 /resource/META-INF/services 目录下创建一个与接口全名相同名称的配置文件,并在配置文件中添加接口的实现类即可。项目编译后不会覆盖 sentinel-core 模块下的相同名称的配置文件,而是将两个配置文件合并成一个配置文件。

自定义 ProcessorSlotChain 构造器

Sentinel 使用 SlotChainBuilder 将多个 ProcessorSlot 构造成一个 ProcessorSlotChain,由 ProcessorSlotChain 按照 ProcessorSlot 的注册顺序去调用这些 ProcessorSlot。Sentinel 使用 Java SPI 加载 SlotChainBuilder 支持使用者自定义 SlotChainBuilder,相当于是提供了插件的功能。

Sentinel 默认使用的 SlotChainBuilder 是 DefaultSlotChainBuilder,其源码如下: public class DefaultSlotChainBuilder implements SlotChainBuilder { @Override public ProcessorSlotChain build() { ProcessorSlotChain chain = new DefaultProcessorSlotChain(); chain.addLast(new NodeSelectorSlot()); chain.addLast(new ClusterBuilderSlot()); chain.addLast(new LogSlot()); chain.addLast(new StatisticSlot()); chain.addLast(new AuthoritySlot()); chain.addLast(new SystemSlot()); chain.addLast(new FlowSlot()); chain.addLast(new DegradeSlot()); return chain; } }

DefaultSlotChainBuilder 构造的 ProcessorSlotChain 注册了 NodeSelectorSlot、ClusterBuilderSlot、LogSlot、StatisticSlot、AuthoritySlot、SystemSlot、FlowSlot、DegradeSlot,但这些 ProcessorSlot 并非都是必须的,如果注册的这些 ProcessorSlot 有些我们用不到,那么我们可以自己实现一个 SlotChainBuilder,自己构造 ProcessorSlotChain。例如,我们可以将 LogSlot、AuthoritySlot、SystemSlot 去掉。

第一步,编写 MySlotChainBuilder,实现 SlotChainBuilder 接口,代码如下: 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; } }

第二步,在当前项目的 /resources/META-INF/services 目录下添加名为 com.alibaba.csp.sentinel.slotchain.SlotChainBuilder 的接口配置文件,并在配置文件中注册 MySlotChainBuilder。

com.wujiuye.sck.provider.config.MySlotChainBuilder

在构造 ProcessorSlotChain 时,需注意 ProcessorSlot 的注册顺序,例如,NodeSelectorSlot 需作为 ClusterBuilderSlot 的前驱节点,ClusterBuilderSlot 需作为 StatisticSlot 的前驱节点,否则 Sentinel 运行会出现 bug。但你可以将 DegradeSlot 放在 FlowSlot 的前面,这就是我们上一篇说到的 ProcessorSlot 的排序。

总结

Sentinel 使用 Java SPI 为我们提供了插件的功能,也类似于 Spring Boot 提供的自动配置类注册功能。我们可以直接替换 Sentinel 提供的默认 SlotChainBuilder,使用自定义的 SlotChainBuilder 自己构造 ProcessorSlotChain,以此实现修改 ProcessorSlot 排序顺序以及增加或移除 ProcessorSlot。在 Sentinel 1.7.2 版本中,Sentinel 支持使用 SPI 注册 ProcessorSlot,并且支持排序。

参考资料

https://learn.lianglianglee.com/%e4%b8%93%e6%a0%8f/%e6%b7%b1%e5%85%a5%e7%90%86%e8%a7%a3%20Sentinel%ef%bc%88%e5%ae%8c%ef%bc%89/07%20Java%20SPI%20%e5%8f%8a%20SPI%20%e5%9c%a8%20Sentinel%20%e4%b8%ad%e7%9a%84%e5%ba%94%e7%94%a8.md