フューチャー技術ブログ

Go1.19 encoding/csv のアップデート

はじめに

TIG真野です。Go 1.19リリース連載の2本目です。

encoding/csv のアップデート内容について紹介します。 Go 1.17のときも encoding/csv を取り上げたので2回目です。

ちなみにその時に説明したFieldPos() ですが、類似のメソッドがGo 1.19の encoding/xml にも追加されています。隣のパッケージに類似の展開がされているのは面白いですね。

encoding/xml
The new method Decoder.InputPos reports the reader’s current input position as a line and column, analogous to encoding/csv’s Decoder.FieldPos.
readerの現在入力位置の行と列を示すを取得します。 encoding/csvDecoder.FieldPos と同様です。

アップデート内容について

さて、encoding/csv のアップデートについてです。

Go 1.19 Release Notes に1行だけ書いてあります。

The new method Reader.InputOffset reports the reader’s current input position as a byte offset, analogous to encoding/json’s Decoder.InputOffset.
新しい Reader.InputOffset メソッドは、現在の入力位置をバイトオフセットとして取得します。encoding/jsonの Decoder.InputOffset と類似しています。

追加された関数はGoDocsに次のように書かれています。

追加されたメソッド
func (r *Reader) InputOffset() int64

これであーこれで便利になるねってピンと来る人は、そこそこディープな encoding/csv のファンではないでしょうか?説明していきます。

まずどういうAPIか動かしましょう

以下のバージョンで動かしています。

>go version
go version go1.19rc2 windows/amd64

改行を含むCSVデータで動かしてみます。

main.go
package main

import (
"encoding/csv"
"fmt"
"io"
"log"
"strings"
)

func main() {
s := `aaa,"b

bb",ccc
ddd,eee,fff
zzz,yyy,xxx
`
r := csv.NewReader(strings.NewReader(s))
for {
fmt.Printf("input offset:%d: ", r.InputOffset())

record, err := r.Read()
if err == io.EOF {
break
}
if err != nil {
log.Fatal(err) // 何かしらのエラーハンドリング
}

fmt.Printf("%#v\n", record)
}

}

結果は次のようになります。

>go run main.go
input offset:0: []string{"aaa", "b\n\nbb", "ccc"}
input offset:16: []string{"ddd", "eee", "fff"}
input offset:28: []string{"zzz", "yyy", "xxx"}
input offset:40:

最初は0で r.Read() が呼び出されるとバイト位置が進み、呼び出されるたびにddd、zzz項目の直前まで先に進んでいることがわかります。バイト位置的にどこまで進んだかがわかります。

Issue上でのやり取り

encoding/csv: add Reader.InputOffset method #43401 が該当のIssueですが、encoding/json.Decoder という前例があるからか割りとすんなり提案から受け入れられています。

議論があったポイントは以下程度。

  • encoding/jsonDecoder.InputOffset はJSONバイナリをどこまで読み取ったか知るすべが無いので有効である
    • CSVの場合は行ベースなので、不要では?
      • いやいや、ランダムアクセスが必要なケースもあるし必要。例えば CSVに対して転置インデックスを作成し、オフセットから特定の行を読み取りたいケースがある
      • (筆者補足)あと、CSVは項目の改行が許容されているので必ずしも読み取った行でポジションが分かるわけでもない

ちなみに、前例とされている encoding/json.Decoder は2020年2月25日の Go 1.14に追加されたメソッドです。

追加された関数の内容は分かるけど、どういう時に嬉しいのか

Issueのくだりで触れられていますが、主要なユースケースは2つ思いつきます。

  1. CSVをデコード時に不正な入力が合った場合、どのバイト位置で失敗したか知らせたい
    1. 通常はこちらの用途が多いのではと思いますが…さていかに。
  2. (巨大な)CSVファイルに対してランダムアクセスをさせるため、正確な位置を知りたい場合
    1. どういうケースで必要になるかイメージがわかないですが(私が扱うような業務システム領域だとRDBとかElasticsearchに突っ込んじゃうので..)、 Read random lines off a text file in go - Stack Overflow にある通り、需要はあるようです

それぞれどんな感じになるか簡易実装で紹介します。

不正なCSV入力例

試しに不正なクウォートを混入させてみます。 ee"e が該当の部分です。

main.goのCSVに不正なクォートを混入させる
	s := `aaa,"b

bb",ccc
ddd,ee"e,fff
zzz,yyy,xxx
`

動かしてみると、次のメッセージが取得できます。

実行結果
>go run main.go
input offset:0: []string{"aaa", "b\n\nbb", "ccc"}
input offset:16: 2022/08/02 12:17:52 parse error on line 4, column 7: bare " in non-quoted-field
exit status 1

やってみて気がついたんですが、CSVパースエラーの場合は行番号も列位置も出ているため、 InputOffset() をわざわざ出さなくても良いかなとは思いました。

信頼できない連携先とのやり取りで、不正値を混入することを考慮して、 InputOffset() を表示しなくてもまぁ大丈夫かなということが分かりました。

巨大なCSVに対するランダムアクセス例

ということで、巨大なCSVのランダムアクセスです。データは国勢調査 - 年齢(5歳階級),男女別人口-都道府県(大正9年~平成27年) から取得したCSVを使っています(c03.csv という名称もそのままです)。1.7万行ほどで1MBほどなのでメモリに載せれるほどですが、いったんこれが1000TBくらいあると仮定します。

最初に対象となる巨大なCSVファイルを読み取り、後で検索したいレコードが登場したらそのオフセット位置を覚えておきます(この情報を後でファイルに出力しても良いですね)。

その後、fetchFirstLine() で覚えておいたオフセット位置をもとに巨大なファイルを f.Seek() を用いてその位置から1行だけCSVを読み込みます。

package main

import (
"bufio"
"encoding/csv"
"fmt"
"io"
"log"
"os"
)

func main() {
// 都道府県ごとのオフセット位置を `indexMap` に格納する
f, err := os.Open("c03.csv")
if err != nil {
log.Fatal(err)
}
defer f.Close()

indexMap := make(map[string]int64)
r := csv.NewReader(f)

var prefectureCD string
for {
offset := r.InputOffset()
record, err := r.Read()
if err == io.EOF {
break
}

if record[0] != prefectureCD {
prefectureCD = record[0]
indexMap[record[1]] = offset
}

if err != nil {
log.Fatal(err) // 何かしらのエラーハンドリング
}
}

// オフセット位置を表示
fmt.Println("北海道", indexMap["北海道"])
fmt.Println("神奈川県", indexMap["東京都"])
fmt.Println("沖縄県", indexMap["沖縄県"])

// 該当の最初の1行を取得(ランダムアクセスを想定)
line, err := fetchFirstLine(indexMap, "東京都")
if err != nil {
log.Fatal(err)
}

fmt.Printf("%#v\n", line)
}

func fetchFirstLine(indexMap map[string]int64, key string) ([]string, error) {
f, err := os.Open("c03.csv")
if err != nil {
return nil, err
}
defer f.Close()

_, err = f.Seek(indexMap[key], 0) //特定の位置から読む
if err != nil {
return nil, err
}

r := csv.NewReader(f)
return r.Read()
}

実行するとこういう感じです。

>go run main.go
北海道 146
神奈川県 262340
沖縄県 1006890
[]string{"13", "東京都", "総数", "大正", "9", "1920", "3699428", "1952989", "1746439"}

こういった用途に関しては、 f.Seek() を使ってファイルの特定位置から読み込むためには、従来の行番号などでは不適切です。なぜなら100万行目から読み込む必要があると分かっても、ファイルの先頭から改行コードをカウントする必要があり、アクセスしたい行番号が大きければ大きいほど時間がかかってしまいます。

このようにして、 InputOffset() で取得したバイト位置をもとに、巨大なファイルに対してランダムアクセスのようなことができます。

まとめ

新しく追加された InputOffset() について調べました。リリースノートの内容だけ見ると不正な入力値に対するトレース用途に用いるのかなと思っていましたが、どちらかといえばもう少しテクニカルな用途での利用を想定していそうです。