通过Java程序修改jar包里的内容

需求

现实中的需求千奇百怪,但只要在理论上没问题的,无论需求多么奇怪,它都能被实现。有时候,我们想要修改jar包中所存储的配置文件。那做法很简单,单纯通过手工解压jar,修改我们所想要的修改的配置文件后,再重新打包。这种劳神费力的操作偶尔1-2次还好,如果这个操作非常频繁,其实最好的办法就是写一个程序,让电脑帮我们把这个事情给自动办妥了。

分析

其实jar包就是一个特殊版的压缩包,而Java是自带一套处理压缩文件的API,所以Java也应该自带了一套处理jar包的API,如果真没有那就没辙了,就自己想办法造轮子吧,不过幸运的是真的有,所以我们可以直接拿来用了。其实,如果Java没有自带这套API才是最搞笑的,毕竟自家的东西,自己反而没有做好处理它的工具提供给用户使用,这就非常不人性化了。至于实现过程是如何的,这个问题就更加简单了,本质上来说就是弄懂Java操作压缩文件的的步骤,只要在网上看一下各种Demo是如何做的,加上再看API文档,那立马就能开干了,就算程序不能立马跑起来,这起码能让自己把程序的大体框架搭建起来。一旦你弄懂了如何通过API操作压缩文件,那么最后那就是换上操作jar包的API即可,步骤大同小异,然后再针对不同的地方再进行修改即可。

实现

打印jar包内所有内容的名称

其实,要实现这个基本功能就相当于实现查看压缩文件内的所有内容一样,我们平常查看压缩文件其实看到的就是和平时查看目录差不多。这里只贴核心代码,我们只关注这些代码即可。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// Java SE7
try (JarFile jarFile = new JarFile(jarPath)) {
for (Enumeration<JarEntry> entries = jarFile.entries(); entries.hasMoreElements();) {
JarEntry entry = entries.nextElement();
System.out.println(entry.getName());
}
} catch (IOException e) {
e.printStackTrace();
}

// Java SE8
try (JarFile jarFile = new JarFile(jarPath)) {
jarFile.stream().forEach(entry -> System.out.println(entry.getName()));
} catch (IOException e) {
e.printStackTrace();
}

在这我们可以很清楚看到,要实现这个功能要运用两个新类:java.util.jar.JarFilejava.util.jar.JarEntry。就算不看文档,光从类名就能稍微猜到它们描述什么事物,前者肯定是jar包,而后者则是jar包内各个内容条目。而前面也说过了,jar包只不过是特殊版本的压缩包,所以说用处理压缩包的类java.util.zip.ZipFilejava.util.zip.ZipEntry来实现这个基本功能,最终效果是一样的。

而如果你看源码的话,你就会发现处理jar包的相关类与处理普通压缩包的相关类是有继承关系的:

  • java.util.zip包内处理普通压缩包的相关类

    1
    2
    3
    4
    5
    6
    7
    public class ZipFile implements ZipConstants, Closeable {
    ...
    }

    public class ZipEntry implements ZipConstants, Cloneable {
    ...
    }
  • java.util.jar包内处理jar包的相关类

    1
    2
    3
    4
    5
    6
    7
    public class JarFile extends ZipFile {
    ...
    }

    public class JarEntry extends ZipEntry {
    ...
    }

所以说,如果并不用操作jar包特有的功能,那直接用处理普通压缩包的类对jar包进行操作也是一样的。

核心代码运行效果如下:

META-INF/
META-INF/MANIFEST.MF
com/
com/makwan/
com/makwan/javaee/
com/makwan/javaee/a_path/
com/makwan/javaee/b_xml_parse/
com/makwan/javaee/b_xml_parse/a_dom/
com/makwan/javaee/b_xml_parse/a_dom/dom4j/
com/makwan/javaee/b_xml_parse/bean/
com/makwan/javaee/c_practice/
com/makwan/javaee/a_path/ClassPathProblem.class
com/makwan/javaee/a_path/ClassPathProblem.java
com/makwan/javaee/a_path/RelativePathProblem.class
com/makwan/javaee/a_path/RelativePathProblem.java
com/makwan/javaee/b_xml_parse/a_dom/dom4j/Dom4jHelloWorld.class
com/makwan/javaee/b_xml_parse/a_dom/dom4j/Dom4jHelloWorld.java
com/makwan/javaee/b_xml_parse/a_dom/dom4j/Dom4jModification.class
com/makwan/javaee/b_xml_parse/a_dom/dom4j/Dom4jModification.java
com/makwan/javaee/b_xml_parse/a_dom/dom4j/Dom4jRecursiveTraversal.class
com/makwan/javaee/b_xml_parse/a_dom/dom4j/Dom4jRecursiveTraversal.java
com/makwan/javaee/b_xml_parse/bean/Person.class
com/makwan/javaee/b_xml_parse/bean/Person.java
com/makwan/javaee/b_xml_parse/data.xml
com/makwan/javaee/c_practice/Book.class
com/makwan/javaee/c_practice/Book.java
com/makwan/javaee/c_practice/config.properties
com/makwan/javaee/c_practice/HelloWorld.class
com/makwan/javaee/c_practice/HelloWorld.java
com/makwan/javaee/c_practice/MainProgram.class
com/makwan/javaee/c_practice/MainProgram.java
com/makwan/javaee/c_practice/Operation.class
com/makwan/javaee/c_practice/Operation.java
com/makwan/javaee/c_practice/personlist_template.html
com/makwan/javaee/c_practice/test_data.xml
default.properties
META-INF/maven/
META-INF/maven/com.makwan.javaee/
META-INF/maven/com.makwan.javaee/day02-xml/
META-INF/maven/com.makwan.javaee/day02-xml/pom.xml
META-INF/maven/com.makwan.javaee/day02-xml/pom.properties

我们可以看到,jar包即压缩包中的所有条目名称正是所有路径名。这很有用,如果你想要定位压缩包中的某个文件,就要用到这个路径名。

复制jar包

如果单纯说复制一个文件,不管这个文件是什么文件,那实现都非常简单了,直接用字节流读取并写入新文件即可。当然这里是要求使用指定的API读取jar包内每一个条目并写到新的jar包内。

这里同样还是只把核心代码贴出来。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
// Java SE7
try (
JarFile jarFile = new JarFile(srcJarPath);
JarOutputStream jos = new JarOutputStream(new FileOutputStream(copyJarPath))
) {
for (Enumeration<JarEntry> entries = jarFile.entries(); entries.hasMoreElements();) {
JarEntry entry = entries.nextElement();

try (InputStream is = jarFile.getInputStream(entry)) {
jos.putNextEntry(entry);
jos.write(IOUtils.readNBytes(is, is.available()));
jos.closeEntry();
}
}
} catch (IOException e) {
e.printStackTrace();
}

// Java SE8
try (
JarFile jarFile = new JarFile(srcJarPath);
JarOutputStream jos = new JarOutputStream(new FileOutputStream(copyJarPath))
) {
jarFile.stream().forEach(entry -> {
try (InputStream is = jarFile.getInputStream(entry)) {
jos.putNextEntry(entry);
jos.write(IOUtils.readNBytes(is, is.available()));
jos.closeEntry();
} catch (IOException e) {
e.printStackTrace();
}
});
} catch (IOException e) {
e.printStackTrace();
}

只要给这段核心代码指定好原jar包的所在路径srcJarPath以及复制后新创建的jar包路径copyJarPath即可运行程序。

这里最主要用到一个新类java.util.jar.JarOutputStream,从名字就可以猜到它的作用了,它是jar输出流,当然,它不是单纯地把整个jar包数据进行输出,它是把一个接一个条目数据进行输出的。所以,首先要设法将jar包的每个条目的数据读取到,才能通过它进行输出,这样要获取每个条目的输入流,而正好可以通过调用java.util.jar.JarFilegetInputStream并给它传递一个java.util.jar.JarEntry对象即可以获取这个条目的输入流,接着通过工具类sun.misc.IOUtils就可以以最简便的形式读取到该条目的数据(当然,不用这个工具类也是可以的,自己再造一遍轮子来实现这个功能也并不是很难),最后通过jar输出流进行输出。

在很多教程里值得关注的是:使用jar输出流进行输出之前调用putNextEntry,输出完毕之后则调用closeEntry,这里我的代码也都遵照这种模式这样做了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
public class JarOutputStream extends ZipOutputStream {
/**
* Begins writing a new JAR file entry and positions the stream
* to the start of the entry data. This method will also close
* any previous entry. The default compression method will be
* used if no compression method was specified for the entry.
* The current time will be used if the entry has no set modification
* time.
*
* @param ze the ZIP/JAR entry to be written
* @exception ZipException if a ZIP error has occurred
* @exception IOException if an I/O error has occurred
*/
public void putNextEntry(ZipEntry ze) throws IOException {
...
super.putNextEntry(ze);
}
}

public class ZipOutputStream extends DeflaterOutputStream implements ZipConstants {
/**
* Begins writing a new ZIP file entry and positions the stream to the
* start of the entry data. Closes the current entry if still active.
* The default compression method will be used if no compression method
* was specified for the entry, and the current time will be used if
* the entry has no set modification time.
* @param e the ZIP entry to be written
* @exception ZipException if a ZIP format error has occurred
* @exception IOException if an I/O error has occurred
*/
public void putNextEntry(ZipEntry e) throws IOException {
ensureOpen();
if (current != null) {
closeEntry(); // close previous entry
}
...
current = new XEntry(e, written);
}

/**
* Closes the current ZIP entry and positions the stream for writing
* the next entry.
* @exception ZipException if a ZIP format error has occurred
* @exception IOException if an I/O error has occurred
*/
public void closeEntry() throws IOException {
ensureOpen();
if (current != null) {
...
current = null;
}
}
}

但从源码发现,调用putNextEntry会间接调用closeEntry,它会把之前还没关闭的条目关闭掉。现阶段,我暂时是不太懂开发者在开发时调用这个closeEntry有什么意义,这个问题有待以后考究。

修改jar包里的配置文件

弄懂上面那些基本操作后,那就可以继续向前。这里同样把之前演示所用的那个jar包作为样例,而这次要修改它里面的配置文件com/makwan/javaee/c_practice/config.properties的内容。

具体思路其实和复制jar包是差不多的:

  1. 读取原jar包的所有条目的数据
  2. 当所读取的条目正好是com/makwan/javaee/c_practice/config.properties配置文件时,则把数据读取出来,根据自己的需求进行修改
  3. 把读取出来不需要改变的条目数据以及根据需求修改后的条目数据写到另一个新jar包里,最后删除原jar包并把新jar包重命名为原jar包的文件名
    不喜欢用这种文件替换法,其实可以先把所有新数据都写到内存内,最后再把它写到原jar包内

思路还算是很简单的,实现起来也并不复杂,下面就把两种方式都演示一遍。

通过临时文件作为桥梁

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
try (
JarFile jarFile = new JarFile(srcJarPath);
JarOutputStream jos = new JarOutputStream(new FileOutputStream(tmpJarPath))
) {
for (Enumeration<JarEntry> entries = jarFile.entries(); entries.hasMoreElements();) {
JarEntry entry = entries.nextElement();
try (InputStream is = jarFile.getInputStream(entry)) {
// 这里与之前不一样, 要创建一个新的Entry传递进去
jos.putNextEntry(new JarEntry(entry.getName()));

if ("com/makwan/javaee/c_practice/config.properties".equals(entry.getName())) {
Properties properties = new Properties();
properties.load(is);

// 根据需求修改该配置文件的数据
if ("".equals(properties.getProperty("xmlPath"))) {
properties.setProperty("xmlPath", "D:/test.xml");
}

properties.store(jos, null);

} else {
jos.write(IOUtils.readNBytes(is, is.available()));
}

jos.closeEntry();
}
}
} catch (IOException e) {
e.printStackTrace();
}

// 删除原jar包, 重命名临时jar包
new File(srcJarPath).delete();
new File(tmpJarPath).renameTo(new File(srcJarPath));

通过内存作为桥梁

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
try (ByteArrayOutputStream bArrOS = new ByteArrayOutputStream()) {
try (
JarFile jarFile = new JarFile(jarPath);
JarOutputStream jos = new JarOutputStream(bArrOS)
) {
for (Enumeration<JarEntry> entries = jarFile.entries(); entries.hasMoreElements();) {
JarEntry entry = entries.nextElement();
try (InputStream is = jarFile.getInputStream(entry)) {
// 这里与之前不一样, 要创建一个新的Entry传递进去
jos.putNextEntry(new JarEntry(entry.getName()));

if ("com/makwan/javaee/c_practice/config.properties".equals(entry.getName())) {
Properties properties = new Properties();
properties.load(is);

// 根据需求修改该配置文件的数据
if ("".equals(properties.getProperty("xmlPath"))) {
properties.setProperty("xmlPath", "D:/test.xml");
}

properties.store(jos, null);

} else {
jos.write(IOUtils.readNBytes(is, is.available()));
}

jos.closeEntry();
}
}
}

bArrOS.writeTo(new FileOutputStream(jarPath));
} catch (IOException e) {
e.printStackTrace();
}

在Java8可以这样实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
try (ByteArrayOutputStream bArrOS = new ByteArrayOutputStream()) {
try (
JarFile jarFile = new JarFile(jarPath);
JarOutputStream jos = new JarOutputStream(bArrOS)
) {
jarFile.stream().forEach(entry -> {
try (InputStream is = jarFile.getInputStream(entry)) {
jos.putNextEntry(new JarEntry(entry.getName()));

if ("com/makwan/javaee/c_practice/config.properties".equals(entry.getName())) {
Properties properties = new Properties();
properties.load(is);

// 根据需求修改该配置文件的数据
if ("".equals(properties.getProperty("xmlPath"))) {
properties.setProperty("xmlPath", "D:/test.xml");
}

properties.store(jos, null);
} else {
jos.write(IOUtils.readNBytes(is, is.available()));
}

jos.closeEntry();
} catch (IOException e) {
e.printStackTrace();
}
});
}

bArrOS.writeTo(new FileOutputStream(jarPath));
} catch (IOException e) {
e.printStackTrace();
}

特殊情况!!!

我们开发好的Java程序,自己可以很方便地通过IDE运行,但是,如果想把程序给别人使用,叫别人安装IDE来运行明显是不实际的,最实际且简单的方法是先让别人安装JRE,然后我们把开发好的程序打成jar包并把它交给别人使用,这样就能让别人运行jar包中的程序。前面这种做法那是十分普遍的,对于之前所写的修改jar包程序,同样也是如此对待,但对于一种特殊需求那就直接不能运行了。

这个特殊的需求具体来说就是:正处于运行中的修改jar包程序所要修改的jar包正好是包含该修改jar包程序的jar包

你觉得这需求很荒诞,但是在现实中,某些很简单的实用功能就是要通过实现这种奇葩的需求支撑起来的。其实较真一点来说,通过Java实现修改jar包也算是比较奇葩的,实现它干嘛呢?
在此,就有这么一个实际需求:程序每次运行后都要把用户修改过的配置信息更新到jar包里头的配置文件中待下次再使用,为了让用户忽略各种细节,所以就是不能把配置文件放到jar包外头。
更通俗直白地就是说:我们把开发好的Java程序打成jar包给别人使用,而在每次程序运行后,都会自动把用户修改过的配置信息或者程序运行过程中产生的有价值信息记录到该jar包里的配置文件中。

而这么一个简单的实际需求,就要建立于通过Java程序修改jar包的功能,更要建立于通过jar包内的Java程序修改jar包自身这种功能。
这需求看似简单而实现起来却又是另一回事,更准确地来说是不能再用常规思路实现它。所以事情并不会顺风顺水,立刻对问题就有头绪。不过这是好事,因为正因为这样才能真正让自己学到新知识。

要实现这个特殊需求,我们立刻可以想到或碰到的问题是:如何通过代码获取当前正在运行的jar包的路径?

我们不能再像之前那样指定jar包的绝对路径,而指定相对路径也是不行的,这些方法都不是通用的。
而我的想法是jar包也是Java专属的产物,那估计同样也会有配套的API解决这个问题,所以顺着这个思路去查了一下,还真的找到解决这个问题的API:

1
String jarPath = CurrentClassName.class.getProtectionDomain().getCodeSource().getLocation().getPath();

这一连串的调用,最后所得出的结果正是用户正在使用的jar包的路径,要注意的是:这里的CurrentClassName一定要写我们自己jar包内的类名

首要问题解决了,然而这并没有告一段落。根据日常经验,我们很快就会知道,若想实现这种特殊的需求,那不得不面对一个问题:正处于运行状态的文件是无法被修改的!

正如平日我们运行了一个应用程序,程序还在运行的时候就尝试把该程序卸载掉或把它相关的文件删掉,那系统肯定不让你那么干,你先得让程序停止运行才能做那种事,所以说,这相当于给之前所演示的通过临时文件作为桥梁这种修改jar包的方法判了死刑。因为我们要修改的是正在运行的jar包,jar包正在被JVM加载使用中,所以那是肯定不能通过代码把这个正在的运行的jar包删除。

所以说,只剩下通过内存作为桥梁这最后一种方案可以用了,如果你尝试一下,确实是可以修改成功的,也没有其余的错误,这十分好。
但值得注意的是:只有当程序完全关闭了,即jar包不再被JVM加载了,修改的新内容才得以写到该jar包内

所以最终结论就出来了:正处于运行状态的jar包需修改自身,在实现时最好是确保修改jar包过程放到程序退出前执行
因为所要更新的数据可能并不只是单纯被改动一次,或许随着程序的运行或者用户的需要会,该数据会被修改很多次,所以并不用每次都执行该修改jar包的过程,从而节省更多的资源。

至于如何确保该修改jar包的过程在程序退出前执行有很多方法,比如:把这个过程放在main方法最后,又或者使用以下这个方法也可以实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
package java.lang;

public class Runtime {
/**
* Registers a new virtual-machine shutdown hook.
*/
public void addShutdownHook(Thread hook) {
SecurityManager sm = System.getSecurityManager();
if (sm != null) {
sm.checkPermission(new RuntimePermission("shutdownHooks"));
}
ApplicationShutdownHooks.add(hook);
}
}

通过调用RuntimeaddShutdownHook方法,把修改jar包的过程放到线程中再传递给该方法,这样,程序结束前,修改jar包的过程也会被执行。

参考资料