一、Java SPI是什么
SPI的英文全称为Service Provider Interface,字面意思为服务提供者接口,它是jdk提供给“服务提供厂商”或者“插件开发者”使用的接口。
在面向对象的设计中,模块之间我们一般会采取面向接口编程的方式,而在实际编程过程过程中,API的实现是封装在jar中,当我们想要换一种实现方法时,还要生成新的jar替换以前的实现类。而通过jdk的SPI机制就可以实现,首先不需要修改原来作为接口的jar的情况下,将原来实现的那个jar替换为另外一种实现的jar即可。
总结一下SPI的思想:在系统的各个模块中,往往有不同的实现方案,例如日志模块的方案、xml解析的方案等,为了在装载模块的时候不具体指明实现类,我们需要一种服务发现机制,java spi就提供这样一种机制。有点类似于IoC的思想,将服务装配的控制权移到程序之外,在模块化设计时尤其重要。
顺便提一下,Java SPI机制在很多大型中间件吗,例如Dubbo中均有采用,属于高级Java开发的进阶必备知识点,务必要求掌握。
二、Java SPI使用规范
定义服务的通用接口,针对通用的服务接口,提供具体的实现类。
在jar包的META-INF/services/目录中,新建一个文件,文件名为 接口的"全限定名"。 文件内容为该接口的具体实现类的"全限定名"。
将spi所在jar放在主程序的classpath中
服务调用方用java.util.ServiceLoader,用服务接口为参数,去动态加载具体的实现类到JVM中。
三、API和SPI的区别
API:提供给调用方,完成某项功能的接口(类、或者方法),你可以使用它完成任务。
SPI:是一种callback的思想,在一些通用的标准中(即API),为实现厂商提供扩展点。当API被调用时,会动态加载SPI路由到特定的实现中。
四、Java SPI 的典型运用场景
案例一:
java.sql.Driver的spi实现,有mysql驱动、oracle驱动等。以mysql为例,实现类是com.mysql.jdbc.Driver,在mysql-connector-java-5.1.6.jar中,我们可以看到有一个META-INF/services目录,目录下有一个文件名为java.sql.Driver的文件,其中的内容是com.mysql.jdbc.Driver。
案例二:
举一个典型的案例:
image.png
slf4j是一个典型的门面接口,早起我们使用log4j作为日记记录框架,我们需要同时引入slf4j和log4j的依赖。后面比较流行logback,我们也想要把项目切换到logback上来,此时利用SPI的机制,我们只需要把log4j的jar包替换为logback的jar包就可以了
五、Java SPI Demo
该示例主要为了展示如何使用SPI,接口是数字操作接口,普通的API的实现类是加法操作;两个SPI实现类分别是减法操作和乘法操作。程序结构如下图:
image.png
INumOperate接口的代码如下:
package com.example.demo.operation;
/**
* @Description 数字操作接口
* @Author louxiujun
* @Date 2019/11/7 14:09
**/
public interface INumOperate {
int operate(int a, int b);
}
普通的api实现,加法操作,代码如下:
package com.example.demo.operation.api;
import com.example.demo.operation.INumOperate;
/**
* @Description 数字相加
* @Author louxiujun
* @Date 2019/11/7 14:09
**/
public class NumPlusOperateImpl implements INumOperate {
@Override
public int operate(int a, int b) {
int r = a + b;
System.out.println("[实现类机制]加法,结果:" + r);
return r;
}
}
实现乘法的spi,在语法结构上和普通api实现一模一样,如下
package com.example.demo.operation.spi;
import com.example.demo.operation.INumOperate;
/**
* @Description 数字相乘
* @Author louxiujun
* @Date 2019/11/7 14:10
**/
public class NumMutliOperateImpl implements INumOperate {
@Override
public int operate(int a, int b) {
int r = a * b;
System.out.println("[SPI机制]乘法,结果:" + r);
return r;
}
}
实现减法的spi,在语法结构上和普通api实现一模一样,如下
package com.example.demo.operation.spi;
import com.example.demo.operation.INumOperate;
/**
* @Description 数字相减
* @Author louxiujun
* @Date 2019/11/7 14:10
**/
public class NumSubtractOperateImpl implements INumOperate {
@Override
public int operate(int a, int b) {
int r = a - b;
System.out.println("[SPI机制]减法,结果:" + r);
return r;
}
}
在resources目录下,新建META-INFO目录,再在其下面新建services目录,新建一个以com.example.demo.operation.INumOperate命名的文件,名称来自于接口的全限定路径,文件内容指明两个SPI的实现类的全限定名称,具体内容如下:
com.example.demo.operation.spi.NumMutliOperateImpl
com.example.demo.operation.spi.NumSubtractOperateImpl
下面我们在test目录下新建一个名为INumOperateTest单元测试类:
package com.example.demo.operation;
import com.example.demo.BaseTest;
import com.example.demo.operation.api.NumPlusOperateImpl;
import org.junit.Test;
import java.util.Iterator;
import java.util.ServiceLoader;
/**
* @Description
* @Author louxiujun
* @Date 2019/11/7 14:14
**/
public class INumOperateTest extends BaseTest {
private int num1 = 9;
private int num2 = 3;
@Test
public void testOperate() {
// 普通的实现类机制,加法
INumOperate plus = new NumPlusOperateImpl();
plus.operate(num1, num2);
// SPI机制,寻找所有的实现类,顺序执行
ServiceLoader loader = ServiceLoader.load(INumOperate.class); // 查找SPI实现类,并加载到jvm
Iterator iter = loader.iterator();
while (iter.hasNext()) {
INumOperate op = iter.next();
op.operate(num1, num2);
}
}
}
其中,单元测试的基类如下:
package com.example.demo;
import org.junit.runner.RunWith;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.junit4.SpringRunner;
@RunWith(SpringRunner.class)
@SpringBootTest
public class BaseTest {
}
测试输出结果如下:
[实现类机制]加法,结果:12
[SPI机制]乘法,结果:27
[SPI机制]减法,结果:6
踩坑:resources下的META-INF/resources目录不能一次性建成,需要逐级建出来。详细的踩坑说明参考: Inteilj IDEA多级目录生成踩坑记
六、项目实战
下面举一个实际生产环境中使用到的使用Java SPI机制实现。先说一下需求背景,一个项目,之前部署在环境A下面(集团内部环境),使用了较多的集团内部中间件,简直可以说是中间件全家桶,使用的时候很爽,后面有了私有化部署的需求,私有化的场景下可没有集团的火力支持,只能使用业界开源方案作为替代品,这个环境就是环境B。由此对此抽象出来的区别如下表所示,无论是消息队列中间件,还是缓存中间件,还是某个下游的元数据服务,都不一样。
中间件/服务
环境A
环境B
消息队列
mq1
mq2
缓存
cache1
cache2
元数据服务
service1
service2
问题来了,怎么解决环境下部署的下游依赖的问题呢?如果只是某个参数不一样,可能通过一个配置中心下发一下配置参数就可以了,但是这里涉及到的是下游完整的业务模块,不是几行代码,也不是几个文件能够搞定的事情,很有可能一个十几万行代码的依赖包。怎么办呢?
为了在不同的部署环境下,使用对应的中间件/服务,我们有以下几种方案:
方案
优点
缺点
不同环境部署不同的应用
1、简单、代码逻辑上不用区分环境
2、代码修改只影响所属应用
1、功能同步升级麻烦
2、多个应用多套代码维护成本高
代码IF,ELSE判断执行对应环境的逻辑
1、一套代码支持多个部署环境
2、同步升级,统一维护,降低成本
不符合"开闭原则",当需要支持更多环境时,需要修改已有代码,风险大
SPI 插件式开发
1、一套代码支持多个部署环境
2、同步升级、维护成本低
3、当需要支持更多环境时,只需要实现对应的服务接口,替换服务插件即可
实现复杂
通过比较以上三种方案,我们决定使用"SPI插件式开发"的方式支持"一套代码多环境部署"的功能。
pom文件:
environment1
true
com.alibaba.work.demo
environment1
environment2
false
com.alibaba.work.demo
environment2
SPI加载类ServiceLoaderContainer,用于保存类及其对应的类加载器:
public class ServiceLoaderContainer {
private static Map, Object> container = new HashMap, Object>();
@SuppressWarnings( {"unchecked", "unused"} )
protected T getService(Class cls) {
T obj = (T) container.get(cls);
if (obj != null) {
return obj;
}
synchronized (this) {
// 并行场景下 当前线程上下文类加载器有可能为null
ClassLoader cl = Thread.currentThread().getContextClassLoader();
if (cl == null) {
cl = this.getClass().getClassLoader();
}
ServiceLoader loaders = ServiceLoader.load(cls, cl);
for (T loader : loaders) {
container.put(cls, loader);
return loader;
}
}
throw new RuntimeException(e.getMessage());
}
}
使用:
@service
public class MyRoleServiceImpl extends ServiceLoaderContainer implments MyRoleService{
protected RoleService getRoleService() {
return getService(RoleService.class);
}
...
}
RoleServiceImpl有两个不同的实现,分别在包environment1和environment2中,使用的时候只需要使用maven -P 指定编译打包时需要使用到的jar包依赖,在运行时通过ServiceLoaderContainer调用ServiceLoader loaders = ServiceLoader.load(cls, cl);加载接口类RoleService的具体的类实现。