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()
しても大丈夫だろうと思っていたのが落とし穴でした。
実際はもっと大きいのを先読みして貯めてるので1回 Peek したら(実際の Body の方は)既に EOF になってるはずです。
— mattn (@mattn_jp) 2017年12月7日
というわけで、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 が返ってきてもデータがあるケース
あとオマケですが、bufio.Reader の Read はデータと EOF を同時に返すケースがあるので、EOF が来たから読まないとしてるとデータを逃してしまう場合がありますのでご注意を。
— mattn (@mattn_jp) 2017年12月7日
気になったので試してみました。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さん ありがとうございます!