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

前言

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

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

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

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

img_2.png

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

guideline.png

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

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.png

不难理解,核心需要:

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

Demo

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

img.png

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

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

  1. 在api中定义接口:
interface DemoApi {
    fun doSth(): String
}
  1. 在module-a中定义实现类
@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版,并需要处理打包资源目录

img_1.png

内容如下:

osp.leobert.android.module.a.ModuleADemoApiImpl

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

使用

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")
}

运行将在控制台观测到:

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()
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() 制约了服务实现类必须要有无参构造函数。

代码如下,泛读体会即可

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

interface DemoApiFactory {
    fun create(): DemoApi
}

模块提供Factory实现

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())
        }
    }
}

使用

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")
}
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模式

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将过程分离

//@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)
            }
        }
    }
}

使用:


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")
}

结果如下:

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的世界获取到目标对象实例。伪代码如下:

class ApiFactoryImpl : ApiFactory {
    fun create(foo: Foo): Api {
        return DaggerComponent.create().provideApi(foo)
    }
}

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

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

重新定义SPI接口:

interface DemoApiFactory2 {
    fun create(foo: Foo): DemoApi
}

服务模块内部使用 DI:

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})

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.