JAVA SPI机制详解

一、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使用规范

  1. 定义服务的通用接口,针对通用的服务接口,提供具体的实现类。
  2. 在jar包的META-INF/services/目录中,新建一个文件,文件名为 接口的"全限定名"。 文件内容为该接口的具体实现类的"全限定名"。
  3. 将spi所在jar放在主程序的classpath中
  4. 服务调用方用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<INumOperate> loader = ServiceLoader.load(INumOperate.class); // 查找SPI实现类,并加载到jvm
        Iterator<INumOperate> 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文件:

<profiles>
 <profile>
            <id>environment1</id>
            <activation>
                <activeByDefault>true</activeByDefault>
            </activation>
            <dependencies>
                <dependency>
                    <groupId>com.alibaba.work.demo</groupId>
                    <artifactId>environment1</artifactId>
                </dependency>
            </dependencies>
 </profile>

<profile>
            <id>environment2</id>
            <activation>
                <activeByDefault>false</activeByDefault>
            </activation>
            <dependencies>
                <dependency>
                    <groupId>com.alibaba.work.demo</groupId>
                    <artifactId>environment2</artifactId>
                </dependency>
            </dependencies>
 </profile>
<profiles>

SPI加载类ServiceLoaderContainer,用于保存类及其对应的类加载器:

public class ServiceLoaderContainer {

    private static Map<Class<?>, Object> container = new HashMap<Class<?>, Object>();

    @SuppressWarnings( {"unchecked", "unused"} )
    protected <T> T getService(Class<T> 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<T> 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<T> loaders = ServiceLoader.load(cls, cl);加载接口类RoleService的具体的类实现。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 216,372评论 6 498
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 92,368评论 3 392
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 162,415评论 0 353
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 58,157评论 1 292
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 67,171评论 6 388
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 51,125评论 1 297
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 40,028评论 3 417
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 38,887评论 0 274
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 45,310评论 1 310
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,533评论 2 332
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,690评论 1 348
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 35,411评论 5 343
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 41,004评论 3 325
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,659评论 0 22
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,812评论 1 268
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 47,693评论 2 368
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,577评论 2 353

推荐阅读更多精彩内容

  • SPI全称Service Provider Interface,是Java提供的一套用来被第三方实现或者扩展的AP...
    探索者_逗你玩儿阅读 441评论 0 2
  • 本文通过探析JDK提供的,在开源项目中比较常用的Java SPI机制,希望给大家在实际开发实践、学习开源项目提供参...
    caison阅读 125,675评论 25 156
  • 本文通过探析JDK提供的,在开源项目中比较常用的Java SPI机制,希望给大家在实际开发实践、学习开源项目提供参...
    哥本哈登_sketch阅读 276评论 0 1
  • 当你认识一个人时,就是远看高山,眼里满是崇拜。了解了,就是上山,你看到的都是普通细节。到了山顶,你眼中也只是看到另...
    笙箫默馨苑阅读 251评论 0 1
  • 聚散无常苦匆匆 风咽河悲云不语 雁阵人字断 欢歌笑语舞升平 转首挥别即 前世千百次回眸 今世挚友逢 怎奈离愁萦 流...
    暮潇潇阅读 319评论 17 9