Go の encoding/csv を読んだ話


ここ 1 ヶ月くらい空き時間で Go をやっている。

有名なチュートリアル A Tour of Go を二周したので、次の課題として言語標準のパッケージがどのように書かれているのかを調べることにした。理由としては、 Go は Go で書かれており標準パッケージのコードを読むとより Go らしい書き方を身につけられそうと考えたため。

その題材として CSV 形式の入出力を処理する encoding/csv のドキュメントとソースコードを読んだので、この記事にまとめておく。これを選んだのは CSV が自分が経理の仕事で慣れ親しんだフォーマットだったからで、それ以上の理由はない。

ちなみに twitter で質問したら io と error をお薦めされた。この 2 つは回答者 tenntenn さんがお薦めされている理由に加えて頻出でもあるので、コードリーディングの題材としてよさそう。実際に encoding/csv の処理をデバッガ等で追っていくと最終的に io や error も現れてくる。

コードリーディングの前に

Go のコードを読む際にはこの動画が参考になった。

Go はコメントが充実していて読みやすい。ソースコードはこちら。

https://cs.opensource.google/go/go

CSV について

コードを読む前に CSV について復習していく。

一般的には Comma-Separated Values で略して CSV と呼ばれる。仕様は RFC 4180 で定められているが、分類は Informational となっており、つまりは国際的に標準化されたものではなく単なる参考と言える。

CSV はデータを受け渡すフォーマットとして幅広く利用されており、例えば Excel や Google Sheets などの表計算ソフトウェアで CSV を取り込んだり出力したりできる。私は長年に亘り経理の仕事をしていたのだが、何かと CSV にはお世話になった。

例えば…

  • 銀行に総合振込(振込先と金額を記載したファイルを送り、一括で振込する処理)を指示するために銀行の指定する形式の CSV を専用の Web サイトからアップロードする
  • 財務会計ソフトに取り込む仕訳データを CSV で作る
  • ERP などの業務システムから CSV 形式でデータを取得し、それを加工して資料を作る

RFC 4180 の概要

CSV の仕様はマネックス社の技術ブログで取り上げられているので、詳しく気になる方はそちらもご覧いただくのがよさそう。

以下に特徴的な CSV の仕様についてまとめる。

レコード

CSV では、レコードは改行 CRLF によって区切られている。

aaa, bbb, ccc CRLF
aaa, bbb, ccc CRLF

このとき、最後の行には改行はなくても構わない。

ヘッダー

最初のレコードをヘッダーとすることができる。ヘッダーレコードは2 つ目以降のレコードと同数のフィールドを持ち、それぞれのフィールドに対応する名前を持つ。

field_a,field_b,field_c CRLF
aaa,bbb,ccc CRLF
aaa,bbb,ccc CRLF

このとき、 MIME type でヘッダーの有無を指定すべきである。

区切り文字

CSV という名前の通り、レコードとヘッダーはコンマ , で区切られる。

ファイル全体に渡り、全てのレコードおよびヘッダーは同じ数のフィールドを持つべきである。スペースは無視されず、フィールドの一部として扱われる。

各レコードの最後のフィールドにはコンマ ,つけてはいけない

つまり、下記は RFC 通りだが

aaa,bbb,ccc

以下の書き方は RFC に反している。

aaa,bbb,ccc,

二重引用符

各フィールドは二重引用符 " で囲んでも良いし、囲まなくても良い。

"aaa","bbb","ccc" CRLF
xxx,yyy,zzz

ただし、フィールド内に改行 CRLF、二重引用符 "、コンマ , を持つ場合は、フィールドを二重引用符 " で囲むべきである。

"aaa","b CRLF
bb","ccc" CRLF
xxx,yyy,zzz

上記の CSV は見た目は 3 行だが、1 行目と 2 行目がひとつのレコードであり、レコード数は 2 となる。

もし " でフィールドを囲む場合、その内側では " を使うべきではない。もし使う場合は、下記のように別の " でエスケープする。

"aaa","b""bb","ccc"

まとめると、 CSV ファイルで使われる二重引用符 " には以下 3 つの役割がある。

  • フィールドの最初と最後につけることでフィールドを囲む
  • フィールドの内容を表す(このとき、エスケープしなければならない
  • フィールドの内容を表す二重引用符をエスケープする

Go の encoding/csv は利用者にこの RFC 4180 に準拠した入力を与えるよう期待している。ただし、 Reader や Writer の構造体に用意されているフィールドを明示的に書き換えることで RFC 4180 に反するような入出力が可能となる。

まず簡単に使ってみる

ドキュメントやソースコードを読む前にとりあえず encoding/csv を使った簡単なプログラムを書いてみることにした。

このような CSV に test.csv という名前をつけて読んでみる。

従業員コード,名前,メールアドレス
1,ヤマダ,yamada@example.com
2,サトウ,sato@example.com
3,スズキ,suzuki@example.com
4,タナカ,tanaka@example.com
5,ハシモト,hashimoto@example.com

このファイルをゼロから自分で解析する場合は先に挙げた仕様を満たすような実装を自分で書く必要があるが、 encoding/csv を使うと NewReader を呼んで返ってくる構造体 Reader を使うだけでよい。

NewReader が返す構造体を使って CSV を読むにはファイル全体を読む ReadAll() と 1 レコードずつ読む Read() の 2 種類の方法があるが、以下では Read() を使った。

package main

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

func main() {
  // CSV ファイルを開く
	file, err := os.Open("test.csv")
	if err != nil {
    panic(err)
	}
	defer file.Close()

  // CSV ファイルを読む
	csvReader := csv.NewReader(file)
	for {
		line, err := csvReader.Read()
		if err == io.EOF {
			break
		}
		if err != nil {
			panic(err)
		}
		fmt.Println(line)
	}
}

実行すると次のような出力を得る。

[従業員コード 名前 メールアドレス]
[1 ヤマダ yamada@example.com]
[2 サトウ sato@example.com]
[3 スズキ suzuki@example.com]
[4 タナカ tanaka@example.com]
[5 ハシモト hashimoto@example.com]

この各行は Read() が返すスライス []string である。 Read() ではなく ReadAll() を使うとファイル全体を二次元のスライス [][]string で返す。

次に、より詳細な挙動を把握するためドキュメントを読んでみる。

ドキュメントとソースコードを読む

encoding/csv には reader.go と writer.go の 2 つのファイルがある。それぞれ CSV を読み書きするための構造体や関数が書かれている。

reader.go

type Reader

src

ReaderNewReader() 関数が返す構造体で、これを通じて RFC 4180 に準拠した入力を読み取ることができる。また、この構造体に定義されているフィールドを書き換えることで Reader の挙動をカスタマイズできる。

// ソースコードには大量のコメントがついているが省略した
// 大文字で始まるフィールドの値を書き換えることで、 RFC 4180 に反する入力を読み取ることができる
type Reader struct {
	Comma rune
	Comment rune
	FieldsPerRecord int
	LazyQuotes bool
	TrimLeadingSpace bool
	ReuseRecord bool
	TrailingComma bool
	r *bufio.Reader
	numLine int
	offset int64
	rawBuffer []byte
	recordBuffer []byte
	fieldIndexes []int
	fieldPositions []position
	lastRecord []string
}

特徴的なフィールドは Comment で、 CSV 中にコメントを残しそのレコードを読み取り時に無視することができる。ちなみに RFC 4180 にはコメントについて特に記述はない。

例えば先述の CSV で、以下のように # をつけたレコードをコメントアウトしたい(つまり、そのレコードを無視したい)とする。

従業員コード,名前,メールアドレス
1,ヤマダ,yamada@example.com
2,サトウ,sato@example.com
3,スズキ,suzuki@example.com
4,タナカ,tanaka@example.com
#メールアドレス変更予定につき一時的にコメントアウト 5,ハシモト,hashimoto@example.com

このとき、先程のプログラムで Comment フィールドにコメントを表す記号 # を入れておくとそのレコードを無視できる。

csvReader := csv.NewReader(file)
csvReader.Comment = '#'

出力は以下のようになる。 # でコメントアウトしたレコードが除かれている。

[従業員コード 名前 メールアドレス]
[1 ヤマダ yamada@example.com]
[2 サトウ sato@example.com]
[3 スズキ suzuki@example.com]
[4 タナカ tanaka@example.com]

なお、コメントアウトできるのはレコードの先頭に記号をつけたときのみである。

より詳しくはドキュメントの Example (Options) を参照。

https://pkg.go.dev/encoding/csv#example-Reader-Options

他にも様々なフィールドがある。よく使いそうなのだと…

  • Comma rune: 区切り文字を設定する(つまりタブなど他の文字も区切りにできる)
  • FieldsPerRecord int: 各レコードに期待されるフィールド数を設定する(負の数字を入れるとチェックしないようにできる)
  • TrimLeadingSpace bool: 先頭の white space を無視する(区切り文字がスペースでも無視する)

FieldsPerRecord に負の数字を入れると各行のフィールド数チェックをしないためレコードごとにフィールド数が異なる(つまり RFC 4180 に準拠しない) CSV を読み書きできるようになる。一見これは何のためにあるんだ?と思うかもしれないが、世の中には RFC 4180 に準拠しない CSV が平然と使われているので便利と言えるかもしれない。

例えば「全銀フォーマット CSV」と呼ばれる銀行の総合振込に用いられるデータ形式では、レコードの種類を各レコード最初のフィールドで指定し、その種類ごとにフィールド数が異なるという仕様になっている。

参考: 楽天銀行 - 依頼ファイル仕様(全銀フォーマット CSV 形式)

NewReader()

src

io.Reader を満たす引数(ファイルや標準入力)を受け取り、上述の構造体 Reader を作ってそのポインタを返すだけの関数。

func NewReader(r io.Reader) *Reader {
	return &Reader{
		Comma: ',',
		r:     bufio.NewReader(r),
	}
}

区切り文字がデフォルトで , にされていることが分かる。

bufio.NewReader() は io.Reader 用のバッファをデフォルトのサイズ 4096 byte で作って返す関数。

(*Reader).FieldPos()

src

CSV 処理中のレコードとカラムのインデックスを返す関数。カラムのインデックスはバイト単位で数える。あまり使う場面は多くないが、ループで Read を呼び出す中で CSV の解析エラーをデバッグするのに便利そう。

func (r *Reader) FieldPos(field int) (line, column int) {
	if field < 0 || field >= len(r.fieldPositions) {
		panic("out of range index passed to FieldPos")
	}
	p := &r.fieldPositions[field]
	return p.line, p.col
}

これは Go 1.17 で追加された新しい関数で、その背景や使い方については以下の記事が詳しい。

次に示す Read() 関数が最後に返した string スライスのうち、引数で指定したバイト単位のインデックスを持つフィールドの先頭に対応する行と列の番号を返す。

(*Reader).Read()

src

構造体 Reader をポインタレシーバで受け取り csv を一行ずつ読んで結果を string のスライスで返す関数。

func (r *Reader) Read() (record []string, err error) {
	if r.ReuseRecord {
		record, err = r.readRecord(r.lastRecord)
		r.lastRecord = record
	} else {
		record, err = r.readRecord(nil)
	}
	return record, err
}

コメントによると、この関数は常に non-nil のレコード(string のスライス)もしくは non-nil エラーのいずれか片方を返すが、もしフィールドの数が異なるレコードが存在する(RFC 4180 に反する)場合は読み取ったレコードと共に ErrFieldCount というエラーを返す。このとき、 Reader 構造体の FieldsPerRecord に負の数字(例えば -1)を設定しておくとこのエラーチェックを迂回できる。

また、最後の行にたどり着くとレコードとしては nil を返し err として io.EOF を返すので、これをチェックしておけばファイルの最後まで読んだことを検知できる。

これら Reader 構造体の各フィールドの設定に応じた実際の読み取り処理は readRecord という別の関数(とても長い)で行われている。

readRecord

src

readRecord は 160 行以上ある巨大な関数で、実際に csv を読み取る実装がほぼ全て含まれている。小文字で始まっているので、この関数をパッケージ外から呼ぶことはできない。つまり、パッケージ内部でのみ使われている。全部読んでいくのはしんどいが、コメントを追っていけば処理の流れはわかりやすい。

L293-296 区切り文字とコメントに使う文字は異なる文字でなければならない。

L297-315: readLine で bufio の ReadSlice を呼び、区切り文字までのバイト列を読み込む。このとき lengNLnextRune を使って行頭にコメントがあるレコードや空のレコードを読み飛ばしている。

L316-433: 読み込んだレコードに対して、 Reader 構造体のフィールドに応じた処理をしていく。このときレコードの中身は Reader 構造体の recordBuffer フィールドにまとめて []byte で書き込まれ、各フィールド終了位置のインデックスは fieldIndexes[]int で、どのバイトまで読んだかは fieldPositions に行と列をそれぞれ int で持つ type position のスライスとして書き込まれる。

L434-447: recordBuffer への読み込みが一通り終わったら、それを fieldIndexes に記録したインデックス毎に区切って []string に変換する。

L448-460: 最後に Reader 構造体の FieldsPerRecord が正の数であるときに各レコードのフィールド数が同じかどうかをチェックする。デフォルト値は最初に読み取ったレコードのフィールド数となる。読み取り前に負の値を設定しておけばこのフィールド数チェックを迂回できる。

この関数で L325 以降のような長い for 文を書くときにラベルをつけて break や continue できるのを知った。

parseField:
  for {
    // 数十行のクソ長い処理
      break parseField
    // 数十行のクソ長い処理
      continue parseField
  }

ループの行数が多くなると breakcontinue が指す先が分かりにくくなるので、これは便利そう。

writer.go

reader.go に比べると writer.go は行数も半分以下で読みやすい。

type Writer

src

Reader と同様に Comma フィールドで区切り文字を書き換えることができる。UseCRLF が true の場合、Writer は各出力行の最後に \n の代わりに \rn を出力する。

type Writer struct {
	Comma   rune // Field delimiter (set to ',' by NewWriter)
	UseCRLF bool // True to use \r\n as the line terminator
	w       *bufio.Writer
}

注意点として、 Writer を使って CSV に書き込んだ後に Flush() 関数を呼ばなければならない。

詳しくは example を参照。

NewWriter()

src

io.Writer インターフェイスを満たす引数を受け取って Writer 構造体を返す関数。この引数が Write する先になるので、 os.Stdout を指定すれば標準出力できる。

// NewWriter returns a new Writer that writes to w.
func NewWriter(w io.Writer) *Writer {
	return &Writer{
		Comma: ',',
		w:     bufio.NewWriter(w),
	}
}

(*Writer).Write()

src

CSV のレコード 1 行(つまり各フィールドのスライス)を []string で受け取り、フィールドの数だけループして Writer 構造体が示すバッファに書き込む関数。

次の順序で処理する。

  • L60-67: 引用符で囲む必要があるかどうかをチェックする
    • 必要なければそのまま書いて次のフィールドへ continue
  • L69-71: 引用符が必要な場合はまず最初の " を書く
  • L72-111: フィールドの長さが 0 でなければ特殊文字(改行 \r \n と引用符 ")を数える
    • 特殊文字の中身と Writer 構造体の UseCRLF フィールドの設定に応じて特殊文字を書き込む
      • 例えば UseCRLF が true なら、 \n に対して \r\n を書く
  • L113-117: UseCRLF が true なら各レコードの末尾に \r\n を、 false なら \n を書く

実際に Write を使って CSV に書き込むコードは以下のようになる。

package main

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

func main() {
  records := [][]string{
    {"first_name", "last_name", "username"},
    {"Rob", "Pike", "rob"},
    {"Ken", "Thompson", "ken"},
    {"Robert", "Griesemer", "gri"},
  }

  file, err := os.Create("users.csv") // CSV のファイル名
  if err != nil {
  	log.Fatal(err)
  }

  csvWriter := csv.NewWriter(file)

  for _, record := range records {
    // ここで Writer 構造体のバッファにデータを書き込む
    if err := csvWriter.Write(record); err != nil {
      log.Fatalln("error writing record to csv:", err)
    }
  }

  // バッファのデータを io.Writer (ここでは CSV ファイル)に書き込む関数
  csvWriter.Flush()

  if err := csvWriter.Error(); err != nil {
    log.Fatal(err)
  }
}

注意点として、(デバッガで動かすとすぐに分かるが)Write は内部的には bufio のバッファに入力をコピーしているだけなので、これを呼んだ後に後述の Flush を呼ばないと何も書き込まれない。

bufio のコードを読む限りではバッファに空きがないときは Flush するようになっているようで、よくできた仕組みだと思った。

(*Writer).Flush

src

encoding/csv の Write 関数は bufio のバッファに書き込む。これを io.Writer に渡すにはこの Flush 関数を呼ばなければならない。

// Flush writes any buffered data to the underlying io.Writer.
// To check if an error occurred during the Flush, call Error.
func (w *Writer) Flush() {
	w.w.Flush()
}

内部ではこのように bufio の Flush を呼んでいるだけ。 bufio の方ではバッファと io.Writer を持つ構造体があって、それを使って io.Writer に書き込んでいる。

繰り返しになるが Write を呼んだ後に Flush を呼ばないと書き込まれないので注意する。

fieldNeedsQuote

src

CSV のレコード中のフィールドを 1 つ受け取り、それが引用符で囲まれなければならないかどうかを bool で返す関数。Write 関数から呼び出される。小文字で始まっていることから分かるように、パッケージ内部でのみ使う。

フィールドが以下の 5 通りのいずれかを満たすとき、この関数は true を返す。

  • 引用符 " を含む
  • 改行 \r もしくは \nを含む
  • コンマ , 、または Writer 構造体の Comma フィールドで設定された区切り文字を含む
  • \.
  • スペースで始まる

なお、空文字列に対しては false を返す。つまり、空文字列は引用符で囲まなくてもよい(Go 1.4 以降)。これは Excel や Google Sheets が CSV を作る際の挙動と合わせるため。

感想

読んでみて非常に面白かったので感想を残しておく。

Go に限らない一般的なプログラミング手法について、および Go の読み書きについて大変参考になった。

Go に限らない一般的なプログラミング手法

まず有用かつ単純なコメントが多いため、初めて読む人間にとっても関数や構造体の振る舞いが分かりやすい。

最近リーダブルコードを読んだのだが、 encoding/csv のコメントを読んでいて「これリーダブルコードじゃん」と思った。むしろリーダブルコードがプログラマーの経験知をまとめた著作なので、順序としては逆かもしれない。皆さんもぜひ読んでみてください。

また、関数の分割方法についても参考になった。encoding/csv ではパッケージの利用者が呼び出す関数はせいぜい数行で、それらの具体的な処理の大部分は内部でしか使わない関数に切り分けられている。例えば RFC に沿って CSV を読む処理の実装はほぼ readLine という巨大な関数に書かれている。

Go では名前が大文字で始まる関数はパッケージ外部から呼び出すことができ、小文字から始まる関数は外部から呼び出せない。この仕様は Go 独特だが、他の言語にも違う記法で同等のことを実現する仕組みはあると思う。利用者が詳しく知る必要がない内部実装を readRecord など内部でのみ使う関数にまとめておいて、大文字で始まるつまり外部から使える関数ではそれを呼び出す状況や引数をコントロールするというのは、プログラムを読み書きしやすくする上で非常に合理的だと思った。

例えば Read 関数では Reader 構造体の ReuseRecord フィールドの値によって readRecord を呼び出す引数を変えている(src)。

これもリーダブルコードの「コードの再構成」で読んだ話に近い。プロとして長年プログラミングをやっている人にとっては当然のことかもしれないが、こうすると読みやすくなるな〜と実感できて良かった。

Go の書き方

Go の特徴?として、エラー処理が挙げられる。

Go では(例えば Java や JavaScript 等で可能なように)例外を投げて try-catch で捕捉するようなエラー処理をせず、原則的には関数から複数の値(処理結果と error)を返すように実装し、呼び出し側で以下のように if 文で分岐することでエラー処理をすることが多い。

line, err := csvReader.Read()
if err != nil {
  // Read() は正常に処理を行ったとき err が nil になる
  // この条件分岐では err が nil ではない、つまりエラーがあるときの処理を書く
}

これはコードが冗長になる一方で、メリットもある。Go の Frequently Asked Questions (FAQ) によれば

  • 例外を制御機構に結びつけるとコードが複雑になってしまうので、それを防ぐ
  • エラー処理すべきところを例外で処理させないよう促す

という意図があるようだ。

We believe that coupling exceptions to a control structure, as in the try-catch-finally idiom, results in convoluted code. It also tends to encourage programmers to label too many ordinary errors, such as failing to open a file, as exceptional.

他にも開発者がエラー処理について書いた記事として以下が挙げられる。

https://go.dev/blog/error-handling-and-go

In Go, error handling is important. The language’s design and conventions encourage you to explicitly check for errors where they occur (as distinct from the convention in other languages of throwing exceptions and sometimes catching them).

過去には様々な議論があると思うので、Go の特徴的な仕様に触れる度に深掘りしていきたい。

とにかく、 Go では処理結果と error を返して呼び出し側で責任持ってその処理をする、プロセス自体は止めない、止める場合は panic を使うがあくまで例外的な状況、という方針が推奨されている。

次の課題

全銀の総合振込フォーマットを検証するパッケージとそれを使ったコマンドラインツールを作る。

自分もプログラミングの傍ら?経理をやっていて、普段は過去のデータをコピペして作っているのだが、より頭を使わずに処理したいため。