简单介绍
SPI 全称为 Service Provider Interface,是一种服务发现机制。SPI 的本质是将接口实现类的全限定名配置在文件中,并由服务加载器读取配置文件,加载实现类。这样可以在运行时,动态为接口替换实现类。正因此特性,我们可以很容易的通过 SPI 机制为我们的程序提供拓展功能。
在许多第三方框架中,SPI机制都得以运用。比如JDBC,Slf4j,Dubbo,spring等。
在使用后jdbc的时候,我们都是通过DriverManager.getConnection获取数据库的连接,连接MySQL时,引入mysql的驱动;连接sqlserver时,引入sqlserver的驱动。。。获取连接的代码始终没变,这就用到了SPI的机制,更多原理参考。这样就使得驱动更像是一个可插拔,可替换换的组件,需要那个,引入那个便可,JDBC只是提供了一个java连接数据库的规范,每个厂商只需要实现规范提供对应的驱动,然后通过SPI机制加载驱动进行使用。
Slf4j也是一样,提供了一套输出日志的规范,具体实现可以有logback,log4j,java-logging,slf4j-nop,slf4j-simple等等。当时用的时候,只需要引入一个对应的实现即可。
SPI机制
JAVA SPI的具体约定为:当服务的提供者提供了服务接口的一种实现之后,在jar包的META-INF/services/目录里同时创建一个以服务接口类全路径的文件。该文件里的内容就是实现该服务接口的具体实现类的全路径。当外部程序装配这个模块的时候,就能通过该jar包META-INF/services/里的配置文件找到具体的实现类名,并装载实例化,完成模块的注入。
基于这样一个约定就能很好的找到服务接口的实现类,而不需要再代码里制定。jdk提供服务实现查找的一个工具类:java.util.ServiceLoader,通过load方法就可以对META-INF/services里面的实现类进行加载和实例化。
写个demo感受一下
场景,现在有一个短信发送的需求,根据不同的业务场景,项目需要选择不同的运营商,要求:
- 在切换运营商的时候不要对代码进行改动
- 保证扩展性
- 使用方便
当然,这个需求的解决方案肯定不止一个,但是通过这个例子可以直观的感受SPI是个啥。
项目结构
sms-api: 定义了一个短信发送提供者ISMSProvider应该具备的功能,发送短信
sms-provider-telecom: 电信,实现了ISMSProvider
sms-provider-unicom: 联通,实现了ISMSProvider
user: 模拟用户调用
sms-api
短信提供商接口定义
public interface ISMSProvider {
void sendSMS(String msg);
}
同时,提供了一个工厂类,获取短信提供商,方便用户调用
public class SMSProviderFactory {
private SMSProviderFactory() {
throw new IllegalStateException("Utinity Class");
}
public static ISMSProvider getProvider() {
ServiceLoader<ISMSProvider> smsProviders = ServiceLoader.load(ISMSProvider.class);
Iterator<ISMSProvider> smsIterator = smsProviders.iterator();
if (!smsIterator.hasNext()) {
throw new IllegalStateException("No valid SMS provider is found!");
}
ISMSProvider provider = smsIterator.next();
System.out.println("Actual SMS provider is: " + provider.getClass());
return provider;
}
}
为了方便,如果同时引入了多个提供商的情况下,默认用第一个。
sms-provider-telecom
对ISMSProvider进行实现
public class TelecomSMSProvider implements ISMSProvider {
public void sendSMS(String msg) {
System.out.println(String.format("Send SMS [%s] by Telecom...", msg));
}
}
最重要的是要在classpath下面准备Java SPI需要的文件,这里是META-INF/services/top.njlife.sms.ISMSProvider, 内容为
top.njlife.sms.TelecomSMSProvider
sms-provider-unicom
与上面一样,进行接口实现
public class UnicomSMSProvider implements ISMSProvider {
public void sendSMS(String msg) {
System.out.println(String.format("Send SMS [%s] by Unicom...", msg));
}
}
准备SPI需要的文件,文件内容为META-INF/services/top.njlife.sms.ISMSProvider。
注意: 文件名都是实现的接口的全路径名。
top.njlife.sms.UnicomSMSProvider
到此两个短信提供商就开发好了。
user
用户在使用的时候,只需要在pom里面引入
<dependency>
<groupId>top.njlife</groupId>
<artifactId>sms-api</artifactId>
<version>1.0-SNAPSHOT</version>
</dependency>
首先试试telecom,继续引入
<dependency>
<groupId>top.njlife</groupId>
<artifactId>sms-provider-telecom</artifactId>
<version>1.0-SNAPSHOT</version>
</dependency>
模拟调用代码
public class SMSSender {
public static void main(String[] args) {
ISMSProvider provider = SMSProviderFactory.getProvider();
provider.sendSMS("test msg");
}
}
运行,结果如下
Actual SMS provider is: class top.njlife.sms.TelecomSMSProvider
Send SMS [test msg] by Telecom...
此时我们需要切换到unicom,在pom里面telecom的依赖改成
<dependency>
<groupId>top.njlife</groupId>
<artifactId>sms-provider-unicom</artifactId>
<version>1.0-SNAPSHOT</version>
</dependency>
再次运行代码,得到
Actual SMS provider is: class top.njlife.sms.UnicomSMSProvider
Send SMS [test msg] by Unicom...
可以看到,短信提供商成功切换了,项目代码不需要做任何改动。
如果这时候需要新的集成新的短信提供商,只需要再开发一个项目,然后引入依赖即可。
这就是SPI的方便之处,基于这个机制,我们可以方便地做到在一个系统/框架中实现一个插件的功能,或者扩展点,可以参考Dubbo的SPI机制。
至于JAVA的SPI内部机制是如何做到的,后续继续探讨。
简单总结
SPI机制可以帮助我们轻松实现解耦,使得第三方服务提供者模块独立于业务代码之外,实现模块的插拔。
但是JAVA原生的SPI也有一些不足的地方
- 无法按需加载。ServiceLoader每次都会加载所有的实现,如果有的没有用到也进行加载和实例化,会造成一定系统资源的浪费。
- 线程安全问题。ServerLoader可以看做是一个工具类,提供了很多static方法,但是其内部用到了一些成员变量,这样就会导致在多线程调用的时候有线程安全问题,需要注意。
- 异常吞噬。ServerLoader在加载类的过程中如果出现异常无法加载没有相关的异常抛出,导致一旦出现问题需要花时间进行定位。
鉴于这些缺点,很多开源框架都实现了一套自己的SPI机制,比如Dubbo对SPI进行了增强,参考:https://dubbo.apache.org/zh-cn/docs/source_code_guide/dubbo-spi.html
Demo源码
最后附上文中demo的源代码:https://gitee.com/nickhan/java-spi