`
deepnighttwo
  • 浏览: 49690 次
  • 性别: Icon_minigender_1
  • 来自: 上海
社区版块
存档分类
最新评论

克服类加载器混乱

 
阅读更多

ClassLoader解决方案只需要投入一次成本,它提供了一个解决类版本冲突的方法

  最近,我不断听到同事和熟人抱怨J2EE应用服务器中出现的软件版本冲突。这个基础问题由来已久,但是,随着应用程序与应用服务器之间共享的Java库日益增多,这个问题似乎也越来越严重。当应用服务器使用一个Java包的A版本,而位于这台服务器上的应用程序却使用这个包的B版本时,如果这两个版本不兼容,那么就会产生版本冲突。当应用程序试图使用这个包,系统加载的是版本A中的类,而不是B版本中的类。如果这两种类的行为不同,就会出现问题。
  这种情况相当普遍,部分原因是因为如此多的应用服务器都在某种程度上依靠于开源软件。商业性软件的发布周期通常没有开源软件的发布周期那么短。因此,新发布的应用服务器经常不包含一些库的最新版本。另外,企业软件升级周期又落后于供应商的发布周期,而且为了保持稳定性,有时候也会跳过发布周期。结果,开发人员想当然地使用最新最好的Java库,一头扎进组合更旧版本的一些库的J2EE服务器中。
  这个问题也会出现在其他方向上。在升级应用服务器时,多年来一直使用的企业应用软件可能会遇见兼容问题。如果程序依靠于与应用服务器打包在一起的旧版本的库,那么在程序试图访问一个不存在的API元素时,新的库会引发运行时异常,比如NoSuchMethodException。
  很多时候,程序员没有注意到正在使用的是不同版本的库(这里,我认为类是Java包的集合),因为他们没有使用已经变化了的那一部分API,或者没有引起在后来版本中得到修正的缺陷。问题一旦发生,开发人员就不得不想办法解决它。替换应用服务器库会使应用服务器或者该服务器上的其他应用程序中断。如果开发人员没有对应用服务器的管理控制权,那么这个解决方案根本不可行。

共享和类共享
  这个问题的症结在于大多数J2EE供应商为他们的产品设计了一个类加载层次结构,这个层次结构最终会把类加载委托给应用服务器的类加载程序。即使给每个Web应用程序指派单独的类加载程序,防止Web应用程序彼此干涉,服务器本身使用的库仍然可以被所有Web应用程序共享。我想肯定一些产品没有这个问题,但是我听到的有关报道说,这个问题几乎出现于大多数主要的开放源代码产品和封闭源代码产品上。
  编程人员用来解决版本冲突的常用方法是:将库的源代码中的包的名称改成只由其自己代码使用的惟一名称,重新编译库,并将所有导入语句及其引用更新为其源代码中的包的名称。这个解决方案只在访问库的源代码时起作用,然而并不是总是如此。尽管源代码修正和重新编译是一个短期可靠的解决方案,从长远角度来看,实际上它花费的时间更长。编写shell脚本或使用IDE插件能够使包自动重新命名。
  查找并替换源代码中出现的所有包的名称,这样将捕获包名称在包声明和导入语句之外的地方的使用情况,比如说,在使用完全限定类名称时,或有在用于反射的字符串中插入名称时。配置文件和其他支持文件都可以包含名称,并且反射中使用的一些名称可以自动生成。您不必总是仔细地检查代码来重新命名某一个名称的所有实例。更重要的是,当升级到新版本时,必须重复一遍整个过程。基于以前的修改的补丁文件不会重新命名出现在新版本中的所有包的名称,并且shell脚本可能要求进行更新来应对代码更改。
  最后,源代码修正为维护带来了一个难题。您要花费人手和时间来维护一个原本不需要维护的东西,它实际上是源代码树的一个独立分支。
  源代码修正的两个主要缺陷是:必须访问源代码,而维护源代码需要做相当多的工作。维护一个单独的库的分支看起来似乎不是十分困难,但是那些想解决这个问题的人必须解决与多个库的冲突。
源代码包重名命名的一个替换解决方案是重写二进制类文件。重写类文件有一个好处:不需要维护一个单独的源代码分支,也不需要维护源代码。惟一需要的是JAR文件。专门重新命名JAR文件中的包的工具很少,但是大多数代码混淆工具(code obfuscation tool)都有重新命名包和JAR文件中包含的类的能力。用这个方法可以使库的升级变得容易一些。您所要做的就是用重写工具处理JAR文件,然后就大功告成。

想做便做!
  尽管类文件重写看上去似乎很有效,但实际上这还不是一个完美的解决方案。当库使用反射以及在字符串或配置文件中嵌入包的名称时,这种方法不起作用。它还不能把您从更改应用程序源代码中包名称的使用方式的不懈努力中解脱出来。
  一个更全面的解决方案是做一些应用服务器供应商应该首先作的事情,然后通过使用一个自定义类加载器把您的库的副本与服务器的库的副本分离开。要做到这一点,必须编写一些额外的代码,但是不必改变现有源文件使用包名称的方式。库升级变得简单是因为您只需使用新的JAR文件取代旧的文件即可。如何做到这一点的呢?
  版本冲突的根源是应用服务器的类加载设计。Web应用程序类加载器在试图自己定位一个类之前,把类的加载委托给了这个类的父类加载器。因此,如果应用服务器的类加载器能够在系统位置上找到这个类,那么它会加载那个版本,而不是加载和Web应用程序一起打包的那个版本。如果使用您自己的没有父类的类加载器来引导应用程序,那么您就可以绕过应用服务器使用的库。
  作为这项技巧的一个例子,我定义了一个叫做Printer的接口和一个叫做VersionPrinter的实现类,这个类表示一个应用程序。  VersionPrinter依靠于Version类,但是需要特定的5.0.0版本。然而,应用服务器用的是1.0.2版本。因此,在调用VersionPrinter.print时,就会输出字符串“version: 1.0.2”。

哪一个版本?
清单1. VersionPrinter使用一个新的5.0.0版本的Version,但是应用服务器装载的是老的1.0.2版本。

            public interface Printer {
            public void print();
}

public class VersionPrinter implements Printer {
            public void print() {
    Version v = new Version();
    System.out.println("version: " + v.getVersion());
   }
}

public class Version {
  public String getVersion() {
    return "1.0.2";
  }
}

public class Version {
  public String getVersion() {
    return "5.0.0";
  }
}

自定义类加载
清单2. 使用自定义类加载器和一个动态代理,您可以绕过应用服务器的类路径。

package example;

import java.io.*;
import java.net.URL;
import java.net.URLClassLoader;
import java.lang.reflect.*;

public final class Main {

  static class PrinterInvoker implements 
    InvocationHandler {
    Object adaptee;

    public PrinterInvoker(Object adaptee) {
      this.adaptee = adaptee;
    }

    public Object invoke(
      Object proxy, Method method, Object[] args)
      throws Throwable
    {
      Method adapteeMethod =
        adaptee.getClass().getMethod(
        method.getName(),
         method.getParameterTypes());
      if(!adapteeMethod.isAccessible())
        adapteeMethod.setAccessible(true);
      return adapteeMethod.invoke(adaptee, args);
    }
  }

  public static final void main(String[] args)
    throws Exception
  {
    VersionPrinter vp = new VersionPrinter();

    vp.print();

    // hardcoded demo paths from build.xml
    File path1 =
      new File(System.getProperty("user.dir"),
      "build.src2");
    File path2 =
      new File(System.getProperty("user.dir") , 
      "build.src");
    URL[] classpath = new URL[] {
      path1.toURL(), path2.toURL()
    };

    URLClassLoader cl = new URLClassLoader(
      classpath, null);
    Object obj =
      cl.loadClass(
      "example.VersionPrinter").newInstance();
    Printer p = 
      (Printer)Proxy.newProxyInstance(
        Printer.class.getClassLoader(),
        new Class[] { Printer.class },
        new PrinterInvoker(obj));

    p.print();
  }
}

      通过定义一个类加载器,可以绕过应用服务器的库,这个类加载器在查看服务器库目录之前,会首先查看自己的库目录。通过把两个Version类放进两个不同的构建目录中,并先在类加载路径中放置了包含Version类的5.0.0版本的目录(参见清单2),我模拟了这个类加载器。接着,我创建了一个URLClassLoade实例,并用自定义路径和一个空的父类对其进行初始化。空的父类可以确保类的加载不会委托给父类。然后,我加载了这个类,并使用一个动态代理把它映射到一个已知接口。在运行这个示例程序时,直接调用VersionPrinter.print将输出“version: 1.0.2”,而动态代理调用将输出“version: 5.0.0”,这些输出结果显示了想要使用的类版本,而不是默认版本。
  使用例子中的技巧,您根本不必更改应用程序代码。有时,编程人员会自己加载一些类似Version的特殊类,但也许您不想那样做。如果打算这样,您将不得不更改VersionPrinter。这样,就必须通过反射访问每一个冲突类。那会使代码变得一团糟。您想做是:建立一个由接口定义的应用程序入口点(比如,Printer),并自定义加载那个应用程序。然后,自定义类加载器将加载这个应用程序使用的所有更深层的类。

一次性购买
  实现一个能够用自定义类路径和委派servlet(delegate servlet)配置的包装器servlet是有可能的。包装器 servlet将使用这个自定义类路径来加载委派的servlet,并将所有调用委派给那个委派servlet。不幸的是,一些应用服务器中的servlet方法需要访问由应用服务器类加载器加载的资源。因此,包装器servlet技术不能保证在所有情况下都有效。您仍然可以使用实现一个选择性地将类加载委派给父类的类加载器的技巧。在从特定包中加载类时,可以将类加载器配置成不将类加载委派给父类。
  类加载器解决方案所需的额外努力是一个缺点,但是该解决方案的花费是一次性的。动态代理的使用应该不会降低性能,只要您在离主应用入口点尽可能近的地方使用它即可,这样会最大程度地减少反射性方法调用的数量。加载的类将消耗额外的内存,但那是为在相同JVM中使用同一个类的不同版本付出的代价。一些新版的J2EE服务器可能提供了他们自己的版本冲突解决方案。至少我想起来有一台服务器重新命名了它所使用的包,因此,您不必重新命名这些表包。不过,即使现在遇见类版本冲突,您也已经有了一个摆脱困境的方法。

关于作者
Daniel F. Savarese是一名独立软件开发人员和技术顾问。他曾是ORO公司的创始人,Caltech高级计算处理中心的高级科学家和WebOS软件开发的副总裁。Daniel是Jakarta ORO 文本处理包和Jakarta Commons NET网络协议库的原始作者.他还是《How to Build a Beowulf》(MIT Press, 1999)一书的合著者之一。

原文出处
http://www.ftponline.com/channels/java/javapro/2005_03/magazine/columns/proshop/

<!--文章其他信息-->
分享到:
评论

相关推荐

Global site tag (gtag.js) - Google Analytics