Goで構造体のタグを活用してExcelのデータを読み込む

Goで構造体のタグを活用してExcelのデータを読み込む

こんにちは。エンジニアの牧田です。
以前実装したアプリケーションの機能として、Excelのデータを読み込む機能があったのですが、その時に行った手法を紹介したいと思います。(実装はGo言語(v1.18)です)

今回用いているサンプルコードは↓にあります。
https://github.com/bbj14/go-tag-example/tree/v1

背景

例として以下のようなExcelの情報を読み込むことを考えます。

この各人の情報を読み取るためにPersonという構造体を作ります。

type Person struct {
    Name      string
    Age       int64
    Weight    float64
    BirthDate time.Time
}

Excelの読み込みは、excelizeというライブラリを使って行うことにします。
excelizeではGetRowsというメソッドが用意されており、これを使うことによってシートのセル全体を[][]stringに変換することができます。

  f, err := excelize.OpenFile("./sample.xlsx")
  rows, err := f.GetRows("Sheet1")

あとは、rowsから各セルの値を取得すればいいわけですが、Excelの列名(A,B,C…)から値を取得するために、getColという関数を作りました。(excelize.ColumnNameToNumberは、列名を配列のインデックスに変換する関数です。)

// 行から特定の列の値を取得
func getCol(row []string, col string) (string, error) {
    c, err := excelize.ColumnNameToNumber(col)
    if err != nil {
        return "", err
    }
    // 行の最後の空欄はGetRowsで読み込まれないため、そこを参照している場合は空文字を返す
    if len(row) < c {
        return "", nil
    }
    return row[c-1], nil
}

これを使うと、各行(ラベルを除く2行目以降)に対して構造体に値をセットするコードは、以下のようになると思います。(エラー処理は省略)

  for _, row := range rows[1:] {
    name, err := getCol(row, "A")
    age, err := getCol(row, "B")
    weight, err := getCol(row, "C")
    birthdate, err := getCol(row, "D")

    p := Person{}

    p.Name = name
    p.Age, err = strconv.ParseInt(age, 10, 64)
    p.Weight, err = strconv.ParseFloat(weight, 64)
    p.Birthdate, err = dateparse.ParseAny(birthdate)

    // (pを使った処理を書く)
  }

しかし、この実装には以下のような問題があります。

  • Excelの列が増えていくに連れて、コードがどんどん長くなってしまうので、バグが生まれやすくなってしまう
  • 構造体のフィールドの型を変えたくなった場合、値を読み込む方の実装もそれに合わせて修正しなければならない

そこで、構造体のタグを使うと、この処理をより簡潔に書くことができます。

構造体のタグについて

構造体のフィールドにメタ情報を与えるために、タグを使うことができます。
よく目にするタグとしては、以下のようなjsonタグがあると思います。

type Person struct {
  Name      string    `json:"name"`
  Age       int64     `json:"age"`
  Weight    float64   `json:"weight"`
  Birthdate time.Time `json:"birth_date"`
}

jsonタグは、例えばjson.Marshalで構造体をJSONに変換するときにJSONのキー名を指定する、というように使われます。今回は、Excelの列名を表すcolumnタグを作成します。

type Person struct {
    Name      string    `column:"A"`
    Age       int64     `column:"B"`
    Weight    float64   `column:"C"`
    BirthDate time.Time `column:"D"`
}

実装

付与したタグの情報はreflectパッケージを使って取得することができます。
各行の値が入っている[]stringを構造体の各フィールドに割り当てる関数は以下のようになります。

// 構造体のタグを元にrowの値を割り当てる
func unmarshalRows(row []string, v any) error {
    var f func(reflect.Value) error
    f = func(v reflect.Value) error {
        for i := 0; i < v.NumField(); i++ {
            structField := v.Type().Field(i)
            field := v.Field(i)

            // 構造体が埋め込まれていた場合、再帰的に探索
            if structField.Anonymous {
                f(field)
                continue
            }

            // columnタグの値を取得
            col := structField.Tag.Get("column")
            if col == "" {
                continue
            }

            str, err := getCol(row, col)
            if err != nil {
                return err
            }

            var val any

            // stringをフィールドの型に合うように変換
            switch field.Interface().(type) {
            case string:
                val = str
            case int64:
                val, err = strconv.ParseInt(str, 10, 64)
            case float64:
                val, err = strconv.ParseFloat(str, 64)
            case time.Time:
                val, err = dateparse.ParseAny(str)
            default:
                return fmt.Errorf("unexpected type: %s", field.Type())
            }
            if err != nil {
                log.Println(err)
                continue
            }

            // 構造体のフィールドに値をセット
            field.Set(reflect.ValueOf(val))
        }

        return nil
    }

    if err := f(reflect.ValueOf(v).Elem()); err != nil {
        return err
    }

    return nil
}

reflectパッケージを使うことによって、構造体のフィールドの数だけループを回し、各フィールドに対して

  • フィールドに設定されたタグを読み取ることでExcelの列名を知る
  • rowsからExcelの列名に対応したstring値を得る
  • string値をフィールドの型に変換して構造体のフィールドにセットする

という操作を行っています。

このreflectパッケージを使った構造体のフィールドごとの処理は、実はjson.Unmarshalの内部でやっていることと似ています(json.Unmarshalはもっと複雑ですが)。
https://cs.opensource.google/go/go/+/refs/tags/go1.18.4:src/encoding/json/decode.go;l=96

また、再帰的な処理をするために、関数内で新たに関数fを定義しています。もし構造体のフィールドが、埋め込まれた構造体だった場合は、再帰的にfを実行します。これによって、例えば以下のような構造体も適切に値を読み取ることができます。

type Teacher struct {
    Person

    Class   int64  `column:"E"`
    Subject string `column:"F"`
}

main関数と実行結果は以下の通りです。

func main() {
    f, err := excelize.OpenFile("./sample.xlsx")
    if err != nil {
        log.Fatal(err)
    }

    rows, err := f.GetRows("Sheet1")
    if err != nil {
        log.Fatal(err)
    }

    // 2行目以降に対して
    for _, row := range rows[1:] {
        p := Person{}
        if err := unmarshalRows(row, &p); err != nil {
            log.Fatal(err)
        }

        fmt.Printf("%+v\n", p)
    }
}
$ go run main.go
{Name:佐藤 Age:10 Weight:42.7 Birthdate:2011-06-05 00:00:00 +0000 UTC}
{Name:鈴木 Age:20 Weight:57.2 Birthdate:2001-09-21 00:00:00 +0000 UTC}
{Name:高橋 Age:30 Weight:68.3 Birthdate:1991-07-19 00:00:00 +0000 UTC}
{Name:田中 Age:40 Weight:58.9 Birthdate:1981-03-06 00:00:00 +0000 UTC}
{Name:伊藤 Age:50 Weight:72.5 Birthdate:1971-11-08 00:00:00 +0000 UTC}

まとめ

今回は、Goで構造体のタグを使用して、Excelのデータを読み込む方法を紹介しました。構造体のタグは、普段のGoプログラミングではあまり使うものではありませんが、適切に使うことでより簡潔な処理を書くことができるので、使える機会があるかどうか意識してみるとよさそうです。

参考にした情報