カミナシ エンジニアブログ

株式会社カミナシのエンジニアが色々書くブログです

バリデーションとパースの分離。Goで実装する「変更に強い」CSV 処理の設計

こんにちは。カミナシで「カミナシ 従業員」の開発を行っている nilpoona です。

業務アプリケーションを作っていると、避けて通れないのが CSV インポート機能 です。

最初は「encoding/csv で読んでループ回せば実装できる」と考えて作り始めるのですが、仕様が複雑になるにつれて、以下のような課題に直面することがあります。

  • バリデーションとパース処理が混在し、エラーの発生箇所が追いづらい。
  • 「文字コードが Shift_JIS だった」など多様なエンコーディングへの対応で、ビジネスロジックが複雑になる。
  • パースやバリデーションエラーを即座にリターンしてしまうと、ユーザーは一つのエラーを直してもまた次のエラーが出る「モグラ叩き」のような修正サイクルを繰り返すことになる。
  • データが正しい状態か保証されないまま、後続の処理(DB 保存など)に渡されてしまう。

今回は、こういった CSV 処理における課題を解決するために、「 Parse, don't validate 」 という考え方を参考に CSV 処理のライブラリを実装しました。

これは「入力を単に検証(Validate)して終わるのではなく、型のあるデータ構造に変換(Parse)することで、その後の安全性を型システムで保証する」という考え方です。

今回はこの考え方を元に実装した CSV ライブラリの設計についてご紹介します。

上記の考え方を参考に、処理を以下の3つのフェーズに分割しました。

  1. Reader: エンコーディングの詳細を隠蔽(Shift_JIS / BOM 対応など)
  2. Parser: CSV行 → 構造体(中間型)への変換
  3. Validator: 中間型 → 検証済みの型への昇格

それぞれの実装詳細と、設計意図を紹介します。


1. Reader 層:エンコーディングの詳細を隠蔽する

日本の業務システムでは「Excel から出力した CSV」が頻繁に使われます。これらは Shift_JIS だったり BOM が付いていたりしますが、ビジネスロジック(バリデーションや変換)の段階では、こうしたエンコーディングの違いを意識せずに済むようにしたいところです。

読み込み時にエンコーディングを自動判定・変換し、常に「UTF-8 の Reader」を返すように実装しました。

// csv.go

package csv

import (
    "bytes"
    "encoding/csv"
    "io"
    "unicode/utf8"
    // ... imports
)

// newCsvReader は Shift_JIS や BOM を透過的に処理する
func newCsvReader(reader io.Reader) (*csv.Reader, error) {
    data, err := io.ReadAll(reader)
    if err != nil {
        return nil, err
    }

    // 1. BOM付きUTF-8 の CSV ファイルをサポート(BOM除去)
    if hasBOM(data) {
        cleanData := removeBOM(data)
        return csv.NewReader(bytes.NewReader(cleanData)), nil
    }

    // 2. BOMなし UTF-8 のサポート
    if utf8.Valid(data) {
        return csv.NewReader(bytes.NewReader(data)), nil
    }

    // 3. Shift_JIS をサポート(UTF-8へ変換)
    if convertData, ok := tryConvertShiftJISToUTF8(data); ok {
        return csv.NewReader(bytes.NewReader(convertData)), nil
    }

    return nil, fmt.Errorf("Unsupported Encoding Type")
}

この層がいわゆる「腐敗防止層(Anti-Corruption Layer)」として機能することで、後続の処理はファイルがどのような形式で保存されていたかを知る必要がなくなります。


2. Parser 層:Generics とリフレクションによる構造化

次に、読み込んだ行を Go の構造体にマッピングします。

ここでは「型変換(String -> Int など)」と「構造体へのマッピング」のみを行い、ビジネス的なバリデーションは行いません。

中間型の定義 (Parsed[T])

// parse.go

// ColumnCountStatus: カラム数の過不足状態
type ColumnCountStatus string
const (
    ColumnCountFew     ColumnCountStatus = "few"
    ColumnCountMatch   ColumnCountStatus = "match"
    ColumnCountTooMany ColumnCountStatus = "tooMany"
)

// ParsedRecord[T]: 1行ごとのパース結果(未検証)
type ParsedRecord[T any] struct {
    Record            T
    ColumnCountStatus ColumnCountStatus
}

// Parsed[T]: パース処理全体の結果
type Parsed[T any] struct {
    Headers []string
    Records []ParsedRecord[T]
}

パース処理の実装

Go のリフレクションを使って、構造体のタグ(csv:"name")を見て値をセットします。

ここで重要なのは、「カラム数が合わない」といった構造的な問題も、即座にエラー(return err)にするのではなく、ステータスとして記録している点です。

ここで処理を中断せずにステータスとして記録しているのは、「エラーの集約(Error Aggregation)」 を実現するためです。

パース段階で見つかった不備で即座にエラーを返してしまうと、ユーザーは修正とアップロードを何度も繰り返すことになってしまいます。

Parser はあくまで「起きたこと(カラム不足など)の記録」に徹し、後続の Validator で他の入力ミスと合わせて 「ファイル内の全エラーをまとめて報告」 できるようにすることで、ユーザー体験(UX)を損なわない設計としています。

// parse.go (続き)

func ParseTypedCSV[T any](ctx context.Context, r io.Reader) (*Parsed[T], error) {
    // ... reader作成処理 ...

    records := make([]ParsedRecord[T], 0)
    for {
        record, err := reader.Read()
        if err == io.EOF { break }
     
        // カラム数不一致などのエラーハンドリング
        var countStatus ColumnCountStatus = ColumnCountMatch
        if err != nil && errors.Is(err, csv.ErrFieldCount) {
             // ... カラム数の過不足を判定して countStatus にセット ...
        } else if err != nil {
            return nil, err
        }

        // リフレクションでマッピング(String -> Int変換など)
        var item T
        if err := mapRecordToStruct(record, rawHeaders, &item); err != nil {
            return nil, err
        }

        records = append(records, ParsedRecord[T]{
            Record:            item,
            ColumnCountStatus: countStatus, // ★エラーではなく状態として保持
        })
    }

    return &Parsed[T]{Headers: rawHeaders, Records: records}, nil
}

3. Validator 層:ロジックの注入と型安全性の保証

ここが設計の要です。

「ライブラリには汎用的な検証フローだけを持たせ、具体的なビジネスルールは外部から注入する」形をとりました。

型定義と Validate 関数

// validate.go

// バリデーション後の「安全な」データ
type Valid[T any] struct {
    Headers []string
    Records []T
}

// 外部から注入するバリデーション関数の型定義
type ValidateRecordFunc[T any] func(ctx context.Context, record ParsedRecord[T]) ErrorCode
funcValidate[T any](
    ctx context.Context,
    parsed *Parsed[T],
    expectedHeader [][]string,
    validateFuncs ...ValidateRecordFunc[T], // ★ ここでルールを注入
) (*Valid[T], []ValidateErrorDetail) {
 
    details := []ValidateErrorDetail{}

    // 1. ヘッダー構造の検証
    if errCode := validateHeader(parsed.Headers, expectedHeader); errCode != "" {
        return nil, []ValidateErrorDetail{{LineNumber: 1, Code: errCode}}
    }

    // 2. レコードごとの検証(注入された関数を実行)
    validRecords := make([]T, 0, len(parsed.Records))
    for idx, record := range parsed.Records {
        lineNumber := idx + 2
        hasError := false

        for _, validateFunc := range validateFuncs {
            if errCode := validateFunc(ctx, record); errCode != "" {
                details = append(details, ValidateErrorDetail{
                    LineNumber: lineNumber,
                    Code:       errCode,
                })
                hasError = true
            }
        }

        // エラーがなければ「有効なレコード」としてリストに追加
        if !hasError {
            validRecords = append(validRecords, record.Record)
        }
    }

    // エラーがあれば、Valid[T] は返さない(nil を返す)
    if len(details) > 0 {
        return nil, details
    }

    // 3. 全て合格して初めて Valid[T] を返す
    return &Valid[T]{
        Headers: parsed.Headers,
        Records: validRecords,
    }, nil
}

この設計により、Parsed[T] から Valid[T] への変換が成功すれば、データは整合性が取れていることが保証されます。


実際に使ってみる

このライブラリを使う側のコードは、非常に宣言的になります。

「年齢チェック」などのロジックは、利用側がそれを定義してライブラリに注入します。

// main.go (利用例)

// 1. 取り込みたいCSVの構造を定義
type UserCSV struct {
    Name string `csv:"氏名"`
    Age  int    `csv:"å¹´é½¢"`
}

func ImportUsers(ctx context.Context, file io.Reader) error {
    // 2. パース(読み込み + 構造化)
    // ここではまだバリデーションエラーにはならない
    parsed, err := csv.ParseTypedCSV[UserCSV](ctx, file)
    if err != nil {
        return err // システムエラー等
    }

    // 3. バリデーションルールの定義(このドメイン固有のロジック)
    // ParsedRecord[T] を受け取ってエラーコードを返す
    validateAge := func(ctx context.Context, r csv.ParsedRecord[UserCSV]) csv.ErrorCode {
        // カラム数が足りない場合のチェック(Parserが残した記録を確認)
        if r.ColumnCountStatus != csv.ColumnCountMatch {
            return csv.ErrTooManyColumns
        }
        // ビジネスロジック
        if r.Record.Age < 20 {
            return "AgeUnder20"
        }
        return ""
    }

    // 4. バリデーション実行(ルールの注入)
    expectedHeader := [][]string{{"氏名"}, {"年齢"}}
 
    // ★ ここでバリデーションを実行
    validData, errDetails := csv.Validate(ctx, parsed, expectedHeader, validateAge)
 
    if len(errDetails) > 0 {
        // エラーがあれば「何行目の何がおかしいか」をまとめて返却できる
        return NewValidationError(errDetails)
    }

    // 5. 後続処理
    // ここに来る時点で validData は *Valid[UserCSV] 型であり、
    // 全データがルールに適合していることが保証されている
    return saveToDB(validData.Records)
}

まとめ

CSV 処理は外部からの入力を扱うため複雑になりがちですが、以下のように責務を分けることで、メンテナビリティを向上させることができました。

  • Reader: エンコーディングの詳細(Shift_JIS / BOM 対応など)を隠蔽し、腐敗防止層として機能。
  • Parser: CSV行を中間型へ変換。エラーは即時リターンせず「状態」として記録し、UX 向上に貢献。
  • Validator: 中間型を検証済みの型へ昇格。汎用的な検証フローを提供し、具体的なルールは外部から注入する。

特に「バリデーションロジックを関数として注入する」設計にしたことで、ライブラリ自体を変更することなく、様々なCSV(ユーザー、商品、在庫など)に柔軟に対応できる構成となりました。

CSV 処理の設計において、少しでも参考になれば幸いです。