Go:不用標準庫如何解壓 zip 文件?
大家好,我是程序員幽鬼。
zip 是一種常見的歸檔格式,本文講解 Go 如何操作 zip。
首先看看 zip 文件是如何工作的。以一個小文件爲例:(類 Unix 系統下)
$ cat hello.text
Hello!
執行 zip 命令進行歸檔:
$ 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 字節的文本文件變成了一個 177 字節的 zip 文件。這並不大,解析 177 個字節聽起來不可能太複雜!
對 zip 文件執行 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
從中我們可以看到文件名和文件內容。
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 元數據如下所示:
在一個有效 zip 文件中,header 簽名是一個整數 (0x04034b50
)。我們將忽略版本、通用 flag 和校驗和。可以是沒有壓縮(用 0 表示),也可以是使用 DEFLATE 方法解壓縮(用 8 表示)。
最後修改時間和日期是 MSDOS 風格的日期 / 時間格式。
我們粗略地將其翻譯爲 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 函數實現
我們的入口點將讀取一個 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 文件
對於每個文件,如果前四個字節不是魔術 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
}
基本模式是讀取輔助函數將獲取一個偏移量並返回一個 Go 值和一個新的偏移量。讀取輔助函數將進行邊界檢查。
遵循相同的模式直到結構體的末尾:
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
}
現在,如果文件內容未壓縮,我們只需複製文件頭後的字節即可。如果文件內容被壓縮,我們將使用 Go 的內置 DEFLATE 支持來解壓縮文件頭之後的字節。
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
}
並返回填充好的結構體實例:
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 讀取輔助函數
現在我們只定義那些帶有邊界檢查的讀取輔助函數,使用 Go 的內置庫來處理二進制編碼。
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
}
並且基本上只對獲取的字節和字符串進行邊界檢查。
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 時間
我猜在創建 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 測試
運行:
$ go build
$ ./gozip test.zip
2021-11-23 23:04:20 +0000 UTC hello.text Hello!
這看起來不錯!現在讓我們嘗試壓縮多個文件。
$ 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 總結
實際上,還有許多標準需要處理(例如目錄)和許多常見的擴展,本文沒有涉及。
文件末尾還有一些空間,這可能是 “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
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/gfUfYfK79f8qOKMNl-l8og