Java 作为一种静态类型语言,以灵活性为代价换来了更多的编译期检查,使得代码在运行时出错的概率相对更低。然而,这也为代码编写者,尤其是 SDK 类项目的实现者带来了一些困难。本文论述了一种以较低成本解决 API 动态性不足的问题,同时又不丧失编译检查能力(对于 SDK 的使用者来说)的方案,希望给有类似需求的开发者一定的启示,旨在抛砖引玉。
假设我们正在编写一个工具包,暴露了一个接口实例给用户。但是该工具包又希望通过插件的方式拓展它的 API,例如增加一个方法。但由于 Java 为静态语言,那么如何让一个静态语言实现动态的方法扩展呢?
现有方案多少都有一些不完美的地方,虽然都能实现类似的效果。
这种方法实现起来非常简单,只需要提供一个单例作为插件注册中心。任何插件想要注入的方法可以通过这个注册中心注入,而调用者则通过插件名称获取相应插件提供的 API:
public class APIUser {
APIRegistry reg = APIRegistry.INSTANCE;
public void registerAPI() {
reg.registerAPI("myAPI", new MyAPIImpl());
}
public void useMyAPI() {
MyAPI api = (MyAPI) reg.getAPI("myAPI");
api.extendedMethod();
}
}
这种方法的好处是极度灵活,还可以在运行时进行加载和卸载操作(生命周期管理);而坏处是需要使用者与插件提供者达成两处默契:使用正确的注册名以及使用正确的类型转换。
正因为它的灵活性,多数系统中采用的插件管理系统是基于这种形式的,例如 OSGI 框架中的服务注册机制。
这种方法是将基础 API 作为一个独立可运行的库,而它的所有插件更像是基于这个库而写的一个新项目。插件的初始化由用户进行,而初始化的过程往往需要用户手动创建其依赖并传入给插件的初始化函数。
public class SDKBaseFunctions {
}
假设这是一个基础依赖,内部方法略
public class SDKPlugin {
private final SDKBaseFunctions base;
SDKPlugin(SDKBaseFunctions base) {
this.base = base;
}
public void extendedFunction() {
}
}
简单的插件实现
public class UserMain {
public void use() {
SDKPlugin plugin = new SDKPlugin(new SDKBaseFunctions());
}
}
使用时必须传入插件所依赖的实例
这种方式对于用户来说较为友好,其与插件所达成的默契只是在需要知道插件的初始化入口,其余都可以通过编译器解决。既没有任意字符串的传入,也没有强制转换的问题。但是这种方式对于插件编写者来说并不十分友好,因为插件的编写者也只能使用 SDK 开放给用户的 API,而且要求用户显示地将包含这些 API 的对象传入初始化过程中。这就会致使在 SDK 中向用户开放的功能过多——因为既要满足用户的使用需求,也要满足插件的集成需求——导致普通用户必须面对一个相对较大的 API 集合。一般这样做的 SDK,其插件开发团队与 SDK 开发团队的合作非常紧密,甚至直接是官方的团队。例如高性能网络框架 Vert.x 及其 Web 组件库就是这样工作的。
前面介绍的两种方法都有各自的优势和广泛的应用,但是他们从本质上来说并不是直接在 SDK 提供的 API 中添加了方法,而是新增了一些接口。
在本问题设想的场景中,SDK 既有供用户使用的部分,也可以由插件扩展出的部分,即最终用户只会初始化一个 SDK 的接口对象实例,却能访问所有插件的功能。而且编写插件的人同时也是或更靠近于 SDK 的用户,而并非靠近于 SDK 的开发人员。这就要求插件化机制同时满足下列条件:
同时为了简便起见,我们假设在客户程序运行时不需要动态加载或卸载插件。同时我们仅讨论插件会引起 SDK 外部 API 发生变化的情况,假设插件只对 SDK 内部功能进行扩展而不影响外部 API,则不在本文讨论的范围。
在用户使用插件时需要知道 SDK 的插件插拔点,以及对应插件的插拔点。用户在使用某个插件时,需要写一个接口,这个接口扩展了 SDK 和插件所暴露的接口。有了这个接口,用户无论调用 SDK 或者插件中暴露的接口时,都不会出现强制类型转换或者传入任意字符串作为参数等现象。
如下代码所示,为插件开发者提供的 API 中有一个供插件编写者标志插件入口位置的注解,SDK 会寻找被标记了这个注解的类,并作为插件入口进行实例化。另一个接口只是为了让 SDK 注入插件所需要的功能,这里就实现了 SDK 本身并不向终端使用者暴露开发插件所需的接口,而仅向插件开发者提供。
@Retention(RetentionPolicy.RUNTIME)
public @interface PluginMarker {
Class<?> implementer();
}
public interface PluginAPI {
// this interface is for SDK to inject functionality into the plugin
}
插件编写时按需实现所需接口,并给需要扩展至 SDK 的 API 加上注解:
@PluginMarker(implementer = PluginImpl.class)
public interface PluginExposedAPI {
void pluginMethod();
}
public class PluginImpl implements PluginAPI, PluginExposedAPI {
public void pluginMethod() {
}
}
最终用户使用该插件时,需要自行编写一个包含了 SDK 原本 API 以及插件 API 的接口,
public interface ExtendedAPI extends SDKBaseAPI, PluginExposedAPI {
// this interface must not have any methods
}
并将该接口本身传入 SDK,而 SDK 返回了一个该接口的实现:
public class UserCode {
public void use() {
ExtendedAPI api = SDKSocketPoint.createAPI(ExtendedAPI.class);
api.pluginMethod();
}
}
至此,用户便可以使用 SDK 以及插件所提供的所有功能。
这种方式还有一个额外的好处:它可以将不同插件的 API 融合进同一个接口内,一次调用后就获得了一个完整的能立即使用的 SDK 接口对象;而不需要像其他两种方式那样需要分别对插件内的各种对象进行初始化。这样可以在运行时的第一行代码就检查传入的参数是否有误,如果检查通过则表示所有插件都可以成功加载,保证了后面调用插件时不会再出现运行时错误(如不恰当的类型转换)。
SDK 内部实现的原理为动态代码生成和动态编译。在用户调用 createAPI 方法并传入自己编写的接口实例时,SDK 会搜索所有该接口继承的接口,并找到那些标记有插件注解的接口,再通过注解找到插件的实现类。此时通过代码生成的方式产生一个新的类,这个类实现了 ExtendedAPI 接口中所有的方法,并持有所有插件实现类的示例(即前面的 PluginImpl ),然后将插件中同名方法通过新类代理给用户。生成的代码大致如下:
public class GeneratedCode implements ExtendedAPI {
SDKBaseAPI base = new SDKImpl();
PluginExposedAPI plugin = new PluginImpl();
public void pluginMethod() {
plugin.pluginMethod();
}
public void baseMethod() {
base.baseMethod();
}
}
实际上实现该方案的时候可以不需要经过代码生成、动态编译的过程,也可以使用 ASM 等库直接生成字节码来获得更快的加载速度。本文只是为了更容易理解而采用了代码生成的方式。
因为 Java 属于静态语言,编译期的类型检查对于减少程序 bug 来说至关重要;而同时也是因为这个原因,导致一个接口或类一旦发布,就无法在编译期被更改。因此为了解决这个矛盾,本文论述了一种由用户自己编写包含了插件 API 的接口,并交给 SDK 去动态生成该接口和插件之间的代理类的方法,给用户以较低的思维负担(只需要知道插件输出的接口类)解决了将任意插件扩展到原基础接口之上的难题。
同时本方法也一定程度上照顾了插件开发者,对于他们来说也有一个独立的插件开发专用 SDK,可以由 SDK 提供更多的灵活性,也不会影响到最终用户的体验。这种方法尤其适合最终用户同时也是插件开发者的应用,例如企业级应用中经常会遇到的二次开发需求,就是由企业客户自己开发插件供自己使用。这样就会要求终端用户和插件开发两者的使用体验都比较友好。
本文至此仅论述了最核心的实现机制和想法,如果想要成熟商用,必定还有很多工作要做。希望有人能受此启发,做出精彩的应用。
米筐邀请各位笔者共同创作更多技术类优质内容,米筐邀请各位笔者共同创作更多技术类优质内容,欢迎联系米筐量化王老师微信 RicequantCS 。