フューチャー技術ブログ

Go1.17のencoding/csv

はじめに

Go1.17連載の2つ目です。

TIG DXユニット真野です。前回Go1.16連載の時も2つ目でGo 1.16のgo installについてについて触れました。今回は encoding/csv のマイナーチェンジ(APIが1個だけ追加した)という小ネタです。

Minor changes to the library in encoding/csv

Go 1.17 Release Notesでencoding/xml, encoding/binaryとともに、encoding/csvも微修正がありました。

The new Reader.FieldPos method returns the line and column corresponding to the start of a given field in the record most recently returned by Read.
csv.ReaderにFieldPostメソッドが追加され、最後に読んだ列を返す

これについて紹介します。選んだ理由は個人的にCSVとかJSONとかApache Parquetといったデータレイアウトが好きだからです。

Issueはencoding/csv: add the ability to get the line number of a record #44221です。2021.2.21に起票され同年5.21にクローズされているので3ヶ月くらいの期間での出来事でしょうか。

どういう関数か

追加されたのはcsv.Readerの以下の関数です。

func (r *Reader) FieldPos(field int) (line, column int)

csv.ReaderはRead関数でCSVデータを1レコードずつ処理しますが(1行と呼ばない理由ですがCSVは項目中の改行が許容されているからです)、現在処理しているファイルの行や列を取得します。行と列は1始まり。列はルーンではなくバイト単位でカウントされるようです。

早速使っていきましょう。

FieldPosをつかったサンプル
package main

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

func main() {
f, err := os.Open(os.Args[1])
if err != nil {
log.Fatal(err)
}
r := csv.NewReader(f)

for {
record, err := r.Read()
if err == io.EOF {
break
}
if err != nil {
log.Fatal(err)
}

for i, item := range record {
lineNo, column := r.FieldPos(i)
fmt.Printf("lineNo:%d column:%d pos:%d record:%s\n", lineNo, i, column, item)
}
}
}

FilePosを利用したコードでした。対象データとしてはGoDocに書いてあったCSVを利用します。

name.csv
first_name,last_name,username
"Rob","Pike",rob
Ken,Thompson,ken
"Robert","Griesemer","gri"

これを実行すると次の実行結果になりました。

> go run main.go name.csv
lineNo:1 column:0 pos:1 record:first_name
lineNo:1 column:1 pos:12 record:last_name
lineNo:1 column:2 pos:22 record:username
lineNo:2 column:0 pos:1 record:Rob
lineNo:2 column:1 pos:7 record:Pike
lineNo:2 column:2 pos:14 record:rob
lineNo:3 column:0 pos:1 record:Ken
lineNo:3 column:1 pos:5 record:Thompson
lineNo:3 column:2 pos:14 record:ken
lineNo:4 column:0 pos:1 record:Robert
lineNo:4 column:1 pos:10 record:Griesemer
lineNo:4 column:2 pos:22 record:gri

lineNoが対象ファイルの行番号です(1行目から4行目まで出ていますね)。次のcoulmnはカラムインデックス、posがCSV各カラムの出現位置です([1 12 22]だと1文字目、12文字目、22文字目からそのカラムが始まっているよという意味です)。シンプルですね。言ってしまえばそれだけです。

なぜ追加されたか

FieldPosというAPI経由しなくても少なくても、ループ変数を用いればファイル番号は取得できるような気がします。Goの思想的に反して余計なAPIを追加したのか?と思われる人も多いのではないでしょうか?

理由としてはおそらく、先程触れたとおりCSVファイルの項目中の改行が許容される(実質標準なRFC4180でも規定)のが理由の1つになると思います。

例えば、以下のようなCSVも許容されます。

lf.csv
"aaa","b

bb","ccc"
zzz,yyy,xxx

上記のlf.csvは4行ですが、CSVとしては2レコードです。項目中の改行もダブルクォートで囲めば許容されるということです。lf.csvのようなケースにおいては空行すら許容されます。

この仕様を理解すると行番号≠CSVレコード番号が分かると思います。どうようにカラム位置も項目中の改行を考慮すると、APIとして利用した方が使い側としては助かるというのがイメージが湧くでしょう。

このlf.csvを先程のサンプルコードで動かすと以下の結果です。

go run main.go lf.csv
> go run main.go lf.csv
lineNo:1 column:0 pos:1 record:aaa
lineNo:1 column:1 pos:7 record:b

bb
lineNo:3 column:2 pos:5 record:ccc
lineNo:4 column:0 pos:1 record:zzz
lineNo:4 column:1 pos:5 record:yyy
lineNo:4 column:2 pos:9 record:xxx

結果を見ると、CSV1レコード目のcccのカラムの行番号が3であるのがわかります。少し面白いですね。そしてFilePosの面白いところは、FieldPosの引数にカラムのインデックスを指定できるため、同一レコードの処理中に置いてlineNoが変わることが、すなわち項目中の改行が含まれている判明するという点です。

一見、ちょっと面倒そうな関数ですが中々考えられていますね。

今回は項目中の改行を例に上げましたが、他にも何かしらCSVの処理中に想定されていないデータが含まれていた場合(例えば予期せぬ区切り文字の混入やダブルクォートのエスケープがされていないなど)に、FieldPosによってトレースがしやすくなるのが大きいです(Issueのニュアンスだとこっちの方が強いかもです)。CSVのパースをしくじって数値項目なのに隣の文字列項目が設定されてエラーにった経験はみなさん何度も何度も経験してきたと思います。それが数百万、数千行のデータともなればトレースが大変。こういったデバック用のAPIが増えるのは本当に好ましいです。

encoding/csvパッケージを生で使う人は直接恩恵を受けられますし、これらのラッパーライブラリも性能改善などでメリットがある良い改修だと思います。

まとめ

  • Go1.17ではencoding/CSVのReaderにFieldPosというCSV処理中の行番号やカラム開始位置を返す関数が追加された
  • 項目中の改行の考慮であったり、違反データのトレーサビリティ(デバッグ)としても有効なAPIで、従来の回避手法に比べて性能が良いとのことです