关于SPI机制,你想知道的都在这里

什么是SPI

    SPI 是一种服务接口扩展的机制,全名service provider interface。通常由JDK定义接口,第三方做接口的实现,它的核心类是java.util.ServiceLoader。

SPI出现的背景

目的是将服务的定义与服务实现分离以达到解耦,从而提升程序可扩展性的机制。当我们开发一套框架、一套机制或者一套API的时候,如果需要第三方的服务支持(接口实现或者抽象类的实现),可以直接写死到代码里,但是这种方式的耦合性太强,不利于多个三方服务的切换。比较好的办法是通过配置文件去指定服务的实现方,这时候SPI机制就体现出价值了。

SPI应用场景示例

Java中的SPI

比如我们经常使用的数据库驱动,由JDK提供统一的规范接口(java.sql.Driver),不同数据库的服务商针对该接口提供各自的数据库处理逻辑实现。当用到数据库时,直接引入不同的SPI服务实现即可。如图所示:

  1. 服务提供者定义了接口标准,如:数据库驱动提供了Java.sql.Driver的接口规范。
  2. 第三方厂商针对该接口,提供自己的实现。
  3. 在项目jar包的META-INF/services目录下,创建一个文本文件,名称为接口的全名(包路径+接口名),内容为实现类的全名,具体见下图:

Oracle驱动

MySQL驱动

  1. 服务调用者引入对应的jar包,放到classpath路径下。
  2. 服务调用者(客户端)通过java.util.ServiceLoader来动态加载数据库驱动实现,具体就是扫描classpath下所有的jar包中的META-INF/services目录下,按照约束格式定义的文件,把文件中指定的实现类进行加载。

上面重复提到了META-INF/services 这个路径,有人可能会有疑问为啥一定要在这个路径下搞事情,那么接下来看下ServiceLoader源码。

ServiceLoader部分源码解析

在该类的第一行便指定了接口实现类的配置路径

  1. public final class ServiceLoader<S>  implements Iterable<S>  
  2. {  
  3.    //定义了配置文件路径
  4.     private static final String PREFIX = "META-INF/services/";  
  5.   
  6. }  

再看下源码中如何读取META-INF/services/路径下配置文件的

  1. try {  
  2.        String fullName = PREFIX + service.getName();  
  3.        if (loader == null)  
  4.             configs = ClassLoader.getSystemResources(fullName);  
  5.        else  
  6.             configs = loader.getResources(fullName);  
  7.      } catch (IOException x) {  
  8.        fail(service, "Error locating configuration files", x);  
  9. }  

如上代码在Serviceloader类的子类LazyIterator(懒加载器)中的代码段,会扫描所有jar包中的配置文件,然后解析全限定名,获得实现类路径,然后在后续的遍历中通过Class.forName()进行实例化。

ServiceLoader基本操作流程如下(源码就不贴出来了):

  1. 通过ServiceLoader的load(Class<S> service)方法进入程序内部;
  2. 上面load方法内获得当前线程的ClassLoader,并再此调用内部的load(Class<S> service,lassLoader loader)方法,该方法内会创建ServiceLoader对象,并初始化一些常量。
  3. ServiceLoader的构造方法内会调用reload方法,来清理缓存,初始化LazyIterator,注意此处是懒加载,此时并不会去加载文件下的内容。
  4. 当遍历器被遍历时,才会去读取配置文件。
  5. 最终被加载的实现类都会缓存到内存中,已经加载过的不重复加载。

模拟实现SPI

我们可以定义自己的一套接口规范,通过SPI的方式对接口进行不同实现。

首先我们创建一个客户端maven工程命名为driver-custom,由该工程定义一个接口规范:

在该工程中我们创建一个DatabaseDriver接口,代码如下:

  1. package com.demo.spi;  
  2.   
  3. /** 
  4.  * @Description: 数据库驱动 
  5.  * @Author xu
  6.  * @Date 2019-12-14 
  7.  * @Version V1.0 
  8.  */  
  9. public interface DatabaseDriver {  
  10.     /** 
  11.       * @Author xu 
  12.       * @Description 创建数据库连接 
  13.       * @Date 2019-12-14 15:05 
  14.       * @Param [text] 
  15.       * @return java.lang.String 
  16.      */  
  17.     String buildConnection(String text);  
  18.   
  19. }  

然后我们创建一个服务实现的工程,用来实现DatabaseDriver接口,工程命名为oracle-driver。该工程引入客户端工程jar包。

  1. <dependency>  
  2.       <groupId>com.demo.spi</groupId>  
  3.       <artifactId>driver-custom</artifactId>  
  4.       <version>1.0-SNAPSHOT</version>  
  5.  </dependency> 

实现类OracleDriver代码如下(输出简单语句):

  1. package com.demo.spi;  
  2.   
  3. /** 
  4.  * @Description: TODO 
  5.  * @Author xuzhiyuan 
  6.  * @Date 2019-12-14 
  7.  * @Version V1.0 
  8.  */  
  9. public class OracleDriver implements DatabaseDriver{  
  10.     public String buildConnection(String s) {  
  11.         return "Oracle driver"+s;  
  12.     }  
  13. }  

同时需要在该工程resources目录下创建META-INF/services路径,在该路径下以客户端接口全限定名创建文本文件,在该文件中写入实现类的全限定名称。具体如图所示:

两个工程创建好之后,我们来进行下测试,为了少创建一个工程,我们使用客户端工程写一个程序调用入口。这里注意客户端工程需要引用服务端工程的jar包,两个工程jar包进行了相互引用,在实现开发中应该避免这种情况。

程序调用入口代码如下:

  1. public class App {  
  2.     public static void main(String[] args) {  
  3.         //加载获取接口实现类,(如果有多个jar包,会加载多个实现类)  
  4.         ServiceLoader<DatabaseDriver> serviceLoader = ServiceLoader.load(DatabaseDriver.class);  
  5.         for(DatabaseDriver databaseDriver:serviceLoader){  
  6.             System.out.println(databaseDriver.buildConnection(" test"));  
  7.         }  
  8.     }  
  9. }  

最后运行App,控制台输出运行结果:Oracle driver test

通过以上可以看出,JDK本身提供的SPI机制存在一定缺陷,它会加载classpath下面的所有文件,而不是用哪个去指定加载哪个。

接下来我们看下常用的Dubbo框架是如何使用SPI机制的。

Dubbo中的SPI

Dubbo SPI机制的体现

Dubbo大家肯定不陌生,我们大多数服务都是基于Dubbo实现的。作为一款高性能RPC框架,通过很好的扩展机制,形成了丰富的核心RPC生态。

  1. 服务注册中心发现支持Nacos、etcd、zookeeper、Consul
  2. 配置中心支持Apollo、Nacos、zookeeper、etcd
  3. 协议层支持REST、JSONRPC、Dubbo、Http等
  4. 序列化Hession2、fast-json、Kryo、Java serialize等
  5. 断路器(高可用组件)支持Hystrix、Sentinel、Resilience4j

以上可见Dubbo本身定义的协议可以支持很多主流第三方组件的扩展,同时开发者也可以自定义Dubbo协议进行扩展。

    扩展点加载是Dubbo的核心功能点,官网对该功能点做了如下描述:

来源:

Dubbo 的扩展点加载从 JDK 标准的 SPI (Service Provider Interface) 扩展点发现机制加强而来。

Dubbo 改进了 JDK 标准的 SPI 的以下问题:

  1. JDK 标准的 SPI 会一次性实例化扩展点所有实现,如果有扩展实现初始化很耗时,但如果没用上也加载,会很浪费资源。
  2. 如果扩展点加载失败,连扩展点的名称都拿不到了。比如:JDK 标准的 ScriptEngine,通过 getName() 获取脚本类型的名称,但如果 RubyScriptEngine 因为所依赖的 jruby.jar 不存在,导致 RubyScriptEngine 类加载失败,这个失败原因被吃掉了,和 ruby 对应不起来,当用户执行 ruby 脚本时,会报不支持 ruby,而不是真正失败的原因。
  3. 增加了对扩展点 IoC AOP 的支持,一个扩展点可以直接 setter 注入其它扩展点。

约定:

在扩展类的 jar 包内 [1],放置扩展点配置文件 META-INF/dubbo/接口全限定名,内容为:配置名=扩展实现类全限定名,多个实现类用换行符分隔。

 

官网链接:http://dubbo.apache.org/zh-cn/docs/dev/SPI.html

        在Dubbo官网,开发者指南中有一个 “SPI扩展实现” 的章节针对Dubbo所有功能点的扩展进行了讲解,我就不在这里赘述了。我针对自定义协议扩展和调用拦截扩展写两个demo说明下。

自定义协议扩展

        比如现在我们对协议进行扩展,实现自己的功能(Dubbo实现的Dubbo协议、Http协议,Rest协议等太高端,满足不了我低端需求的时候),我要自定义自己的协议,这里只更改下协议的默认版本号,方便查看测试效果。

创建一个名为Myprotocol的类,实现Protocol接口,设置默认端口号为999,具体如下

  1. public class MyProtocol implements Protocol {  
  2.     @Override  
  3.     public int getDefaultPort() {  
  4.         return 999;  
  5.     }  
  6.     /** 
  7.       * @Author xu
  8.       * @Description 暴露服务 
  9.       * @Date 2019-12-16 14:27 
  10.       * @Param [invoker] 
  11.       * @return org.apache.dubbo.rpc.Exporter<T> 
  12.      */  
  13.     @Override  
  14.     public <T> Exporter<T> export(Invoker<T> invoker) throws RpcException {  
  15.         return null;  
  16.     }  
  17.     /** 
  18.       * @Author xu
  19.       * @Description 引用、调用服务 
  20.       * @Date 2019-12-16 14:27 
  21.       * @Param [type, url] 
  22.       * @return org.apache.dubbo.rpc.Invoker<T> 
  23.      */  
  24.     @Override  
  25.     public <T> Invoker<T> refer(Class<T> type, URL url) throws RpcException {  
  26.         return null;  
  27.     }  
  28.     /** 
  29.       * @Author 销毁服务 
  30.       * @Description TODO 
  31.       * @Date 2019-12-16 14:28 
  32.       * @Param [] 
  33.       * @return void 
  34.      */  
  35.     @Override  
  36.     public void destroy() {  
  37.   
  38.     }  

然后需要在工程的resources目录下创建META-INF/dubbo的文件夹,在该文件夹下以Protocol的全限定名创建文本文件,文本内容为:

myprotocol=com.example.dubbo.rest.demodubbo.protocol.MyProtocol  

最后创建一个执行类,测试下自定义协议是否被Dubbo识别生效,具体如下:

  1. public class DemoProtocol {  
  2.     public static void main(String[] args) {  
  3.         Protocol protocol =  ExtensionLoader.getExtensionLoader(Protocol.class).getExtension("myprotocol");  
  4.         System.out.println(protocol.getDefaultPort());  
  5.     }  
  6. }  

执行程序,控制打印如下,输出端口号999,说明自定义协议加载成功:

(dubbo官网的协议扩展说明:http://dubbo.apache.org/zh-cn/docs/dev/impls/protocol.html

自定义拦截扩展

比如我们要实现一个功能,对Dubbo每个服务接口进行请求入参和响应出参以及接口处理时长的日志打印功能。这时候我们就可以基于org.apache.dubbo.rpc.Filter进行扩展。官网也有简要说明,我们这里还是直接上栗子吧。

创建一个名为MonitorServiceFilter的类,实现对Filter的扩展,具体代码如下:

  1. @Activate  
  2. @Slf4j  
  3. public class MonitorServiceFilter implements Filter {  
  4.     @Override  
  5.     public Result invoke(Invoker<?> invoker, Invocation invocation) throws RpcException {  
  6.         Result result = null;  
  7.         long spendTime = 0L;  
  8.         try {  
  9.             String interfaceName = invoker.getInterface().getName();  
  10.             String methodName = invocation.getMethodName();  
  11.             String logPrefix = interfaceName+"."+methodName;  
  12.             long startTime = System.currentTimeMillis();  
  13.             log.info("interface:{},request:{}",logPrefix,invocation.getArguments());  
  14.             result = invoker.invoke(invocation);  
  15.             if(result.hasException()){  
  16.                 Throwable e = result.getException();  
  17.                 if(e.getClass().getName().equals("java.lang.RuntimeException")){  
  18.                     log.error("interface:{}RuntimeException->{}",logPrefix, JSONObject.toJSONString(result));  
  19.                 }else{  
  20.                     log.error("interface:{}Exception->{}",logPrefix, JSONObject.toJSONString(result));  
  21.                 }  
  22.                 if(result.getException() instanceof Exception){  
  23.                     throw new Exception(result.getException());  
  24.                 }  
  25.             }else{  
  26.                 spendTime = System.currentTimeMillis()-startTime;  
  27.                 log.info("interface:{},response:{},spendTime:{} ms",logPrefix,JSONObject.toJSONString(result.getValue()),spendTime);  
  28.             }  
  29.         } catch (Exception e) {  
  30.            log.error("Exception:{},request{},curr error:{},msg:{}",invocation.getClass(),invocation.getArguments(),  
  31.                    e.toString(), ExceptionUtils.getRootCause(e));  
  32.            return result;  
  33.         }  
  34.         return result;  
  35.     }  
  36. }  

以上代码中,@Activate为**扩展点的注解,作用就是将我们自定义实现的扩展**,在Dubbo容器中能够被加载到。

MonitorServiceFilter类创建好之后,需要在工程的resources目录下创建META-INF/dubbo的文件夹,在该文件夹下以 dubbo Filter的全限定名创建一个文本文件,文件内容为KV格式,key为我们自定义的扩展名(在dubbo配置文件中会被用到),value 为我们实现扩展类的全限定名。具体如图所示:

最后在application.properties 中加入如下配置 :

  1. #接口入参出参响应监听  
  2. dubbo.provider.filter=monitorServiceFilter 

执行服务自测类,查看日志输出如下:

  1. [2019-12-16 14:16:33:513] [INFO][main] - cn.fl.preloan.filter.MonitorServiceFilter.invoke(MonitorServiceFilter.java:25) - interface:cn.fl.preloan.insurance.ICapFeeOutLogService.modifyCapFeeOutLog,request:[ModifyCapFeeOutLogRequest(dto=CapFeeOutLogDTO(thrFlag=nullpaySchId=nullfeeTypCd=nullhandleWayCd=nulloutAmt=nulloutDt=nulloutRem=测试一下isDel=nullpaySchNo=nullcrtUsrNm=nullpaySchDId=nullbizDataId=nullfeeTypCdNm=nullhandleWayCdNm=nullfeeAmt=nullfundId=nulloutFlag=0))]  
  2. [2019-12-16 14:16:33:860] [INFO][main] - cn.fl.preloan.filter.MonitorServiceFilter.invoke(MonitorServiceFilter.java:39) - interface:cn.fl.preloan.insurance.ICapFeeOutLogService.modifyCapFeeOutLog,response:{"code":"000000","message":"成功","success":false,"timestamp":1576476993514},spendTime:260 ms 

以上是两个简单例子体现的Dubbo的SPI扩展机制,Dubbo本身的SPI扩展通过查看Dubbo jar包中的META-INF目录我们可以了解更多。

SPI机制是Dubbo架构的核心,我们了解了Dubbo SPI的使用以及原理后,对于通过源码了解Dubbo架构思想会起到事半功倍的效果,起码在读源码的过程中,不会不知道很多第三方类是从哪里来的。

另外Dubbo官网文档是很棒的学习资料,核心源码的注释解读在官网都有体现,希望大家伙通过官网能够得到自己更多的心得体会。

SPI和API区别

    SPI:客户端定义接口规范,外部第三方服务针对该接口做实现,更偏向于客户端的解耦。

    API:服务方定义接口并实现该接口,提供给客户端调用,客户端别无选择,更偏向服务端的解耦。