[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のNextmethodを呼び出すと次の*tar.Headerまで読み込んでそれを返し,
- 
ReaderのReadmethodで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を加える加えました(69ddd3691ee, d80e27dd28a, 60f73cb8c5c, 981b498c87b)。

Discussion