zipファイルの構造をgolangの標準ライブラリから勉強してみました。

zipファイルの構造

zipファイルのデータは以下の3領域に分類されます。

  1. 各ファイルのローカルファイルヘッダとファイルのデータが交互に並ぶ領域
  2. セントラルディレクトリヘッダがファイル分並ぶ領域
  3. セントラルディレクトリヘッダの終端領域(ファイル数、データ数などのZIP全体の情報が格納される)

構造に関してはwikipediaの画像がわかりやすいです。

セントラルディレクトリの終端領域にはセントラルディレクトリヘッダの開始オフセットを格納しており、セントラルディレクトリヘッダには各ファイルのローカルファイルヘッダの開始オフセットが入っています。よって解凍する場合もこの順番で辿っていくことになります。

golang標準ライブラリを読む(読み込み)

archive/zipがgoのzipライブラリになります。以下のようにしてzipファイルを読み込み、展開することができます。

zip.NewReaderではzip.Readerのインスタンス化と初期化(init)をしています。

init内では以下が行われます

  • セントラルディレクトリの終端領域の取得(readDirectoryEnd)
  • セントラルディレクトリヘッダの取得

readDirectoryEndで終端領域のシグネチャを検索します

検索している実体はfindSignatureInBlock関数です。シグネチャのオフセットは22バイト以上なので初期値はlen(b)-directoryEndLen(=22)してます。P K 0x05 0x06がセントラルディレクトリ終端領域のシグネチャです。

終端領域の開始点から20〜21byteにコメントの長さが入っており、整合性が取れていればその値を返して、整合性が取れていない(シグネチャではなく、たまたまシグネチャに合致した他のデータだった)場合は検査を続けます。

取得できたら、終端領域の情報を構造体に詰め込みます。

次にinit内でセントラルディレクトリヘッダの取得を行います。

SectionReaderはRead、ReadAt、Seekが可能なioの構造体です。これを使ってセントラルディレクトリヘッダのオフセットに移動して、bufio.NewReaerに包んでreadDirectoryHeaderを呼び出します。

※SectionReader使っているのがちょっと謎です。sizeはbufのサイズが入る気がするので全量のReaderでも良さそう。

readDirectoryHeaderではセントラルディレクトリヘッダを読み出して構造体に格納します。

ファイル名、拡張フィールド、コメントは可変長なので、それぞれの長さを足し合わせたバッファを用意してそれにデータを読み出してから切り出してます。

上記の処理によって、zip.File構造体が作成されます。zip.File構造体はOpen関数でio.ReadCloserを返します。

findBodyOffset()はローカルヘッダを辿ってデータ部分のオフセットを返します。NewSectionReaderでは各ファイルのデータ部分のみ読み取るようなレンジ指定になっています。f.ziprはzip.NewReaderの引数のio.ReadAtな構造体が入ります。decompressorには解凍するためのメソッドが格納されており、返り値としてio.ReadCloseを返します。さらにchecksumReaderに包んで返し、これをRead()することでzip内のファイルを読み込むことができます。

golang標準ライブラリを読む(書き込み)

書き込みはこんな感じで書きます↓

Readerよりシンプルな作りで、zip.Writer#Createでローカルファイルヘッダとローカルファイルデータを順次バッファに書き込んでいき、最後のClose関数でセントラルディレクトリヘッダ+終端領域を書き込みます。

CreateではCreateHeaderを呼び出します。CreateHeader内では色々やってるんですがメインは以下です。

  • ローカルファイルヘッダの値を構造体にセット
  • セントラルディレクトリを書き出すためにdirメンバ変数(配列)に追加
  • ローカルファイルヘッダをio.Writer#Writeする(writerHeader)
  • ファイルデータ書き込み用のfileWriter構造体を作成し返却

ローカルファイルヘッダの書き出しはwriteHeader関数内で行われており、バッファ経由でWriteします(io.Writerインターフェース)。コメントに記載がある通り、データディスクリプタ領域にCRC32、ファイルサイズを格納するため、ローカルファイルヘッダには0をセットしています。

fileWriter#Writeの処理ではrawCount.Writeでデータを書き込みます。rawCountはcomp関数を噛ませたcountWriterなので、圧縮しつつファイルが書き込まれることになります。また、zipではcrc32による誤り検知を行っており、crc32の計算も行っています。

最後にzip.Writer#Closeでdirメンバ変数を使ってセントラルディレクトリヘッダとセントラルディレクトリ終端領域の書き込みを行います。

CreateHeaderやCloseの関数の最初にはlast変数のCloseが呼び出されています。

last変数には最後に発行したfileWriter構造体が入っているので、直前までデータを書き込んでいたfileWriterのcloseが呼び出されます。closeではヘッダの値がデータディスクリプタの領域に書き込まれます。データディスクリプタはデータ領域の後ろに書き込まれる領域で、ファイルサイズとCRCを格納します。データディスクリプタを使う場合は、ローカルファイルヘッダのFlagに0x08をセットします。

※データ出力が順方向にしか書き込まれないシステムの場合によく使われる形式らしいです。

archive/zipを使っていてハマりそうなところ

次のように書くとセントラルディレクトリが書き込まれません。

unzipしようとすると以下のエラーになります。

一見するとdeferが効いてないように見えますが、そうではなくてreturnの値が評価されたあとにdeferのClose()がBufferの操作を行っているために戻り値にはセントラルディレクトリが反映されていないことが原因です。この場合、deferを取り除いてreturnの直前でCloseするか、以下のようにbytes.Bufferを返せばOK。

参考URL