如果您曾经试图把 Java 应用程序交付为单一的 Java 档案文件(JAR 文件),那么您很有可能遇到过这样的需求:在构建最终档案文件之前,要展开支持 JAR 文件(supporting JAR file)。这不但是一个开发的难点,还有可能让您违反许可协议。在本文中,Tuffs 向您介绍了 One-JAR 这个工具,它使用定制的类装入器,动态地从可执行 JAR 文件内部的 JAR 文件中装入类。<!--START RESERVED FOR FUTURE USE INCLUDE FILES--><!-- include java script once we verify teams wants to use this and it will work on dbcs and cyrillic characters --> <!--END RESERVED FOR FUTURE USE INCLUDE FILES-->
有人曾经说过,历 史总是在不断地重复自身,首先是悲剧,然后是闹剧。 最近,我第一次对此有了亲身体会。我不得不向客户交付一个可以运行的 Java 应用程序,但是我已经交付了许多次,它总是充满了复杂性。在搜集应用程序的所有 JAR 文件、为 DOS 和 Unix(以及 Cygwin)编写启动脚本、确保客户端环境变量都指向正确位置的时候,总是有许多容易出错的地方。如果每件事都能做好,那么应用程序能够按它预期的方式 运行。但是在出现麻烦时(而这又是常见的情况),结果就是大量时间耗费在客户端支持上。
最近与一个被大量
ClassNotFound
异常弄得晕头转向的客户交谈之后,我决定自己再也不能忍受下去了。所以,我转而寻找一个方法,可以把我的应用程序打包到单一 JAR 文件中,给我的客户提供一个简单的机制(比如
java -jar
)来运行程序。
努力的结果就是 One-JAR,一个非常简单的软件打包解决方案,它利用 Java 的定制类装入器,动态地从单一档案文件中装入应用程序所有的类, 同时保留支持 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 的目标。
我为了创建单一可执行 JAR 文件所做的第一个尝试,显然就是在可交付的 JAR 文件内展开支持 JAR 文件,我们把可交付的文件称为 main.jar。假设有一个应用程序的类叫做
com.main.Main
,而且它依赖两个类 ——
com.a.A
(在 a.jar 中) 和
com.b.B
(在 b.jar 中),那么 One-JAR 文件看起来应该像这样:
main.jar |
这样,最初来源于 a.jar 文件的
A.class
丢失了,
B.class
也是如此。虽然这看起来只是个小问题,但却会真正带来问题,我很快就会解释为什么。
|
把 JAR 文件展开到文件系统以创建一个扁平结构,这可能非常耗时。还需要使用 Ant 这样的构建工具来展开和重新归档支持类。
除了这个小麻烦之外,我很快又遇到了两个与展开支持 JAR 文件有关的严重问题:
-
如果 a.jar 和 b.jar 包含的资源的路径名相同 (比如说,都是
log4j.properties
),那么您该选哪个?
- 如果 b.jar 的许可明确要求您在重新发布它的时候不能修改它,那您怎么办?您无法在不破坏许可条款的前提下像这样展开它。
我觉得这些限制为另外一种方法提供了线索。
我决定研究
java -jar
装入器中的另外一种机制:装入的类是在档案文件中一个叫做 META-INF/MANIFEST.MF 的特殊文件中指定的。通过指定称为
Class-Path
的属性,我希望能够向启动时的类装入器添加其他档案文件。下面就是这样的一个 One-JAR 文件看起来的样子:
main.jar |
|
这能工作么? 当我把 main.jar 移动到另外一个地方,并试着运行它时,好像是可以了。 为了装配 main.jar ,我创建了一个名为 lib 的子目录,并把 a.jar 和 b.jar 放在里面。不幸的是,应用程序的类装入器只从文件系统提取支持 JAR 文件,而不能从嵌入的 JAR 文件中装入类。
为了克服这一问题,我试着用神秘的
jar:!/
语法的几种变体来使用
Class-Path
(请参阅 “
说明和线索
”),但是没有一次成功。我
能
做的,就只有分别交付 a.jar 和 b.jar ,并把它们与 main.jar 一起放在文件系统中了;但是这正是我想避免的那类事情。
|
|
此时,我感到备受挫折。我如何才能让应用程序从它自己的 JAR 文件中的
lib
目录装入它自己的类呢?我决定应当创建定制类装入器来承担这个重任。编写定制类装入器不是一件容易的事情。但是实际上这个工作并没有那么复杂,类装入器对 它所控制的应用程序有非常深刻的影响,所以在发生故障的时候,很难诊断和解释故障。虽然对于类装入的完整处理超出了本文的范围(请参阅
参考资料
),我还是要介绍一些基本概念,好保证您能从后面的讨论中得到最大收获。
当 JVM 遇到一个对象的类未知的时候,就会调用类装入器。类装入器的工作是找到类的字节码(基于类的名称),然后把这些字节传递给 JVM,JVM 再把这些字节码链接到系统的其余部分,使得正在运行的代码可以使用新装入的类。JDK 中关键的类是
java.lang.Classloader
以及
loadClass
方法,摘要如下:
public abstract class ClassLoader { |
ClassLoader
类的主要入口点是
loadClass()
方法。您会注意到,
ClassLoader
是一个抽象类,但是它没有声明任何抽象方法,这样,关于
loadClass()
方法是不是要关注的方法,一点线索也没留下。实际上,它
不是
要关注的主方法:回到过去的好时光,看看 JDK 1.1 的类装入器,可以看到
loadClass()
是您可以有效扩展类装入器的惟一地方,但是从 JDK 1.2 起,最好让类装入器单独做它所做的工作,即以下工作:
- 检查类是否已经装入。
- 检查上级类装入器能否装入类。
-
调用
findClass(String name)
方法,让派生的类装入器装入类。
ClassLoader.findClass()
的实现是抛出一个新的
ClassNotFoundException
异常,并且是我们实现定制类装入器时要考虑的第一个方法。
为了能够装入在 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); |
注意,
LIB_PREFIX
生成字符串
lib/
,
MAIN_PREFIX
生成字符串
main/
。我想把任何以
lib/
或
main/
开始的东西的字节码装入内存,供类装入器使用,并在循环中忽略任何其他 JAR 文件项。
前面我已经谈到过 lib/ 子目录的角色,那么 main/ 目录是干什么的呢? 简要来说,类装入器的代理模式要求我把主要类
com.main.Main
放在它自己的 JAR 文件中, 这样它才能找到库类(它依赖的库类)。新的 JAR 文件看起来像这样:
one-jar.jar |
在上面的清单 1 中,
loadByteCode()
方法接受来自 JAR 文件项的流和一个项名称,把项的字节装入内存,并根据项代表的是
类
还是
资源
,给它分配最多两个名称。演示这个技术的最好方法是通过示例。假设 a.jar 包含一个类
A.class
和一个资源
A.resource
。One-JAR 类装入器构造以下
Map
结构,名为
JarClassLoader.byteCode
,它对于类只有一对关键字/值组合,而对于资源则有两个关键字。
图 1. One-JAR 在内存中的结构
如 果您多看图 1 一会,您可以看到类项是按照类名称设置关键字的,而资源关键字的设置则根据一对名称:全局名称和局部名称。用来解析资源名称冲突的机制是:如果两个库 JAR 文件都用相同的全局名称定义一个资源,那么则根据调用程序的堆栈帧来采用局部名称。更多细节请参阅 参考资料 。
回忆一下,我在概述类装入的时候,最后介绍的是
findClass()
方法。方法
findClass()
以类的名称作为
String
参数,而且必须找到并定义该名称所代表的字节码。由于
loadByteCode
很好地构建了类名和字节码之间的
Map
,所以实现这个方法现在非常简单:只要根据类名查找字节码,然后调用
defineClass()
,如清单 2 所示:
清单 2. findClass() 摘要
protected Class findClass(String name) throws ClassNotFoundException { |
|
|
在 One-JAR 开发期间,
findClass
是我把自己的想法付诸实施的第一件事。 但是,当我开始部署更复杂的应用程序时,我发现除了要装入类之外,还必须要处理资源的装入问题。这一次,事情有点棘手。为了查找资源,需要在
ClassLoader
中找到一个合适的方法去覆盖,我选了我最熟悉的一个,如清单 3 所示:
清单 3. getResourceAsStream() 方法
public InputStream getResourceAsStream(String name) { |
这个时候应当响起警钟:我就是无法理解为什么用 URL 来定位资源。所以我不用这个实现,而是插入我自己的实现,如清单 4 所示:
清单 4. One-JAR 中的 getResourceAsStream() 实现
public InputStream getResourceAsStream(String resource) { |
我对
getResourceAsStream()
方法的新实现看起来解决了问题,但是直到我试着用 One-JAR 来处理一个用
URL url = object.getClass().getClassLoader().getResource()
模式装入资源的应用程序时,才发现实际情况与想像的不一样。为什么?因为
ClassLoader
的默认实现返回的 URL 是 null,这个结果破坏了调用程序的代码。
这时,事情变得真的是说不清了。我必须弄清应当用什么 URL 来引用 lib/ 目录中的 JAR 文件内部的资源。是不是应该像
jar:file:main.jar!lib/a.jar!com.a.A.resource
这样才好?
我试尽所有我能想到的组合,但是没有任何一个起作用。
jar:
语法就是不支持嵌套 JAR 文件,这使得我的整个 One-JAR 方法好像面临着死路一条。虽然大多数应用程序好像都不使用
ClassLoader.getResource
方法,但是确实有些使用了这个方法,所以我实在不愿意有需要排除的情况,让我说“如果您的应用程序使用
ClassLoader.getResource()
,您就不能用 One-JAR。”
当我试图弄清楚
jar:
语法的时候,我意外地了解到了 Java 运行时环境把 URL 前缀映射到处理器的机制。这成为我修复
findResource
问题所需要的线索:我只要发明自己的协议前缀,称为
onejar:
。 这样,我就能把新的前缀映射到协议处理器,处理器就会返回资源的字节流,如清单 5 所示。注意,清单 5 表示的是两个文件中的代码,这两个文件是 JarClassLoader 和一个叫做
com/simontuffs/onejar/Handler.java
的新文件。
清单 5. findResource 和 onejar: 协议
|
|
|
到现在,您可能只剩下一个问题了:我是怎样把
JarClassLoader
插入启动顺序,让它首先开始从 One-JAR 文件装入类的?具体的细节超出了本文的范围;但是,基本上说,我没有用主类
com.main.Main
作为
META-INF/MANIFEST.MF/Main-Class
属性,而是创建了一个新的启动主类
com.simontuffs.onejar.Boot
,它被指定作为
Main-Class
属性。新类要做以下工作:
-
创建新的
JarClassLoader
。
-
用新的装入器从 main/main.jar 装入
com.main.Main
(基于 main.jar 中的META-INF/MANIFEST.MF Main-Class
项)。
-
装入类,用反射调用
main()
,从而调用com.main.Main.main(String[])
(或者诸如main.jar/MANIFEST.MF
文件中的Main-Class
的名称)。在 One-JAR 命令行上传递的参数,被不加修改地传递到应用程序的主方法。
|
|
如果前面这些让您头痛,不要担心:使用 One-JAR 要比理解它的工作方式容易得多。随着 FatJar Eclipse 插件(请参阅 参考资料 中的 FJEP)的推出, Eclipse 的用户现在只要在向导中选中一个复选框,就可以创建 One-JAR 应用程序。依赖的库被放进 lib/ 目录,主程序和类被放进 main/main.jar,并自动写好 META-INF/MANIFEST.MF 文件。如果您使用 JarPlug(还是请参阅 参考资料 ),您可以查看您构建的 JAR 文件的内部结构,并从 IDE 中启动它。
总之,One-JAR 是一个简单而强大的解决方案,解决了应用程序打包交付的问题。但是,它没有解决所有的应用程序场景。例如,如果您的应用程序使用老式的 JDK 1.1 的类装入器,不把装入委托给上一层,那么类装入器就无法在嵌套 JAR 文件中找到类。您可以构建和部署一个“包装”类装入器来修改顽固的类装入器,从而克服这个问题,不过这可能需要与 Javassist 或者字节码工程库(Byte Code Engineering Library,BCEL)这样的工具一起使用字节码操纵技术。
对于嵌入式应用程序和 Web 服务器使用的特定类型的类装入器,您还可能遇到问题。特别是对于那些不把装入工作先委托给上一级的类装入器,以及那些在文件系统中查找代码基的装入器,您 可能会碰到问题。不过,One-JAR 中包含了一个机制,可以在文件系统中展开 JAR 文件项,这应当有帮助。这个机制由 META-INF/MANIFEST.MF 文件中的
One-JAR-Expand
属性控制。另外,您可以试着用字节码操纵技术动态地修改类装入器,这样可以不破坏支持 JAR 文件的完整性。如果您采用这种方法,那么每种个别情况可能都需要一个定制的包装类装入器。
请参阅 参阅资料 以下载 FatJar Eclipse 插件和 JarPlug,并了解更多关于 One-JAR 的内容。
-
您可以参阅本文在 developerWorks 全球站点上的
英文原文
。
-
在 Sourceforge.net 上,您会找到更多
documentation, downloads, and examples for One-JAR
。
-
One-JAR 最近已经与
Fat JAR Eclipse Plugin
(FJEP)集成,这个工具可以帮助您构建扁平的用于部署的 JAR 文件。从 0.0.12 发行版就可以使用。
-
Java Archive Eclipse Plugin
工具允许您在 Eclipse 内部查看和启动 JAR 文件。
-
“
Java programming dynamics, Part 1: Classes and class loading
” (developerWorks,2003 年 4 月),对于类装入器的主题,在高层次上进行了介绍,还有许多您可以使用的资源。作者 Dennis Sosnoski 在同一系列中后来还讨论了 Javassist 和 BCEL ;到这些文章的链接包含在这个第一部分中。
-
“
J2EE class loading demystified
”(developerWorks,2002 年 8 月) 是入门级的类装入介绍。
-
David Gallardo 的“
Getting started with the Eclipse platform
“(developerWorks, 2003 年 4 月) 介绍了 Eclipse 背后的特性和架构。
|
P. Simon Tuffs 博士是一位独立顾问,目前的研究领域是 Java Web 服务的可伸缩性。在业余时间里,他创建并发布一些开源项目,比如 One-JAR。 |