zip是一种常见的归档格式,本文讲解Go如何操作zip。
首先看看zip文件是如何工作的。以一个小文件为例:(类Unix系统下)
$cathello.textHello!
执行zip命令进行归档:
$ziptest.ziphello.textadding:hello.text(stored0%)$ls-lahtest.zip-rw-r--r--1philphil177Nov2323:04test.zip
一个6字节的文本文件变成了一个177字节的zip文件。这并不大,解析177个字节听起来不可能太复杂!
对zip文件执行hexdump:
$hexdump-Ctest.zip00000000504b03040a00000000008ab877539ed8|PK..........wS..|0000001042b007000000070000000a001c006865|B.............he|000000206c6c6f2e74657874555409000374739d|llo.textUT...ts.|000000306174739d6175780b000104eb03000004|ats.aux.........|00000040eb03000048656c6c6f210a504b01021e|....Hello!.PK...|00000050030a00000000008ab877539ed842b007|.........wS..B..|00000060000000070000000a0018000000000001|................|00000070000000a4810000000068656c6c6f2e74|.........hello.t|00000080657874555405000374739d6175780b00|extUT...ts.aux..|000000900104eb03000004eb030000504b050600|...........PK...|000000a000000001000100500000004b00000000|.......P...K....|000000b000|.|000000b1
从中我们可以看到文件名和文件内容。
我们来看看这里[1]定义的zip结构。根据第4.3.6节,看起来文件元数据后跟文件内容一个接一个地存储,最后一块是“centraldirectory”元数据。
zipformatheader
图片来源:https://www.codeproject.com/Articles/8688/Extracting-files-from-a-remote-ZIP-archive
本地header元数据如下所示:
字段大小localfileheadersignature4bytesversionneededtoextract2bytesgeneralpurposebitflag2bytescompressionmethod2byteslastmodfiletime2byteslastmodfiledate2bytescrc-324bytescompressedsize4bytesuncompressedsize4bytesfilenamelength2bytesextrafieldlength2bytesfilename可变extrafield可变
在一个有效zip文件中,header签名是一个整数(0x04034b50)。我们将忽略版本、通用flag和校验和。可以是没有压缩(用0表示),也可以是使用DEFLATE方法解压缩(用8表示)。
最后修改时间和日期是MSDOS风格的日期/时间格式。
我们粗略地将其翻译为Go代码:
packagemainimport("os""bytes""compress/flate""io/ioutil""encoding/binary""time""fmt")typecompressionuint8const(noCompressioncompression=iotadeflateCompression)typelocalFileHeaderstruct{signatureuint32versionuint16bitFlaguint16compressioncompressionlastModifiedtime.Timecrc32uint32compressedSizeuint32uncompressedSizeuint32fileNamestringextraField[]bytefileContentsstring}
02main函数实现
我们的入口点将读取一个zip文件并遍历该文件,直到我们无法解析zip文件条目。
funcmain(){f,err:=ioutil.ReadFile(os.Args[1])iferr!=nil{panic(err)}end:=0forend<len(f){varerrerrorvarlfh*localFileHeadervarnextintlfh,next,err=parseLocalFileHeader(f,end)iferr==errNotZip&&end>0{break}iferr!=nil{panic(err)}end=nextfmt.Println(lfh.lastModified,lfh.fileName,lfh.fileContents)}}
03文件
对于每个文件,如果前四个字节不是魔术zip签名(即0x04034b50),则报错。
varerrNotZip=fmt.Errorf("Notazipfile")funcparseLocalFileHeader(bs[]byte,startint)(*localFileHeader,int,error){signature,i,err:=readUint32(bs,start)ifsignature!=0x04034b50{returnnil,0,errNotZip}iferr!=nil{returnnil,0,err}
基本模式是读取辅助函数将获取一个偏移量并返回一个Go值和一个新的偏移量。读取辅助函数将进行边界检查。
遵循相同的模式直到结构体的末尾:
version,i,err:=readUint16(bs,i)iferr!=nil{returnnil,0,err}bitFlag,i,err:=readUint16(bs,i)iferr!=nil{returnnil,0,err}compression:=noCompressioncompressionRaw,i,err:=readUint16(bs,i)iferr!=nil{returnnil,0,err}ifcompressionRaw==8{compression=deflateCompression}lmTime,i,err:=readUint16(bs,i)iferr!=nil{returnnil,0,err}lmDate,i,err:=readUint16(bs,i)iferr!=nil{returnnil,0,err}lastModified:=msdosTimeToGoTime(lmDate,lmTime)crc32,i,err:=readUint32(bs,i)iferr!=nil{returnnil,0,err}compressedSize,i,err:=readUint32(bs,i)iferr!=nil{returnnil,0,err}uncompressedSize,i,err:=readUint32(bs,i)iferr!=nil{returnnil,0,err}fileNameLength,i,err:=readUint16(bs,i)iferr!=nil{returnnil,0,err}extraFieldLength,i,err:=readUint16(bs,i)iferr!=nil{returnnil,0,err}fileName,i,err:=readString(bs,i,int(fileNameLength))iferr!=nil{returnnil,0,err}extraField,i,err:=readBytes(bs,i,int(extraFieldLength))iferr!=nil{returnnil,0,err}
现在,如果文件内容未压缩,我们只需复制文件头后的字节即可。如果文件内容被压缩,我们将使用Go的内置DEFLATE支持来解压缩文件头之后的字节。
varfileContentsstringifcompression==noCompression{fileContents,i,err=readString(bs,i,int(uncompressedSize))iferr!=nil{returnnil,0,err}}else{end:=i+int(compressedSize)ifend>len(bs){returnnil,0,errOverranBuffer}flateReader:=flate.NewReader(bytes.NewReader(bs[i:end]))deferflateReader.Close()read,err:=ioutil.ReadAll(flateReader)iferr!=nil{returnnil,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的内置库来处理二进制编码。
varerrOverranBuffer=fmt.Errorf("Overranbuffer")funcreadUint32(bs[]byte,offsetint)(uint32,int,error){end:=offset+4ifend>len(bs){return0,0,errOverranBuffer}returnbinary.LittleEndian.Uint32(bs[offset:end]),end,nil}funcreadUint16(bs[]byte,offsetint)(uint16,int,error){end:=offset+2ifend>len(bs){return0,0,errOverranBuffer}returnbinary.LittleEndian.Uint16(bs[offset:end]),end,nil}
并且基本上只对获取的字节和字符串进行边界检查。
$ziptest.ziphello.textadding:hello.text(stored0%)$ls-lahtest.zip-rw-r--r--1philphil177Nov2323:04test.zip0
05MSDOS时间
我猜在创建zip时,MSDOS时间格式很流行。但它在今天并不流行,所以花了一些时间才最终用一些代码(模仿C语言)找到对该格式的解释[2]。
$ziptest.ziphello.textadding:hello.text(stored0%)$ls-lahtest.zip-rw-r--r--1philphil177Nov2323:04test.zip1
06测试
运行:
$ziptest.ziphello.textadding:hello.text(stored0%)$ls-lahtest.zip-rw-r--r--1philphil177Nov2323:04test.zip2
这看起来不错!现在让我们尝试压缩多个文件。
$ziptest.ziphello.textadding:hello.text(stored0%)$ls-lahtest.zip-rw-r--r--1philphil177Nov2323:04test.zip3
一切正常。
07总结
实际上,还有许多标准需要处理(例如目录)和许多常见的扩展,本文没有涉及。