Kettle Plugin ClassCastException 解决思路

1.前言

最近在开发一个Kettle步骤插件的时候遇到CCE(ClassCastException)异常,在网上Google了很多资料,自己又调试了很久,才摸索出一些解决方法,分享给大家,相信对于经常开发Kettle插件的开发者难免会碰到这种奇葩的问题。

在开发调试过程中通过使用-DKETTLE_PLUGIN_CLASSES参数指定加载插件进行调试的时候并未报任何异常。然而,当我把插件打包部署到plugins目录下进行调试的时候,奇怪的异常就出现了:

1
2
3
4
Caused by: java.lang.ClassCastException: class org.apache.cxf.bus.spring.SpringBusFactory
at java.lang.Class.asSubclass(Class.java:3208)
at org.apache.cxf.BusFactory.newInstance(BusFactory.java:327)
... 31 more

当时就懵了,后来查阅了一些资料,调试了相关代码,发现通过指定-DKETTLE_PLUGIN_CLASSES参数调试插件的时候,插件使用的ClassLoader是AppClassLoader,而当插件部署到plugins目录使用的ClassLoader是Kettle自定义的ClassLoader -> KettleURLClassLoader

KettleURLClassLoader加载class的机制违反了传统的双亲委托机制,默认先从本地加载,加载不到的情况下才委托父级ClassLoader加载。原因是Kettle为了实现不同插件之间依赖库的隔离。

2.ClassCastException分析

出现这个问题是因为不同的ClassLoader加载了相同的接口和实现类(包全名一致),然后进行强制转换。在JDK实现的ClassLoader机制(双亲委托)里,这种问题基本上不会存在,但是一旦应用中通过自定义ClassLoader来加载类并打破这种机制,就容易出现ClassCastException。很不幸,为了实现不同插件之间依赖库的隔离,Kettle自定义了KettleURLClassLoader并打破了双亲委托机制。所以当你开发的插件依赖比较复杂,特别是用到了一些框架,如Spring、CXF等,这种情况就容易发生

打破JDK原有ClassLoader机制的自定义ClassLoader通常都是先从当前定义的ClassLoader加载,加载不到类的情况下再委托父classLoader里加载。

3.验证过程

在我开发的插件中需要用到CXF来调用webservice,由如下调用会出现上面提到的异常:

1
Bus bus = BusFactory.getDefaultBus();

根据前面给出的异常堆栈调试CXFBusFactory源码:

通过调试分析出异常路径为:

BusFactory.newInstance(String className) -> getBusFactoryClass(ClassLoader classLoader)

接下来注意getBusFactoryClass方法里面以下代码片段:

1
2
3
4
5
6
7
8
9
10
11
12
// .....
if (isValidBusFactoryClass(busFactoryClass)
&& busFactoryCondition != null) {
try {
Class<?> cls = ClassLoaderUtils.loadClass(busFactoryClass, BusFactory.class)
.asSubclass(BusFactory.class);
// ....
}
}

debug断点进入Class.asSubClass()方法,我们就可以知道为何会抛出CCE异常了:

1
2
3
4
5
6
public <U> Class<? extends U> asSubclass(Class<U> clazz) {
if (clazz.isAssignableFrom(this))
return (Class<? extends U>) this;
else
throw new ClassCastException(this.toString());
}

asSubClass()方法的处理逻辑是:判断clazz是否为当前类的子类(注意必须是由同一个ClassLoder加载),如果是执行强制转换,否则抛出CCE异常。

当我的程序debug到该方法下,通过Eclipse Debug视图下的Expresssions增加并观察变量:

  • this
1
class org.apache.cxf.bus.spring.SpringBusFactory
  • this .getClassLoader()
1
sun.misc.Launcher$AppClassLoader@2c8fc809
  • clazz
1
class org.apache.cxf.BusFactory
  • BusFactory .class .getClassLoader()
1
org.pentaho.di.core.plugins.KettleURLClassLoader@31d7e3a9 : XXXX
  • clazz .isAssignableFrom(this)
1
false

到此已经可以很清楚的分析出,产生CCE的原因是因为子类和父类是由不同的ClassLoader加载,尝试强制转换失败后抛出该异常

4.巧妙的解决方法

庆幸的是,许多的框架,如Spring、CXF加载Class使用的ClassLoader都是从当前线程的context class loader中获取:

1
Thread.currentThread().getContextClassLoader()

因此我们可以通过巧妙的方式绕过CCE,通过临时设置当前线程的context loader为当前插件所使用的classloader(表示为KettleURLClassLoader类的对象),然后在执行结束以后还原当前线程的context class loader。示例如下:

1
2
3
4
5
6
7
Classloader originalClassloader = Thread.currentThread().getContextClassLoader();
Thread.currentThread().setContextClassLoader(MyPluginClass.class.getClassLoader());
....//Execute library code
Thread.currentThread().setContextClassLoader(originalClassloader);

Thread ConextClassLoader相当于JDK给我们提供了一个后门,可以让我们违背ClassLoader原来的设计模型:双亲委托模型,通过自定义的ClassLoader来加载类。

这种方式不知道是不是最合理的解决方案,因为我们必须无奈地在任何会出现CCE的调用前后植入临时设置context class loader的代码。

5.奇怪的LinkageError出现了

解决了CCE问题,也许问题不会到此结束,可能你还会遇到LinkageError

1
java.lang.LinkageError: loader constraint violation: loader (instance of org/pentaho/di/core/plugins/KettleURLClassLoader) previously initiated loading for a different type with name "org/w3c/dom/Node"

出现这种情况是由于一个ClassLoader加载了一个类,而后另外一个ClassLoader又加载了这个相同的类。在前面CCE基础上出现这个问题我猜测是因为插件的ClassLoader先加载了自身依赖库中的一个类,而最后应用程序使用非插件ClassLoader(一般是AppClassLoader)加载同一个类,导致抛出这种异常。我们可以猜测很有可能是因为jar包冲突,即插件的lib下某些jar可能与Kettle根目录下的lib下里的某些jar包冲突导致。解决的办法是排除插件里面与kettle自带的那些冲突jar。

我们可以自己写一个小程序遍历两个目录下的jar包,分析比较出交集,交集即代表了冲突的jar。

在我开发的插件中存在以下jar包与Kettle lib包下面的jar包冲突:

  • commons-codec
  • asm
  • stax-api
  • commons-logging
  • xmlbeans
  • wsdl4j
  • xercesImpl
  • xml-apis

我的解决方法就是在开发的插件中删掉这些jar包,使用Kettle lib下自带的jar就可以了。其实也就是让Kettle的ClassLoader(AppClassLoader)统一加载这个类,而不是插件的ClassLoader(KettleURLClassLoader)。

通过以上解决方法,事情也终于告一段落了。

6.参考

坚持原创技术分享,您的支持将鼓励我继续创作!