Go:不用標(biāo)準(zhǔn)庫如何解壓 Zip 文件?
大家好,我是程序員幽鬼。
zip 是一種常見的歸檔格式,本文講解 Go 如何操作 zip。
首先看看 zip 文件是如何工作的。以一個小文件為例:(類 Unix 系統(tǒng)下)
- $ cat hello.text
- Hello!
執(zhí)行 zip 命令進(jìn)行歸檔:
- $ zip test.zip hello.text
- adding: hello.text (stored 0%)
- $ ls -lah test.zip
- -rw-r--r-- 1 phil phil 177 Nov 23 23:04 test.zip
一個 6 字節(jié)的文本文件變成了一個 177 字節(jié)的 zip 文件。這并不大,解析 177 個字節(jié)聽起來不可能太復(fù)雜!
對 zip 文件執(zhí)行 hexdump:
- $ hexdump -C test.zip
- 00000000 50 4b 03 04 0a 00 00 00 00 00 8a b8 77 53 9e d8 |PK..........wS..|
- 00000010 42 b0 07 00 00 00 07 00 00 00 0a 00 1c 00 68 65 |B.............he|
- 00000020 6c 6c 6f 2e 74 65 78 74 55 54 09 00 03 74 73 9d |llo.textUT...ts.|
- 00000030 61 74 73 9d 61 75 78 0b 00 01 04 eb 03 00 00 04 |ats.aux.........|
- 00000040 eb 03 00 00 48 65 6c 6c 6f 21 0a 50 4b 01 02 1e |....Hello!.PK...|
- 00000050 03 0a 00 00 00 00 00 8a b8 77 53 9e d8 42 b0 07 |.........wS..B..|
- 00000060 00 00 00 07 00 00 00 0a 00 18 00 00 00 00 00 01 |................|
- 00000070 00 00 00 a4 81 00 00 00 00 68 65 6c 6c 6f 2e 74 |.........hello.t|
- 00000080 65 78 74 55 54 05 00 03 74 73 9d 61 75 78 0b 00 |extUT...ts.aux..|
- 00000090 01 04 eb 03 00 00 04 eb 03 00 00 50 4b 05 06 00 |...........PK...|
- 000000a0 00 00 00 01 00 01 00 50 00 00 00 4b 00 00 00 00 |.......P...K....|
- 000000b0 00 |.|
- 000000b1
從中我們可以看到文件名和文件內(nèi)容。
01 結(jié)構(gòu)
我們來看看這里[1]定義的 zip 結(jié)構(gòu) 。根據(jù)第 4.3.6 節(jié),看起來文件元數(shù)據(jù)后跟文件內(nèi)容一個接一個地存儲,最后一塊是 “central directory” 元數(shù)據(jù)。
zip format header
圖片來源:https://www.codeproject.com/Articles/8688/Extracting-files-from-a-remote-ZIP-archive
本地 header 元數(shù)據(jù)如下所示:
字段 | 大小 |
---|---|
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 簽名是一個整數(shù) (0x04034b50 )。我們將忽略版本、通用 flag 和校驗和??梢允菦]有壓縮(用 0 表示),也可以是使用 DEFLATE 方法解壓縮(用 8 表示)。
最后修改時間和日期是 MSDOS 風(fēng)格的日期/時間格式。
我們粗略地將其翻譯為 Go 代碼:
- package main
- import (
- "os"
- "bytes"
- "compress/flate"
- "io/ioutil"
- "encoding/binary"
- "time"
- "fmt"
- )
- type compression uint8
- const (
- noCompression compression = iota
- deflateCompression
- )
- type localFileHeader struct {
- signature uint32
- version uint16
- bitFlag uint16
- compression compression
- lastModified time.Time
- crc32 uint32
- compressedSize uint32
- uncompressedSize uint32
- fileName string
- extraField []byte
- fileContents string
- }
02 main 函數(shù)實現(xiàn)
我們的入口點將讀取一個 zip 文件并遍歷該文件,直到我們無法解析 zip 文件條目。
- func main() {
- f, err := ioutil.ReadFile(os.Args[1])
- if err != nil {
- panic(err)
- }
- end := 0
- for end < len(f) {
- var err error
- var lfh *localFileHeader
- var next int
- lfh, next, err = parseLocalFileHeader(f, end)
- if err == errNotZip && end > 0 {
- break
- }
- if err != nil {
- panic(err)
- }
- end = next
- fmt.Println(lfh.lastModified, lfh.fileName, lfh.fileContents)
- }
- }
03 文件
對于每個文件,如果前四個字節(jié)不是魔術(shù) zip 簽名(即 0x04034b50),則報錯。
- var errNotZip = fmt.Errorf("Not a zip file")
- func parseLocalFileHeader(bs []byte, start int) (*localFileHeader, int, error) {
- signature, i, err := readUint32(bs, start)
- if signature != 0x04034b50 {
- return nil, 0, errNotZip
- }
- if err != nil {
- return nil, 0, err
- }
基本模式是讀取輔助函數(shù)將獲取一個偏移量并返回一個 Go 值和一個新的偏移量。讀取輔助函數(shù)將進(jìn)行邊界檢查。
遵循相同的模式直到結(jié)構(gòu)體的末尾:
- version, i, err := readUint16(bs, i)
- if err != nil {
- return nil, 0, err
- }
- bitFlag, i, err := readUint16(bs, i)
- if err != nil {
- return nil, 0, err
- }
- compression := noCompression
- compressionRaw, i, err := readUint16(bs, i)
- if err != nil {
- return nil, 0, err
- }
- if compressionRaw == 8 {
- compression = deflateCompression
- }
- lmTime, i, err := readUint16(bs, i)
- if err != nil {
- return nil, 0, err
- }
- lmDate, i, err := readUint16(bs, i)
- if err != nil {
- return nil, 0, err
- }
- lastModified := msdosTimeToGoTime(lmDate, lmTime)
- crc32, i, err := readUint32(bs, i)
- if err != nil {
- return nil, 0, err
- }
- compressedSize, i, err := readUint32(bs, i)
- if err != nil {
- return nil, 0, err
- }
- uncompressedSize, i, err := readUint32(bs, i)
- if err != nil {
- return nil, 0, err
- }
- fileNameLength, i, err := readUint16(bs, i)
- if err != nil {
- return nil, 0, err
- }
- extraFieldLength, i, err := readUint16(bs, i)
- if err != nil {
- return nil, 0, err
- }
- fileName, i, err := readString(bs, i, int(fileNameLength))
- if err != nil {
- return nil, 0, err
- }
- extraField, i, err := readBytes(bs, i, int(extraFieldLength))
- if err != nil {
- return nil, 0, err
- }
現(xiàn)在,如果文件內(nèi)容未壓縮,我們只需復(fù)制文件頭后的字節(jié)即可。如果文件內(nèi)容被壓縮,我們將使用 Go 的內(nèi)置 DEFLATE 支持來解壓縮文件頭之后的字節(jié)。
- var fileContents string
- if compression == noCompression {
- fileContents, i, err = readString(bs, i, int(uncompressedSize))
- if err != nil {
- return nil, 0, err
- }
- } else {
- end := i + int(compressedSize)
- if end > len(bs) {
- return nil, 0, errOverranBuffer
- }
- flateReader := flate.NewReader(bytes.NewReader(bs[i:end]))
- defer flateReader.Close()
- read, err := ioutil.ReadAll(flateReader)
- if err != nil {
- return nil, 0, err
- }
- fileContents = string(read)
- i = end
- }
并返回填充好的結(jié)構(gòu)體實例:
- return &localFileHeader{
- signature: signature,
- version: version,
- bitFlag: bitFlag,
- compression: compression,
- lastModified: lastModified,
- crc32: crc32,
- compressedSize: compressedSize,
- uncompressedSize: uncompressedSize,
- fileName: fileName,
- extraField: extraField,
- fileContents: fileContents,
- }, i, nil
04 讀取輔助函數(shù)
現(xiàn)在我們只定義那些帶有邊界檢查的讀取輔助函數(shù),使用 Go 的內(nèi)置庫來處理二進(jìn)制編碼。
- var errOverranBuffer = fmt.Errorf("Overran buffer")
- func readUint32(bs []byte, offset int) (uint32, int, error) {
- end := offset + 4
- if end > len(bs) {
- return 0, 0, errOverranBuffer
- }
- return binary.LittleEndian.Uint32(bs[offset:end]), end, nil
- }
- func readUint16(bs []byte, offset int) (uint16, int, error) {
- end := offset+2
- if end > len(bs) {
- return 0, 0, errOverranBuffer
- }
- return binary.LittleEndian.Uint16(bs[offset:end]), end, nil
- }
并且基本上只對獲取的字節(jié)和字符串進(jìn)行邊界檢查。
- func readBytes(bs []byte, offset int, n int) ([]byte, int, error) {
- end := offset + n
- if end > len(bs) {
- return nil, 0, errOverranBuffer
- }
- return bs[offset:offset+n], end, nil
- }
- func readString(bs []byte, offset int, n int) (string, int, error) {
- read, end, err := readBytes(bs, offset, n)
- return string(read), end, err
- }
05 MSDOS 時間
我猜在創(chuàng)建 zip 時,MSDOS 時間格式很流行。但它在今天并不流行,所以花了一些時間才最終用一些代碼(模仿 C 語言)找到對該格式的解釋[2]。
- func msdosTimeToGoTime(d uint16, t uint16) time.Time {
- seconds := int((t & 0x1F) * 2)
- minutes := int((t >> 5) & 0x3F)
- hours := int(t >> 11)
- day := int(d & 0x1F)
- month := time.Month((d >> 5) & 0x0F)
- year := int((d >> 9) & 0x7F) + 1980
- return time.Date(year, month, day, hours, minutes, seconds, 0, time.Local)
- }
06 測試
運(yùn)行:
- $ go build
- $ ./gozip test.zip
- 2021-11-23 23:04:20 +0000 UTC hello.text Hello!
這看起來不錯!現(xiàn)在讓我們嘗試壓縮多個文件。
- $ cat bye.text
- Au revoir!
- $ rm test.zip
- $ zip test.zip *.text
- adding: bye.text (stored 0%)
- adding: hello.text (stored 0%)
- $ ./gozip test.zip
- 2021-11-24 03:40:00 +0000 UTC bye.text Au revoir!
- 2021-11-23 23:04:20 +0000 UTC hello.text Hello!
一切正常。
07 總結(jié)
實際上,還有許多標(biāo)準(zhǔn)需要處理(例如目錄)和許多常見的擴(kuò)展,本文沒有涉及。
文件末尾還有一些空間,這可能是 “central directory” 元數(shù)據(jù),但我還沒有深入研究。如果你有興趣可以查閱相關(guān)資料了解最后剩下的部分內(nèi)容。
原文鏈接: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