理解SPI
概念
SPI,Service Provider Interface,即服务提供者接口,是Java 6提供的新特性:对于给定接口,发现和加载该接口的实现类。
在面向对象的编程中,我们推荐面向接口编程。假如在项目A中有接口EchoService
,它有两个实现类SimpleEchoServiceImpl
和PrettyEchoServiceImpl
,在项目B中,我们需要调用EchoService
,并选择其实现SimpleEchoServiceImpl
,则项目B中需要依赖项目A的jar文件,并编写代码如下:
如果后期在项目B中,需要使用实现PrettyEchoServiceImpl
,则需要修改代码为:
这样硬编码的方式,违反了可插拔的原则。
SPI就是为解决这种问题而生的。
现在,我们使用SPI的方式去解决上述问题,需要遵循以下步骤:
①在项目B中的resource
目录下新建目录META/service
,并在该目录中新建名为com.xxx.yyy.EchoService
的文件,并在该文件中写上如下内容:
②在项目B中,使用SPI提供的ServiceLoader
工具类加载实现类,代码如下:
SPI应用方式
通过上面的介绍和示例,我们了解了SPI的核心概念和一般使用流程。在实际场景中,SPI的使用方式或者项目组织方式并非总是如此。
方式1
上面的示例中,接口与实现方是在同一项目中,最终会打包为一个jar文件。而调用方则会依赖这个jar文件,并在自己的代码结构中添加指定实现类的目录和文件,最后使用ServiceLoader去加载实现类。
方式2
对于接口与实现方是在同一项目中的情况,通常会按照接口/实现1/实现2/...这种方式将整个项目划分为多个子模块(一个模块会打包为一个jar文件),在具体的实现者模块的代码结构中,新建META/service
目录,并在该目录中新建名为com.xxx.yyy.ZzzService
的配置文件,并在该文件中写明自己实现类全限定名。最后,调用方或使用方依赖接口模块的jar,并根据自己的需求,选择依赖一个或多个实现模块的jar,再通过ServiceLoader
工具类加载实现类。
方式3
更为常见的应用方式是:接口和实现方分别是不同的项目。这是Java提供SPI机制的经典应用方式。
比如,JDBC中不同数据库驱动的加载,之前我们往往会通过如下方式去获取(假如获取MySQL的驱动):
对于不同的数据库,如MySQL/Oracel/PostgreSQL,它们的驱动器肯定是不同的,甚至对同一种数据库,也可能会有多种驱动实现。如果使用上面硬编码的方式,很显然是不妥的。
看看使用SPI机制是如何解决上述问题的:Java提供了java.sql.Driver
接口,不同的数据库厂商需要在自己的驱动实现中,按照Java SPI规范,在驱动包中,新建META/service
目录,在其中新建一个名为java.sql.Driver
的配置文件,并在该配置文件中指明自己的实现类。如mysql-connector-java.jar
:
此后,通过Java提供的DriverManager进行加载:
注意:Oracel并没有遵循该规范,所有还是要使用Class.forName
的方式去加载驱动。
方式4
接口与调用方在同一项目中,不同实现者为不同的项目。这种方式下,需要在调用方和接口所在的项目中,根据需要去选择依赖实现者,再借助ServiceLoader去加载实现者。
在Java中,除了Driver这个大家比较熟悉的接口,还要很多SPI接口(但很少有实现方去遵循):
CurrencyNameProvider: provides localized currency symbols for the Currency class.
LocaleNameProvider: provides localized names for the Locale class.
TimeZoneNameProvider: provides localized time zone names for the TimeZone class.
DateFormatProvider: provides date and time formats for a specified locale.
NumberFormatProvider: provides monetary, integer and percentage values for the NumberFormatclass.
Driver: as of version 4.0, the JDBC API supports the SPI pattern. Older versions uses the Class.forName() method to load drivers.
PersistenceProvider: provides the implementation of the JPA API.
JsonProvider: provides JSON processing objects.
JsonbProvider: provides JSON binding objects.
Extention: provides extensions for the CDI container.
ConfigSourceProvider: provides a source for retrieving configuration properties.
注意
假如在配置文件中,写了多个实现类,那么在ServiceLoader加载具体实现类的时候,要根据自己的需求去调用实现者:是任意选择一个实现类,还是根据某种规则选择一个实现类,还是这多个实现类都要去调用。
SPI思想
SPI的思想,其实就是让“使用方-接口-实现者”这三者尽量松耦合,避免使用硬编码的方式,而是声明一些规范,并通过配置文件的方式来解决硬编码的问题(当然,使用方与实现者是一定会耦合的,这里只是说让耦合方式更合理,改动的代价更小,维护的成本更低)。
这种思想的体现,其实非常常见。
1、日志框架
一开始,Java的日志工作都由log4j来处理,但后来发现项目太依赖log4j或者log4j不能满足需求想要更换日志框架,发现代价太大。这时,Apache Common Logging(前身为Jakarta Commons Logging,JCL)出现了,它只提供接口,不提供具体实现。这样,项目中只要依赖这个Common Logging,并选择依赖一种日志实现框架即可,项目可以随时切换日志实现方式, 避免了和具体的日志方案直接耦合。这里,Common Logging通过配置信息动态加载实现框架。这是SPI思想的一种体现。
当程序规模越来越庞大时,JCL的动态绑定并不是总能成功。解决方法之一就是在程序部署时静态绑定指定的日志工具,这就是 SLF4J 产生的原因。跟 JCL 一样,SLF4J 也是只提供 log 接口,具体的实现是在打包应用程序时所放入的绑定器(名字为 slf4j-XXX-version.jar)来决定,XXX 可以是 log4j12, jdk14, jcl, nop 等,他们实现了跟具体日志工具(比如 log4j)的绑定及代理工作。举个例子:如果一个程序希望用 log4j 日志工具,那么程序只需针对 slf4j-api 接口编程,然后在打包时再放入 slf4j-log4j12-version.jar 和 log4j.jar 就可以了。
2、各种插件
作为Java程序员,会对IDE工具如Eclipse/IDEAJ/Sublime等非常熟悉。通过,在使用这些IDE工具时,我们会下载很多扩展插件,以便使其具有我们想要的某个特定功能:下载插件文件,将其放在IDE工具安装目录下的某个文件夹下(通常是叫plugins),然后重启IDE,IDE就拥有了这个插件提供的功能。
这其实是IDE工具制定了一系列的规则,如文件结构、类型、参数等。插件开发者遵循这些规则去开发自己的插件,IDE工具并不需要知道插件具体是怎样开发的,只需要在启动的时候根据配置文件解析、加载到系统里就行了,这也是SPI思想的一种体现。
3、Spring
Spring的IOC容器可以看做是各种接口的实现类的大集合。我们在Bean的时候,需要指定bean的class属性,这个操作其实就是在告诉IOC容器实现类的路径在哪,在使用的时候,我们通常会使用注解并指明一个接口,这个操作其实就是在告诉IOC容器我需要这个接口的实现类。这种配置方式和使用与Java原生SPI机制的配置文件和Serviceloader就很像,也是SPI思想的体现。
在定义Bean的时候,我们还可以通过设置scope属性来告诉IOC容器,在创建实现者实例的时候,通过哪种方式去创建,是单例,还是每次使用都新建一个实现者实例。这可以理解为Spring为原生SPI机制提供了扩展:不仅接管发现和去哪加载实现者的工作,还接管了如何创建这个实现者的这个工作。
4、Dubbo
Dubbo插件化的实现其实就是SPI,但它对原生的SPI进行了扩展:Dubbo的规则是在META-INF/dubbo
、META-INF/dubbo/internal
或者META-INF/services
下面去创建一个文件,并且在文件中以properties
的规则去配置实现类,如