好玩系列 | 当SPI 和 设计模式、依赖注入发生碰撞,可以擦出怎样的火花

前言

前段时间阅读到一篇文章,关于Service-Provider-Interface机制(SPI机制),在评论区看到一条评论:

spi实现类是不是只能是空构造函数?

后续我又回味了一下,这个问题可以引出很多有趣的内容,决定系统性的思考并分享讨论一番。

作者按:有时候思考未必能获得令人振奋的完美答案,但这种思考是触发质变的积累

因为讨论内容的scope比较广,而我的行文思路比较跳跃,为尽可能避免阅读时乏力,读者诸君可参考以下导图:

文中涉及的代码可于本仓库获取

SPI机制简介

Service provider interface (SPI) is an API intended to be implemented or extended by a third party. It can be used to enable framework extension and replaceable components.

Service provider interface 是能被第三方继承或者实现的API,可以用作框架扩张或者可变组件

不难理解,核心需要:

  • 预先定义服务接口,即SPI接口
  • 由提供服务的模块自行实现SPI接口,并在Meta info中注册
  • 提供服务的模块由某种机制被加载,例如编译时、运行时,一般使用编译时,运行时将涉及插件化等
  • 发现并加载服务实现

Demo

定义以下module,依赖关系如下:

  • api 用于接口和模型类定义
  • host 为主工程
    • 编码时,依赖api
    • 编译时,依赖api 和 服务提供模块
  • module-a 一个服务提供模块,依赖api

方便起见,不再定义多个服务提供模块,实现类均置于module-a中,读者应当能够理解,host通过编译时确定服务提供模块,是一种"可变组件"的实现方式

  1. 在api中定义接口:
kotlin 复制代码
interface DemoApi {
    fun doSth(): String
}
  1. 在module-a中定义实现类
kotlin 复制代码
@AutoService(DemoApi::class)
class ModuleADemoApiImpl : DemoApi {
    companion object {
        const val NAME = "ModuleADemoApiImpl"
    }

    override fun doSth(): String {
        return "the result by $NAME"
    }
}

注意,还需要在Meta info中进行注册,手工操作比较麻烦,直接借助Google的AutoService。

注意,Demo中图方便使用Kotlin,因此需使用kapt,如果日常习惯使用ksp,Zacsweers提供了AutoService的ksp版,并需要处理打包资源目录

内容如下:

arduino 复制代码
osp.leobert.android.module.a.ModuleADemoApiImpl

模块加载即为声明dependency并编译,略去。

使用

kotlin 复制代码
fun directLoadDemo() {

    val loader = ServiceLoader.load(DemoApi::class.java)
    val iterator = loader.iterator()
    var hasNext = false
    do {
        try {
            hasNext = iterator.hasNext()
            if (hasNext) {
                iterator.next().let {
                    println("find a impl of DemoApi, doSth:")
                    println(it.doSth())
                    println()
                }
            }
        } catch (e: Throwable) {
            println("thr: " + e.message)
        }
    } while (hasNext)

    println("finish directLoadDemo\r\n")
}

运行将在控制台观测到:

arduino 复制代码
find a impl of DemoApi, doSth:
the result by ModuleADemoApiImpl

面向问题

上文已废诸多笔墨,演示了SPI的使用,让我们重新回到问题:

使用SPI时,SPI实现类是不是必须要无参构造函数?

不难理解,这需要从服务加载过程寻找答案。接下来我们分析下 ServiceLoader 中关于服务加载的核心代码。

原因分析-ServiceLoader核心代码

作者按:为什么要写这一段?

SPI是一种机制,既然是机制,就可以有多种实现手段,这里是Java中提供的一种手段!

阅读了解这一手段的实现可以帮助理解机制,并且能举一反三地联想到其他手段.唯有自行阅读才能有最深地体会!

读者应当留意到,服务发现和服务加载的核心是ServiceLoader,答案也在其中。

作者按:可能大部分读者都是Android开发者,我挑选android.jar中的代码。注意,JDK中不同版本的源码不一致;Android发展历程中可能也发生过演变,未寻找证据

我们先关注两个核心方法:

  • static <S> ServiceLoader<S> load(Class<S> service)
  • Iterator<S> iterator()
java 复制代码
class ServiceLoader {
    private LazyIterator lookupIterator;

    public static <S> ServiceLoader<S> load(Class<S> service) {
        ClassLoader cl = Thread.currentThread().getContextClassLoader();
        return ServiceLoader.load(service, cl);
    }

    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;
                return lookupIterator.hasNext();
            }

            public S next() {
                if (knownProviders.hasNext())
                    return knownProviders.next().getValue();
                return lookupIterator.next();
            }

            public void remove() {
                throw new UnsupportedOperationException();
            }
        };
    }
}

很明显,服务发现和服务加载过程中需要利用 ClassLoader相关知识不再展开,此处可引发大量黑科技联想

knownProviders 是已加载实例的池,不是重点,重点是 lookupIterator

这部分代码略长,核心点在于:

  • 基于 ClassLoader 加载指定的Resource,即 META-INF/services/{接口类名},还记得AutoService生成的文件吗?
  • 解析内容获得类名
  • 通过反射加载类
  • 调用 Class#newInstance() 获得实例

Class#newInstance() 制约了服务实现类必须要有无参构造函数。

代码如下,泛读体会即可

java 复制代码
private class LazyIterator implements Iterator<S> {

    Class<S> service;
    ClassLoader loader;
    Enumeration<URL> configs = null;
    Iterator<String> pending = null;
    String nextName = null;

    private LazyIterator(Class<S> service, ClassLoader loader) {
        this.service = service;
        this.loader = loader;
    }

    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) {
                fail(service, "Error locating configuration files", x);
            }
        }
        while ((pending == null) || !pending.hasNext()) {
            if (!configs.hasMoreElements()) {
                return false;
            }
            pending = parse(service, configs.nextElement());
        }
        nextName = pending.next();
        return true;
    }

    private S nextService() {
        if (!hasNextService())
            throw new NoSuchElementException();
        String cn = nextName;
        nextName = null;
        Class<?> c = null;
        try {
            c = Class.forName(cn, false, loader);
        } catch (ClassNotFoundException x) {
            fail(service, "Provider " + cn + " not found", x);
        }
        if (!service.isAssignableFrom(c)) {
            ClassCastException cce = new ClassCastException(service.getCanonicalName() + " is not assignable from " + c.getCanonicalName());
            fail(service, "Provider " + cn + " not a subtype", cce);
        }
        try {
            S p = service.cast(c.newInstance());
            providers.put(cn, p);
            return p;
        } catch (Throwable x) {
            fail(service, "Provider " + cn + " could not be instantiated", x);
        }
        throw new Error();          // This cannot happen
    }

    public boolean hasNext() {
        return hasNextService();
    }

    public S next() {
        return nextService();
    }

    public void remove() {
        throw new UnsupportedOperationException();
    }
}

解决方案

严格来说,如果项目中:

  • 严谨且健壮且全面 的 对象生命周期管理,并且与对象实例化时间无关联
  • 通过其他途径,不依靠构造器做依赖注入

那么将可消灭源头问题,即:没有使用有参构造器的必要。但现实比较骨感,这种假设过于理想化,并且会对编码习惯带来很多冲击。

如果非要使用含参构造器,有以下思路:

  • 暗度陈仓,不直接提供服务实现,而是基于SPI机制和现有实现,提供一个新服务,该服务满足"创建、获取特定服务"的需求,将实例的创建过程与获取过程分离 。通俗地讲,定义的Interface为目标api-Interface的Factory或者Builder
  • 力大砖飞,自实现SPI机制,通俗地讲,即自定义ServiceLoader

与设计模式碰撞

换个角度看待问题,使用 ServiceLoader 时,其同时实现了:

  • 服务发现
  • 服务加载(实例化)

问题在于,实例化方式不满足服务提供者期望,而服务使用者关心点在于发现服务并使用服务,此时则不难想到设计模式。

如Factory模块,其创建对象时,无需对使用者暴露创建的逻辑。

使用Factory模式

此时,SPI接口不再是原服务接口,而是原服务接口的Factory

Demo

kotlin 复制代码
interface DemoApiFactory {
    fun create(): DemoApi
}

模块提供Factory实现

kotlin 复制代码
class SomeOp {
    fun execute(): String {
        return "[result of SomeOp $this]"
    }
}

//@AutoService(DemoApi::class)
class ModuleADemoApiImpl2(val someOp: SomeOp) : DemoApi {
    companion object {
        const val NAME = "ModuleADemoApiImpl2"
    }

    override fun doSth(): String {
        return "${someOp.execute()} - the result by $NAME"
    }

    @AutoService(DemoApiFactory::class)
    class Factory : DemoApiFactory {
        override fun create(): DemoApi {
            return ModuleADemoApiImpl2(SomeOp())
        }
    }
}

使用

kotlin 复制代码
fun useFactoryDemo() {
    val loader = ServiceLoader.load(DemoApiFactory::class.java)
    val iterator = loader.iterator()
    var hasNext = false
    do {
        try {
            hasNext = iterator.hasNext()
            if (hasNext) {
                iterator.next().create().let {
                    println("find a impl of DemoApi, doSth:")
                    println(it.doSth())
                    println()
                }
            }
        } catch (e: Throwable) {
            println("thr: " + e.message)
        }
    } while (hasNext)

    println("finish useFactoryDemo\r\n")
}
sql 复制代码
find a impl of DemoApi, doSth:
[result of SomeOp osp.leobert.android.module.a.SomeOp@26ba2a48]
 - the result by ModuleADemoApiImpl2

finish useFactoryDemo

问题

读者诸君不难理解,Demo中的情况模拟的非常简单,而实际情况往往比较复杂,例如:获取构造器所需的参数往往比较复杂,可能来自不同模块

此时可与Builder模式相结合,如果Builder已存在有参构造函数,在不修改的情况下,可继续套用Factory

使用Builder模式

kotlin 复制代码
interface DemoApi {

    fun doSth(): String

    interface Builder {

        var foo: Foo

        fun build(): DemoApi

        interface Factory {
            fun create(): Builder
        }
    }
}

class Foo {
    val createdAt = Throwable().stackTrace[1].toString()
}

模拟一个服务实现类,需要的参数分别由当前模块和宿主模块提供,因而使用Builder将过程分离

kotlin 复制代码
//@AutoService(DemoApi::class)
class ModuleADemoApiImpl3(val someOp: SomeOp, val needProvideByHost: Foo) : DemoApi {
    companion object {
        const val NAME = "ModuleADemoApiImpl3"
    }

    override fun doSth(): String {
        return "${someOp.execute()} ,param2 create at${needProvideByHost.createdAt} - the result by $NAME"
    }
    class Builder(val someOp: SomeOp) : DemoApi.Builder {
        override lateinit var foo: Foo
        override fun build(): DemoApi {
            return ModuleADemoApiImpl3(someOp, foo)
        }

        @AutoService(DemoApi.Builder.Factory::class)
        class Factory : DemoApi.Builder.Factory {
            override fun create(): DemoApi.Builder {
                //the logic to get SomeOp instance,it may be complex
                val someOp = SomeOp()
                return Builder(someOp)
            }
        }
    }
}

使用:

kotlin 复制代码
fun useBuilderDemo() {
    val loader = ServiceLoader.load(DemoApi.Builder.Factory::class.java)
    val iterator = loader.iterator()
    var hasNext = false
    do {
        try {
            hasNext = iterator.hasNext()
            if (hasNext) {
                iterator.next().create().let {
                    it.foo = Foo()
                    it.build()
                }.let {
                    println("find a impl of DemoApi, doSth:")
                    println(it.doSth())
                    println()
                }
            }
        } catch (e: Throwable) {
            println("thr: " + e.message)
        }
    } while (hasNext)

    println("finish useBuilderDemo\r\n")
}

结果如下:

sql 复制代码
find a impl of DemoApi, doSth:
[result of SomeOp osp.leobert.android.module.a.SomeOp@180bc464] ,
param2 create at osp.leobert.android.host.MyClassKt.useBuilderDemo(MyClass.kt:75)
 - the result by ModuleADemoApiImpl3

finish useBuilderDemo

然即便如此,实际项目中,依旧会有诸多麻烦。依赖获取或依赖注入,永远会面临极端复杂的情况。我们模拟的情况永远比不上实际情况复杂。

读者诸君应当能够理解,当面临极端复杂的情况时,例如参数来自3个甚至更多模块时,即便利用设计模式仍能解决问题,但其设计和理解成本已然极高!

与依赖注入碰撞

虽然从广义上看,SPI机制也是一种特定场景下的DI实现,本章节暂不无限扩展,仅在下个章节中留下开端。

当与依赖注入的手段相碰撞时,可考虑两个方向:

  • 服务模块内部使用依赖注入,考量SPI(ServiceLoader)是否可无缝衔接模块内DI
  • 力大砖飞,自实现SPI机制,通俗地讲,即自定义ServiceLoader,并在其中无缝衔接DI

与Dagger2兼容性探寻

首先可以明确一点:@AutoService 必须注解于非抽象类上,所以,假如ServiceLoader可以和Dagger2生成的代码兼容,也需要手动注册 (或者自扩展Dagger2的编译,对生成类添加标记)

其次,不难通过思考得出结论:ServiceLoader 直接加载 Dagger2 的生成类,将会破坏Dagger2对依赖的生命周期管理。即便模仿 Anvil(类似Hilt)进行一系列自定义扩展,也无法降低设计难度,不再展开讨论。

因此,可靠的实现思路为:SPI接口实现类依赖 DaggerComponent ,并据此进入Dagger的世界获取到目标对象实例。伪代码如下:

kotlin 复制代码
class ApiFactoryImpl : ApiFactory {
    fun create(foo: Foo): Api {
        return DaggerComponent.create().provideApi(foo)
    }
}

注意,伪代码仅示意,实际编写时仍需要考虑Component的生命周期控制,以避免产生潜在BUG

很显然,由宿主模块提供的依赖使用 @Assisted 注解标记,其他依赖通过 Dagger2 进行管理

重新定义SPI接口:

kotlin 复制代码
interface DemoApiFactory2 {
    fun create(foo: Foo): DemoApi
}

服务模块内部使用 DI:

kotlin 复制代码
class ModuleADemoApiImpl4
@AssistedInject constructor(
    val someOp: SomeOp,
    @Assisted val needProvideByHost: Foo
) : DemoApi {

    companion object {
        const val NAME = "ModuleADemoApiImpl4"
    }

    override fun doSth(): String {
        return "${someOp.execute()} ,param2 create at${needProvideByHost.createdAt} - the result by $NAME"
    }

    @AutoService(DemoApiFactory2::class)
    class SpiFactory : DemoApiFactory2 {
        //注意,仅示例代码,实际使用时需严格遵循Component的生命周期需求获取实例
        private val factory by lazy {
            DaggerAppComponent.create().provideFactory()
        }

        override fun create(foo: Foo): DemoApi {
            return factory.create(foo)
        }
    }

    @AssistedFactory
    abstract class Factory {
        abstract fun create(@Assisted needProvideByHost: Foo): ModuleADemoApiImpl4
    }
}

DI部分的代码忽略,详见仓库代码。

这个思路可以解决采用多种设计模式带来的编码复杂度问题。

那么,是否得到了银弹呢?答案是否定的!SPI机制的设计是"轻量级"的,当按照这一思路,完美解决依赖注入和依赖管理难点时(显然它需要拥有一种聚合能力方可解决问题),简单推理即可以发现:

即便不使用SPI机制,也可以基于此时的DI框架的聚合能力,实现:enable framework extension and replaceable components 的目标

代价是每个framework extension 都被一个特定的三方DI框架所捆绑,这显然不是好主意!

作者按:可能我这样表述很不便于理解,读者诸君可以从SpringBoot的相关知识进行横向对比理解:

在SpringBoot中有 spring.factories,它可以在不侵入代码的情况下,使用第三方Jar包中的Bean,它的实现与JDK中SPI机制实现基本类似, 但SpringBoot本身就蕴含IOC容器,只要使用SpringBoot生态则意味着接受了它的DI,因此可无视DI框架捆绑

自定义ServiceLoader

读到此处的读者诸君,我们将进行这次思考中最关键的一步!我们已经收集到诸多思路的弊端,如果自定义ServiceLoader实现SPI机制,最佳实践应当如何?

正如我前文所言,这里仅仅只有开端,没有最终答案,只有一些思路提供参考

  • 1.服务发现部分:
    • 结合注册清单、反射手段等,应当获得是否有实现类
  • 2.服务加载部分:
    • 结合注册清单、反射手段等,应当获得实现类的构建途径和所需依赖

ServiceLoader中处理第一点时,将所有的注册类反射遍历,利用类型推断实现目标。处理第二点时,直接反射无参构造器,致使存在一定限制。

因此,在设计时可以考虑:

  • 注册清单中可以获知服务实现类的实例化路径
  • 注册清单中可以获知服务实现类实例化时需要的依赖信息
  • 将ServiceLoader设计为轻量级的IOC容器,撇去读写生命周期管理,仅提供有限的依赖获取途径

例如:注册清单内容可以设计为:

{Interface/abstract class}:{Implement Class}#construcor({Param Type1},{Param Type2})

kotlin 复制代码
osp.leobert.android.api.DemoApi:osp.leobert.android.module.a.ModuleADemoApiImpl4#ModuleADemoApiImpl4(osp.leobert.android.module.a.SomeOp,osp.leobert.android.api.Foo)

//方便阅读,进行换行:
osp.leobert.android.api.DemoApi:
    osp.leobert.android.module.a.ModuleADemoApiImpl4#
        ModuleADemoApiImpl4(
          osp.leobert.android.module.a.SomeOp,
          osp.leobert.android.api.Foo
        )

结语

最近写文章时,有一些苦恼:对于单纯的知识点,不太愿意动笔;而容易发散的知识,发散出去又难以收束。

最近也在思考,希冀寻找到源于内心深处的无穷力量,解开束缚精神的枷锁,照亮前行的道路。

前段时间读到一句话,分享给读者诸君:

there are three pillars in life: health, time, and money. At any given moment, most people have at most two. If you're fortunate enough to have all three, you make the most of it while you can.

相关推荐
HoneyMoose2 分钟前
IDEA 2024.3 版本更新主要功能介绍
java·ide·intellij-idea
我只会发热3 分钟前
Java SE 与 Java EE:基础与进阶的探索之旅
java·开发语言·java-ee
是老余5 分钟前
本地可运行,jar包运行错误【解决实例】:通过IDEA的maven package打包多模块项目
java·maven·intellij-idea·jar
crazy_wsp5 分钟前
IDEA怎么定位java类所用maven依赖版本及引用位置
java·maven·intellij-idea
.Ayang7 分钟前
tomcat 后台部署 war 包 getshell
java·计算机网络·安全·web安全·网络安全·tomcat·网络攻击模型
一直学习永不止步13 分钟前
LeetCode题练习与总结:最长回文串--409
java·数据结构·算法·leetcode·字符串·贪心·哈希表
博风22 分钟前
设计模式:6、装饰模式(包装器)
设计模式
A_cot23 分钟前
理解设计模式与 UML 类图:构建稳健软件架构的基石
microsoft·设计模式·简单工厂模式·工厂方法模式·uml
君败红颜25 分钟前
设计模式之创建模式篇
设计模式
hummhumm27 分钟前
第 22 章 - Go语言 测试与基准测试
java·大数据·开发语言·前端·python·golang·log4j