📝

Goでcsvを構造体として扱う方法(csvutil)

2023/02/09に公開

はじめに

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 を生成して構造体に入れてあげます。
自力で[][]stringstringに変換して(文字列をつなげただけ)[]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