背景
相关软件版本:
Eclipse:2020-06(4.16)
JDK:1.8.0_172
Eclipse插件ClassLoader现状
通常Eclipse的插件的ClassLoader默认为org.eclipse.osgi.internal.loader.EquinoxClassLoader
,该ClassLoader负责Eclipse插件的加载类、加载Resource,在该ClassLoader内部,loadClass或者findResource的操作一般都是代理给org.eclipse.osgi.internal.loader.BundleLoader
来执行的,BundleLoader以一定的顺序(Import-Package -> Require-Bundle -> Local等)来加载类或者资源。
需求场景
什么时候需要自定义插件的ClassLoader呢?
场景
我们的一个RCP产品里包含了一个纯Java编写的流程引擎,该引擎包含了许多jar包,作为一个整体打包成了一个Eclipse插件供RCP UI层调用。目前我们想让这个流程引擎支持Java SPI方式的扩展,扩展包和实现可以由外部用户自行开发,RCP端支持下载用户自行开发的扩展,且这些下载安装后的扩展可以被内部的流程引擎插件发现及加载。
问题及难点
问题
在OSGi环境中SPI Consumer(也就是调用java的ServiceLoader.load()方法的一方)如何发现SPI扩展?
OSGi Service Loader Mediator & spifly
纯Java的SPI扩展也是一个纯的jar包,但是RCP只能支持安装Eclipse插件,这就需要一个自动工具将Java SPI扩展编译打包为Eclipse插件,BND Tools可以做到这一点。
但是这样的话就会面临另一个问题,SPI扩展插件如何被流程引擎插件发现其中的扩展。众所周知,Eclipse插件的扩展发现机制是主要是通过plugin.xml
文件来实现,而Java的SPI扩展发现机制是通过META-INF/services
来实现的,同时要求实现包需要和API包在同一个ClassLoader内,否则就不能发现扩展的文件。Eclipse每个插件都有自己的ClassLoader,通常情况下,一个插件的ClassLoader只负责加载自己插件内的类和资源,以及所依赖的插件和导入的包中的类和资源。Java SPI扩展文件不属于代码文件也没有被Export,所以SPI扩展实现插件中的文件资源是不会被API所在的插件所发现的,也就意味说在OSGi环境下,SPI的Consumer是不能发现其他插件中的SPI实现的。
为了在OSGi的bundle中可以使用Java SPI机制,OSGi规范中提供了一种称为Service Loader Mediator
的支持方案,通过在bundle的MANIFEST.MF中添加Require-Capability
和Provide-Capability
来达到定义API和暴露SPI实现的目的。详情可以参考OSGi 5以上规范的第133章节。
OSGi的“Service Loader Mediator”只是一种规范,还需要有该规范的实现才能真正使用。apache提供了一种实现spifly
。spifly提供了静态和动态两种模式,静态模式就是将扩展插件预先转换为spifly支持的扩展插件然后再安装到RCP中;动态模式就是spifly提供了一个动态的bundle,它在启动时会自动发现当前OSGi容器中的扩展插件并通过字节码增强的方式自动转换。
spifly除了支持OSGi的Service Loader规范外,也提供了一种spifly自己支持的方案:SPI-Provider和SPI-Consumer。与OSGi的支持方案相比,OSGi的方案通用性更好,只要是OSGi规范的实现者都会支持这种方式;spifly的方案更简单易用,但只能用于spifly,可移植性稍差。
spifly参考资源:SPI Fly :: Apache Aries
使用spifly的话需要遵循以下原则或配置:(以下步骤以动态模式的spifly方案为例)
- 安装spifly dynamic-bundle,这是一个OSGi Bundle,安装后需要将启动级别设置为低于默认启动级别的值,autostart设置成true,表示其要自动启动。
- 提供SPI 实现的插件,需要在MANIFEST.MF中增加一个header:SPI-Provider,值为扩展实现的接口的全限定名,或者“*”表示所有扩展实现的接口。
- 使用SPI扩展的插件,需要在MANIFEST.MF中增加一个header:SPI-Consumer,值为*或者使用的ServiceLoader及其方法。
- 安装SPI实现或consumer插件后,需要将这些插件的autostart设为true,因为spifly只会从active的插件中检测扩展。
总的来说,spifly可以满足加载SPI扩展的基本使用要求。但是也有一些缺点:
- 使用较繁琐,特别是在我们这种使用场景下,插件都是通过工具来自动打包生成的,那么在什么时候添加SPI header就是一个比较困难的事情。
- 使用场景有限,例如如果在API代码中需要加载实现中提供的资源文件,换句话说就是扩展是通过资源文件的方式来提供的,而SPI的ServiceLoader只能加载到扩展类实现,那这种场景spifly就无法满足了。
现在回过头来再看一下上面的场景,我们可以发现其实只要在OSGi插件中,还能像Java SPI程序中那样,API和实现包都在一个ClassLoader中,那么这些问题就迎刃而解,并且在OSGi中SPI程序的表现跟在OSGi外面运行的表现一样。
但是Eclipse插件的ClassLoader都是默认创建的,能不能为插件指定ClassLoader呢?经过各种调研,我发现答案是肯定的。
自定义插件的ClassLoader
想要自定义插件的ClassLoader,需要通过OSGi提供的扩展机制。
关于OSGi扩展的详情可以参考OSGi规范中的定义:http://docs.osgi.org/specification/osgi.core/7.0.0/framework.module.html#framework.module.extensionbundles
这里我们只讲如何来做。
先说明我们要达到的效果:
- SPI API打包为Eclipse 插件。
- SPI扩展包放到RCP产品的根目录下的“addons”文件夹中。
- SPI API插件在加载类和资源时,除了可以加载本插件内的,还可以加载产品根目录下的“addons”文件夹中的jar中的资源。
实现步骤:
- 创建一个
fragment
工程,名称我们假定为com.ming.osgi.hook
,Host Plugin选择“org.eclipse.osgi”,也可以使用该插件的别名“system.bundle”,效果是一样的。 - 创建类
AddonsClassLoader
继承自java.net.URLClassLoader
,负责从“addons”文件夹中加载jar及加载jar中的资源。 - 创建类
TestModuleClassLoader
继承自org.eclipse.osgi.internal.loader.EquinoxClassLoader
,保持在加载插件中的类和资源时和原来一样的表现。覆写findLocalClass
、findLocalResource
、findLocalResources
方法,修改方法逻辑为在parent中加载不到资源时,尝试从我们的AddonsClassLoader
中加载。
@Override
public Class> findLocalClass(String classname) throws ClassNotFoundException {
try {
return super.findLocalClass(classname);
} catch (ClassNotFoundException e) {
return externalClassLoader.findClass(classname);
}
}
@Override
public URL findLocalResource(String resource) {
URL result = super.findLocalResource(resource);
if (result == null) {
result = externalClassLoader.findResource(resource);
}
return result;
}
@Override
public Enumeration findLocalResources(String resource) {
Enumeration result = super.findLocalResources(resource);
Enumeration[] tmp = (Enumeration[]) new Enumeration>[2];
tmp[0] = result;
try {
tmp[1] = externalClassLoader.findResources(resource);
} catch (IOException e) {
e.printStackTrace();
}
return new CombinedEnumeration(tmp);
}
- 创建类
TestClassLoaderHook
继承org.eclipse.osgi.internal.hookregistry.ClassLoaderHook
,该抽象类中有很多hook方法,我们只关心createClassLoader()
方法。覆写该方法,可以根据插件id或其他信息来为特定插件创建我们上面自定义的ModuleClassLoader了。
@Override
public ModuleClassLoader createClassLoader(ClassLoader parent, EquinoxConfiguration configuration,
BundleLoader delegate, Generation generation) {
String externalClasspath = generation.getHeaders().get("External-ClassPath");
if (externalClasspath != null) {
externalClasspath = externalClasspath.trim();
if (!externalClasspath.isEmpty()) {
System.out
.println("use external classloader for " + delegate.getWiring().getBundle().getSymbolicName());
Set classpathSet = new HashSet();
String[] classpaths = externalClasspath.split(",");
for (String classpath : classpaths) {
classpathSet.add(classpath.trim());
}
return new TestModuleClassLoader(parent, configuration, delegate, generation,
classpathSet.toArray(new String[classpathSet.size()]));
}
}
return super.createClassLoader(parent, configuration, delegate, generation);
}
- 创建类
TestHookConfigurator
,实现org.eclipse.osgi.internal.hookregistry.HookConfigurator
接口。覆写addHooks
,用来向HookRegistry添加我们自定义的ClassLoaderHook。
@Override
public void addHooks(HookRegistry hookRegistry) {
hookRegistry.addClassLoaderHook(new TestClassLoaderHook());
}
- 到现在为止,需要的类我们都创建完了,现在我们需要让osgi能发现我们的
TestHookConfigurator
扩展。
在fragment工程根目录下创建hookconfigurators.properties
文件,在其中添加如下内容:
hook.configurators=com.ming.osgi.hook.TestHookConfigurator
- 最后,如果需要在Eclipse内调试,则需要在启动配置中添加一个JVM参数“-Dosgi.framework.extensions=com.ming.osgi.hook”,让osgi从我们的fragment中加载扩展。
发布时只需将fragment插件添加到build列表中,或者加到product的插件列表中,打包程序会自动在生成的config.ini中添加osgi.framework.extensions的配置。
其实我们上面的ClassLoaderHook实现中是采用了一种更好的方案,就是在要自定义ClassLoader的插件的MANIFEST.MF中添加一个自定义的headerExternal-ClassPath
,值为一个外部的目录(可以是addons,也可以是其他),这样不同的插件可以使用各自的扩展,达到隔离的一个目的。
参考资料
- SPI Fly :: Apache Aries
- Instrumenting OSGi Bundles Through Equinox Adaptor Hooks
- OSGi规范中关于extension的说明