[Go]tarのReaderにReadAtを実装する(ついでにfs.FSにもする)
[Go]tarのReaderにReadAtを実装する(ついでにfs.FSにもする)
案外できた
実装成果物は以下で管理されます。
Overview
-
archive/tarが返してくる*tar.Readerはio.ReaderAtを実装しません。
- 入力/出力をstreamとして処理できるようにするため当然ではあります。
-
tar
の中身がPDF
のようなランダムアクセスを必要とするファイルなどであると、上記より一旦展開するほかありません。-
gzip
はランダムアクセス可能ではないのでtar.gz
ならそもそも一旦展開は必須です。 - ただし、
zstd
など、オプションによりランダムアクセス可能にできる圧縮方式もあります。 -
.tar.zstd
にし、tar
の中身を読むときio.ReaderAtを実装できれば「一旦展開して書き出す」を挟まないでよくなります。
-
- 「一旦展開」で殆どの場面でこまならないですが、例えば
docker container
でcontainer fs
をread-onlyにしていたりするとき、書き込めるディレクトリのマウントが必要になったりするので避けたいです。 - まあ要するに理屈上いらん処理を書いててなんかもやもやしていたわけです。
- tarをfsとしてmountできるOSSが存在していることは知っていたため、これができることだとはわかっていました。
ここまでがbackgroundです
- prior artとしてgithub.com/nlepage/go-tarfsがあります
- これは
tar
を受けとってfs.FSを返すもの
- これは
- 上記に倣うと、
tar.NewReader
に渡すio.Reader
実装を、Read
で読んだbyte数を記録できるものにしておくことで、tar header
のオフセットを所得できます。 - 実際のファイルコンテンツの
tar
ファイル内でのオフセットは、headerの終わりと*tar.HeaderのSize
フィールドから取得できます - 元のファイル内のfile content bodyの位置さえわかれば*io.SectionReaderでio.ReaderAtを実装した形で読み込み可能です。
- ただし、sparse(疎) fileの取り扱いだけがこれだけでは済みません。
- なぜならば、*tar.Readerがtar headerに存在するsparse情報を捨ててしまうため
- そこでsparse情報を取得する必要があります。
- 実装方針に以下の2案がありました。
- 前提として
tar
の解析処理はなるだけGo
のstdに任せる- 何かの修正があるたびに追従するのは個人では現実的ではありません。
-
Go
のモジュールシステムではgo.mod
に書かれたバージョンにかかわらず、コンパイルするときのtoolchainのstd libが使われます。つまり、新しいコンパイラとstdライブラリのセットでコンパイルされればarchive/tarを使っている部分は何もしなくても修正を受けることができます。
- (1) *tar.Readerをすべて読み切って、
tar
ファイル内の実データと突き合わせることでholeを再建する - (2) archive/tarのソースの一部をコピーし、sparse情報を取り出せるようにする。
- 前提として
- (1)は大きなファイルで時間がかかりすぎて使い物にならない可能性が高いため却下
- そもそもファイルを疎にしたいのはコアダンプなど容量はデカいけど実際に何かが書かれている領域は小さい時であるため。
- (2)はsparse情報を取り出すだけなら今後も追従しなきゃいけない変更も少なさそうだしこちらに決定。
- sparse holeの情報さえあれば以前作ったライブラリで仮想的にio.ReaderAtを結合して1つのio.ReaderAtのように見せることができます。
- ということでできた
~完~
前提知識
最低限A Tour of Goはこなしている
会社の同僚を想定しますので基本的な話を盛り込みたい。間違っていたりした場合はおしえていただけるとさいわいです。
環境
$ go version
go version go1.24.0 linux/amd64
前提
Goでtarを扱うにはarchive/tarを使う
Goのstdの範疇でtar
を扱うにはarchive/tarを用います。
今回はtar
を読み込む話しかしないので、読み込みのサンプルを以下に掲載します。
package main
import (
"archive/tar"
"bytes"
"compress/gzip"
"encoding/base64"
"fmt"
"io"
)
var treeTarGzBase64 = `H4sIAAAAAAAAA+2YTW7DIBBGWecUPoENZAbOg622yyg/VquevkNRlLRSf1gMScT3Nkg2EkjPD1mM` +
`k1HHCpE5jy6yvR7PGEcxeLcN7OS5s+y9GVh/a8asx1M6DIN5Taf08vTzvL/ePyjjNM+z8jdQ5Z+9` +
`+HdepsN/A4r/ZVkUv4Eq/zH3762F/yZc/O/X9U1njSw4EP3in7/7j178W53tfKVz/9n65tabADfj` +
`un+l/P/RP136D5z7Z9qi/xbskX/XlP7ndFBco6p/Cvn/P5JD/y0Q8+i/Y879vyuuUdN/DO6zf3mN` +
`/hsg5tF/x4xTSume7v+Iy/0f4f6nBcX/826nuEbd/x+X859w/rdAzOP8BwAAAAAAAIAO+ABC8URH` +
`ACgAAA==`
func main() {
treeTarGz, err := base64.StdEncoding.DecodeString(treeTarGzBase64)
if err != nil {
panic(err)
}
gr, err := gzip.NewReader(bytes.NewReader(treeTarGz))
if err != nil {
panic(err)
}
defer gr.Close()
tr := tar.NewReader(gr)
for {
hdr, err := tr.Next()
if err != nil {
if err == io.EOF {
break
} else {
panic(err)
}
}
fmt.Printf("name = %q", hdr.Name)
switch hdr.Typeflag {
case tar.TypeReg, tar.TypeRegA:
content, err := io.ReadAll(tr)
if err != nil {
panic(err)
}
fmt.Printf(", regular file, size = %d, content = %q\n", hdr.Size, string(content))
case tar.TypeDir:
fmt.Printf(", dir\n")
default:
fmt.Printf(", unknown(%d)\n", hdr.Typeflag)
}
}
}
/*
name = "./", dir
name = "./bbb/", dir
name = "./bbb/ccc/", dir
name = "./bbb/ccc/quux", regular file, size = 5, content = "quux\n"
name = "./bbb/ccc/qux", regular file, size = 4, content = "qux\n"
name = "./bbb/bar", regular file, size = 4, content = "bar\n"
name = "./bbb/baz", regular file, size = 4, content = "baz\n"
name = "./aaa/", dir
name = "./aaa/foo", regular file, size = 4, content = "foo\n"
*/
- tar.NewReaderで*tar.Readerを作成
-
Reader
のNext
methodを呼び出すと次の*tar.Headerまで読み込んでそれを返し, -
Reader
のRead
methodでNext
が返したheaderが示すファイルエントリの中身を読み込めます。
Next
を呼び出すと*tar.Readerの中身が次のエントリの内容にセットされるような感じです。
初見だとちょっとわかりにくい気がします(筆者はちょっと混乱しました)。ですがtar
のデータ構造とstreamで処理したいという方針を鑑みればこうなるのもわかるかと思います。
つまり、streamとして処理するのであれば、次のデータが準備できた時には前のデータにはアクセスできないということです。そのためstatefulに作る方針になりがちです。
これは[Go]続・なるだけすべてをiteratorにする#利点で説明した「ScanやNextメソッドで次のデータを準備し、データが存在するときtrueを返すパターン」でのデータの繰り返し表現です。*bufio.Scannerや*sql.Rowsと同じようなAPI方式ですね。これらのどちらかをすでに使ったことがある場合は理解しやすいかもしれません。
ちなみにソースに埋め込まれたtar
の内容はGNU tar(1.35)
で作成しています。
io.ReaderAtはとっても便利
まずどうしてio.ReaderAtが実装されているのかうれしいのかという話を前提としてします。
- いくつかの処理はランダムアクセスができたほうが楽
-
*http.Requestの
GetBody
フィールドの実装 -
PDF
などランダムアクセスによって効率的に処理できるフォーマット - これらに対してランダムアクセス性がないともう一度開きなおす、事前にバッファして置くなどが必要
- それらの処理がどの程度高価なのか事前にわからないことがある。ものすごく大きなPDFをバッファに乗せたらメモリが足りなくてOut Of Memoryでkillされることもあり得ます。
-
*http.Requestの
-
io.Seekerに比べてio.ReaderAtは規約が厳しく、使う側が楽
- seekはio.Readerなどが内部的に持つ現在のオフセット値を変更するという処理であるので、concurrentに呼び出すと一種のrace conditionとなる
- その点
ReadAt
はconcurrentに複数回呼び出されてもよいという規約になっている
-
*io.SectionReaderとの組み合わせによって楽に扱える
- io.NewSectionReaderはio.ReaderAtを受けとって一定のオフセット内(=section)だけを読み込めるio.Reader/io.Seeker/io.ReaderAtの実装を返します。
色々考えることが減るのでいいわけですね。
sparse file
この先sparse fileとかsparse holeとか単にholeというワードが出てきますが意味は以下です
- sparseは疎という意味で、ファイルサイズに対して、実際のストレージの割り当てが少ないファイルのことを言います
- holeというのはこの「実際にはストレージが割り当てられていない」場所のことを言います。
- holeを読み込むとき、linuxでは単に
0x00
として読み込まれます - データベースや、コアダンプ(プログラムがクラッシュしたときにそのプログラムのメモリをそのまま書き出すやつ)は、実際のデータ容量に対してなにも書き込んでいない領域がたくさんあるのが普通です。こういったものを素直に
0x00
部分にもストレージを割り当ててしまうと容量があっという間に枯渇してしまう(こともあるため)、割り当てないでおくということができます。 - Advanced Programming in the UNIX Environmentとか読んでおいてください
*tar.Readerはio.ReaderAtを実装しない
*tar.ReaderのAPI docを見ればわかる通り、これはio.ReaderAtを実装しません。
前述通りstreamとして処理できることを念頭にされているため当然ではあります。
基本的な話: そもそもtarとは
会社の同僚に説明するならtar
がどういうフォーマットかなどを軽く説明しておいてほうがいいと思うので先に説明します。
知ってる人も多いことでしょうからtarの内容にランダムアクセス性が欲しくなる時まで飛ばしてください。
tar
tar
というファイルフォーマットとは何ぞやという話を一応入れておきます。
tar
は複数のファイル/ディレクトリを1つのファイルにまとめるアーカイバです。データの圧縮は仕様に含まれませんので、圧縮が必要な場合はtar
をさらにgzip
やzstd
で圧縮します。
以下の各リンクで説明されています。
FreeBSD
とGNU(linux)
のtarコマンドのマニュアルです
お互い微妙に違うのがわかりますね。
フォーマットや来歴の話はWikipedia(英語版)のtar(computing)
の項目で説明されています。
GNU
のtar manualにヘッダーフォーマットやsparse file, dumpdirやsnapshot周りの話が説明されています。
IBM
のtar
のフォーマットの説明があります。GNU tar
とは微妙に異なるフォーマットとして説明されています。
上記各リンクで述べられているところによると、
-
tar
はTape ARchive
をとったもの -
tarball
となぞらえた呼称(tarball
はなんでもくっつけてひとまとめにしてしまうから) - 磁気テープのようなファイルシステムのないところにファイルを保存するために必要であった
とあります。今日でも多用されていますがオリジナルの開発時期はversion 7 Unixと同時期、とあります。(≒初期リリースは1979年)
Go
のarchive/tar内部の実装でもv7
という呼称が出現します。
Punched card(wikipedia)によると1980年代あたりに磁気テープが紙のパンチカードを置き換えだしたらしいです。
磁気テープは文字通り、テープ状の紙/プラスティックフィルムに磁性のある粉とか液とかを封入するなり塗布するなりしたものををリール(ボビン)に巻き付け、テープを送って磁気を読み取る/磁化することで情報の読み取り/書き込みを行います。(参考)
これに書き込むことを意図されたデータフォーマットであるならばいくつかのことが示唆されることになります。
- シーケンシャルアクセスのみを前提とする
- テープの送り量がデータアクセス位置となります。
- HDDやCD/BDのようなディスク媒体のように周方向にデータセグメントを分けながら半径方向に読み取り機を動かすようなことはできません。ランダムアクセス性は低いです。
- 末尾にジャンクデータが残っていることがありうる
- 磁気テープは磁化することで情報を書き込みますので繰り返しの書き込みができます。
- 送りのスピードが遅いならわざわざジャンクデータを消そうとしないこともあるでしょう。
うーんまだなんかありそうだけど筆者はこの程度しか言えないなあ。
フォーマットはPOSIXに載ったりドロップしたりをしている・・・とあります。
どちらにせよPOSIX
は買わないと読めないはずなのでここでリンクを張ることができません。そのためフォーマットの完全な説明はリンク先や、archive/tarのソースコードに当たってもらうとします。
ここではいくつかの特徴を説明します。
block size = 512 bytes
+--------------+
| header block | 1 to many
+--------------+
| data block | 1 to many
+--------------+
| data block |
+--------------+
.
. variable length
.
+--------------+
| header block | extended header
+--------------+
| data block | extension data only relavant to next file
+--------------+
| header block |
+--------------+
| data block |
+--------------+
.
. variable length
.
+--------------+
| empty block | all `0x00`
+--------------+
| empty block |
+--------------+
| junk data | EOF or junk
| |
.
.
.
- データは512バイトのブロック単位で書き込まれます。
- ヘッダー、データ、ヘッダー、データの繰り返しで表現されます。
- データの部分がファイルの中身です。
- いくつかはデータ部がないものがあります
- e.g. Symlink, Char dev, size 0のregular fileなど
- いくつかの拡張ヘッダーは次のファイルに拡張情報を与えるものがあります。
- いくつかの拡張ヘッダーは通常のヘッダーの後に現れて補足的な情報を与えます。
-
GNU tar: an archiver tool/Basic Tar Format参照。
old GNU
のsparse fileの情報は拡張ヘッダーとして通常のヘッダーのあとに現れます。
-
GNU tar: an archiver tool/Basic Tar Format参照。
- ヘッダーから開始さえできていれば、どこかでちょん切ったり、逆に末尾に新しいエントリを追加してかまいません。
- 実際インクリメンタルアップデートを行った
tar
がテストデータとしてGo
のstd内にありました。
- 実際インクリメンタルアップデートを行った
- EOFか、
0x00
のみで構成されるブロックが二つ連続して並んでいるとアーカイブ末尾となります。 -
GNU
,old GNU
,PAX
,UStar
などいくつかの仕様/拡張仕様があります。
streamが有利な場面
tar
がstreamとして処理可能な利点は送受するときに順次処理できることです。
zip
などのファイルへのランダムアクセスが必要となるフォーマットであればファイルのダウンロードが終わってからようやく展開の処理ができるようになります。
tar
ではダウンロード中に展開処理ができるため、サイズが大きければこちらのほうが速いということもあるでしょうね。
streamの利点を生かした手技には、例えば以下のようにssh
でディレクトリを送る時にtar
してun-tar
するというものがあります
tar -cf - -C /path/to/src . | ssh ${target} "tar -xf - -C /path/to/dst"
(出展はどっかにいってしまったので書けないのですが、scp
のプロトコルを調べている時に見つけた記事の末尾に)scp
を使ってディレクトリをコピーするとファイルチェックを毎度するためパフォーマンスが悪い、ローカルでtar
して結果をパイプしリモートでun-tar
するほうが速いと聞いたことがあります。
(ただし今でもそうなのかはわかりません。scp(1)#HISTORYにある通りOpenSSH 9.0
よりscp
はsftp
を使用して送信するのがデフォルトになったためです。)
c.f. zip
Go
のarchive/zipのトップからリンクが張られていますが、以下がarchive/zipが参照するzip
のフォーマットです。
記載のとおり、4.3.16 End of central directory record、4.3.15 Zip64 end of central directory locator, 4.3.14 Zip64 end of central directory record などをファイル末尾から読み込み、そこから 4.3.12 Central directory structure にseek back、central directory structureに各file headerへのオフセットが入っています。
つまり、基本的には読み込みにランダムアクセス性を必要とします。
zipはフォーマットとしてincremental updateが可能なようです。その中でも「ファイルを消した」をcentral directoryの追記によって表現可能なようです。
specを見る限りstream-decodeは特定の条件において可能です。この「消した」の表現を尊重する面で余計なファイルを書き出してしまうという微妙さはありますが。
(実際「すべてではないが大概のzipには可能」というstackoverflowでの回答があります: 参考)
tarの内容にランダムアクセス性が欲しくなる時
例えば以下が思いつきます。
-
tar
ファイルにランダムアクセス可能であり- 無圧縮、もしくは
-
zstd
(Zstandard Seekable Format),xz
("limited random-access reading")などのような、ランダムアクセス可能オプションのある圧縮方式で圧縮されているとき
-
tar
内部のファイルがランダムアクセスを必要とし- e.g.
PDF
(参考:PDF 1.7 spec, 7.5.5 File Trailerより、PDF
はファイル末尾から読み込む。更に別のFile Tailerに向けてseek backする必要があるときがある。)
- e.g.
-
tar
ファイルを展開したくないとき- e.g.
tar
が非常に大きく、ファイルは一時的に利用されるのみ、もしくはファイルシステム経由でアクセスすされることはないので展開したファイルの配置が不要である - e.g. アプリが
container
内で動作しており、container fs
がread-only modeにされているとき、やらんでいいなら書き込めるディレクトリをマウントしたくない。
- e.g.
tar
に内包されたファイルの特定のオフセット内を*io.SectionReader(包まれる側にio.ReaderAtの実装を必要とする)で包んで*http.RequestのGetBody
から返せるようにするっていうのがいちばん思いつきましたが、
代替する方法はいくらでもあるので立て付け上では上記のようなことになると思います。
prior arts
archivemount
下記を用いるとFUSEによりアーカイブファイルをmount可能です。
このアーカイブの中にはtar
も含まれます。
これを使用したことはありませんが、seekできないのにファイスシステムとしてアクセス可能と言い張るのは無理があるので、tar
内部のファイルへのランダムアクセスが可能なのは間違いないことでしょう。
github.com/nlepage/go-tarfs
github.com/nlepage/go-tarfsは、Go
のモジュールでtar
を受け取ってfs.FSとしてアクセス可能にします。
このライブラリでもio.ReaderAtは実装されません。
このライブラリがどのように動作するのかというと、
まず以下のように、
-
tar.NewReaderに読み込まれたバイト数をカウントできる
io.Reader
を渡します。 -
*tar.Readerの
Next
を順次呼び、読み込まれたバイト数-512(blockSize
)=ヘッダー開始地点オフセットを記録しておきます。 - このようにしてファイル情報を収集します。
fs.FSとしてファイルを開く際には以下のように、
- 記録しておいたヘッダー開始地点から始まるように元のio.ReaderAtを*io.SectionReaderで包みます。
- これをtar.NewReaderに渡し、最初のエントリを読み込ませて返します。
tar
がフォーマットとして途中から分割しても正常に動作することを利用した巧みな実装だといえます。
ただし以下の点で正しくないと思われます。
-
r.Count()-blockSize
は正しくない-
Go
のarchive/tarが一部の拡張ヘッダーの、次のヘッダーにメタデータを与える系のヘッダーを読み込んだ際、それはユーザーに返さずに次のヘッダーを読み込んでメタデータをマージしてから返します - つまり1度の
Next
呼び出しは複数ヘッダー(とデータ)を読み込んでいることもありうるため、単に-blockSize
では足りず、もしかしたら-blockSize*2
かもしれないし、それ以上かもしれないということです。 -
PAX extended header
でGNU.sparse.
が指定されていた場合、これが無視されることになります。- それに関するissueが上がっていないところを見ると実用上sparse fileは使われていないのかもしれないですね。
-
実装
github.com/nlepage/go-tarfsは便利で利用していました。作者に感謝です。
ただやっぱりio.ReaderAtが欲しい。あるとio.ReaderAtを利用する他のライブラリにそのまま渡せて便利だから・・・
なので作ることにします。
実装方針
- 各エントリのheader start/end offset, file content start/end offsetをとることで、*io.SectionReaderと組み合わせることでファイルを読めるようにします。
-
tar
のheader自体の解析はarchive/tarに任せます。- それに何かの修正があるたびに追従するのはできると思いますが、気づいてから取り込むまでにラグができるのは望ましくありません。
-
Go
のモジュールシステムではgo.mod
に書かれたバージョンにかかわらず、コンパイルするときのtoolchainのstd libが使われます。つまり、新しいコンパイラとstdライブラリのセットでコンパイルされればarchive/tarを使っている部分は何もしなくても修正を受けることができます。
-
github.com/nlepage/go-tarfsと違い、元からio.ReaderAtを引数とします。
- github.com/nlepage/go-tarfsはio.Readerを受けとり、io.ReaderAtの実装をチェックしてそれがなかったら*bytes.Bufferに一旦内容をバッファします。暗黙的な挙動は怖いです。
- 上記実装に倣い、tar.NewReaderに読み込まれたバイト数をカウントできるio.Readerを渡します。
-
*tar.Readerの
Next
を呼び、この時点での読み込まれたバイト数が、エントリのheader end offset兼file content start offsetとなりますのでこれを記録します。 - 上記実装とは違い、header start offsetは、1つ前のエントリのfile content end offsetを512バイトのブロック単位になるようにパディングしたものを得ます。
-
file conent end offset = (file content start offset) + tar.Header.Size
で取得します。- ただし、これはsparse fileおよび各種LinkName系のエントリに対しては正しくありません。
- sparseのhole情報を何とかして得ます。
- 得たsparse holeの情報から、
-
tar
の実際に格納されたデータを*io.SectionReaderでsparse holeがある場所で切り取り - (これのために作った)ByteRepeaterを使って
0x00
を無限に繰り返すio.ReaderAtを作り - (これは以前に作った)NewMultiReadAtSeekCloserで仮想的に結合します。
-
offsetの収集
前述通りあるファイルエントリへのheader, file contentの開始/終了オフセットを集めますので以下の型を定義します。
headerEnd - headerStart
は常に512となるとは限りません。archive/tarはPAX extended header
などを読み込むと、それをユーザーに返さずに次のヘッダーを読み込んで情報をマージしてからユーザーに返すためです。
github.com/nlepage/go-tarfsに倣い、Read
されたバイト数を記録するReaderを定義します。
Seek
が実装されていますが、これはarchive/tarがデータを読み捨てる際にSeek
が実装されていれば使うからです。
offsetの収集は以下のようにできます。github.com/nlepage/go-tarfsとほぼ同じです。(ただしGo 1.23で追加されたiteratorの形でですが。)
func tryMapsCollect[K comparable, V any](keyMapper func(V) K, seq iter.Seq2[V, error]) (map[K]V, error) {
collected := make(map[K]V)
for v, err := range seq {
if err != nil {
return collected, err
}
collected[keyMapper(v)] = v
}
return collected, nil
}
func tryCollectHeaderOffsets(seq iter.Seq2[*headerOffset, error]) (map[string]*headerOffset, error) {
return tryMapsCollect(func(ho *headerOffset) string { return path.Clean(ho.h.Name) }, seq)
}
func iterHeaders(r io.ReaderAt) iter.Seq2[*headerOffset, error] {
return func(yield func(*headerOffset, error) bool) {
countingR := &countingReader{R: io.NewSectionReader(r, 0, math.MaxInt64-1)}
tr := tar.NewReader(countingR)
var (
prev *headerOffset
blk block
)
for {
h, err := tr.Next()
if err != nil {
if err == io.EOF {
break
} else {
yield(nil, fmt.Errorf("read tar archive: %w", err))
return
}
}
headerEnd := countingR.Count
hh := &headerOffset{h: h, headerEnd: headerEnd, bodyStart: headerEnd}
if prev != nil {
// bodyEnd padded to 512 bytes block boundary
hh.headerStart = prev.bodyEnd + (-prev.bodyEnd)&(blockSize-1)
}
switch hh.h.Typeflag {
case tar.TypeLink, tar.TypeSymlink, tar.TypeChar, tar.TypeBlock, tar.TypeDir, tar.TypeFifo,
tar.TypeCont, tar.TypeXHeader, tar.TypeXGlobalHeader,
tar.TypeGNULongName, tar.TypeGNULongLink:
// They may have size for name.
hh.bodyEnd = hh.bodyStart
default:
hh.bodyEnd = hh.bodyStart + int(hh.h.Size)
}
if !yield(hh, nil) {
return
}
prev = hh
}
}
}
*tar.Readerが0バイトしか読まないにもかかわらず、*tar.Header.Sizeが0でないことがテストデータ上あったため、常にhh.bodyEnd = hh.bodyStart + int(hh.h.Size)
としてはだめで、とりあえず上記のようにswitch-caseしています。
こうして収集した情報を用いると、以下のように*io.SectionReaderを用いてファイルの内容を読みだすことができます。
type seekReadReaderAt interface {
io.Reader
io.ReaderAt
io.Seeker
}
func makeReader(ra io.ReaderAt, h *header) seekReadReaderAt {
return io.NewSectionReader(ra, int64(h.bodyStart), int64(h.bodyEnd)-int64(h.bodyStart))
}
sparse情報の再解析
ただしこれだけではsparse fileを取り扱うことができません。
GNU Tar Manual: Storing Sparse Filesによると、
GNU tar
は3通りの記法でsparse(疎) fileの格納が可能であるとあります。実際にはほかの拡張によって別な記法があるかもしれませんが少なくともGo
のarchive/tarはこの3つのみをサポートします。
問題はこれらの情報はGo
のarchive/tarがハンドルしきってしまい(GNU PAX sparse format version 0.1
を除いて)ユーザーにsparseの情報が返ってこないことがです。つまり、何とかして自前でこれらの情報を解析しなお必要があります。
前述通りtar
にはいろんな拡張仕様があり1から10まで自前で実装するのは資料に当たるのが大変そうという意味で困難ですのでarchive/tarのソースをコピーして改変するのが最も現実的な解法かと思います。
わお、stdのソースをコピーして改変。したくないですねえ。ですがよくよくarchive/tarのソースを読んでいるとsparseの情報解析しなおすだけなら案外少量のコピーで行けそうです。
archive/tarのnext
methodを読むと、handleSparse
はここで呼ばれていることがわかります。
rawHdr
という名前で、解析された最後のheader blockを渡しています。
必要最小限だけをまねると以下のようになります
headerのTypeflag
がtar.TypeXHeader
や、tar.TypeGNULongName
のとき、archive/tarのnext
では情報を解析したりしています。
すでに解析済みであるので要するにこれらの時はdata sectionを読み飛ばせばいいので上記の通りになります。
handleSparseFile
, readOldGNUSparseMap
, readGNUSparsePAXHeaders
はarchive/tarでは*tar.Readerのmethodでしたがこれらを単なる関数になるように若干改変します。
以下がarchive/tarから未改変のままコピーされたコード群です。コメントアウトされたものを含んでも400行以下で少量にとどめることができました。
このsparse情報の再建部分ををoffsetの収集部分に盛り込むと以下のようになります。
sparse file readerの作成
github.com/ngicks/go-fsys-helper/streamで
- NewMultiReadAtSeekCloser: 複数のio.ReaderAtを受けとって仮想的に結合して1つのio.ReaderAtにする
-
ByteRepeater: 特定の
byte
を無限に繰り返す
を定義しておいたので、これらを組み合わせてsparse hole部分を0x00
で読み込むio.ReaderAtが完成します。
test
で、この考え方は正しいの?というのが気になるので、テストを行います
$(go env GOROOT)/src/archive/tar/testdata/
以下にarchive/tarがテストに用いているtar
ファイル群があるのでこれをコピーしてこれを*tar.Readerで読みこんだ内容と、iterHeaders
とmakeReader
で作ったio.Readerから読み込んだ内容が同じであるかをチェックします。
...今考えると別にファイルをコピーしておく必要はないですね。まあいいでしょう。(多分go:embed
するつもりでこうしていたんでしょう)
実際には以下のステップを踏みます
-
setup_test.go:
-
go mod edit -json
でこのモジュールのgo.mod
に書かれた内容をJSONで取得します。 - 内容から
go.mod
のgo version
を取得します(現在go 1.24.0
ですので以後1.24.0
と書きます。) go install golang.org/dl/go1.24.0@latest
-
go1.24.0 download
でsdkをダウンロード -
go1.24.0 env GOROOT
でstd libraryが格納されたディレクトリパスを取得 cp ${GOROOT}/src/archive/tar/testdata/* ./testdata/go1.24.0/
-
-
headers_test.go:
- *tar.Readerで読みこんだファイル内容を収集
-
iterHeaders
とmakeReader
で作ったio.Readerから読み込んだ内容を収集 -
bytes.Equal
で比較
gnu-sparse-big.tar
, pax-sparse-big.tar
はテストから除外しました。内容が大きいせいかテストがタイムアウトするためです。
他のすべてでbytes.Equal
で一致したためこの実装はある程度正しいようです。
テスト実行者のgo versionによらずに同じtestdataを引っ張ってこれるからこのほうがいいかな~っていう薄い考えでこういうセットアップ実装にしましたが、go.mod
にtoolchainを指定してしまったほうがよかったかもですね。(ciと相性悪そうだし)
fs.FS interfaceを実装する
fs.FSとして利用したいのでそうなるようにinterfaceを整えます。
とは言え特段ここに関しては述べることはありません。
前述のiterHeaders
で収集した情報をもとにfile system風のtrieを構築し、ファイルをopenする際には前述のmakeReader
を呼び出すようにします。
staticなdirentry
interfaceとOpen
で開かれたstatefulなopenDirentry
interfaceを定義します。開かれたファイルにはRead
で読み込んだカーソル位置などのstateがありますが、保存されているデータそのものはダイナミックに変化しないことを期待します。
これらはfile
とdir
によって実装されます。
file
はopen時に前述のmakeReader
を呼びます。
dir
はaddChild
でdirentryの追加、
openChild
で子要素の読み込みを行います。これらでtrieの構築と探索を行うわけですね。
なのでNew
はrootとなるdir
にdirentry
をaddChild
するだけになります。
再帰呼び出しだとstackが深くなるためfor-loopで処理できたほうが計算効率はいいと思いますがシンプルにしたかったのでこんなもんで良しとします。
こちらも同じく*tar.Readerで読みこんだ内容と*tarfs.FS
で読み込んだ内容が一致するかをテストします。
さらに、fstest.TestFSでfs.FSとしての挙動が正しいのかもテストします。
regular fileとdirectory以外は無視する実装なので適当にそれらしかないtar
を用意して実行します。
パスしました。
おわりに
- 前から欲しかった
tar
をio.ReaderAtで読める実装が用意できました。 - こういった実装はあまり見ないので、多分需要は少ないんだと思います。
-
tar
というフォーマット自体をしらべたので理解が深まってよかったです。
今後は
- readerにio.WriterToを実装させる
- 今の実装ではsparse fileのholeが単なる
0x00
として読み込まれるためコピー時に非効率です - linuxや他のunix系のシステムではlseek(2)でファイルの終端を超えてseekを行うとholeが作られますが、windowsでは違うというのを聞いたことがあるため調査が大変そうなのでやめておきました
- そもそもsparseのあるファイルを取り扱わないかも
- 現状archive/tar自身もsparse fileの書き込みでholeを作る実装になっていません(#22735)ので問題ないといえばないです。
- 今の実装ではsparse fileのholeが単なる
- symlink supportを加える
Discussion