LoginSignup
11
2

More than 5 years have passed since last update.

bufio の Peek() を理解していなかった

Last updated at Posted at 2017-12-07

Go でコードを書いていて「おや、これは…?」と頭の中がハテナで一杯になる現象に出くわして、@nobonoboさん@mattnさんに教えてもらったのでまとめておきます。ありがとうございます! もし間違いがあればご指摘ください。

問題のコード

これは goquery というライブラリを使って <title> タグのテキストを抜き出そうとしているコードです。が、これは正しくテキストが抜き出せません。(説明のために色んな処理を省いたり、変なコードの書き方になっています…)

res, err := http.Get("http://example.com/")
if err != nil {
    fmt.Printf("err: %v\n", err)
}
defer res.Body.Close()

br := bufio.NewReader(res.Body)
_, _ := br.Peek(1024)

doc, _ := goquery.NewDocumentFromReader(res.Body)
title := doc.Find("title").Text()
fmt.Printf("title: %v\n", title)

以下のように bufio をそのまま渡すと期待通りテキストが抜き出せます。

doc, _ := goquery.NewDocumentFromReader(br)

これは res.Body がすでに一定量 Read() されていて EOF になっているのが原因です。空っぽの res.Body を渡されても goquery (正しくは html.Parse() )は困っちゃうわけです。

なので、現実的にはあまりないケースですが <title> までにものすごく長い文字列が入っていたら取得できるケースもあります。

bufio Reader.Peek

Peek() の以下のドキュメントを読んで、これは Read() とは違いファイルポインタは先頭にあるんだなぁ程度の理解をしていました。

Peek returns the next n bytes without advancing the reader.

実際に試した所、期待通りの動きもしていたので安心していました。

br := bufio.NewReader(res.Body)
_, _ = br.Peek(1024)

buf := make([]byte, 1024)
br.Read(buf)
fmt.Printf("buf: %v\n", string(buf))
// ちゃんと先頭から Read できている

ファイルポインタがずれていないなら、 res.Body を使いまわして Read() しても大丈夫だろうと思っていたのが落とし穴でした。

というわけで、bufio のソースコードを読んでみました。

func (b *Reader) Peek(n int) ([]byte, error) {

  // snip...

    for b.w-b.r < n && b.w-b.r < len(b.buf) && b.err == nil {
        b.fill() // b.w-b.r < len(b.buf) => buffer is not full

Peek() の中で fill() が呼ばれます。

func (b *Reader) fill() {

  // snip...

    // Read new data: try a limited number of times.
    for i := maxConsecutiveEmptyReads; i > 0; i-- {
        n, err := b.rd.Read(b.buf[b.w:])

なるほど、ここで Read() している。読み込んでいるサイズは defaultBufSize = 4096 と定義されています。つまり、 4KB ほどすでに読まれていて元々の res.Body は欠損しているという感じになります。

bufio には NewReaderSize() という読み込むサイズを指定できるものがあったので、これで試してみます。小さいサイズ(<title> に届かないレベル)を指定すれば、 res.Body が欠損していたとしても <title> が読み込めるということになります。

以下のように 16 に指定してみると、期待通りテキストが取得できました!

br := bufio.NewReaderSize(res.Body, 16)
_, _ = br.Peek(1024)

doc, _ := goquery.NewDocumentFromReader(res.Body)
title := doc.Find("title").Text()
fmt.Printf("title: %v\n", title)

なお minReadBufferSize で最小のサイズが 16 に指定されているので、それ以下の数値を指定しても挙動は変わりません。

EOF が返ってきてもデータがあるケース

気になったので試してみました。dummy.txtは33バイトの適当なファイルです。

url := "http://localhost/dummy.txt"
res, _ := http.Get(url)
for {
    buf := make([]byte, 32)
    num, err := res.Body.Read(buf)
    fmt.Printf("read: %v\tnum: %v\terr: %v\n", string(buf), num, err)

    if err == io.EOF {
        break
    }
}

実行してみると、たしかに EOF と共にデータが帰ってきてますね。EOF だけを終了条件にするのではなく、読み取ったバイト数もちゃんと見るようにしましょう。

read: 01234567890123456789012345678901  num: 32 err: <nil>
read: 2 num: 1  err: EOF

非常に初歩的なところだと思いますが、よく使うような部分だと思うので少しでも理解が進んで良かったです。あらためて@nobonoboさん @mattnさん ありがとうございます!

11
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
11
2