zl程序教程

您现在的位置是:首页 >  其他

当前栏目

Go:不用标准库如何解压 Zip 文件?

2023-04-18 15:42:53 时间

大家好,我是程序员幽鬼。

zip 是一种常见的归档格式,本文讲解 Go 如何操作 zip。

首先看看 zip 文件是如何工作的。以一个小文件为例:(类 Unix 系统下)

  1. $ cat hello.text 
  2.  
  3. Hello! 

执行 zip 命令进行归档:

  1. $ zip test.zip hello.text 
  2.  
  3. adding: hello.text (stored 0%) 
  4.  
  5. $ ls -lah test.zip 
  6.  
  7. -rw-r--r-- 1 phil phil 177 Nov 23 23:04 test.zip 

一个 6 字节的文本文件变成了一个 177 字节的 zip 文件。这并不大,解析 177 个字节听起来不可能太复杂!

对 zip 文件执行 hexdump:

  1. $ hexdump -C test.zip 
  2. 00000000  50 4b 03 04 0a 00 00 00  00 00 8a b8 77 53 9e d8  |PK..........wS..| 
  3. 00000010  42 b0 07 00 00 00 07 00  00 00 0a 00 1c 00 68 65  |B.............he| 
  4. 00000020  6c 6c 6f 2e 74 65 78 74  55 54 09 00 03 74 73 9d  |llo.textUT...ts.| 
  5. 00000030  61 74 73 9d 61 75 78 0b  00 01 04 eb 03 00 00 04  |ats.aux.........| 
  6. 00000040  eb 03 00 00 48 65 6c 6c  6f 21 0a 50 4b 01 02 1e  |....Hello!.PK...| 
  7. 00000050  03 0a 00 00 00 00 00 8a  b8 77 53 9e d8 42 b0 07  |.........wS..B..| 
  8. 00000060  00 00 00 07 00 00 00 0a  00 18 00 00 00 00 00 01  |................| 
  9. 00000070  00 00 00 a4 81 00 00 00  00 68 65 6c 6c 6f 2e 74  |.........hello.t| 
  10. 00000080  65 78 74 55 54 05 00 03  74 73 9d 61 75 78 0b 00  |extUT...ts.aux..| 
  11. 00000090  01 04 eb 03 00 00 04 eb  03 00 00 50 4b 05 06 00  |...........PK...| 
  12. 000000a0  00 00 00 01 00 01 00 50  00 00 00 4b 00 00 00 00  |.......P...K....| 
  13. 000000b0  00                                                |.| 
  14. 000000b1 

从中我们可以看到文件名和文件内容。

01 结构

我们来看看这里[1]定义的 zip 结构 。根据第 4.3.6 节,看起来文件元数据后跟文件内容一个接一个地存储,最后一块是 “central directory” 元数据。

zip format header

图片来源:https://www.codeproject.com/Articles/8688/Extracting-files-from-a-remote-ZIP-archive

本地 header 元数据如下所示:

字段 大小
local file header signature 4 bytes
version needed to extract 2 bytes
general purpose bit flag 2 bytes
compression method 2 bytes
last mod file time 2 bytes
last mod file date 2 bytes
crc-32 4 bytes
compressed size 4 bytes
uncompressed size 4 bytes
file name length 2 bytes
extra field length 2 bytes
file name 可变
extra field 可变

在一个有效 zip 文件中,header 签名是一个整数 (0x04034b50 )。我们将忽略版本、通用 flag 和校验和。可以是没有压缩(用 0 表示),也可以是使用 DEFLATE 方法解压缩(用 8 表示)。

最后修改时间和日期是 MSDOS 风格的日期/时间格式。

我们粗略地将其翻译为 Go 代码:

  1. package main 
  2.  
  3. import ( 
  4.     "os" 
  5.     "bytes" 
  6.     "compress/flate" 
  7.     "io/ioutil" 
  8.     "encoding/binary" 
  9.     "time" 
  10.     "fmt" 
  11.  
  12. type compression uint8 
  13. const ( 
  14.     noCompression compression = iota 
  15.     deflateCompression 
  16.  
  17. type localFileHeader struct { 
  18.     signature uint32 
  19.     version uint16 
  20.     bitFlag uint16 
  21.     compression compression 
  22.     lastModified time.Time 
  23.     crc32 uint32 
  24.     compressedSize uint32 
  25.     uncompressedSize uint32 
  26.     fileName string 
  27.     extraField []byte 
  28.     fileContents string 

02 main 函数实现

我们的入口点将读取一个 zip 文件并遍历该文件,直到我们无法解析 zip 文件条目。

  1. func main() { 
  2.     f, err := ioutil.ReadFile(os.Args[1]) 
  3.     if err != nil { 
  4.         panic(err) 
  5.     } 
  6.  
  7.     end := 0 
  8.     for end < len(f) { 
  9.         var err error 
  10.         var lfh *localFileHeader 
  11.         var next int 
  12.         lfh, next, err = parseLocalFileHeader(f, end
  13.         if err == errNotZip && end > 0 { 
  14.             break 
  15.         } 
  16.         if err != nil { 
  17.             panic(err) 
  18.         } 
  19.  
  20.         end = next 
  21.  
  22.         fmt.Println(lfh.lastModified, lfh.fileName, lfh.fileContents) 
  23.     } 

03 文件

对于每个文件,如果前四个字节不是魔术 zip 签名(即 0x04034b50),则报错。

  1. var errNotZip = fmt.Errorf("Not a zip file"
  2.  
  3. func parseLocalFileHeader(bs []byte, start int) (*localFileHeader, int, error) { 
  4.     signature, i, err := readUint32(bs, start) 
  5.     if signature != 0x04034b50 { 
  6.         return nil, 0, errNotZip 
  7.     } 
  8.     if err != nil { 
  9.         return nil, 0, err 
  10.     } 

基本模式是读取辅助函数将获取一个偏移量并返回一个 Go 值和一个新的偏移量。读取辅助函数将进行边界检查。

遵循相同的模式直到结构体的末尾:

  1. version, i, err := readUint16(bs, i) 
  2. if err != nil { 
  3.     return nil, 0, err 
  4.  
  5. bitFlag, i, err := readUint16(bs, i) 
  6. if err != nil { 
  7.     return nil, 0, err 
  8.  
  9. compression := noCompression 
  10. compressionRaw, i, err := readUint16(bs, i) 
  11. if err != nil { 
  12.     return nil, 0, err 
  13. if compressionRaw == 8 { 
  14.     compression = deflateCompression 
  15.  
  16. lmTime, i, err := readUint16(bs, i) 
  17. if err != nil { 
  18.     return nil, 0, err 
  19.  
  20. lmDate, i, err := readUint16(bs, i) 
  21. if err != nil { 
  22.     return nil, 0, err 
  23. lastModified := msdosTimeToGoTime(lmDate, lmTime) 
  24.  
  25. crc32, i, err := readUint32(bs, i) 
  26. if err != nil { 
  27.     return nil, 0, err 
  28.  
  29. compressedSize, i, err := readUint32(bs, i) 
  30. if err != nil { 
  31.     return nil, 0, err 
  32.  
  33. uncompressedSize, i, err := readUint32(bs, i) 
  34. if err != nil { 
  35.     return nil, 0, err 
  36.  
  37. fileNameLength, i, err := readUint16(bs, i) 
  38. if err != nil { 
  39.     return nil, 0, err 
  40.  
  41. extraFieldLength, i, err := readUint16(bs, i) 
  42. if err != nil { 
  43.     return nil, 0, err 
  44.  
  45. fileName, i, err := readString(bs, i, int(fileNameLength)) 
  46. if err != nil { 
  47.     return nil, 0, err 
  48.  
  49. extraField, i, err := readBytes(bs, i, int(extraFieldLength)) 
  50. if err != nil { 
  51.     return nil, 0, err 

现在,如果文件内容未压缩,我们只需复制文件头后的字节即可。如果文件内容被压缩,我们将使用 Go 的内置 DEFLATE 支持来解压缩文件头之后的字节。

  1. var fileContents string 
  2. if compression == noCompression { 
  3.     fileContents, i, err = readString(bs, i, int(uncompressedSize)) 
  4.     if err != nil { 
  5.         return nil, 0, err 
  6.     } 
  7. else { 
  8.     end := i + int(compressedSize) 
  9.     if end > len(bs) { 
  10.         return nil, 0, errOverranBuffer 
  11.     } 
  12.     flateReader := flate.NewReader(bytes.NewReader(bs[i:end])) 
  13.  
  14.     defer flateReader.Close() 
  15.     read, err := ioutil.ReadAll(flateReader) 
  16.     if err != nil { 
  17.         return nil, 0, err 
  18.     } 
  19.  
  20.     fileContents = string(read
  21.  
  22.     i = end 

并返回填充好的结构体实例:

  1. return &localFileHeader{ 
  2.     signature: signature, 
  3.     version: version, 
  4.     bitFlag: bitFlag, 
  5.     compression: compression, 
  6.     lastModified: lastModified, 
  7.     crc32: crc32, 
  8.     compressedSize: compressedSize, 
  9.     uncompressedSize: uncompressedSize, 
  10.     fileName: fileName, 
  11.     extraField: extraField, 
  12.     fileContents: fileContents, 
  13. }, i, nil 

04 读取辅助函数

现在我们只定义那些带有边界检查的读取辅助函数,使用 Go 的内置库来处理二进制编码。

  1. var errOverranBuffer = fmt.Errorf("Overran buffer"
  2.  
  3. func readUint32(bs []byte, offset int) (uint32, int, error) { 
  4.     end := offset + 4 
  5.     if end > len(bs) { 
  6.         return 0, 0, errOverranBuffer 
  7.     } 
  8.  
  9.     return binary.LittleEndian.Uint32(bs[offset:end]), end, nil 
  10.  
  11. func readUint16(bs []byte, offset int) (uint16, int, error) { 
  12.     end := offset+2 
  13.     if end > len(bs) { 
  14.         return 0, 0, errOverranBuffer 
  15.     } 
  16.  
  17.     return binary.LittleEndian.Uint16(bs[offset:end]), end, nil 

并且基本上只对获取的字节和字符串进行边界检查。

  1. func readBytes(bs []byte, offset int, n int) ([]byte, int, error) { 
  2.     end := offset + n 
  3.     if end > len(bs) { 
  4.         return nil, 0, errOverranBuffer 
  5.     } 
  6.  
  7.     return bs[offset:offset+n], end, nil 
  8.  
  9. func readString(bs []byte, offset int, n int) (string, int, error) { 
  10.     readend, err := readBytes(bs, offset, n) 
  11.     return string(read), end, err 

05 MSDOS 时间

我猜在创建 zip 时,MSDOS 时间格式很流行。但它在今天并不流行,所以花了一些时间才最终用一些代码(模仿 C 语言)找到对该格式的解释[2]。

  1. func msdosTimeToGoTime(d uint16, t uint16) time.Time { 
  2.     seconds := int((t & 0x1F) * 2) 
  3.     minutes := int((t >> 5) & 0x3F) 
  4.     hours := int(t >> 11) 
  5.  
  6.     day := int(d & 0x1F) 
  7.     month := time.Month((d >> 5) & 0x0F) 
  8.     year := int((d >> 9) & 0x7F) + 1980 
  9.     return time.Date(yearmonthday, hours, minutes, seconds, 0, time.Local

06 测试

运行:

  1. $ go build 
  2. $ ./gozip test.zip 
  3. 2021-11-23 23:04:20 +0000 UTC hello.text Hello! 

这看起来不错!现在让我们尝试压缩多个文件。

  1. $ cat bye.text 
  2. Au revoir! 
  3. $ rm test.zip 
  4. $ zip test.zip *.text 
  5.   adding: bye.text (stored 0%) 
  6.   adding: hello.text (stored 0%) 
  7. $ ./gozip test.zip 
  8. 2021-11-24 03:40:00 +0000 UTC bye.text Au revoir! 
  9.  
  10. 2021-11-23 23:04:20 +0000 UTC hello.text Hello! 

一切正常。

07 总结

实际上,还有许多标准需要处理(例如目录)和许多常见的扩展,本文没有涉及。

文件末尾还有一些空间,这可能是 “central directory” 元数据,但我还没有深入研究。如果你有兴趣可以查阅相关资料了解最后剩下的部分内容。

原文链接:https://notes.eatonphil.com/implementing-zip-in-go-unzipping.html

参考资料

[1]这里: https://pkware.cachefly.net/webdocs/casestudies/APPNOTE.TXT

[2]对该格式的解释: https://groups.google.com/g/comp.os.msdos.programmer/c/ffAVUFN2NbA