GOでCSV import ~ ヘッダ検証編

タム

2023.05.26

80

こんにちは。タムです。

今回は前回に引き続きCSVインポートについての2回目の投稿です。

前回は前提知識的なお話でしたが、今回からはいよいよ実装の中身についてご紹介します。


ヘッダ検証の必要性

CSVのバリデーションというと、ファイル名検証、文字コード検証、ヘッダ検証、レコード単位のバリデーションなどがあると思います。

今回その中でヘッダ検証に焦点を当てたのは、

自分がざっと調べた感じ意外とGOを使ったCSVのヘッダ検証について紹介している記事がなさそうだったためです。

まあ最悪ヘッダ検証はなくてもレコード単位のバリデーションで必須チェックがあれば弾かれるとは思うのですが、

ファイル単位ではなくレコード単位でコミットする設計のため、レコード単位でエラーだった場合も後続が走り、

結果的に全レコード分のエラーが出力されてしまうため、好ましくないです。

そのため今回はヘッダ検証の実装を行いました。

実装内容

全体像

以下が自分の実装したヘッダ検証を行う関数です。

引数としてCSVから読み込んだバイト列が渡され、検証の結果NGだった場合はInvalidHeaderErrorが返されます。

type InvalidHeaderError struct {
    notExistHeaders []string
}

func (i *InvalidHeaderError) Error() string {
    return fmt.Sprintf("CSVファイルのヘッダが欠損しています: %s", strings.Join(i.notExistHeaders, ","))
}

func NewInvalidHeaderError(notExistHeaders []string) *InvalidHeaderError {
    return &InvalidHeaderError{notExistHeaders: notExistHeaders}
}

func ValidateHeader[T any](csv []byte) error {
    scanner := bufio.NewScanner(bytes.NewBuffer(csv))
    for scanner.Scan() {
        unquoted := strings.ReplaceAll(scanner.Text(), "\"", "")
        headers := strings.Split(unquoted, ",")

        notExistHeaders := []string{}

        model := new(T)
        t := reflect.TypeOf(*model)
    L:
        for i := 0; i < t.NumField(); i++ {
            csvTag := t.Field(i).Tag.Get("csv")
            for _, header := range headers {
                if header == csvTag {
                    continue L
                }
            }
            notExistHeaders = append(notExistHeaders, csvTag)
        }
        if len(notExistHeaders) > 0 {
            return NewInvalidHeaderError(notExistHeaders)
        }
        // ヘッダのみでいいので1行読み終わったら抜ける
        break
    }
    return nil
}

解説

T anyはCSVをマッピングするモデルクラスです。

例として以下のような構造体を定義します。

type User struct {
    ID    int    `csv:"id"`
    Name  string `csv:"name"`
    Email string `csv:"email"`
}

このように定義しておくと、csvuti.Unmarshal()を使ってcsvから一発で構造体にマッピングしてくれます。

(csvのタグ名がCSVのヘッダ名に紐づきます。)

CSVのヘッダ情報を別の変数などで管理すると、もしもヘッダ名が変更になった場合に複数箇所を修正しなくてはならないため、

csvのタグ情報で一元管理するようにしています。


まずヘッダ検証なので最初の1行のみが欲しいのですが、

scanner.Scan()で1行ずつ読み込んでくれるので、最初のループが回った時点でbreakします。

        // ヘッダのみでいいので1行読み終わったら抜ける
        break

次にヘッダの文字列からカラムの配列を取り出しています。

CSVのフォーマットはダブルクォートありの想定なのでダブルクォートを取り除いてから、

カンマで区切って配列を取得しています。

        unquoted := strings.ReplaceAll(scanner.Text(), "\"", "")
        headers := strings.Split(unquoted, ",")

そしてその後が重要なのですが、

モデルのインスタンスを生成し、reflect.TypeOf(*model) でモデルの型情報を取得。

t.NumField() でモデル構造体のカラム数が取得できるのでforループで回し、

t.Field(i).Tag.Get("csv") でCSVのタグ情報を取得しています。

あとは先程取得したCSV側のヘッダカラム配列を走査し、

ヒットした時点でcontinue L によってモデル構造体のカラム毎のループから抜けます。

そうするとCSVヘッダカラムのループが最後まで回り終わった時点で該当カラムが欠損していることがわかるので、

notExistHeadersに該当カラムを追加します。

    L:
        for i := 0; i < t.NumField(); i++ {
            csvTag := t.Field(i).Tag.Get("csv")
            for _, header := range headers {
                if header == csvTag {
                    continue L
                }
            }
            notExistHeaders = append(notExistHeaders, csvTag)
        }

最終的にnotExistHeadersが存在していれば、カラムの欠損があったということでエラーを返しています。

最後に

今回はCSVのヘッダ検証の実装をご紹介しました。

自分で解説していて、改善点として

  • Tがanyなのであらゆる型が入れられてしまうのがあまり良くない
    • できればTは構造体であるという制約をつけたい
    • csvのタグがないとおそらく落ちる

などがあるなと思いました。

今後上記の課題が解決できたらまた記事にしようと思います。

最後までご覧いただきありがとうございました。

この記事をシェアする