一、前言
今年初,我在做需求时一位好友告诉我,有一种非常高明的技术方案可以将现有项目中的代码变的更加精简、漂亮,它的名字叫SPI,问我想不想搞。由于我当时忙着搞一堆紧急需求,实在没心思搞新技术,于是含糊的婉拒了。现在回想起这事儿,有点后悔,若当时挤出时间搞搞,说不定我后来再要工资时还能再多加点,哈哈哈哈哈。
二、SPI 技术原理剖析
2.1 传统面向接口编程的局限性
在正式讲SPI 编程前,先用一个接口编程举个例子作为话题的引入。
现在出一个关于播放音乐的业务接口:
Business.kt
interface Business {
fun play()
}
- A 渠道要求每次播放前先弹一个Toast。
- B 渠道要求每次播放前先弹一个对话框。
接下来我们按照传统的面向接口形式开发去实现上述业务。
A.kt
class A :Business{
override fun play() {
// 显示Toast
}
}
B.kt
class B :Business {
override fun play() {
// 显示弹窗
}
}
class Core {
lateinit var mAction:Business
/**
* 2、注册接口实现
*/
fun registerBusiness(action : Business){
mAction = action
}
/**
* 3、执行播放操作
*/
fun doPlay(){
mAction.play()
}
}
User.kt
class User {
init {
// 1、指明一个具体实现。
Core().registerBusiness(A())
}
}
用户在播放时显示Toast,直接在使用时注册A对象即可。
上面的写法也是绝大多数面向接口编程的操作(基本都是这个操作流程)。但是这样的写法流程是否真的很完美?在我看来有如下问题:
- 实现模块注册的时候需要具体指明,如果要用户想要换成B实现,需要手动修改代码,注册方法里变成B()。
- 注册对象单一,如果一个接口有多个实现,上述的mAction不支持,需要用数组包装,不够智能。
- 注册流程较为繁琐,不够精简。
上面列的问题,最核心的就是第一条,即每次要换个业务的具体实现就要改代码!违反了软件设计原则中的”可插拔原则“。
那么有什么办法可以解决这个问题 ?
2.2 SPI 技术
2.2.1 SPI技术简介
SPI的全名为Service Provider Interface.java spi,机制的思想: 系统里抽象的各个模块,往往有很多不同的实现方案,在面向的对象的设计里,一般推荐模块之间基于接口编程,模块之间不对实现类进行硬编码。一旦代码里涉及具体的实现类,就违反了可拔插的原则,如果需要替换一种实现,就需要修改代码。为了实现在模块装配的时候能不在程序里动态指明,这就需要一种服务发现机制。 java spi就是提供这样的一个机制:为某个接口寻找服务实现的机制。有点类似IOC的思想,就是将装配的控制权移到程序之外,在模块化设计中这个机制尤其重要。所以SPI的核心思想就是解耦。
有了SPI标准,SUN公司只需要提供一个播放接口,在实现播放的功能上通过ServiceLoad的方式加载服务,那么第三方只需要实现这个播放接口,再按SPI标准的约定进行打包,再放到classpath下面就OK了,没有一点代码的侵入性。
2.2.2 SPI的约定
java spi的具体约定为:当服务的提供者,提供了服务接口的一种实现之后,在jar包的META-INF/services/目录里同时创建一个以服务接口命名的文件。该文件里就是实现该服务接口的具体实现类。而当外部程序装配这个模块的时候,就能通过该jar包META-INF/services/里的配置文件找到具体的实现类名,并装载实例化,完成模块的注入。 基于这样一个约定就能很好的找到服务接口的实现类,而不需要再代码里制定。jdk提供服务实现查找的一个工具类:java.util.ServiceLoader。
2.2.3 补充
前面说的这个约定操作是不是很眼熟,没错,它和自定义Gradle插件非常相似!只是META-INF后面的文件名和SPI定义的不同。
上图是自定义Gradle插件的资源声明。
上图是SPI文件的声明。
2.3 使用SPI技术改造
接口的声明和接口的实现部分,和之前写的一样。
接下来主要有两步:
- 创建业务接口SPI文件,并声明要使用的业务接口实现类的类路径信息。
还是这张图,这里的"com.coffer.test1.Business",对应的就是接口Business的路径。在这个文件里,将所需要使用的业务实现接口实现类的路径写上。这个时候你再点击图中"coffer.test1.A",会自动跳转A类文件中。 - 加载业务实现类,并调用接口方法,具体实现如下:
Core.kt
class Core {
// 1、使用ServiceLoader加载Business接口的实现类,并实例化对象,存储到内部的HashMap中
val businessService: ServiceLoader<Business> = ServiceLoader.load(Business::class.java)
fun play() {
// 2、遍历map里的实现类,调用实现类的接口方法
val business: Iterator<Business> = businessService.iterator()
while (business.hasNext()) {
business.next().play()
}
}
}
对比之前的写法,是不是发现精简了很多。总结下:
- 使用SPI配置文件,取代手动业务注册。想要使用哪个业务实现类,直接修改SPI文件里的声明接口,不需要写代码,这个文件设置可以后台动态下发。
- 使用ServiceLoader加载SPI文件,并自动创建该业务接口所配置实现的对象(实例化),同时也支持多个接口的实现,不需要手动去创建HashMap去管理接口实现。
关于ServiceLoader的原理,我在下一小结分析。
2.4 SPI技术原理分析
SPI 的核心就是ServiceLoader.java。接下来就带着各位分析这个类的实现原理。
Android SDK下的ServiceLoader.java,和JDK下的ServiceLoader.java实现上会有有点点不同。我下面分析ServiceLoader.java是Android-28里的。
ServiceLoader.java
public final class ServiceLoader<S>
implements Iterable<S>
{
// 1
private static final String PREFIX = "META-INF/services/";
....
// 2
private final ClassLoader loader;
// 3
private LinkedHashMap<String,S> providers = new LinkedHashMap<>();
....
- 指向的就是我们创建SPI所在的文件夹的位置。
- 这里的loader 是指当前类所在的父ClassLoader,注意 : 这里我格外强调它是因为在Android平台,有个插件化技术,可以自定义ClassLoader,一旦ClassLoader找错了,这里必崩!一定要注意!!!
- providers是一个LinkedHashMap,其中key就是SPI文件里接口实现类的路径字符串,S就是接口实现类的实例化对象。
public static <S> ServiceLoader<S> load(Class<S> service) {
ClassLoader cl = Thread.currentThread().getContextClassLoader();
return ServiceLoader.load(service, cl);
}
.....
构造方法的调用链较长,且逻辑不复杂,这里我说下流程,不展示源码。
- 给前面提到的成员变量赋值,例如loader。并对变量的有效性校验。
- 重置成员变量的状态,例如providers.clear()。
总结一句话,load方法并没有真正的load数据,只是做了load的准备操作。
那真正load数据是什么时候 ?
// 1
val business: Iterator<Business> = businessService.iterator()
// 2
while (business.hasNext()) {
business.next().play()
}
关于迭代器Iterator,ServiceLoader里自己实现了一个LazyIterator,并重写了里面的方法。
加载解析逻辑较为复杂,这里我分三个部分来解析
public Iterator<S> iterator() {
return new Iterator<S>() {
Iterator<Map.Entry<String,S>> knownProviders
= providers.entrySet().iterator();
public boolean hasNext() {
if (knownProviders.hasNext())
return true;
// 1
return lookupIterator.hasNext();
}
//
public S next() {
if (knownProviders.hasNext())
return knownProviders.next().getValue();
return lookupIterator.next();
}
.....
};
}
public boolean hasNext() {
return hasNextService();
}
private boolean hasNextService() {
if (nextName != null) {
return true;
}
if (configs == null) {
try {
String fullName = PREFIX + service.getName();
if (loader == null)
configs = ClassLoader.getSystemResources(fullName);
else
configs = loader.getResources(fullName);
} catch (IOException x) {
......
}
}
while ((pending == null) || !pending.hasNext()) {
if (!configs.hasMoreElements()) {
return false;
}
// 2
pending = parse(service, configs.nextElement());
}
// 3
nextName = pending.next();
return true;
}
- 首次加载时,providers里没有数据,因此执行下面的hasNext,hasNext会中转执行hasNextService。
- 这是去读SPI文件里的信息,主要是将文件里的接口实现类的地址字符串放到Iterator中。
- 获取一个接口实现类的地址字符串,方便后面使用。
接下来业务调用方法看
business.next().play()
里面的具体实现如下:
public S next() {
return nextService();
}
private S nextService() {
if (!hasNextService())
throw new NoSuchElementException();
String cn = nextName;
nextName = null;
Class<?> c = null;
try {
// 1
c = Class.forName(cn, false, loader);
} catch (ClassNotFoundException x) {
...
}
......
try {
// 2
S p = service.cast(c.newInstance());
// 3
providers.put(cn, p);
return p;
} catch (Throwable x) {
....
}
....
}
始化接口实例化,并将实例化的对象放入providers 中,进而可以调用实现的接口方法。
总结:
ServiceLoader主要做的事情就是读取SPI文件里的接口实现类信息,然后通过反射创建接口实现类的对象,并存储到LinkedHash中方便后续使用。
三、Android 使用SPI技术
既然SPI 技术如此好用,为什么在Android领域很少看到?
3.1 原生SPI 技术在Android 上的局限性
- 易造成资源冲突;Java中的SPI是随jar包发布的,每个不同的jar都包含一堆SPI的配置信息,而Android应用在构建的时候最终会将项目中所有的jar包进行合并,这会导致相同的SPI会产生资源冲突。
- 影响性能;根据前面关于SPI 原理的分析,SPI 是通过ClassLoader在运行时从jar包中读取,由于apk是签名的,在从jar中读取的时候,签名校验的耗时问题严重影响应用的启动速度。同时ServiceLoader每次加载都有读文件的操作,以及使用反射创建对象,这些对性能的影响不能忽视。
- 配置繁琐;每个接口都要手动创建一个SPI文件,并且还要手动去写文件的内容。如果项目中的接口数量非常多,这个配置SPI文件的工作量将会非常的大!
3.2 自定义Android SPI
由于原生SPI 有很大的局限性,因此,只要我们理解了ServiceLoader的工作原理,我们自己实现一个ServiceLoader,然后逐个攻破原生的局限性。
3.2.1 设计思路
- 为了解决资源冲突问题,可以将SPI文件不放在META-INF中,而是放在一个我们自定义的文件夹中,为此需要自定义ServiceLoader,修改文件解析方法。
- 由于第一个问题和第二个问题是关联性的,因此第二个问题也很好解决,同时可以直接new对象,无需反射。
- 关于配置繁琐问题,我们可以自定义Gradle插件,在编译编译APK时,通过扫描所有的class文件,生成对应的SPI文件,并将该文件写入我们自定义的文件夹中。
- 还是关于第三个问题优化,为了方便控制哪些接口需要写SPI,哪些接口不写SPI,可以给需要声明SPI接口的类添加一个自定义的注解。在编译扫描class时,只要该类上有我们声明的注解标签,就为该接口创建一个实现类。
- 原生的ServiceLoader比较功能比较臃肿。它将SPI文件解析、注册、存储都集结与一身,可以优化下,将文件解析、注册放入单独的一个类去做,这里建议放在自定义Gradle插件中去自动生成实现。
3.2.2 功能实现
根据前面的分析,我们需要设计如下几个部分:
- 自定义ServiceLoader。
- 自定义Gradle插件去写SPI文件,同时还需要在编译期创建一个注册类将SPI文件的接口和实现类进行绑定。这里涉及到JavaCompile、JavaFile相关的知识。
- 自定义注解,以及注解解析。
上面三个部分,主要是第二部分最为复杂,自定义注解的解析也是放在自定义Gradle插件中去做的。因此接下来代码实现,我将不会把完整的代码贴出,而是伪代码和部分实现代码相结合。各位主要搞懂实现原理,这个很重要。
【关于完整的Demo,这里先挖个坑,后续有时间把Demo代码完善后再提交】
3.2.2.1 自定义Gradle插件
所要做的事情:
- 创建SPI文件。
- 创建SPI注册描述类。
- 声明注解、解析注解
先声明一个自定义注解:
CofferTag.java
// 这里的注解是精简版,可以根据自己的需求加很多属性
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
public @interface CofferTag {
// 需要生成的SPI接口类
Class<?>[] value();
}
CofferPlugin.java
public class CofferPlugin implements Plugin<Project> {
@Override
public void apply(Project target) {
// 1、创建一个SPI文件夹
File spiFile;
// 2、创建一个SPI信息注册管理类,这里可以新开一个Task去实现
CofferReisterTask task;
// 3、创建一个编译Java的文件对象,将刚刚生成的Java文件进行编译
JavaCompile compile;
// 4、设置任务依赖关系和执行顺序,必须是先生成Java文件,然后才能对文件进行编译
......
}
}
CofferReisterTask.java
public class CofferTask extends DefaultTask {
@TaskAction
protected void generateCofferCode(){
// 1、根据文件路径,加载工程中所有的类,这里会涉及到读文件的过程
List<CtClass> classes = loadClass();
// 2、遍历这个集合,过滤并生成SPI文件
process();
// 3、生成SPI 信息注册实现类CofferReister.java
generateCode();
}
}
private void process(){
// 1、判断这个类是否包含自定义注解,如果不包含,直接return
// 2、获取注解属性的值
// 3、根据注解的值、前面传入的生成SPI文件夹的信息,生成当前接口对应的SPI文件
}
// 这个方法除了要生成CofferReister.java
// CofferReister.java索要做的事情就是遍历SPI文件的接口信息,然后行注册,
// 将接口类和接口实现类使用map进行关联存储。
private void generateCode(){
// 使用JPT(JavaPoet)技术生成CofferReister.java,这个东西其实也不是很难,我举个例子
// 比如要import java.util.map;
ClassName.get("java.util","map");
// 如果要新增一个get方法
TypeSpec.classBuilder("CofferRegister")
.addMethod(...)
// 这个方法的复杂程度,取决于你要生成的类的复杂程度
// 最后记得把Java文件写入自定义的文件夹
javaFile.builder(...).write(...);
}
3.2.2.1 自定义ServiceLoader
由于将SPI文件的解析、接口的对象的创建放到了我们在编译时生成的CofferReister.java文件,因此我们自定义的ServiceLoader将会变的非常精简。
public final class ServiceLoader<S>
implements Iterable<S>
{
// 将原先关于SPI文件解析、定义Iterator统统不要,这些都在CofferReister.java处理了
// 我们只需要调用初始化方法即可。
//
}
3.2.2.3 效果
接下来还是用我们前面写的demo进行改造
@CofferTag(Business::class)
class A :Business{
override fun play() {
// 显示Toast
}
}
@CofferTag(Business::class)
class B :Business{
override fun play() {
// 显示弹窗
}
}
我们在引入自定义的Gradle插件,编译后就会在上述目录中生成如上内容。
class Core {
val businessService: ServiceLoader<Business> = ServiceLoader.load(Business::class.java)
fun play() {
val business: Iterator<Business> = businessService.iterator()
while (business.hasNext()) {
business.next().play()
}
}
}
调用逻辑还是不变。
如果该实现类不想要了,直接去掉实现类上的注解去掉。如果想要新增,直接加上注解即可,其他的什么都不用管。
四、总结
很多好的东西,虽有瑕疵,但只要动脑筋改造,就会变的完美。