环境
Java 1.8
API
想必大家都很熟悉,开发人员,日常开发中干的基本都是API开发
。
API
全称:Application Programming Interface
应用程序接口。
类似上图的情形,我们称之为API
,规则如下:
- 服务方提供接口规则,并实现。
- 调用方需对服务方有依赖引用。
我们现在就基于API
思路来,来为JDK
设计一套数据库访问的接口,即:我们自己来设计一下JDBC
的思路;
我们首先要定义一套数据库的交互接口,接着去实现它,然后开发人员在引入JDK
时,就可以开发使用了。
如下图:
根据上面的流程图,很容易发现按照API
的思路去设计会有很严重的问题:
-
JDK
和数据库进行了强耦合。
导致,如:MySQL
迭代升级后,实现变更了,JDK
也得跟着变;
这种问题,很明显是设计上的问题,因为数据库厂家不同,实现应该由数据库厂家自己去实现,但是数据库访问的接口该谁来定义呢?由数据库厂家定义的话,大家都各自定义一套自己的标准,这样JDK
用起来非常麻烦,依然是强耦合。那就只能JDK
来定义标准了。
像这种:JDK
定义接口标准,数据库厂商去实现这些标准类,我们把这种设计形式称为 SPI
。
SPI
全称:Service Provider Interface
,是一种服务发现机制
。
SPI机制的概念
以下概念来自 复制粘贴
SPI
的全名为Service Provider Interface
,主要是应用于厂商自定义组件或插件中,在java.util.ServiceLoader
的文档里有比较详细的介绍。
简单的总结下java SPI机制
的思想:我们系统里抽象的各个模块,往往有很多不同的实现方案,比如日志模块、xml解析模块、jdbc模块等方案。面向的对象的设计里,我们一般推荐模块之间基于接口编程,模块之间不对实现类进行硬编码。一旦代码里涉及具体的实现类,就违反了可拔插的原则,如果需要替换一种实现,就需要修改代码。为了实现在模块装配的时候能不在程序里动态指明,这就需要一种服务发现机制
。 Java SPI
就是提供这样的一个机制:为某个接口寻找服务实现的机制。
Java SPI的具体约定为:当服务的提供者提供了服务接口的一种实现之后,在jar包的META-INF/services/
目录里同时创建一个以服务接口命名的文件
,该文件里就是实现该服务接口的具体实现类。而当外部程序装配这个模块的时候,就能通过该jar包META-INF/services/里的配置文件找到具体的实现类名,并装载实例化,完成模块的注入。基于这样一个约定就能很好的找到服务接口的实现类,而不需要再代码里制定。jdk
提供服务实现查找的一个工具类:java.util.ServiceLoader
。
OK,概念看完了,是不是看的头晕,我看的也头晕,但是不要慌,且听我叨叨;
假设我们按照SPI
来设计JDBC
,那么流程图如下:
实际上JDK就是按照SPI的思路来设计JDBC的。
根据SPI
的特点,我们会发现:
- 服务方(
JDK
)提供接口标准 - 实现方独立的
jar
包,作为接口的实现;即:实现类的提供方。此处场景就是 - 服务方(
JDK
),需要有一种服务发现的能力,才能找到实现类
。
概念基本讲完了,很好理解,接下来我们来探讨下,服务发现机制。
服务发现机制
顾名思义,服务方定义好标准接口后,利用办法去把其实现类找到。
我们日常开发中会遇到两类:
- 一类是在项目里引入jar包。
- 另一类是在微服务的环境中的服务治理中去发现。
这里只讲引jar包的方式,对JDK
来说,就是从classpath
中如何加载类的问题?
这里就得讲讲JDK工具类ServiceLoader
。
我们看看JDK
是怎么使用它的:
这段代码是DriverManager类中loadInitialDrivers()方法里一段逻辑;
//代码逻辑片段
...
ServiceLoader<Driver> loadedDrivers = ServiceLoader.load(Driver.class);
Iterator<Driver> driversIterator = loadedDrivers.iterator();
try{
while(driversIterator.hasNext()) {
driversIterator.next();
}
} catch(Throwable t) {
// Do nothing
}
...
简单分析下源码:
public static <S> ServiceLoader<S> load(Class<S> service) {
//从当前线程中获取 线程上下文类加载器
//默认情况下,就是AppClassLoader,也就是系统加载器。
ClassLoader cl = Thread.currentThread().getContextClassLoader();
return ServiceLoader.load(service, cl);
}
private ServiceLoader(Class<S> svc, ClassLoader cl) {
//这里就是Driver.class
service = Objects.requireNonNull(svc, "Service interface cannot be null");
//线程上下文类加载器
loader = (cl == null) ? ClassLoader.getSystemClassLoader() : cl;
acc = (System.getSecurityManager() != null) ? AccessController.getContext() : null;
reload();
}
接着我们来看看while(driversIterator.hasNext())
这段源码:
public boolean hasNext() throws ServiceConfigurationError {
if (this.nextName != null) {
return true;
} else {
if (this.configs == null) {
try {
//会去classpath路径中的META-INF/services/去寻找以接口标准命名的文件。
//在JDK中JDBC实现中就是java.sql.Driver。
//也就是说JDK提供的ServiceLoader工具类会去classpath中META-INF/services/路径下去找以java.sql.Driver命名的文件
//然后读取文件,文件里写着的就是接口的实现类
String var1 = "META-INF/services/" + this.service.getName();
if (this.loader == null) {
this.configs = ClassLoader.getSystemResources(var1);
} else {
this.configs = this.loader.getResources(var1);
}
} catch (IOException var2) {
Service.fail(this.service, ": " + var2);
}
}
while(this.pending == null || !this.pending.hasNext()) {
if (!this.configs.hasMoreElements()) {
return false;
}
this.pending = Service.parse(this.service, (URL)this.configs.nextElement(), this.returned);
}
this.nextName = (String)this.pending.next();
return true;
}
}
通过阅读源码我们知道:
- JDK提供的
ServiceLoader
工具类会去classpath中META-INF/services/路径下去找以java.sql.Driver
命名的文件。 - 接着从文件中得到标准接口的实现类,并用
线程上下文类加载器
加载进JVM
。
我们进一步思考,为什么要使用线程上下文类加载器?
为什么要设计线程上下文类加载器?
我们知道jdk从1.2
开始,使用双亲委派的方式来加载类。
Bootstrap classLoader
:主要负责加载核心的类库(java.lang.*等)
,构造ExtClassLoader和APPClassLoader。ExtClassLoader
:主要负责加载jre/lib/ext目录
下的一些扩展的jar。AppClassLoader
:主要负责加载应用程序的主函数类
我们开发人员编写的类,包括第三方类库,都是由AppClassLoader
来进行加载的。
回到上面的SPI
设计思路,MySQL提供的驱动类库,就是AppClassLoader
类加载器来加载的。
既然如此为什么还要设计上下文类加载器
呢?
首先我们要知道,标准接口java.sql.Driver
是在核心类库里的,也就是说JDK中的java.sql.Driver
是由Bootstrap classLoader
(启动类加载器)进行加载的。那么它的实现类,自然也得是Bootstrap classLoader
进行加载。那如果Bootstrap classLoader
加载不了怎么办?(第三方类库不在核心包路径下,Bootstrap classLoader是无法加载的)
Bootstrap classLoader无法加载怎么办?
按照双亲委派机制
,如果上层类加载器加载不了,那就就给下一层的类加载器。
按照双亲委派
的机制,由系统类加载器,加载第三方类库(比如:MySQL驱动
),根据规则,最终会交给启动类加载器
进行加载。也就符合了SPI接口java.sql.Driver
由启动类加载器加载,实现类也得是启动类加载器加载。
这么看来,线程上下文类加载器,设计的不是有点多余吗?
到底哪里理解错了?
真正的原因:
在加载第三方类库时,加载Driver
类的实现类时,一开始就不是系统类加载器去加载的,而是直接使用启动类加载器
进行加载:
如图,一上来就是启动类加载器进行加载,而启动类加载器只加载核心包的类,第三方的类库肯定加载不了。为了解决这个问题,才设计了线程上下文类加载器
。
而ServiceLoader
工具类,可以理解为就是对线程上下文类加载器
进行了封装。
并且我自己理解:线程上下文类加载器,其实也就是一个壳,默认情况下,它会被赋值为系统类加载器。
也就是为什么要破坏双亲委派,引入线程上下文类加载器
的原因。
双亲委派的破坏
上面讲的SPI
的场景,是第二次破坏双亲委派。
到目前为止,总共有四次破坏了双亲委派。
第一次,历史原因,在jdk1.2
开始引入双亲委派机制,那么在此之前如 jdk1.1的时候,存在的自定义的类加载器,它们就不符合双亲委派。而实际上,我们可以继承ClassLoader
类,然后重写loadClass
方法,也可以打破双亲委派。(打破双亲委派,不一定是坏事)
第二次,SPI,上面已经讲了,引入了线程上下文特意打破双亲委派。
第三次,用户对程序动态性的追求导致的;即:代码热替换、模块热部署。OSGi
实现模块化热部署的关键则是它自定义的类加载器机制的实现。每一个程序模块(Bundle)都有一个自己的类加载器,当需要更换一个Bundle时,就把Bundle连同类加载器一起换掉以实现代码的热替换。在OSGi幻境下,类加载器不再是双亲委派模型中的树状结构,而是进一步发展为更加复杂的网状结构。
第四次,是JDK9,引入了模块化,当平台及应用程序类加载器收到类加载请求,在委派给父加载器加载前,要先判断该类是否能够归属到某一个系统模块中,如果可以找到这样的归属关系,就要优先委派给负责哪个模块的加载器完成加载。
经过破坏后的双亲委派模型更加高效,减少了很多类加载器之间不必要的委派操作。
练习
最近人民日报,批评国内互联网巨头,不要盯着老百姓的菜篮子,要向往科技创新的星辰大海。但商人逐利,肯定不怎么听话。
假设高层要求,外卖接口以后全国统一,国家实现统一的外卖平台。现有的外卖平台:开水团和饿不死,将作为内容提供方;即:国家制定SPI接口,开水团和饿不死 实现这些接口,该怎么实现?
自己模拟的话,就需要有个思路:
- 模拟服务方定义
SPI接口
- 模式提供方实现
SPI的接口
- 利用工具类
ServiceLoader
,模拟服务发现。在resources目录下创建META-INF/services
创建SPI接口全类限定名的文件,里面存放实现类的全类限定名。
模拟服务方定义接口
package com.ssm.boot.admin.java.spi.waimai.spi;
/**
* SPI 点外卖
*/
public interface OrderTakeOut {
void order();
}
模式提供方实现SPI的接口
饿不死:
package com.ssm.boot.admin.java.spi.waimai.provider;
import com.ssm.boot.admin.java.spi.waimai.spi.OrderTakeOut;
public class EbsOrder implements OrderTakeOut {
@Override
public void order() {
System.out.println("饿不死下单了");
}
}
开水团:
package com.ssm.boot.admin.java.spi.waimai.provider;
import com.ssm.boot.admin.java.spi.waimai.spi.OrderTakeOut;
public class WaterOrder implements OrderTakeOut {
@Override
public void order() {
System.out.println("开水团下单了");
}
}
模拟服务发现
文件存在:
目录显示META-INF.services这是Intellij IDEA显示问题。
package com.ssm.boot.admin.java.spi.waimai;
import com.ssm.boot.admin.java.spi.waimai.spi.OrderTakeOut;
import java.util.Iterator;
import java.util.ServiceLoader;
public class TakeOutTest {
public static void main(String[] args) {
//这里是模拟服务发现
ServiceLoader<OrderTakeOut> load = ServiceLoader.load(OrderTakeOut.class);
Iterator<OrderTakeOut> iterator = load.iterator();
while (iterator.hasNext()) {
OrderTakeOut next = iterator.next();
next.order();
}
}
}
打印的结果:
开水团下单了
饿不死下单了
总结
-
SPI
最常用的场景就是插件的开发。比如eclipse
制定接口标准,然后开发人员根据这些接口标准去做具体实现,来开发插件。 - SPI 开发步骤:
2.1 服务方提供SPI接口
2.2 提供方(或者调用方)实现接口
2.3 服务方利用服务发现机制,找到SPI接口的实现。 - 服务发现机制有两种:1. 引jar包的方式;2. 利用服务治理的技术来发现。
参考地址:
Java SPI思想梳理JDBC与SPI机制SPI与API设计原则:小议 SPI 和 API看评论,文章写得一般
谈谈双亲委派模型的第四次破坏-模块化