フューチャー技術ブログ

Go 1.22リリース連載 archive/tar, archive/zip, bufio, io

The Gopher character is based on the Go mascot designed by Renée French

はじめに

TIG 真野です。Go1.22連載の3本目です。

Go 1.22のマイナーアップデートのうち、ファイルなどの入出力に関連しそうな archive/tararchive/zipbufioio を取り上げて紹介します。

アップデートサマリ

  • archive/zip に Writer.AddFS が追加された #54898
  • archive/tar に Writer.AddFS が追加された #58000
  • bufio の Scannerが、SplitFuncErrFinalToken を返すときに即時停するようになった。従来は []byte{}を返していた #56381
  • io の SectionReader.Outer() メソッドが追加された #61870

archive/zip #54898

archive/zipパッケージにWriter.AddFS というメソッドが追加されました。処理としては、FS、つまりファイルシステムを入力として、ルートからディレクトリツリーを辿ってフォルダ構成を維持しながら全ファイルをzip化します。便利ですね。

これが登場する以前は、Stack Overflowなど複数の記事でいくつか実装例を参考にしながら各自が実装していたようで、揺れていたり実装ミスが発生したようです。zip.NewWriter()でzipに追加したいファイルを1つ1つ追加する必要がありました。

Stack Overflowの例もFileWalkerなどを使って(それなりの量を)実装する必要があります。また、w.Create()の前に書いてあるコメント通り、指定されたパスが相対パスにする必要があったり、Windowsでも動作するようにするためには、一工夫がさらに必要です。

StackOverflowトップ回答の実装例からコメント追加
package main

import (
"archive/zip"
"fmt"
"io"
"os"
"path/filepath"
)

// Zips "./input" into "./output.zip"
func main() {
zipFile, err := os.Create("output.zip")
if err != nil {
panic(err) // サンプルコードなのでpanicしています
}
defer zipFile.Close()

w := zip.NewWriter(zipFile)
defer w.Close()

walker := func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
if info.IsDir() {
return nil // ディレクトリは無視してOK(ファイル追加のときに自動で追加されるため)
}
file, err := os.Open(path)
if err != nil {
return err
}
defer file.Close()

// Ensure that `path` is not absolute; it should not start with "/".
// This snippet happens to work because I don't use
// absolute paths, but ensure your real-world code
// transforms path into a zip-root relative path.
f, err := w.Create(path)
if err != nil {
return err
}

_, err = io.Copy(f, file)
return err
}

if err = filepath.Walk("input", walker); err != nil {
panic(err) // サンプルコードなのでpanicしています
}
}

これが次のように書き換わります。

package main

import (
"archive/zip"
"fmt"
"os"
)

func main() {
zipFile, err := os.Create("output.zip")
if err != nil {
panic(err) // サンプルコードなのでpanicしています
}
defer zipFile.Close()

w := zip.NewWriter(zipFile)
defer w.Close()

if err := w.AddFS(os.DirFS("input")); err != nil {
panic(err)
}
}

とても楽ですし、直感的ですね! ちなみに、空フォルダはzip化されないようです。

また、何かしらのファイルを除去したいなどのフィルター処理をしたい場合は、それを行う fs.FS を作成して回避するといった考えのようです。

fs.FS を引数に取るということは、別の応用も効かせられます。jszwec/s3fs はS3の指定されたバケットに対してfs.FS インタフェースを満たすライブラリです。例えばこれを用いると、S3バケットがそのままzip化されます。

package main

import (
"archive/zip"
"fmt"
"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/aws/endpoints"
"github.com/aws/aws-sdk-go/aws/session"
"github.com/aws/aws-sdk-go/service/s3"
"github.com/jszwec/s3fs"
"os"
)

func main() {

zipFile, err := os.Create("output.zip")
if err != nil {
return
}
defer zipFile.Close()

w := zip.NewWriter(zipFile)
defer w.Close()

fs := s3fs.New(s3.New(session.Must(session.NewSessionWithOptions(session.Options{
Profile: "YOUR PROFILE",
SharedConfigState: session.SharedConfigEnable,
Config: aws.Config{
Region: aws.String(endpoints.ApNortheast1RegionID),
},
}))), "my-example-bucket")

if err := w.AddFS(fs); err != nil {
panic(err)
}
fmt.Println("finish")
}

ビルディングブロック的に、zip化ができるようになったのは画期的だと思います。

【注意】上記のコードはS3バケットまるごとダウンロードするので、バケットのデータ量によっては利用を控えたほうが無難です。また、s3fs側の実装とAddFS()の組み合わせが悪いのか、S3に空フォルダオブジェクト(キー名が/で終わるオブエジェクト)が含まれる場合は、上手くzip化されないようです(実行時エラーとなります)。ご注意ください。

archive/tar #58000

archive/tarパッケージにWriter.AddFS というメソッドが追加されました。背景や内容については、archive/zip と全く同じでしたので割愛します。

bufio #56381

Scannerが、bufio.SplitFunc を受け取り、 ErrFinalToken を返した場合は停止するようになりました。従来は []byte を返していました。

まずScannerにはSplit() という関数があり、Split()SplitFunc を引数に取ります。

Scanner
func (s *Scanner) Split(split SplitFunc)

type SplitFunc func(data []byte, atEOF bool) (advance int, token []byte, err error)

例としてIssueにあったScannerの実装を上げます。

func main() {
const input = "1,2,STOP,4,"

// Scannerの宣言
scanner := bufio.NewScanner(strings.NewReader(input))

// SplitFunc
onComma := func(data []byte, atEOF bool) (advance int, token []byte, err error) {
for i := 0; i < len(data); i++ {
if data[i] == ',' {
// if the token is "STOP", ignore the rest
if string(data[:i]) == "STOP" {
return i + 1, nil, bufio.ErrFinalToken
}

return i + 1, data[:i], nil
}
}
return 0, data, bufio.ErrFinalToken
}
// ScannerにSplitFuncを設定
scanner.Split(onComma)

// 読み取り処理(SplitFuncで入力を分割しながら読み取れる)
for scanner.Scan() {
fmt.Printf("Got a token %q\n", scanner.Text())
}
if err := scanner.Err(); err != nil {
fmt.Fprintln(os.Stderr, "reading input:", err)
}
}

上記で、scanner.Scan() ですが、inputは1行ですが、ループはカンマごとにSplitFunc() で分割され、またSTOPという文字列で停止するために2回ループが実行されるのが想定だと思います。

go1.21以前では、これが3回実行されていましたが、go1.22以降では2回の実行となります。

go1.21以前:

Got a token "1"
Got a token "2"
Got a token ""

go1.22以降:

Got a token "1"
Got a token "2"

変更理由は、このデータを取りたいケースは存在しないだろうということで、どちらかといえばあるべき動きに訂正されたようです。

io(#61870)

io.SectionReader に以下のメソッドが追加されました。

func (s *SectionReader) Outer() (r ReaderAt, off int64, n int64)

SectionReader自体はRead(), Seek(), ReadAt() を実装する、入力を指定された オフセット~長さに区切ったReaderです。GoDocのExampleを見ると何をするようなものか一目瞭然です。

package main

import (
"io"
"log"
"os"
"strings"
)

func main() {
r := strings.NewReader("some io.Reader stream to be read\n")
s := io.NewSectionReader(r, 5, 17)

if _, err := io.Copy(os.Stdout, s); err != nil { // 「io.Reader stream」と出力
log.Fatal(err)
}

}

これに Outer() を追加します。

func main() {
r := strings.NewReader("some io.Reader stream to be read\n")
s := io.NewSectionReader(r, 5, 17)

if _, err := io.Copy(os.Stdout, s); err != nil {
log.Fatal(err)
}
+ outer, off, n := s.Outer()
+ fmt.Println(reflect.TypeOf(outer), off, n) // 「io.Reader stream *strings.Reader 5 17」が出力
}

実行すると、引数で渡したstrings.Reader, offset=5, length=17 が取得できます。

さて、機能はわかったところで、これが追加された理由です。io 側のコメントを追っていくと、net: support zero-copy send on TCPConn when reading from File via SectionReader #61727がモチベーションのようです。

#61727 の内容は私が理解しきれた範囲だと以下です(補足、訂正大歓迎です)

  • net.TCPConn でファイル送信する場合に、ゼロコピーになるのは現在、LimitedReader のみ
  • SectionReader もゼロコピー対応したい
  • 対応できると、FD(File descriptor)を同時に使用して同じファイルを複数の TCP 接続に送信できるようになり、他のユースケース (範囲リクエストなど) もサポートできる
  • 今でもそれはできるが、システムコールやメモリ割り当てが発生してしまう(≒genericReadFromが呼ばれる)

この対応が入れば、GoのHTTPサーバの応答性能がさらに上がりそう、というのがわかります。今後に期待ですね。

まとめ

派手さはないですが、こういった細かいアップデートを確認していくと、Stack Overflowなどでコミュニティ側が混乱していそうな点を標準パッケージ側で吸収しGoアプリケーションとしての品質向上に努めたり、性能観点など、確実にGoが良くなっているのが感じられます。

個人的には、fs.FSをインタフェースにzip化できるのは、便利で応用力が高くて良い設計だな感嘆しました。私が設計者なら、普通にディレクリパスを渡して、あるフォルダごとzip化するようなインタフェースを考えてしまいそうです。

ライブラリのAPI設計の勉強にもなり学びでした。次のバージョンでもこのブログ連載に参加しようと思います。