随笔 - 1, 文章 - 44, 评论 - 2, 引用 - 0
数据加载中……

通过ClassLoader管理组件依赖

通过ClassLoader管理组件依赖

作者:Don Schwarz

译者:xMatrix


版权声明:任何获得Matrix授权的网站,转载时请务必以超链接形式标明文章原始出处和作者信息及本声明
作者:Don Schwarz;xMatrix
原文地址:http://www.onjava.com/pub/a/onjava/2005/04/13/dependencies.html
中文地址:http://www.matrix.org.cn/resource/article/43/43918_ClassLoader.html
关键词: ClassLoader

Java的类加载机制是非常强大的。你可以利用外部第三方的组件而不需要头文件或静态连接。你只需要简单的把组件的JAR文件放到classpath下的目录中。运行时引用完全是动态处理的。但如果这些第三方组件有自己的依赖关系时会怎么样呢?通常这需要开发人员自己解决所有需要的相应版本的组件集,并且确认他们被加到classpath中。

JAR清单文件
实际上你不需要这样做,Java的类加载机制可以更优雅地解决这个问题。一种方案是需要每一个组件的作者在JAR清单中定义内部组件的依赖关系。这里清单是指一个被包含在JAR中的定义文件元数据的文本文件(META-INF/MANIFEST.MF)。最常用的属性是Main-Class,定义了通过java –jar方式定位哪个类会被调用。然而,还有一个不那么有名的属性Class-Path可以用来定义他所依赖的其他JAR。Java缺省的ClassLoader会检查这些属性并且自动附加这些特定的依赖到classpath中。

让我们来看一个例子。考虑一个实现交通模拟的Java应用,他由三个JAR组成:
·simulator-ui.jar:基于Swing的视图来显示模拟的过程。
·simulator.jar:用来表示模拟状态的数据对象和实现模拟的控制类。
·rule-engine.jar:常用的第三方规则引擎被用来建立模拟规则的模型。
simulator-ui.jar依赖simulator.jar,而simulator.jar依赖rule-engine.jar。

而通常执行这个应用的方法如下:
$ java -classpath
   simulator-ui.jar:simulator.jar:rule-engine.jar
   com.oreilly.simulator.ui.Main


编者注:上面的命令行应该在同一行键入;只是由于网页布局的限制看起来好像是多行。

但我们也可以在JAR的清单文件中定义这些信息,simulator-ui的MANIFEST.MF如下:

Main-Class: com.oreilly.simulator.ui.Main
Class-Path: simulator.jar


而simulator的MANIFEST.MF包含:
Class-Path: rule-engine.jar

rule-engine.jar或者没有清单文件,或者清单文件为空。
现在我们可以这样做:
$ java -jar simulator-ui.jar

Java会自动解析清单的入口来取得主类及修改classpath,甚至可以确定simulator-ui.jar的路径和解释所有与这个路径相关的Class-Path属性,所以我们可以简单按照下面的方式之一来做:
$ java -jar ../simulator-ui.jar
$ java -jar /home/don/build/simulator-ui.jar


依赖冲突

Java的Class-Path属性的实现相对于手工定义整个classpath是一个大的改善。然而,两种方式都有自己的限制。一个重要的限制就是你只能加载组件的一个特定版本。这看起来是很显然的因为许多编程环境都有这个限制。但是在大的包含多个第三方依赖的多JAR项目中依赖冲突是很常见的。

例如,你正在开发一个通过查询多个搜索引擎并比较他们的结果的搜索引擎。Google和Amazon的Alexa都支持使用SOAP作为通讯机制的网络服务API,也都提供了相应的Java类库方便访问这些API。让我们假设你的JAR- metasearch.jar,依赖于google.jar和amazon.jar,而他们都依赖于公共的soap.jar。
现在是没有问题,但如果将来SOAP协议或API发生改变时会怎么样呢?很可能这两个搜索引擎不会选择同时升级。可能在某一天你访问Amazon时需要SOAP1.x版本而访问Google时需要SOAP2.x版本,而这两个版本的SOAP并不能在同一个进程空间中共存。在这里,我们可能包含下面的JAR依赖:
$ cat metasearch/META-INF/MANIFEST.MF
Main-Class: com.onjava.metasearch.Main
Class-Path: google.jar amazon.jar

$ cat amazon/META-INF/MANIFEST.MF
Class-Path: soap-v1.jar

$ cat google/META-INF/MANIFEST.MF
Class-Path: soap-v2.jar


上面正确地描述了依赖关系,但这里并没有包含什么魔法--这样设置并不会像我们期望地那样工作。如果soap-v1.jar和soap-v2.jar定义了许多相同的类,我们肯定这是会出问题的。
$ java -jar metasearch.jar
SOAP v1: remotely invoking searchAmazon
SOAP v1: remotely invoking searchGoogle


你可以看到,soap-v1.jar被首先加在classpath中,因此实际上也只有他会被使用。上面的例子等价于:
$ java -classpath
   metasearch.jar:amazon.jar:google.jar:soap-v1.jar:soap-v2.jar
   # WRONG!


编者注:上面的命令行应该在同一行键入;只是由于网页布局的限制看起来好像是多行。

有趣的是如果Yahoo也发布了一个网络服务API,而他看起来并没有依赖于现有的SOAP/XML-RPC类库。在较小的项目中,组件依赖冲突常被用来作为在你只要手工包装方案或者只需要一两个类时而不使用让你不使用全量组件(如集合类库)的原因之一。手工包装方案有他的用处,但使用已有的组件是更普遍的方式。而且复制其他组件的类到你的代码库永远不是一个好主意。实际上你已经与组件的开发产生分岐而且没有机会在有问题修复或安全升级时合并他。

许多大的项目,如主要的商业组件,已经采用将他们使用的整个组件构建到他们的JAR内部。为了这么做,他们改变了包名使其唯一(如com/acme/foobar/org/freeware/utility),而且直接在他们的JAR中包含类。这样做的好处是可以防止在这些组件中多个版本的冲突,但这也是有代价的。这么做对开发人员来说完全隐藏了对第三方的依赖。但如果这种方式大规模的应用,将会导致效率的降低(包括JAR文件的大小和加载多个JAR版本到进程中的效率降低)。这种方式的问题在于如果两个组件依赖于同一个版本的第三方组件时,就没有协调机制来确定共享的组件只被加载一次。这个问题我们会在下一节进行研究。除了效率的降低外,很可能你这种绑定第三方软件的方式会与那些软件的许可协议冲突。

另一种解决这个问题的方式是每一个组件的开发人员显式的在他的包名中编码一个版本号。Sun的javac代码就采用这个方式—一个com.sun.tools.javac.Main类会简单地转发给com.sun.tools.javac.v8.Maino。每次一个新的Java版本发布,这个代码的包名就改变一次。这就允许一个组件的多个发布版本可以共存在同一个类加载器中并且这使得版本的选择是显式的。但这也不是一个非常好的解决方案,因为或者客户需要准确知道他们计划使用的版本而且必须改变他们的代码来转换到新的版本,或者他们必须依赖于一个包装类来转发方案调用给最新的版本(在这种情况下,这些包装类就会承受我们上面提到的相同问题)。

加载多个发布版本
这里我们遇到的问题在大多数项目中也存在,所有的类都会被加载到一个全局命名空间。如果每一个组件有自己的命名空间而且他会加载所有他依赖的组件到这个命名空间而不影响进程的其他部分,那又会怎么样呢?实际上我们可以在Java中这么做!类名不需要是唯一的,只要类名和其所对应的ClassLoader的组合是唯一的就可以了。这意味着ClassLoader类似于命名空间,而如果我们可以加载每一个组件在自己的ClassLoader中,他就可以控制如何满足依赖。他可以代理类定位给其他的包含他的依赖组件所需要的特定版本的ClassLoader。如图1。

image
Figure 1. Decentralized class loaders

然而这个架构并不比绑定每一个依赖的JAR在自己的JAR中好多少。我们需要的是一个可以确保每一个组件版本仅被一个类加载器加载的中央集权。图2中的架构可以确定每一个组件版本仅被加载一次。


Figure 2. Class loaders with mediator

为了实现这种方式,我们需要创建两个不同类型的类加载器。每一个ComponentClassLoader需要扩展Java的URLClassLoader来提供需要的逻辑来从一个JAR中获取.class文件。当然他也会执行两个其他的任务。在创建的时候,他会获取JAR清单文件并定位一个新属性Restricted-Class-Path。不像Sun提供的Class-Path属性,这个属性暗示特定的JAR应该只对这个组件有效。
public class ComponentClassLoader extends URLClassLoader {
  // ...

  public ComponentClassLoader (MasterClassLoader master, File file)
  {
    // ...
    JarFile jar = new JarFile(file);
    Manifest man = jar.getManifest();
    Attributes attr = man.getMainAttributes();

    List l = new ArrayList();
    String str = attr.getValue("Restricted-Class-Path");
    if (str != null) {
        StringTokenizer tok = new StringTokenizer(str);
        while (tok.hasMoreTokens()) {
            l.add(new File(file.getParentFile(),
                           tok.nextToken());
        }
    }

    this.dependencies = l;
  }

  public Class loadClass (String name, boolean resolve)
    throws ClassNotFoundException
  {
    try {
      // Try to load the class from our JAR.
      return loadClassForComponent(name, resolve);
    } catch (ClassNotFoundException ex) {}

    // Couldn't find it -- let the master look for it
    // in another components.
    return master.loadClassForComponent(name,
                           resolve, dependencies);
  }
    
  public Class loadClassForComponent (String name,
                                   boolean resolve)
    throws ClassNotFoundException
  {
    Class c = findLoadedClass(name);
    
    // Even if findLoadedClass returns a real class,
    // we might simply be its initiating ClassLoader.
    // Only return it if we're actually its defining
    // ClassLoader (as determined by Class.getClassLoader).
    //
    if (c == null || c.getClassLoader() != this) {
        c = findClass(name);
    
        if (resolve) {
            resolveClass(c);
        }
    }
    return c;
  }
}


当一个请求要求加载一个在特定JAR中不存在的类时,他会显式的调用MasterClassLoader并传递他的JAR依赖列表作为参数而不是简单的转发给父类加载器。然后MasterClassLoader将每一个特定依赖请求转发给ComponentClassLoader
public class MasterClassLoader extends ClassLoader {
  // ...

  public Class loadClassForComponent (String name,
                      boolean resolve, List files)
    throws ClassNotFoundException
  {
    try {
      return loadClass(name, resolve);
    } catch (ClassNotFoundException ex) {}

    for (Iterator i = files.iterator(); i.hasNext(); ) {
      File f = (File)i.next();

      try {
        ComponentClassLoader ccl =
            getComponentClassLoader(f);
        return ccl.loadClassForComponent(name, resolve);
      } catch (Exception ex) {
        // simplified for clarity
      }
    }

    throw new ClassNotFoundException(name);
  }
}


这种方法有许多有用的特性。最重要的是我们现在可以满足原始的依赖图而不需要修改代码(理论上是这样的,但还需要看一下面给出的警告)。他减少了组件间的耦合,因为每一个组件可以依赖于他所需要的组件版本,而不需要强制其他组件升级或降级版本来满足他。

另一个优点是这种技术增加了透明性。每一个组件的运行时依赖被显式地列出来了,而且这是强制的。即使使用Class-Path清单属性,你也不能确信你没有误匹配一个依赖。考虑一下当你的组件使用commons-log组件时,后来使用log4j来做日志处理。你可能有其他组件依赖log4j但没有定义这个依赖。因为他已经被加在classpath,你也不会检查到这个问题,但如果有一天你用其他的日志处理代替了log4j,你就有问题了。相反,如果使用Restricted-Class-Path而你没有列出log4j作为依赖,你会得到一个ClassNotFoundException异常。

重写系统类加载器
现在我们已经有了一个类加载器可以实现我们新的版本策略,我们需要通过某种方式来安装了。如果我们的代码会被嵌入在应用服务器中或其他类型的解释器,那么解释器的代码可以编程创建新的类加载器并使用他来加载我们的代码。通过这种方式,一个服务器进程可以通过定义在请求中定义需要的版本来执行多个代码版本。但如果我们只想在普通的Java应用中使用时需要怎么做呢?

一种主观的方式是使用Java1.5的-javaagent命令行参数。这样我们可以在加载我们应用的主类之前初始化特定的JAR(这称为代理)。不幸地是,代理类被与加载主类的同一个类加载器加载(系统类加载器),因此在这时安装我们的自定义类加载器已经太迟了,因为我们的代理的方法已经执行了。

另一种方式是创建一个“引导”主类来建立类加载器并且使用他来定位我们实际的主类并执行主方法。这种方式很简单,但去掉了一些Java的好的用法如-classpath和-jar选项并且需要我们自己调用主方法。

实际上,我们可以重写java.system.class.loader系统属性来使我们的类加载器作为系统类加载器被初始化。这样做的话,我们会创建第三个类加载器WrapperClassLoader作为系统类加载器的代替。他的父类会是引导类加载器,他包含Java运行时类库(rt.jar)。在初始化的时候,他会读取java.library.path系统属性并且为每一个特定的JAR创建ComponentClassLoader。

public static List initClassLoaders (MasterClassLoader master)
  throws MalformedURLException, IOException
{
  List loaders = new ArrayList();

  String classpath =
                System.getProperty("java.class.path");

  StringTokenizer tok = new StringTokenizer(classpath,
                                  File.pathSeparator);

  while (tok.hasMoreTokens()) {
    File file = new File(tok.nextToken());
    loaders.add(master.getComponentClassLoader(file));
  }

  return loaders;
}


现在我们可以像下面那样运行我们的搜索引擎了:
$ java -Xbootclasspath/a:classloader.jar \
    -Djava.system.class.loader=
        com.onjava.classloader.WrapperClassLoader \
    -jar metasearch.jar
SOAP v1: remotely invoking searchAmazon
SOAP v2: remotely invoking searchGoogle (with newFlag = true)


小结
在最后版本中,我们实际上进行了超过原始需求的更多研究。除了在一个静态字段中嵌入版本号之外,我们现在可以从属性文件中获取了。这意味着可以通过我们的类加载器加载资源文件,而且必须包含与实际类加载类似的逻辑。我们也可以修改一下soap-v2.jar的API,从 public Object invokeMethod (String name, Object[] args)
到public Object invokeMethod (String name, Object[] args,                    boolean newFlag)

这看起来有些奇怪,但这意味站如果我们将刚才运行的源程序放在同一个目录下,我们可能不能编译他。如果我们尝试用同一版本的soap.jar同时构建google 和amazon,其中一个的方法标识可能不匹配。如果我们用两个soap.jar的版本,又会得到重复类错误。但是,我们可以分别编译google.jar和amazon.jar,而不需要考虑他们是否使用兼容的soap.jar版本,而且我们可以在同一进程中用不同的类加载器运行他们。

考虑一下,如果将这种技术运用在一个在构建时管理组件依赖的构建工具(如Maven),你将不会遇到缺少依赖或JAR冲突的问题了。


关于作者:Don Schwarz是一家专注于元编程和语言集成的大投资银行的Java开发人员。

资源
·onjava.com:onjava.com
·Matrix-Java开发者社区:http://www.matrix.org.cn/

posted on 2006-05-29 15:08 ASONG 阅读(267) 评论(0)  编辑  收藏 所属分类: JAVA


只有注册用户登录后才能发表评论。


网站导航: