SPI的工作机制如图1所示。
图1 SPI的工作机制
简单来说,就是我定义了一个接口,但没有给出具体实现,这些实现可以交由第三方来提供。我们的应用程序只依赖于该接口,运行时,根据某种机制找到一个第三方提供的实现类来完成整个应用。不同的第三方可以提供不同的实现,这不就扩展了程序的功能吗。

在SPI机制中,有三个参与角色,如下所示:
服务即对外开放的接口或者基类,通常接口居多。
服务提供者第三方提供的接口实现类,或者子类。实现类必须有一个无参的构造方法。
服务加载器发现并加载在运行时环境中部署的服务提供者。
Java已经为我们准备好了服务加载器,即java.util包中的ServiceLoader类。一个ServiceLoader类的实例是针对特定服务的,要创建ServiceLoader类的实例,可以调用它的静态方法load,该方法的签名如下所示:
public static <S> ServiceLoader<S> load(Class<S> service)使用当前线程的上下文类加载器为给定的服务类型创建新的服务加载器。
要得到可用的服务提供者,有两种方式,一种是调用ServiceLoader对象的iterator方法,通过返回的迭代器来迭代处理可用的服务提供者。迭代器会延迟加载并实例化服务实现类的对象,正因为会自动创建服务实现类的对象,所以要求服务实现类必须要有一个无参的构造方法。ServiceLoader类实现了Iterable接口,因此你也可以使用“for each”循环来遍历所有可用的服务提供者。
另一种方式是使用流来查找可用的服务提供者,ServiceLoader类的stream方法返回一个包含ServiceLoader.Provider对象的流,该方法的签名如下所示:
public Stream<ServiceLoader.Provider<S>> stream()Provide是ServiceLoader类中定义的一个静态接口,该接口只有两个方法,如下所示:
Class<? extends S> type()返回服务提供者的类型。
S get()返回服务实现类的实例。
接下来我们编写一个模拟计算机组装的程序,来看看如何利用SPI机制找到并加载CPU和显卡接口的实现类。
新建一个service文件夹,然后按照下面的步骤遵循Java的SPI机制来实现计算机组装程序。
1、定义服务
服务即对外开放的标准接口。在本例中定义两个接口:CPU和GraphicsCard。代码如下所示:
service\CPU.java
package computer;public interface CPU{ void calculate();}
service\GraphicsCard.java
package computer;public interface GraphicsCard {void display();}
编写主板类Mainboard,使用CPU和GraphicsCard,完成计算和图形显示功能。Mainboard类的代码如下所示:
service\Mainboard.java
package computer;public class Mainboard {private CPU cpu;private GraphicsCard gCard;public void setCpu(CPU cpu) {this.cpu = cpu;}public void setGraphicsCard(GraphicsCard gCard) {this.gCard = gCard;}public void run(){System.out.println("Starting computer...");cpu.calculate();gCard.display();}}
在service文件夹下,执行javac -d . .java,编译上述三个源文件。
2、编写服务实现类
服务实现类一般是由第三方提供,所以都是位于第三方定义的包中。在service目录下,新建spi文件夹,在该文件夹下新建IntelCPU.java和NVIDIACard.java,分别实现CPU和GraphicsCard接口。IntelCPU和NVIDIACard实现类的代码如下所示:
service\spi\IntelCPU.java
package computer.spi;import computer.CPU;public class IntelCPU implements CPU {public void calculate() {System.out.println("Intel CPU calculate.");}}
service\spi\NVIDIACard.java
package computer.spi;import computer.GraphicsCard;public class NVIDIACard implements GraphicsCard {public void display() {System.out.println("Display something");}}
要注意,现在实现类和接口并不在同一个目录下,编译实现类代码时,会提示找不到接口。知道怎么解决吗?当然是设置CLASSPATH,给出接口的字节码文件所在的文件夹路径。
在命令提示符窗口中,进入service\spi目录下,执行下面的命令,设置CLASSPATH,如下所示:
set classpath=.;..
..代表上一级目录。执行javac -d . .java,编译IntelCPU.java和NVIDIACard.java。
要想让ServiceLoader能够找到这两个实现类,我们需要把实现类的完整限定名添加到META-INF/services目录下的以接口的完整限定名命名的文件中。
在spi目录下,新建META-INF文件夹,然后在该文件夹下,再新建services文件夹。在META-INF\services目录下,新建两个文件,文件名是CPU和GraphicsCard接口的完整限定名,如下所示:
computer.CPU
computer.GraphicsCard
这两个文件的内容都只有一行,分别是其实现类的完整限定名。computer.CPU文件的内容如下所示:
computer.spi.IntelCPU
computer.GraphicsCard文件的内容如下所示:
computer.spi.NVIDIACard
第三方给出的服务实现一般是以JAR包的方式提供的,总不能我需要服务实现类的时候,第三方给一堆字节码文件和文件夹吧。
接下来在spi目录下执行下面的命令将服务实现类与META-INF目录一起打包为一个JAR文件。
jar tvf myspi.jar computer META-INF
生成的myspi.jar文件的内部结构如图2所示。
图2 myspi.jar文件的内部结构
jar命令的参数t表示要列出JAR文件的内容。
3、编写Computer类,使用ServiceLoader加载服务实现类
在service目录下新建Computer.java,在Computer类的main方法中使用ServiceLoader查找并加载CPU和GraphicsCard接口的实现类。代码如下所示。
service\Computer.java
package computer;import java.util.ServiceLoader;import java.util.Optional;public class Computer { / 使用迭代器得到CPU接口的实现类 / private static CPU getCPU(){ ServiceLoader<CPU> cpuLoader = ServiceLoader.load(CPU.class); for(CPU cpu : cpuLoader){ return cpu; } return null; } / 使用流得到GraphicsCard接口的实现类 / private static GraphicsCard getGraphicsCard(){ ServiceLoader<GraphicsCard> gcLoader = ServiceLoader.load(GraphicsCard.class); Optional<GraphicsCard> optGC = gcLoader.stream() .findFirst() .map(ServiceLoader.Provider::get); return optGC.orElse(null); } public static void main(String[] args) { Mainboard mb = new Mainboard(); mb.setCpu(getCPU()); mb.setGraphicsCard(getGraphicsCard()); mb.run();}}
要注意,创建ServiceLoader类实例的时候,并没有开始加载服务实现类。在迭代的时候,或者流的终端操作触发时,才会去扫描JAR包中的META-INF/services目录下的文件,根据文件名和文件内容进行解析,解析成功的话,通过反射API调用服务实现类的无参构造方法创建实现类的对象。
不同的JAR包中可能会有相同服务接口的不同实现类,如果需要使用特定的服务实现类,可以通过实现类的Class对象来进行判断。
正常情况下,第1步和第3步是同时进行的,因为第2步的编写服务实现类通常是交由第三方来完成的。我们编写一个框架程序,可以有默认实现或者没有默认实现,然后发布公开的服务接口,第三方可以提供接口的实现类来扩展框架的功能,或者替换框架的某个组件。
执行javac -d . Computer.java编译Computer类,执行下面的命令,将myspi.jar文件放到类路径中。
set classpath=.;.\spi\myspi.jar
执行java computer.Computer,输出结果为:
Starting computer...
Intel CPU calculate.
Display something
SPI是一套单独的服务提供发现机制,其好处就是无缝的对接第三方的实现,例如,Java访问数据库时,需要用的数据库厂商的JDBC驱动,而这些驱动就使用了SPI机制,他们相当于服务的提供者。我们只需要将JDBC驱动放到类路径下,就可以自动发现驱动类,从而自动完成驱动类的注册,而不需要显式的去调用Class.forName(...)去加载驱动类了。