如果您曾经试图把 Java 应用程序交付为单一的 Java 档案文件(JAR 文件),那么您很有可能遇到过这样的需求:在构建最终档案文件之前,要展开支持 JAR 文件(supporting JAR file)。这不但是一个开发的难点,还有可能让您违反许可协议。在本文中,Tuffs 向您介绍了 One-JAR 这个工具,它使用定制的类装入器,动态地从可执行 JAR 文件内部的 JAR 文件中装入类。
有人曾经说过,历史总是在不断地重复自身,首先是悲剧,然后是闹剧。 最近,我第一次对此有了亲身体会。我不得不向客户交付一个可以运行的 Java 应用程序,但是我已经交付了许多次,它总是充满了复杂性。在搜集应用程序的所有 JAR 文件、为 DOS 和 Unix(以及 Cygwin)编写启动脚本、确保客户端环境变量都指向正确位置的时候,总是有许多容易出错的地方。如果每件事都能做好,那么应用程序能够按它预期的方式运行。但是在出现麻烦时(而这又是常见的情况),结果就是大量时间耗费在客户端支持上。
最近与一个被大量 ClassNotFound 异常弄得晕头转向的客户交谈之后,我决定自己再也不能忍受下去了。所以,我转而寻找一个方法,可以把我的应用程序打包到单一 JAR 文件中,给我的客户提供一个简单的机制(比如 java -jar)来运行程序。
努力的结果就是 One-JAR,一个非常简单的软件打包解决方案,它利用 Java 的定制类装入器,动态地从单一档案文件中装入应用程序所有的类,同时保留支持 JAR 文件的结构。在本文中,我将介绍我开发 One-JAR 的过程,然后告诉您如何利用它在一个自包含的文件中交付您自己的可以运行的应用程序。
One-JAR 概述 在介绍 One-JAR 的细节之前,请让我首先讨论一下我构建它的目的。我确定一个 One-JAR 档案文件应该是:
可以用 java -jar 机制执行。
能够包含应用程序需要的 所有 文件 —— 也就是说,包括原始形式(未展开)的类和资源。
拥有简单的内部结构,仅仅用 jar 工具就可以被装配起来。
对原来的应用程序不可见 —— 也就是说,无需修改原来的应用程序,就可以把它打包在 One-JAR 档案文件内部。
问题和解决方案 在开发 One-JAR 的过程中,我解决的最大问题,就是如何装入包含在另外一个 JAR 文件中的 JAR 文件。 Java 类装入器 sun.misc.Launcher$AppClassLoader(在 java -jar 开始的时候出现)只知道如何做两件事:
装入在 JAR 文件的根出现的类和资源。
装入 META-INF/MANIFEST.MF 中的 Class-Path 属性指向的代码基中的类和资源。
而且,它还故意忽略针对 CLASSPATH 的全部环境变量设置,还忽略您提供的命令行参数 -cp 。所以它不知道如何从一个包含在其他 JAR 文件中的 JAR 文件装入类或资源。
显然,我需要克服这个问题,才能实现 One-JAR 的目标。
解决方案 1:展开支持 JAR 文件
我为了创建单一可执行 JAR 文件所做的第一个尝试,显然就是在可交付的 JAR 文件内展开支持 JAR 文件,我们把可交付的文件称为 main.jar。假设有一个应用程序的类叫做 com.main.Main,而且它依赖两个类 —— com.a.A (在 a.jar 中) 和 com.b.B(在 b.jar 中),那么 One-JAR 文件看起来应该像这样:
main.jar
| com/main/Main.class
| com/a/A.class
| com/b/B.class
这样,最初来源于 a.jar 文件的 A.class 丢失了,B.class 也是如此。虽然这看起来只是个小问题,但却会真正带来问题,我很快就会解释为什么。
One-JAR 和 FJEP 最近发布的一个叫做 FJEP (FatJar Eclipse Plugin) 的工具支持在 Eclipse 内部直接构建扁平 JAR 文件。 One-JAR 已经与 FatJar 集成在一起,以支持在不展开 JAR 文件的情况下嵌入 JAR 文件。请参阅 参考资料 了解有关详细内容。
把 JAR 文件展开到文件系统以创建一个扁平结构,这可能非常耗时。还需要使用 Ant 这样的构建工具来展开和重新归档支持类。
除了这个小麻烦之外,我很快又遇到了两个与展开支持 JAR 文件有关的严重问题:
如果 a.jar 和 b.jar 包含的资源的路径名相同 (比如说,都是 log4j.properties ),那么您该选哪个?
如果 b.jar 的许可明确要求您在重新发布它的时候不能修改它,那您怎么办?您无法在不破坏许可条款的前提下像这样展开它。
我觉得这些限制为另外一种方法提供了线索。
解决方案 2: MANIFEST Class-Path 我决定研究 java -jar 装入器中的另外一种机制:装入的类是在档案文件中一个叫做 META-INF/MANIFEST.MF 的特殊文件中指定的。通过指定称为 Class-Path 的属性,我希望能够向启动时的类装入器添加其他档案文件。下面就是这样的一个 One-JAR 文件看起来的样子:
main.jar
| META-INF/MANIFEST.MF
| + Class-Path: lib/a.jar lib/b.jar
| com/main/Main.class
| lib/a.jar
| lib/b.jar
说明与线索 URLClassloader 是 sun.misc.Launcher$AppClassLoader 的基类,它支持一个相当神秘的 URL 语法,让您能够引用 JAR 文件内部的资源。这个语法用起来像这样: jar:file:/fullpath/main.jar!/a.resource。
从理论上讲,要获得一个在 JAR 文件 内部 的 JAR 文件中的项,您必须使用像 jar:file:/fullpath/main.jar!/lib/a.jar!/a.resource 这样的方式,但是很不幸,这么做没有用。JAR 文件协议处理器在找 JAR 文件时,只认识最后一个 “!/” 分隔符。
但是,这个语法确实为我最终的 One-JAR 解决方案提供了线索……
这能工作么? 当我把 main.jar 移动到另外一个地方,并试着运行它时,好像是可以了。为了装配 main.jar ,我创建了一个名为 lib 的子目录,并把 a.jar 和 b.jar 放在里面。不幸的是,应用程序的类装入器只从文件系统提取支持 JAR 文件,而不能从嵌入的 JAR 文件中装入类。
为了克服这一问题,我试着用神秘的 jar:!/ 语法的几种变体来使用 Class-Path(请参阅 “说明和线索”),但是没有一次成功。我能 做的,就只有分别交付 a.jar 和 b.jar ,并把它们与 main.jar 一起放在文件系统中了;但是这正是我想避免的那类事情。
进入 JarClassLoader 此时,我感到备受挫折。我如何才能让应用程序从它自己的 JAR 文件中的 lib 目录装入它自己的类呢?我决定应当创建定制类装入器来承担这个重任。编写定制类装入器不是一件容易的事情。但是实际上这个工作并没有那么复杂,类装入器对它所控制的应用程序有非常深刻的影响,所以在发生故障的时候,很难诊断和解释故障。虽然对于类装入的完整处理超出了本文的范围(请参阅 参考资料),我还是要介绍一些基本概念,好保证您能从后面的讨论中得到最大收获。
装入类 当 JVM 遇到一个对象的类未知的时候,就会调用类装入器。类装入器的工作是找到类的字节码(基于类的名称),然后把这些字节传递给 JVM,JVM 再把这些字节码链接到系统的其余部分,使得正在运行的代码可以使用新装入的类。JDK 中关键的类是 java.lang.Classloader 以及 loadClass 方法,摘要如下:
public abstract class ClassLoader {
...
protected synchronized Class loadClass(String name, boolean resolve)
throws ClassNotFoundException {...}
}
ClassLoader 类的主要入口点是 loadClass() 方法。您会注意到, ClassLoader 是一个抽象类,但是它没有声明任何抽象方法,这样,关于 loadClass() 方法是不是要关注的方法,一点线索也没留下。实际上,它不是 要关注的主方法:回到过去的好时光,看看 JDK 1.1 的类装入器,可以看到 loadClass() 是您可以有效扩展类装入器的惟一地方,但是从 JDK 1.2 起,最好让类装入器单独做它所做的工作,即以下工作:
检查类是否已经装入。
检查上级类装入器能否装入类。
调用 findClass(String name) 方法,让派生的类装入器装入类。
ClassLoader.findClass() 的实现是抛出一个新的 ClassNotFoundException 异常,并且是我们实现定制类装入器时要考虑的第一个方法。
JAR 文件何时不是 JAR 文件?
为了能够装入在 JAR 文件内部 的 JAR 文件中的类(这是关键问题,您可以回想起来),我首先必须能够打开并读取顶层的 JAR 文件(上面的 main.jar 文件)。现在,因为我使用的是 java -jar 机制,所以, java.class.path 系统属性中的第一个(也是惟一一个)元素是 One-JAR 文件的完整路径名!用下面的代码您可以得到它:
jarName = System.getProperty("java.class.path");
我接下来的一步是遍历应用程序的所有 JAR 文件项,并把它们装入内存,如清单 1 所示:
清单 1. 遍历查找嵌入的 JAR 文件
JarFile jarFile = new JarFile(jarName);
Enumeration enum = jarFile.entries();
while (enum.hasMoreElements()) {
JarEntry entry = (JarEntry)enum.nextElement();
if (entry.isDirectory()) continue;
String jar = entry.getName();
if (jar.startsWith(LIB_PREFIX) || jar.startsWith(MAIN_PREFIX)) {
// Load it!
InputStream is = jarFile.getInputStream(entry);
if (is == null)
throw new IOException("Unable to load resource /" + jar + " using " + this);
loadByteCode