「連結されたgzipを1行ずつ見る」をgolangでやったらハマった

  • 12
    いいね
  • 0
    コメント
この記事は最終更新日から1年以上が経過しています。

元ネタ:連結されたgzipを1行ずつ見る
そういえばgolangにもgzipあったよなぁと思って書き始めたらかなりハマってしまった話です。

単純に1行ずつ見るやつ

複数ファイルを区別せず1行1行見ていくコードは結構簡単に書けました。
compress/gzipReaderにはMultistreamというオプション関数があって、これにtrueを渡すと複数のgzipされたデータストリームを1つのデータストリームとして扱ってくれるそうです(デフォルトでtrueになっている)
今回のお題にうってつけですね( ´・‿・`)

package main

import (
    "bufio"
    "compress/gzip"
    "fmt"
    "os"
)

func gzipEachLine(filename string, proc func(string)) {
    f, err := os.Open(filename)
    if err != nil {
        return
    }
    defer f.Close()

    r, err := gzip.NewReader(f)
    if err != nil {
        return
    }
    defer r.Close()

    scanner := bufio.NewScanner(r)
    for scanner.Scan() {
        proc(scanner.Text())
    }
}

func main() {
    gzipEachLine("foo.gz", func(line string) {
        fmt.Println(line)
    })
}

ただしbufio.Scannerは1行あたりの文字数制限があるので1行が結構長くなりそうな場合は工夫が必要っぽいです。
参考:Go 言語で標準入力から読み込む競技プログラミングのアレ --- 改訂第二版

ファイルの名前とかも見るやつ

trueにすればひとつにまとまるってことはfalseにすれば1個1個見れるんじゃないかと思ってソースを覗いたらそれっぽいコードがありました。
https://golang.org/src/compress/gzip/gunzip_test.go#L372

これを参考に次のように書いてみました

package main

import (
    "compress/gzip"
    "fmt"
    "os"
)

func main() {
    f, err := os.Open("foo.gz")
    if err != nil {
        return
    }
    defer f.Close()

    var r gzip.Reader
    var needClose bool
    for {
        if err := r.Reset(f); err != nil {
            break
        }

        needClose = true
        fmt.Printf("filename : %s\n", r.Header.Name)

        r.Multistream(false)
        if data, err := ioutil.ReadAll(&r); err == nil {
            fmt.Println(string(data))
        }
    }
    if needClose {
        r.Close()
    }
}

がこれは動きませんでした。
最初のファイルの名前と中身は出力されましたが2番目以降のファイルが読み取れませんでした。

原因がわからないので元ソースをあたってみると元ソースではos.Fileではなくbytes.Readerが使われていました。
(そもそもgzipされたデータがソースに埋め込まれている)

これかな?と思って一旦[]byteに読み込んでbytes.Readerに変換すると2番目以降も読み取れる!
これはどういうこっちゃと思ってソース読んだり、printfデバッグしたり、gdbしたりしてやっとこさわかりました。

原因はbufio.Reader

gzip.NewReaderもしくはgzip.ReaderResetを呼んだとき内部ではmakeReaderという関数を呼んでいます。

func makeReader(r io.Reader) flate.Reader {
    if rr, ok := r.(flate.Reader); ok {
        return rr
    }
    return bufio.NewReader(r)
}

https://golang.org/src/compress/gzip/gunzip.go#L30

この関数は受け取った引数rflate.Readerを実装していればそのまま返して、そうでないならbufio.Readerに包んで返すというものです。
*bytes.Readerflate.Readerを実装していましたが、*os.Fileは実装していなかったのでbufio.Readerに包まれていました。

bufio.Readerはその名の通りバッファを持ったIOでReadの際には元となるio.Readerからバッファに読み込めるだけ読み込んでいます。https://golang.org/src/bufio/bufio.go?#L96

今回検証に使ったgzipファイルは91byteと小さかったため最初のファイルのヘッダ情報を読み取った時点でファイルの中身がすべてバッファに移ってしまっていました。

ちゃんと書いてあった

で原因がわかったあとにドキュメントを見てたらちゃんと注意書きがありました。。。(´・ω・`)
ドキュメントはちゃんと読もう!!!

If r does not also implement io.ByteReader, the decompressor may read more data than necessary from r.
https://golang.org/pkg/compress/gzip/

解決策

一旦[]byteに読み込んでbytes.Readerにすればできるんですが、これじゃ展開してるのと同じなのでio.Readerをアドホックにflate.Readerに変換するコードを書いてみました。

package main

import (
    "compress/gzip"
    "fmt"
    "io"
    "io/ioutil"
    "os"
)

type adhocFlateReader struct {
    io.Reader
}

func (r adhocFlateReader) ReadByte() (byte, error) {
    var b [1]byte
    if n, _ := r.Read(b[:]); n > 0 {
        return b[0], nil
    }
    return 0, io.EOF
}

func main() {
    f, err := os.Open("foo.gz")
    if err != nil {
        return
    }
    defer f.Close()

    fr := adhocFlateReader{f}

    var r gzip.Reader
    var needClose bool
    for {
        if err := r.Reset(fr); err != nil {
            break
        }

        needClose = true
        fmt.Printf("filename : %s\n", r.Header.Name)

        r.Multistream(false)
        if data, err := ioutil.ReadAll(&r); err == nil {
            fmt.Println(string(data))
        }
    }
    if needClose {
        r.Close()
    }
}

追記

最初の1回はgzip.NewReaderを呼び出す方が自然っぽいです。

package main

import (
    "compress/gzip"
    "fmt"
    "io"
    "io/ioutil"
    "os"
)

type adhocFlateReader struct {
    io.Reader
}

func (r adhocFlateReader) ReadByte() (byte, error) {
    var b [1]byte
    if n, _ := r.Read(b[:]); n > 0 {
        return b[0], nil
    }
    return 0, io.EOF
}

func main() {
    f, err := os.Open("foo.gz")
    if err != nil {
        return
    }
    defer f.Close()

    fr := adhocFlateReader{f}
    r, err := gzip.NewReader(fr)
    if err != nil {
        return
    }
    defer r.Close()

    for {
        fmt.Printf("filename : %s\n", r.Header.Name)

        r.Multistream(false)
        if data, err := ioutil.ReadAll(r); err == nil {
            fmt.Println(string(data))
        }

        if err := r.Reset(fr); err != nil {
            break
        }
    }
}

追記

これもっと単純にこれで良かった

package main

import (
    "compress/gzip"
    "fmt"
    "io/ioutil"
    "os"
    "bufio"
)

func main() {
    f, err := os.Open("foo.gz")
    if err != nil {
        return
    }
    defer f.Close()

    br := bufio.NewReader(f)
    r, err := gzip.NewReader(br)
    if err != nil {
        return
    }
    defer r.Close()

    for {
        fmt.Printf("filename : %s\n", r.Header.Name)

        r.Multistream(false)
        if data, err := ioutil.ReadAll(r); err == nil {
            fmt.Println(string(data))
        }

        if err := r.Reset(br); err != nil {
            break
        }
    }
}