在Java中,我们经常会提到面向接口编程,这样减少了模块之间的耦合,更加灵活。
在一个项目中我们也通常将接口和实现类放在一起,但是如果哪天我们要替换其它的实现类,或者是修改实现类,涉及到实现类的代码也要相应地修改。
能不能这样:在调用服务的时候,我们只调用接口,不用关心实现类呢?无论我们怎么切换实现类,调用接口的部分代码都能正常运行?
当然是可以的,Java SPI (Service Provider Interface
)就提供了这样的机制。
Java SPI机制中,我们不再是手动指定接口和实现类的关系,而是让接口去寻找可用的实现类。
事实上,我们经常使用的Spring框架、日志接口等等,都是使用了SPI机制实现了扩展。
1,SPI
和API
在说起SPI
之前,我们还是先看一下API
,API
我们已经很熟悉了,和SPI
都可以被称作接口。
只不过API
的功能的实现,以及接口的定义全部是接口的实现者提供的,调用者只需要调用接口即可:
不过SPI
就不一样了,在SPI
机制中,调用者仍然是调用接口,但是这个接口是独立存在的,并且可以由不同的实现者实现:
也就是说,这里接口只是一个标准,并且提供接口的那一方并不一定回去实现接口,而是根据接口的定义,由更多的第三方实现。
这个接口可以由一个甚至是多个实现者去实现。也因此,调用者在调用接口时,可能还需要指定一下使用哪个实现者的实现类。
实现者也叫做 服务提供者。
事实上,我们日常生活中经常使用的U盘也很类似SPI
机制,U盘使用的是USB接口,USB接口仅仅是一个规范(接口),但是发明USB接口的公司并没有去生产U盘,而是由不同的U盘厂商例如金士顿、闪迪(实现者)等等去根据这个规范生产U盘,然后我们就可以去选择自己喜欢的牌子(选择实现者)购买U盘,不过平时无论使用什么牌子的U盘,我们只需要插入到电脑的USB接口(调用接口)即可使用,而不用关心不同的厂商是怎么实现USB接口的功能的。
可见,SPI
机制将实现者和接口再次解耦合了,使得接口更加易于扩展。
事实上,我们常常用的SLF4J
就是一个Java的日志接口,但是它也仅仅是一个接口,所以被称作门面。而它的实现有Logback
、Log4j
等等,并且在切换实现的时候,我们只需要修改一下依赖配置即可,代码并不需要任何变动,因为代码中也仅仅是调用了接口。
2,自己完成一个SPI
那么现在,我们也来以一个最简单的日志接口为例,实现自己的SPI
。
(1) 定义SPI
接口
先新建一个空的Maven项目log-interface
,然后在里面创建一个日志接口,声明日志接口具备的方法(功能):
package com.gitee.swsk33.loginterface.spi;
/**
* 定义日志接口
*/
public interface Logger {
/**
* INFO级别日志方法
*
* @param message 日志打印消息
*/
void info(String message);
/**
* DEBUG级别日志方法
*
* @param message 日志打印消息
*/
void debug(String message);
}
这样,我们便定义了这么一个日志接口,并声明日志接口需要有info
和debug
这两个日志功能。
然后就是编写服务类,这个服务类是这里最为重要的地方,它的作用是扫描所有实现了Logger
接口的实现类并加载进来,然后供调用者去调用。
先看代码:
package com.gitee.swsk33.loginterface.service;
import com.gitee.swsk33.loginterface.spi.Logger;
import java.util.ArrayList;
import java.util.List;
import java.util.ServiceLoader;
/**
* 服务,用于加载所有服务使用者的实现类,以及供外部调用
* 该类为一个单例
*/
public class LoggerService {
/**
* 该类唯一单例
*/
private static final LoggerService LOGGER = new LoggerService();
/**
* 默认的Logger实现类
*/
private final Logger defaultLogger;
/**
* 所有的Logger实现类列表
*/
private final List<Logger> allLoggers = new ArrayList<>();
/**
* 私有化构造器
*/
private LoggerService() {
// 加载全部Logger接口的实现类
ServiceLoader<Logger> loader = ServiceLoader.load(Logger.class);
// 将实现类放入我们的Logger实现类列表
for (Logger logger : loader) {
allLoggers.add(logger);
}
// 这里取出第一个作为默认实现类
if (!allLoggers.isEmpty()) {
defaultLogger = allLoggers.get(0);
} else {
defaultLogger = null;
}
System.out.println("加载到" + allLoggers.size() + "个服务实现!");
}
/**
* 获取该服务类的唯一单例
*
* @return 该服务类的唯一单例
*/
public static LoggerService getInstance() {
return LOGGER;
}
/**
* 调用默认的实现类的info日志打印方法
*
* @param message 消息
*/
public void info(String message) {
if (defaultLogger == null) {
System.err.println("没有找到实现了Logger接口的类!");
return;
}
defaultLogger.info(message);
}
/**
* 调用默认的实现类的debug日志打印方法
*
* @param message 消息
*/
public void debug(String message) {
if (defaultLogger == null) {
System.err.println("没有找到实现了Logger接口的类!");
return;
}
defaultLogger.debug(message);
}
}
首先这个类是一个单例的类,在构造器中,我们使用ServiceLoader
这个类来将实现了Logger
接口的所有类都扫描进来,并存入我们的实现类列表,然后我们取出列表中的第一个作为默认实现。
在下面我们定义了info
和debug
来完成对接口的默认实现类的调用。
最后,在项目目录下执行mvn install
命令将其安装至本地Maven仓库,以便后续服务提供者引入并实现。
(2) 完成一个接口的实现
现在再新建一个空的Maven项目logservice-one
,并引入上面接口项目为依赖:
然后编写实现类:
package com.gitee.swsk33.logserviceone.service;
import com.gitee.swsk33.loginterface.spi.Logger;
/**
* Logger SPI的实现类
*/
public class LogOne implements Logger {
@Override
public void info(String s) {
System.out.println("[LogOne INFO] " + s);
}
@Override
public void debug(String s) {
System.out.println("[LogOne DEBUG] " + s);
}
}
然后在resources
目录下创建目录META-INF/services
,这个目录中是用于声明该服务实现中有哪些实现类实现了什么接口。
在这个目录下我们新建一个文件名为com.gitee.swsk33.loginterface.spi.Logger
,文件中的内容为:
com.gitee.swsk33.logserviceone.service.LogOne
可见,该目录下文件名是要实现的接口的全限定类名(包名 + 类名),而文件中内容是实现了该接口的实现类的全限定类名。
大家参考这里的文件名及其中的内容,与我们上述的接口全限定类名、实现类全限定类名对比一下就知道了!
如果说这个项目中有多个类实现了Logger
接口,那么我们都需要在文件中声明,一行一个实现类的全限定类名。
最终整个项目结构如下:
同样地,最后记得在项目目录下执行mvn install
命令将其安装至本地Maven仓库,以便调用者调用。
(3) 测试接口
这里再新建一个Maven空项目log-test
,作为接口的调用者,在依赖中引入实现者:
然后创建一个主类调用一下接口试试:
package com.gitee.swsk33.logtest;
import com.gitee.swsk33.loginterface.service.LoggerService;
public class Main {
private static final LoggerService LOGGER = LoggerService.getInstance();
public static void main(String[] args) {
LOGGER.info("测试info消息");
LOGGER.debug("测试debug消息");
}
}
结果:
可见,我们成功地调用了Logger
接口中的方法。
通常调用者的依赖中可能会同时引入SPI
接口依赖和服务提供者(实现)的依赖,这样也没问题,不过通常服务提供者本身就依赖于SPI
接口,因此只引入服务提供者依赖,也会间接地引入SPI
接口依赖,不影响我们调用SPI
接口。
我们这里只有一个服务提供者logservice-one
,如果说还有logservice-two
等等多个服务提供者,我们只需要在依赖中更换一下即可,代码完全不需要改变。
也可见调用者在调用接口的时候,只需要关注接口就行了,不需要关心实现类。
3,再看ServiceLoader
可见在SPI
接口中,我们使用ServiceLoader
完成了对所有实现了Logger
接口的类的扫描和加载,那么具体的过程是什么样的呢?
如果大家去查看这个类的源码,可以发现它实现了Iterable
接口,这也说明我们可以通过迭代的方式去完成多个实现类的切换。
然后在其源码中,有这么一个常量定义:
static final String PREFIX = "META-INF/services/";
这就说明,ServiceLoader
会去扫描服务提供者的classpath
路径下的META-INF/services
目录,来扫描哪些类实现了指定接口,而其静态方法load
的参数,正是指定了被实现的接口。也因此我们要在服务提供者的项目的resources
目录下创建这个目录并申明接口和对应实现类的全限定类名。
在Maven项目中,resources
目录就对应的是classpath
的根目录。
简而言之,ServiceLoader
加载实现类的过程如下:
- 先是调用
load
方法并指定要扫描的接口 - 然后扫描项目中
META-INF/services
目录,这包括调用者项目以及它所引入的所有依赖包中的META-INF/services
目录下的声明 - 扫描到所有实现类后,根据其类名,先判断是否跟
SPI
接口为同一类型,如果是则利用反射的方式将所有实现类实例化,加载进内存,并返回所有实现类的实例列表
可见,这就是JDK中SPI
机制加载服务的大致过程,事实上,现在很多框架也利用SPI
机制实现了灵活地扩展。
示例仓库地址:传送门