用One-JAR简化应用程序交付

系统 1421 0
如果您曾经试图把 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 的细节之前,请让我首先讨论一下我构建它的目的。我确定一个 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(is, jar);
...


注意, LIB_PREFIX 生成字符串 lib/ MAIN_PREFIX 生成字符串 main/ 。我想把任何以 lib/ main/ 开始的东西的字节码装入内存,供类装入器使用,并在循环中忽略任何其他 JAR 文件项。

main 目录

前面我已经谈到过 lib/ 子目录的角色,那么 main/ 目录是干什么的呢? 简要来说,类装入器的代理模式要求我把主要类 com.main.Main 放在它自己的 JAR 文件中, 这样它才能找到库类(它依赖的库类)。新的 JAR 文件看起来像这样:

            	one-jar.jar
            
| META-INF/MANIFEST.MF
| main/main.jar
| lib/a.jar
| lib/b.jar

在上面的清单 1 中, loadByteCode() 方法接受来自 JAR 文件项的流和一个项名称,把项的字节装入内存,并根据项代表的是 还是 资源 ,给它分配最多两个名称。演示这个技术的最好方法是通过示例。假设 a.jar 包含一个类 A.class 和一个资源 A.resource 。One-JAR 类装入器构造以下 Map 结构,名为 JarClassLoader.byteCode ,它对于类只有一对关键字/值组合,而对于资源则有两个关键字。


图 1. One-JAR 在内存中的结构
图 1.  One-JAR 在内存中的结构

如 果您多看图 1 一会,您可以看到类项是按照类名称设置关键字的,而资源关键字的设置则根据一对名称:全局名称和局部名称。用来解析资源名称冲突的机制是:如果两个库 JAR 文件都用相同的全局名称定义一个资源,那么则根据调用程序的堆栈帧来采用局部名称。更多细节请参阅 参考资料

找到类

回忆一下,我在概述类装入的时候,最后介绍的是 findClass() 方法。方法 findClass() 以类的名称作为 String 参数,而且必须找到并定义该名称所代表的字节码。由于 loadByteCode 很好地构建了类名和字节码之间的 Map ,所以实现这个方法现在非常简单:只要根据类名查找字节码,然后调用 defineClass() ,如清单 2 所示:


清单 2. findClass() 摘要
                protected Class findClass(String name) throws ClassNotFoundException {
            
ByteCode bytecode = (ByteCode)JarClassLoader.byteCode.get(name);
if (bytecode != null) {
...
byte bytes[] = bytecode.bytes;
return defineClass(name, bytes, pd);
}
throw new ClassNotFoundException(name);
}







装入资源

在 One-JAR 开发期间, findClass 是我把自己的想法付诸实施的第一件事。 但是,当我开始部署更复杂的应用程序时,我发现除了要装入类之外,还必须要处理资源的装入问题。这一次,事情有点棘手。为了查找资源,需要在 ClassLoader 中找到一个合适的方法去覆盖,我选了我最熟悉的一个,如清单 3 所示:


清单 3. getResourceAsStream() 方法
                public InputStream getResourceAsStream(String name) {
            
URL url = getResource(name);
try {
return url != null ? url.openStream() : null;
} catch (IOException e) {
return null;
}
}

这个时候应当响起警钟:我就是无法理解为什么用 URL 来定位资源。所以我不用这个实现,而是插入我自己的实现,如清单 4 所示:


清单 4. One-JAR 中的 getResourceAsStream() 实现
                public InputStream getResourceAsStream(String resource) {
            
byte bytes[] = null;
ByteCode bytecode = (ByteCode)byteCode.get(resource);
if (bytecode != null) {
bytes = bytecode.bytes;
}
...
if (bytes != null) {
return new ByteArrayInputStream(bytes);
}
...
return null;
}

最后一个障碍

我对 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: 协议
            
com/simontuffs/onejar/JarClassLoader.java
protected URL findResource(String $resource) {
try {
// resolve($resource) returns the name of a resource in the
// byteCode Map if it is known to this classloader.
String resource = resolve($resource);
if (resource != null) {
// We know how to handle it.
return new URL(Handler.PROTOCOL + ":" + resource);
}
return null;
} catch (MalformedURLException mux) {
WARNING("unable to locate " + $resource + " due to " + mux);
}
return null;
}
com/simontuffs/onejar/Handler.java
package com.simontuffs.onejar;
...
public class Handler extends URLStreamHandler {
/**
* This protocol name must match the name of the package in which this class
* lives.
*/
public static String PROTOCOL = "onejar";
protected int len = PROTOCOL.length()+1;

protected URLConnection openConnection(URL u) throws IOException {
final String resource = u.toString().substring(len);
return new URLConnection(u) {
public void connect() {
}
public InputStream getInputStream() {
// Use the Boot classloader to get the resource. There
// is only one per one-jar.
JarClassLoader cl = Boot.getClassLoader();
return cl.getByteStream(resource);
}
};
}
}









启动 JarClassLoader

到现在,您可能只剩下一个问题了:我是怎样把 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 的内容。



参考资料

关于作者

P. Simon Tuffs 博士是一位独立顾问,目前的研究领域是 Java Web 服务的可伸缩性。在业余时间里,他创建并发布一些开源项目,比如 One-JAR。

用One-JAR简化应用程序交付


更多文章、技术交流、商务合作、联系博主

微信扫码或搜索:z360901061

微信扫一扫加我为好友

QQ号联系: 360901061

您的支持是博主写作最大的动力,如果您喜欢我的文章,感觉我的文章对您有帮助,请用微信扫描下面二维码支持博主2元、5元、10元、20元等您想捐的金额吧,狠狠点击下面给点支持吧,站长非常感激您!手机微信长按不能支付解决办法:请将微信支付二维码保存到相册,切换到微信,然后点击微信右上角扫一扫功能,选择支付二维码完成支付。

【本文对您有帮助就好】

您的支持是博主写作最大的动力,如果您喜欢我的文章,感觉我的文章对您有帮助,请用微信扫描上面二维码支持博主2元、5元、10元、自定义金额等您想捐的金额吧,站长会非常 感谢您的哦!!!

发表我的评论
最新评论 总共0条评论