前言

在具体介绍如何实现文件 zip 压缩之前我们需要对 zip 压缩有个基础了解。相信各位在实际中经常使用 zip 文件压缩,但是你可能对压缩包了解甚少。

先来看个使用命令行压缩文件的示例,在我当前的 Downloads 目录下有两个文件:

  1. $ ls
  2. 050617-425.png test.md

现在使用 zip 命令进行压缩成一个 example.zip 文件:

  1. $ zip example.zip ./*

接下来要做的是使用 l 参数列出这个压缩包文件信息,如下:

  1. $ unzip -l example.zip
  2. Archive: example.zip
  3. Length Date Time Name
  4. --------- ---------- ----- ----
  5. 1159311 2021-11-05 20:23 050617-425.png
  6. 148 2021-11-05 20:23 test.md
  7. --------- -------
  8. 1159459 2 files

注意看输出的信息,一共有四列。第一列 Length 输出的是文件的大小,单位是字节。最主要的是看最后一列 Name,这列输出的就是压缩文件的名称。一定要知道这点,因为如何不知道这个是什么就会对 Java ZIP 压缩不理解。

现在我们就对 zip 文件有了基本的认识,那来看下如何使用 Apache 工具包 Commons-Compress 如何创建 ZIP 文件和 ZIP 文件的解压。

文件压缩

有了前面的知识就会发现创建 zip 压缩对象就特别简单,只要创建一个 ZipArchiveOutputStream 对象即可,怎么创建呢?两种方式,直接 NEW 或者工厂方法,如下。

直接 NEW(推荐):

  1. ZipArchiveOutputStream zipOutputStream = new ZipArchiveOutputStream(outputStream);

使用工厂创建:

  1. // 使用工厂方式返回的是一个 ArchiveOutputStream 抽象IO对象
  2. ArchiveOutputStream archiveOutputStream = new ArchiveStreamFactory().createArchiveOutputStream(ArchiveStreamFactory.ZIP, outputStream, "UTF-8");
  3. // 在使用之前最好进行一次强转
  4. ZipArchiveOutputStream zipOutputStream = (ZipArchiveOutputStream) archiveOutputStream;

这两种方法不管使用哪种都可,除非你使用 SIP 扩展点,否则我更推荐你直接使用 NEW 的方式。

压缩文件

这里的压缩文件不能包含目录,支持多个文件。代码如下:

  1. public static void createZipArchive(Collection<File> fileList, OutputStream outputStream) {
  2. try (ZipArchiveOutputStream zipOutputStream = new ZipArchiveOutputStream(outputStream)) {
  3. zipOutputStream.setUseZip64(Zip64Mode.AsNeeded);
  4. // 解决中文乱码问题
  5. // issue: https://stackoverflow.com/questions/4212577/zip-file-created-by-java-doesnt-support-chineseutf-8
  6. zipOutputStream.setEncoding("Cp437");
  7. zipOutputStream.setFallbackToUTF8(true);
  8. zipOutputStream.setUseLanguageEncodingFlag(true);
  9. zipOutputStream.setCreateUnicodeExtraFields(ZipArchiveOutputStream.UnicodeExtraFieldPolicy.NOT_ENCODEABLE);
  10. // 创建条目
  11. for (File file : fileList) {
  12. // 创建压缩条目
  13. ZipArchiveEntry zipEntry = new ZipArchiveEntry(file, file.getName());
  14. zipOutputStream.putArchiveEntry(zipEntry);
  15. // 写入数据
  16. IOUtils.copy(new FileInputStream(file), zipOutputStream);
  17. // 该条目创建完成
  18. zipOutputStream.closeArchiveEntry();
  19. }
  20. zipOutputStream.finish();
  21. } catch (IOException e) {
  22. e.printStackTrace();
  23. }
  24. }

这里我们需要指的一点就是就是 创建条目,在最开始的时候还特意让大家看了下 zip 压缩包文件的数据内容,其中就有一个 Name。那么这个 Name 是怎么来的呢?其实就是我们 Java 代码里面的 ZipArchiveEntry 对象。

ZipArchiveEntry 对象主要有两个属性:file 和 name。file 就是具体的文件,而这个 name 就是文件的名称,对应于 ZIP 压缩包的 Name 输出列。相信说到这大家就对 ZIP 压缩包有了全新的认识。

当一个 ZipArchiveEntry 创建完成后就需要写入对应的文件流,对应上面代码中的 IOUtils.copy(in, out)。当文件流写玩后就需要关闭这个 ZipArchiveEntry,对应代码中的 closeArchiveEntry() 方法,表示这个条目(文件)创建完成了,之后如何还有文件就重复这个操作!

另外注意看方法内部的对中文的处理。在压缩含有中文名的文件时,解压时会有中文会有乱码的问题,这是 JDK 的一个 BUG。在 StackOverflow 上也有对应的问题,并指出了 Oracle ISSUE 地址。链接是:

https://stackoverflow.com/questions/4212577/zip-file-created-by-java-doesnt-support-chineseutf-8

看下演示示例:

在我的 /home/kali/Downloads/test 目录下有六个文件,如下:

  1. $ ls /home/kali/Downloads/test
  2. 2021_10_04_17_49_IMG_0328.MOV photo_2021-09-15_20-11-51.jpg video_2021-10-30_18-30-58.mp4
  3. photo_2021-09-15_12-22-14.jpg photo_2021-10-09_09-36-06.jpg 代码没写完哪有脸睡觉.jpg

我想将这六个文件压缩成一个 test.zip 文件,写到 /home/kali/Downloads/exp 目录下,代码如下:

  1. public static void main(String[] args) throws IOException {
  2. File file = new File("/home/kali/Downloads/test");
  3. if (file.isDirectory()) {
  4. File[] files = file.listFiles();
  5. if (files != null && files.length != 0) {
  6. createZipArchive(Arrays.asList(files), new FileOutputStream("/home/kali/Downloads/exp/test.zip"));
  7. }
  8. }
  9. }

看下生成后的压缩文件:

  1. $ ls /home/kali/Downloads/exp/
  2. test.zip

来看下压缩文件的内容:

  1. $ unzip -l /home/kali/Downloads/exp/test.zip
  2. Archive: test.zip
  3. Length Date Time Name
  4. --------- ---------- ----- ----
  5. 51014 2021-10-30 18:11 photo_2021-10-09_09-36-06.jpg
  6. 78909 2021-10-30 18:30 代码没写完哪有脸睡觉.jpg
  7. 93505 2021-10-30 21:22 photo_2021-09-15_20-11-51.jpg
  8. 586305 2021-10-30 18:30 video_2021-10-30_18-30-58.mp4
  9. 67048 2021-10-30 21:22 photo_2021-09-15_12-22-14.jpg
  10. 702499037 2021-10-30 18:47 2021_10_04_17_49_IMG_0328.MOV
  11. --------- -------
  12. 703375818 6 files

这样 ZIP 文件就创建完成了。

注意
关于这个中文乱码的问题,我使用 stackoverflow 上面的方法在 Linux 测试正常,但是在 MacOS 下测试依然有乱码,而 Windows 平台因为没环境所以没做测试。关于这个问题的解决方案还在查资料中…

压缩目录和文件

上面的方法虽然能够批量压缩文件但是只能够压缩具体的文件,也就是说指定的 File 必须是文件,不能是目录。那如何压缩子目录呢?

比如在我当前的 Download 目录下有几个文件,树形结构如下(其中 emptydir 是一个空文件夹):

  1. $ tree ~/Downloads/
  2. .
  3. ├── 050617-425.png
  4. ├── emptydir
  5. ├── html
  6. ├── errno-404.html
  7. └── index.html
  8. └── test.md

那现在该怎去压缩才能达到压缩文件夹的目录呢?我们还是用使用 zip 命令来演示下该怎么去压缩。命令如下:

  1. $ zip example.zip ./*
  2. adding: 050617-425.png (deflated 6%)
  3. adding: emptydir/ (stored 0%)
  4. adding: html/ (stored 0%)
  5. adding: test.md (deflated 20%)

之后会得到一个 example.zip 文件,现在来看下这个 example.zip 文件中的内容:

  1. $ unzip -l example.zip
  2. Archive: example.zip
  3. Length Date Time Name
  4. --------- ---------- ----- ----
  5. 1159311 2021-11-04 23:15 050617-425.png
  6. 0 2021-11-05 20:10 emptydir/
  7. 0 2021-11-05 20:10 html/
  8. 148 2021-11-04 23:00 test.md
  9. --------- -------
  10. 1159459 4 files

注意看 Name 列的输出,如果是文件夹的话 Name 就要包含文件夹信息。这其实就是对应我们 Java 类的 ZipArchiveEntry 对象,所以我们也可以这么实现,代码如下:

  1. public static void createZipArchive(File dir, OutputStream outputStream) throws IOException {
  2. if (!dir.isDirectory()) {
  3. throw new IOException(String.format("%s 不是一个目录", dir.getPath()));
  4. }
  5. try (ZipArchiveOutputStream zipOutputStream = new ZipArchiveOutputStream(outputStream)) {
  6. zipOutputStream.setUseZip64(Zip64Mode.AsNeeded);
  7. // 解决中文乱码问题
  8. // issue: https://stackoverflow.com/questions/4212577/zip-file-created-by-java-doesnt-support-chineseutf-8
  9. zipOutputStream.setEncoding("Cp437");
  10. zipOutputStream.setFallbackToUTF8(true);
  11. zipOutputStream.setUseLanguageEncodingFlag(true);
  12. zipOutputStream.setCreateUnicodeExtraFields(ZipArchiveOutputStream.UnicodeExtraFieldPolicy.NOT_ENCODEABLE);
  13. String dirPath = dir.getPath();
  14. Files.walk(dir.toPath(), FileVisitOption.FOLLOW_LINKS).forEach(new Consumer<Path>() {
  15. @Override
  16. public void accept(Path path) {
  17. File file = path.toFile();
  18. String filePath = file.getPath();
  19. // 如果 dirPath 和 filePath 相等表示是指定的文件目录, 跳过.
  20. if (filePath.equals(dirPath)) {
  21. return;
  22. }
  23. // 如果 file 是一个文件就开始写入文件流.
  24. // 如果 file 是一个目录但是文件夹没有内容表示是一个空目录, 也需要j将这个空文件夹写入压缩包.
  25. if (file.isFile() || (file.isDirectory() && file.listFiles().length == 0)) {
  26. // 空文件夹处理
  27. if (file.isDirectory()) {
  28. try {
  29. // 注意看这个文件名, 不再是之前的 file.getName()
  30. String fileName = filePath.substring(dirPath.length() + 1);
  31. ZipArchiveEntry entry = new ZipArchiveEntry(file, fileName);
  32. zipOutputStream.putArchiveEntry(entry);
  33. // 注意这里不再需要写入流, 因为是一个这里的 file 是一个空文件,
  34. // 我们只需要使用 ZipArchiveEntry 创建一个标记即可!!!!
  35. // IOUtils.copy(inputStream, zipOutputStream);
  36. zipOutputStream.closeArchiveEntry();
  37. return;
  38. } catch (IOException e) {
  39. // 空文件夹创建失败, Handler exception
  40. e.printStackTrace();
  41. }
  42. }
  43. // 文件处理
  44. try (FileInputStream inputStream = new FileInputStream(file)) {
  45. // 注意看这个文件名, 不再是之前的 file.getName()
  46. String fileName = filePath.substring(dirPath.length() + 1);
  47. ZipArchiveEntry entry = new ZipArchiveEntry(file, fileName);
  48. zipOutputStream.putArchiveEntry(entry);
  49. IOUtils.copy(inputStream, zipOutputStream);
  50. zipOutputStream.closeArchiveEntry();
  51. } catch (IOException e) {
  52. e.printStackTrace();
  53. }
  54. }
  55. }
  56. });
  57. zipOutputStream.finish();
  58. } catch (IOException e) {
  59. e.printStackTrace();
  60. }
  61. }

这个代码有点长,另外使用了 Stream,但是因为阅读方便所以没有 Lambda,下面开始对这个代码说下。

首先呢前面的代码没什么区别,区别在于 Files.walk() 内部的处理。Files.walk() 是用于一个一个读取指定文件夹下内容的函数,你可以理解为就是递归下钻。在开始递归之前我们先得到这个文件夹的 Path,即 dir.getPath()。主要是为了后面的文件比较。

在递归内部,我们要比对递归的 Path 和 dirPath,是因为截取 Path 得到 ZIP 的 Name。当然第一次递归就是 dir 目录本身,所以我们要进行跳过。之后我们需要判断是否为文件,如果是文件我们就正常写入即可。

但是如果是文件夹呢?我们需要判断这个文件夹是否是空文件夹,如果是我们需要记录到 ZIP 压缩文件中,创建一个对应的 ZipArchiveEntry 对象即可。注意,因为是空文件夹所以不需要写入流,创建这个对象也仅仅是为了标记,ZIP 压缩包会将这个 ZipArchiveEntry 当做文件处理,在解压时就能得到对应的空文件夹。

另外,这段代码为了阅读方便所以没做任何的优化。下面执行看下效果如何:

  1. public static void main(String[] args) throws IOException {
  2. createZipArchive(new File("/home/kali/Downloads"), new FileOutputStream("/home/kali/tmp/examlpe.zip"));
  3. }

ZIP 文件创建完成后使用 unzip 命令看下压缩包内容:

  1. $ unzip -l examlpe.zip
  2. Archive: examlpe.zip
  3. Length Date Time Name
  4. --------- ---------- ----- ----
  5. 148 2021-11-04 23:00 test.md
  6. 0 2021-11-05 20:10 emptydir/
  7. 1159311 2021-11-04 23:15 050617-425.png
  8. 259288 2021-11-04 21:21 html/index.html
  9. 17043 2021-11-04 21:21 html/errno-404.html
  10. --------- -------
  11. 1435790 5 files

你会看到 Name 列输出的信息就是我们之前目录下的所有文件内容。另外注意 emptydir/ 对应的 Length 为 0,是一个空文件夹,其他的文件也一切正常。

这样一个包含目录的 ZIP 压缩文件就创建完成了~

指定压缩级别

创建 ZIP 压缩包时我们还可以指定压缩级别,ZipArchiveOutputStream 有个 setLevel 方法用于设置压缩级别:

  1. org.apache.commons.compress.archivers.zip.ZipArchiveOutputStream#setLevel

另外,这个这个级别在 Java 标准库 java.util.zip.Deflater 中有定义,几个常量如下:

  1. public static final int DEFLATED = 8;
  2. public static final int NO_COMPRESSION = 0;
  3. public static final int BEST_SPEED = 1;
  4. public static final int BEST_COMPRESSION = 9;
  5. public static final int DEFAULT_COMPRESSION = -1;
  6. public static final int FILTERED = 1;
  7. public static final int HUFFMAN_ONLY = 2;
  8. public static final int DEFAULT_STRATEGY = 0;
  9. public static final int NO_FLUSH = 0;
  10. public static final int SYNC_FLUSH = 2;
  11. public static final int FULL_FLUSH = 3;

有兴趣的可以自己试下,这里就不再演示了。

设置压缩方法

ZipArchiveOutputStream 同样的还有一个 setMethod 方法用于设置压缩方式:

  1. org.apache.commons.compress.archivers.zip.ZipArchiveOutputStream#setMethod

压缩方式有两个,也是在 Java 标准库 java.util.zip.ZipEntry 中定义:

  1. public static final int STORED = 0;
  2. public static final int DEFLATED = 8;

STORED 表示打包归档而不是压缩,这个就类似于 tar 命令打包一个。而 DEFLATED 表示的是压缩存储,我们使用 ZIP 的原因就是因为压缩,所以该值是默认值。

文件解压

相比较压缩,解压更简单些。因为解压就是根据 Zip Entry Name 创建文件或者文件夹,也就是说在解压时要重点关注对文件夹的处理。看下我们之前创建的 example.zip 文件中的条目信息:

  1. $ unzip -l examlpe.zip
  2. Archive: examlpe.zip
  3. Length Date Time Name
  4. --------- ---------- ----- ----
  5. 148 2021-11-04 23:00 test.md
  6. 0 2021-11-05 20:10 emptydir/
  7. 1159311 2021-11-04 23:15 050617-425.png
  8. 259288 2021-11-04 21:21 html/index.html
  9. 17043 2021-11-04 21:21 html/errno-404.html
  10. --------- -------
  11. 1435790 5 files

在解压时我们一定一定一定一定要特别注意对 Entry Name 的处理!也就是说在压缩时文件是什么样的解压时也要是什么样的。

比如第一个 Entry Name 是一个 test.md,这个是一个具体的文件,所以在解压时将其输出到指定的目录下即可。而第二个 Entry Name 是 emptydir/,这明显是一个文件夹,并且是个空文件夹。那么在解压时对这类数据该如何处理呢?是不是应该创建一个文件夹呢?再来看最后两个 Entry Name:html/index.html 和 html/errno-404.html。这个就比较特别了,是一个包含文件夹的文件,那么我们在输出时就应该先创建对应的 html 文件夹然后再将两个 html 文件解压到这两个文件夹下!

这是在解压时要注意的地方。另外你有没有发现这个 Entry Name,事实上我们可以通过这个 Entry Name 只解压某一个文件,这个也被称为随机访问解压。而有随机的肯定也有顺序的,现在直接看代码:

顺序访问解压

顺序解压就是按照 Entry Name 的顺序依次读取文件流进行解压,而且是全部解压!这个就没有任何的技巧,直接读取文件流即可。看下下面的代码:

  1. public static void unpackZipArchive(File zipFile, File outputDir) throws IOException {
  2. if (!outputDir.isDirectory()) {
  3. throw new IOException(outputDir + "不是一个目录");
  4. }
  5. Set<String> existPaths = new HashSet<>();
  6. try (ZipArchiveInputStream inputStream = new ZipArchiveInputStream(new FileInputStream(zipFile))) {
  7. ArchiveEntry entry;
  8. while ((entry = inputStream.getNextEntry()) != null) {
  9. // Zip Entry Name
  10. String fileName = entry.getName();
  11. // 解压的文件对象输出到 outputDir 目录下
  12. File newFile = new File(outputDir.getPath(), fileName);
  13. // 这里要判断是 entry 是不是目录, 如果是目录我们应该创建对应的目录.
  14. // 而如果是文件的话, 我们需要先得到文件的目录, 因为 zip 中的 fileName
  15. // 可以是 dir/file 的格式, 我们需要判断是否存在 / 符号. 如果存在就表示
  16. // file 前面有一个或多个目录, 所以要先创建对应的目录, 之后再输出具体的文件.
  17. if (entry.isDirectory()) {
  18. Files.createDirectories(newFile.toPath());
  19. } else {
  20. if (fileName.contains(File.separator)) {
  21. String newFilePath = newFile.getPath();
  22. String dirPath = newFilePath.substring(0, newFilePath.lastIndexOf(File.separator));
  23. if (!existPaths.contains(dirPath)) {
  24. Path path = Paths.get(dirPath);
  25. Files.createDirectories(path);
  26. existPaths.add(dirPath);
  27. }
  28. }
  29. IOUtils.copy(inputStream, new FileOutputStream(newFile));
  30. }
  31. }
  32. } catch (FileNotFoundException e) {
  33. e.printStackTrace();
  34. } catch (IOException e) {
  35. e.printStackTrace();
  36. }
  37. }

这个就是顺序解压,最重要的是看 while 循环中对 Entry Name 的处理,我们先判断 Entry 是否是文件夹,是文件夹就要进行创建。不是文件夹的话要要继续判断文件是否包含目录,如果包含目录我们要先创建目录然后再写文件。

现在来看下解压效果,还是以之前创建的 example.zip 为例:

  1. public static void main(String[] args) throws IOException {
  2. unpackZipArchive(new File("/home/kali/tmp/examlpe.zip"), new File("/home/kali/tmp/unpark"));
  3. }

最后看下解压后的文件:

  1. $ tree /home/kali/tmp/unpark
  2. /home/kali/tmp/unpark
  3. ├── 050617-425.png
  4. ├── emptydir
  5. ├── html
  6. ├── errno-404.html
  7. └── index.html
  8. └── test.md
  9. 2 directories, 4 files

Prefect~

随机访问解压

随机访问解压相比较顺序而言更加的灵活,因为是根据 Entry Name 来的。什么意思呢?就是说我们可以根据 Entry Name 来解压指定的文件,也被称为随机访问。代码在实现上的区别是不直接创建一个文件流,而是得到一个 ZipFile 对象:

  1. public static void unpackZipArchive(File zipFile, File outputDir) throws IOException {
  2. ZipFile zip = new ZipFile(zipFile);
  3. }

这个 ZipFile 比较有意思,它有几个重要的方法(下面是其中的四个):

  1. public InputStream getInputStream(final ZipArchiveEntry ze);
  2. public Enumeration<ZipArchiveEntry> getEntries();
  3. public Iterable<ZipArchiveEntry> getEntries(final String name);
  4. public ZipArchiveEntry getEntry(final String name);

你发现问题了吗?先看最后两个方法,参数是 name,这个 name 就是 Entry Name。也就是我们之前 example.zip 中的 Name 数据:

  1. $ unzip -l examlpe.zip
  2. Archive: examlpe.zip
  3. Length Date Time Name
  4. --------- ---------- ----- ----
  5. 148 2021-11-04 23:00 test.md
  6. 0 2021-11-05 20:10 emptydir/
  7. 1159311 2021-11-04 23:15 050617-425.png
  8. 259288 2021-11-04 21:21 html/index.html
  9. 17043 2021-11-04 21:21 html/errno-404.html
  10. --------- -------
  11. 1435790 5 files

比如我只想要解压 050617-425.png 文件怎么办呢?使用 getEntry(final String name) 方法即可,伪代码如下:

  1. public static void unpackZipArchive(File zipFile, File outputDir) throws IOException {
  2. ZipFile zip = new ZipFile(zipFile);
  3. // 得到指定的 Entry
  4. ZipArchiveEntry entry = zip.getEntry("050617-425.png");
  5. // 解压
  6. try (InputStream inputStream = zip.getInputStream(entry)) {
  7. // 因为知道这是一个普通的文件, 所以这里什么判断都不做. 仅做演示使用, 直接解压.
  8. File newFile = new File(outputDir.getPath(), entry.getName());
  9. IOUtils.copy(inputStream, new FileOutputStream(newFile));
  10. }
  11. }

解压看下 /home/kali/tmp/unpark 目录下的文件:

  1. $ tree ~/tmp/unpark/
  2. /home/kali/tmp/unpark/
  3. └── 050617-425.png
  4. 0 directories, 1 file

现在有没有体会到其中的精髓?

另外,相信也注意到了 getEntries() 方法,我们同样也可以作为顺序解压使用,伪代码如下:

  1. public static void unpackZipArchive(File zipFile, File outputDir) throws IOException {
  2. ZipFile zip = new ZipFile(zipFile);
  3. // 直接获取所有的 Entry 对象, 循环解压
  4. Enumeration<ZipArchiveEntry> entries = zip.getEntries();
  5. while (entries.hasMoreElements()) {
  6. ZipArchiveEntry entry = entries.nextElement();
  7. try (InputStream inputStream = zip.getInputStream(entry)){
  8. // do something...
  9. }
  10. }
  11. }

怎么样?有没有觉得很棒?