原来我们每天都在使用 SPI 服务提供者设计模式

1,020 阅读6分钟

1.引言

接口是对行为的抽象,是接口的实现者和调用者之间建立的约定(协议-protocol)。我们可以根据需要为接口提供不同的实现。通过将不同的接口实现注入到调用方,就可以实现在不修改调用方代码的情况下改变调用方的行为。

SPI(Service Provider Interface)就是服务提供者接口模式。框架或者系统可以 SPI 模式支持,让使用方可以根据需要为某个行为选择不同的提供方实现版本,从而实现灵活的可定制扩展。比如Java SDK JDBC 定义了数据库操作的标准接口,但是 Java SDK 并没有提供具体的实现,而是由各个数据库厂商来实现。使用方根据自己的需要选择对应数据库的JDBC 实现就好了。

2.SPI 模式

image.png

2.1 模式包含的组件

服务提供者接口模式中有 4 个重要的组件

  • 服务接口 Service Interface
  • 服务接口实现:不同的服务提供方可以提供一个或多个实现;框架或者系统本身也可以提供默认的实现。
  • 提供者注册 API(Provider Registration API),这是提供者用来注册实现的;
  • 服务访问 API (Service Access API) ,这是调用方用来获取服务的实例的接口。

2.2 简单示例

举个例子,假如我们有个软件产品。该产品需要实现一个新功能,以支持从其他内容系统检索信息。 为了能够支持任意可能的内容系统,我们决定使用 SPI 模式来解决这个问题。Java 提供了 SPI 支持,我们的示例将会基于 Java SPI。

2.2.1 定义服务接口

首先,我们需要定义服务接口

package com.examples.spi
public interface Searchable {
    public List<String> searchDoc(String keyword);   
}

2.2.2 提供者实现服务接口

然后,服务提供者实现接口。在我们的内容搜索服务中,我们可以客户提供实现,客户自己如果有开发能力,可以可以为自己的内容系统提供搜索实现。

我们有很多客户使用的内容系统就是同一个电脑上的文件系统,为了这个这类客户,我们提供了支持文件系统的 Searchable 实现:

package com.examples.spi.impl.file;
public class FileSearchEngine implements Searchable{
    @Override
    public List<String> searchDoc(String keyword) {
        log.info("在文件系统中搜索包含 {} 的内容", keyword);
        ... //省略具体实现
        return contents;
    }
}

除了使用文件内容系统的客户,其他的客户使用数据库内容系统,为此,我们提供了支持数据库系统的 Searchable 实现:

package com.examples.spi.impl.db;
public class DatabaseSearchEngine implements Searchable{
    @Override
    public List<String> searchDoc(String keyword) {
        log.info("在数据库系统中搜索包含 {} 的内容", keyword);
        ... //省略具体实现
        return contents;
    }
}

2.2.3 注册服务提供者的实现

完成实现后,我们需要将实现通过注册接口注册到系统中。 Java SPI 提供了注册机制。该机制是通过配置文件:META-INF/services/interface全限定名 来实现服务实现注册。即在使用服务的代码工程中创建 META-INF/services/目录,然后新建文件名为接口全限定名的服务实现注册文件:com.examples.spi.Searchable。如果我们在这个服务实现注册文件中配置com.examples.spi.impl.file.FileSearchEngine,系统将会使用 FileSearchEngine;如果我们在这个服务实现注册文件中配置com.examples.spi.impl.db.DatabaseSearchEngine,那么系统使用的就是 DatabaseSearchEngine。

2.2.4 通过服务访问接口调用服务

Java SPI 的服务访问接口时 ServiceLoader。ServiceLoader 可以从服务实现注册文件中加载服务实现,并返回服务对象实例。下面我们通过简单的例子来看看如果获取服务实现对象,并调用服务接口。在这个例子中我们采用了支持多个服务实现的方式。所以如果在服务实现注册文件配置了多个实现,那么将会可以从多个内容系统搜索内容。

public class SeachableTest {
    public static void main(String[] args) {
        ServiceLoader<Search> s = ServiceLoader.load(Searchable.class);
        Iterator<Search> iterator = s.iterator();
        while (iterator.hasNext()) {
           Search search =  iterator.next();
           search.searchDoc("hello world");
        }
    }
}

假如你是公司基础架构组的,负责提供公司范围内使用的基础框架和基础设施组件,你们组提供的框架和组件都是用 log4j 写日志。但是业务开发组有的用log4j、有的用logback、有的用JUL(Java Util Logging)。没有使用log4j 的开发组不得不为工程维护两个日志配置文件。

2.3 示例:JDBC

在JDBC4.0之前,我们开发有连接数据库的时候,通常会用Class.forName("com.mysql.jdbc.Driver")这句先加载数据库相关的驱动,然后再进行获取连接等的操作。而JDBC4.0之后不需要用Class.forName("com.mysql.jdbc.Driver")来加载驱动,直接获取连接就可以了,现在这种方式就是使用了Java的SPI扩展机制来实现

2.3.1 JDBC 服务接口定义

Java SDK 中定义了接口java.sql.Driver,并且没有具体的实现,具体的实现都是由不同数据库厂商来提供的。

2.3.2 实现

mysql 实现

在 mysql 的 jar 包 mysql-connector-java-6.0.6.jar 中,可以找到META-INF/services目录,该目录下会有一个名字为java.sql.Driver的文件,文件内容是 com.mysql.cj.jdbc.Driver,这里面的内容就是针对Java中定义的接口的实现。

postgresql 实现

同样在 postgresql 的 jar 包 postgresql-42.0.0.jar中,也可以找到同样的配置文件,文件内容是 org.postgresql.Driver,这是postgresql对Java的java.sql.Driver的实现。

服务访问接口

上面说了,现在使用SPI扩展来加载具体的驱动,我们在Java中写连接数据库的代码的时候,不需要再使用Class.forName("com.mysql.jdbc.Driver")来加载驱动了,而是直接使用如下代码:

String url = "jdbc:xxxx://xxxx:xxxx/xxxx";
Connection conn = DriverManager.getConnection(url,username,password);
.....

由于JDBC 服务接口涉及内容多,所以 Java SDK 提供了服务访问接口的封装 DriverManagerDriverManager.loadInitialDrivers 方法中调用了ServiceLoader.load

private static void loadInitialDrivers() {
    String drivers;
    ...

    AccessController.doPrivileged(new PrivilegedAction<Void>() {
        public Void run() {
            //使用SPI的ServiceLoader来加载接口的实现
            ServiceLoader<Driver> loadedDrivers = ServiceLoader.load(Driver.class);

            Iterator<Driver> driversIterator = loadedDrivers.iterator(); 
            try{ 
                // 遍历所有的驱动实现,在遍历的时候,首先调用`driversIterator.hasNext()`方法,
                // 搜索classpath下和jar包中所有的`META-INF/services`目录下的`java.sql.Driver`文件,并找到文件中的实现类的名字
                while (driversIterator.hasNext()) { 
                    driversIterator.next(); } 
            } catch(Throwable t) { 
                // Do nothing 
            }
            ...
        }
    });

    println("DriverManager.initialize: jdbc.drivers = " + drivers);
    ...
}

我们现在更多的是使用 Spring 及 ORM(MyBatis),所以我们基本上很少会之间与 JDBC 打交道了,这里只是作为 SPI 的示例讲解。JDBC 是经典的 SPI 模式的应用。

2.4 示例:slf4j 日志框架

3 总结

还有很多我们日常使用的组件/框架使用了SPI,比如 slf4j,common-logging,Springboot 的自动装配插件机制等等。当一个服务行为需要由第三方或者使用方根据场景做不同的适配或扩展的时候,可以使用 SPI 模式来实现灵活的定制扩展。