人们注意到,历史重演,首先是悲剧,然后是闹剧。 最近,当我不得不将可运行的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文件(我们称为main.jar)中扩展支持的JAR文件。 给定一个名为com.main.Main
的应用程序类,并假设它依赖于两个类com.aA
(在a.jar内)和com.bB
(在b.jar内)– One-JAR文件如下所示:
main.jar
| com/main/Main.class
| com/a/A.class
| com/b/B.class
A.class
最初来自a.jar的事实以及A.class
的原始位置B.class
。 尽管这似乎只是个小问题,但可能会引起实际的问题,正如我稍后会解释的那样。
将支持的JAR文件扩展到文件系统以创建平面结构可能会非常耗时。 它还需要使用诸如Ant之类的构建工具来扩展和重新归档支持类。
除了这种小麻烦之外,在扩展支持的JAR文件时,我很快遇到了两个严重的问题:
- 如果a.jar和b.jar包含一个具有相同路径名的资源(例如
log4j.properties
),那么您会选择哪一个? - 如果b.jar许可证明确要求您以未经修改的形式重新分发它,该怎么办。 在不违反该许可条款的前提下,您不能像这样扩展它。
我认为这些限制值得另一种方法。
解决方案2:清单类路径
我决定研究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
这个工作了吗? 好吧,直到我将main.jar文件移动到其他地方并尝试运行它为止。 为了组装main.jar,我创建了一个名为lib的子目录,并将a.jar和b.jar压入其中。 不幸的是,应用程序类加载器只是从文件系统中获取支持的JAR文件。 它不是从嵌入式JAR文件中加载类。
为了解决这个问题,我尝试使用Class-Path
,并在相当神秘的jar:!/
语法上使用了几种变体(请参阅“ 注释和线索 ”),但是我什么都无法工作。 我可以做的是分别交付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文件条目。
主目录
我已经讨论过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类加载器构建以下名为JarClassLoader.byteCode
Map
结构, JarClassLoader.byteCode
具有用于类的单个键值对和用于资源的两个键。
图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. getResourceAsStream()的一个JAR实现
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()
方法的新实现似乎可以解决问题,直到我尝试对使用URL url = object.getClass().getClassLoader().getResource()
模式加载资源的应用程序进行一次JAR操作之前,该方法一直存在。 在这一点上,事情崩溃了。 为什么? 由于由ClassLoader
的默认实现返回的URL为null,因此破坏了调用者代码。
在这一点上,事情开始变得非常混乱。 我必须弄清楚应该使用什么URL来引用lib /目录中JAR文件中的资源。 会像jar:file:main.jar!lib/a.jar!com.aAresource
吗?
我尝试了所有可能想到的组合,但没有一个起作用。 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
条目)。 - 调用
com.main.Main.main(String[])
或任何的名称Main-Class
是在main.jar/MANIFEST.MF
文件)通过装载类和使用反射来调用main()
在One-JAR命令行上传递的参数将直接传递给应用程序main方法,而无需进行修改。
结论
如果这一切使您烦恼,请不要担心:使用One-JAR比尝试了解其工作原理要简单得多。 随着FatJar Eclipse插件的出现(见FJEP的相关信息 ),Eclipse用户现在可以选择在向导复选框创建一个-JAR应用程序。 从属库放置在lib /目录中,主程序和类放置在main / main.jar中,并且META-INF / MANIFEST.MF文件是自动编写的。 如果你使用JarPlug(再次参见相关主题 ),您可以查看您构建的JAR文件中,并从IDE中启动它。
总体而言,One-JAR是一个简单但功能强大的解决方案,用于解决包装应用程序的交付问题。 但是,它并不能提供所有可能的应用方案。 例如,如果您的应用程序使用未委托其父级的较旧样式的JDK 1.1类加载器,则该类加载器将无法从嵌套的JAR文件中定位类。 您可以通过构建和部署“包装”类加载器来修改顽强的类加载器来克服此问题,尽管这需要使用字节码操纵技术和Javassist或字节码工程库(BCEL)之类的工具。
您可能还会遇到嵌入式应用程序和Web服务器使用的某些特定类型的类加载器的问题。 具体来说,您可能会遇到以下问题:没有首先委派给父类加载器的类加载器,或那些在文件系统中查找代码库的类加载器。 One-JAR包含一种可以将JAR文件条目扩展到文件系统中的机制应该会有所帮助。 该机制由META-INF / MANIFEST.MF文件中的One-JAR-Expand
属性控制。 或者,您可以尝试使用字节码操作即时修改类加载器,而不会破坏支持的JAR文件的完整性。 如果走这条路,每种情况可能都需要定制的包装类加载器。
请参阅相关主题下载FatJar Eclipse插件和JarPlug,并更多地了解一个-JAR。
翻译自: https://www.ibm.com/developerworks/java/library/j-onejar/index.html