Goでcsvを構造体として扱う方法(csvutil)
はじめに
Go では encoding/csv という標準ライブラリを使って csv を扱うことができます。しかし[][]string
として読み込まるので使いにくいと思っていました。
そこで、csv データを構造体として扱えるcsvutilを紹介します。
csvutil を試す
では csvutil を使って色々試していきます。
csv データ([]byte 型)を構造体に落とし込む
package main
import (
"fmt"
"time"
"github.com/jszwec/csvutil"
)
type User struct {
Name string `csv:"name"`
Age int `csv:"age,omitempty"`
CreatedAt time.Time
}
func main() {
var csvInput = []byte(`
name,age,CreatedAt
jacek,26,2012-04-01T15:00:00Z
john,,0001-01-01T00:00:00Z`,
)
var users []User
if err := csvutil.Unmarshal(csvInput, &users); err != nil {
fmt.Println("error:", err)
}
for _, u := range users {
fmt.Printf("%+v\n", u)
}
}
出力
{Name:jacek Age:26 CreatedAt:2012-04-01 15:00:00 +0000 UTC}
{Name:john Age:0 CreatedAt:0001-01-01 00:00:00 +0000 UTC}
まず構造体にcsv:"name"
のようにカラム名を記述します。
name
カラムのデータがUser.Name
に格納されます。
CreatedAt
は構造体のキー名と csv のヘッダーが一致しているのでタグは不要です。
かなり使いやすいですね。
type User struct {
Name string `csv:"name"`
Age int `csv:"age,omitempty"`
CreatedAt time.Time
}
csv ファイルを構造体に落とし込む
今度は csv ファイル(test.csv)を構造体に落とし込んでみます。
name,age,CreatedAt
jacek,26,2012-04-01T15:00:00Z
john,,0001-01-01T00:00:00Z
package main
import (
"bytes"
"encoding/csv"
"fmt"
"log"
"os"
"time"
"github.com/jszwec/csvutil"
)
type User struct {
Name string `csv:"name"`
Age int `csv:"age,omitempty"`
CreatedAt time.Time
}
func main() {
file, err := os.Open("test.csv")
if err != nil {
log.Fatal(err)
}
defer file.Close()
r := csv.NewReader(file)
rows, err := r.ReadAll()
if err != nil {
log.Fatal(err)
}
b := &bytes.Buffer{}
err = csv.NewWriter(b).WriteAll(rows)
if err != nil {
log.Fatal(err)
}
var users []User
if err := csvutil.Unmarshal(b.Bytes(), &users); err != nil {
log.Fatal(err)
}
for _, u := range users {
fmt.Printf("%+v\n", u)
}
}
出力
{Name:jacek Age:26 CreatedAt:2012-04-01 15:00:00 +0000 UTC}
{Name:john Age:0 CreatedAt:0001-01-01 00:00:00 +0000 UTC}
csv.NewWriter()
によって[]byte を生成して構造体に入れてあげます。
自力で[][]string
をstring
に変換して(文字列をつなげただけ)[]byte
に変換したら動作しませんでした。
csv データ([]byte 型以外)を構造体に落とし込む
例えば google spread sheet API ではシートの情報が[][]interface{}
として取得できます。
csvutil は[]byte
を受け付けるので頑張って変換する必要があります。
おそらく[][]interface{}
(どんな型でもいい) -> [][]string
-> []byte
の順に変換するしかないようで、[][]stirng
に変換する理由はゼロ値が空文字なのでデータを正しく表現できるからです。
package main
import (
"bytes"
"encoding/csv"
"fmt"
"time"
"github.com/jszwec/csvutil"
)
type User struct {
Name string `csv:"name"`
Age int `csv:"age,omitempty"`
CreatedAt time.Time
}
func main() {
csvInput := [][]interface{}{
[]interface{}{"name", "age", "CreatedAt"},
[]interface{}{"jacek", 26, "2012-04-01T15:00:00Z"},
[]interface{}{"john", "", "0001-01-01T00:00:00Z"},
}
// 一旦[][]string型に変える。
rows := make([][]string, len(csvInput))
// すべてのスライスをヘッダーと同じ長さで揃えてエラーにさせない。 ## ハマりどころで解説
headerLength := len(csvInput[0])
for i := range csvInput {
row := make([]string, headerLength)
for j := range csvInput[i] {
row[j] = fmt.Sprintf("%v", csvInput[i][j])
}
rows[i] = row
}
// string型から"encoding/csv"を使って[]byteを生成
b := &bytes.Buffer{}
err := csv.NewWriter(b).WriteAll(rows)
if err != nil {
return
}
var users []User
if err := csvutil.Unmarshal(b.Bytes(), &users); err != nil {
fmt.Println("error:", err)
}
for _, u := range users {
fmt.Printf("%+v\n", u)
}
}
ハマりどころ
csv データを二次元スライスで持つ場合、それぞれのスライスが同じ長さでないとエラーになります。
具体的には下記のようなエラーです。
2023/02/08 00:19:51 record on line 3: wrong number of fields
どうやらissueを見るに csvutil の仕様で各行に同じ数のフィールドを持つようにする必要があります。
なので読み込み時はすべてのスライスをヘッダーと同じ長さに設定するようにコードを見直しましょう。
スライス宣言時に長さに気をつければいいと思います。
余談
余談になりますが、紹介したエラーは encoding/csv によるものになります。
ccsvutil のソースコードを読んでみると encoding/csv が import されてます。
package csvutil
import (
"bytes"
"encoding/csv"
"io"
"reflect"
)
...
func Unmarshal(data []byte, v interface{}) error {
val := reflect.ValueOf(v)
if val.Kind() != reflect.Ptr || val.IsNil() {
return &InvalidUnmarshalError{Type: reflect.TypeOf(v)}
}
switch val.Type().Elem().Kind() {
case reflect.Slice, reflect.Array:
default:
return &InvalidUnmarshalError{Type: val.Type()}
}
typ := val.Type().Elem()
if walkType(typ.Elem()).Kind() != reflect.Struct {
return &InvalidUnmarshalError{Type: val.Type()}
}
dec, err := NewDecoder(newCSVReader(bytes.NewReader(data)))
if err == io.EOF {
return nil
} else if err != nil {
return err
}
// for the array just call decodeArray directly; for slice values call the
// optimized code for better performance.
if typ.Kind() == reflect.Array {
return dec.decodeArray(val.Elem())
}
c := countRecords(data)
slice := reflect.MakeSlice(typ, c, c)
var i int
for ; ; i++ {
// just in case countRecords counts it wrong.
if i >= c && i >= slice.Len() {
slice = reflect.Append(slice, reflect.New(typ.Elem()).Elem())
}
if err := dec.Decode(slice.Index(i).Addr().Interface()); err == io.EOF {
break
} else if err != nil {
return err
}
}
val.Elem().Set(slice.Slice3(0, i, i))
return nil
}
newCSVReader
の中でencoding/csv
の NewReader を使っているのがわかります。
// +build !go1.9
package csvutil
import (
"encoding/csv"
"io"
)
func newCSVReader(r io.Reader) *csv.Reader {
return csv.NewReader(r)
}
さいごに
csvutil を使った楽に csv を扱う方法でした。
役に立ったと思ったらいいねお願いします。
Discussion