LoginSignup
9
4

More than 5 years have passed since last update.

csv形式のデータバリデーションツールをGo言語で実装した話

Last updated at Posted at 2019-03-11

概要

データ検証ツールを Go で作成しました。その製作物の紹介と、設計/実装について記載します。

対象者

  • データ検証ツールについて気になる方
  • どのように仕様/設計/実装を進めていったのかについて気になる方
  • Go で汎用性を持った実装方法について気になる方

製作物

gkeeper というツールを作成しました。
機能は、csv データのバリデーションを実施するツールです。
ある csv データが、特定の仕様を満たしているかどうかを判別することができます。

動作

gkeeper_qiita_large.gif

経緯

私は、現在所属する 交通コンサルティング事業部 にて、様々な分析データを csv 形式 で納品する仕事を担当しています。
その際に、データが正しく仕様を満たしているか検証する必要がありました。
元々 SQL で検証作業を実施していましたが、下記の 2 点の問題が発生していました。

  • SQL のテストができないため、検証が正しく行えているかのチェックができない
  • csv データに対して直接チェックすることができず、前段の DB 内でしかチェックができない

この 2 点の問題を解消することを目的として開発を実施しました。

全体設計

仕様

上記で記載した 2 点の問題点を解消する かつ 運用にのせるツールを開発するために、下記の仕様を定めました。

  1. csv データに対して直接チェックを実行することができる
  2. テストのテストを実施することができる
  3. 暗黙的にチェックできる項目は自動で検証を行う
  4. 実行速度が高速である (数百万行に対する実行で数時間レベルでかからない)
  5. データ検証観点の追加を容易に実施できる

設計

なぜ Go を選んだのか

Go を選んだ理由としては、 実行速度が高速 かつ 静的型付け言語 であることが挙げられます。
当ツールは、数百万行のデータに対して実行することが想定されています。
実行時は、単純なループ処理になることが想定されたため、言語自体の速度が速いものを選びました。
また、検証ツールという性質上、検証結果の安全性が求められます。
そのため、型のゆらぎ等がでると安定性に欠けると考え、静的型付け言語の Go を選びました。

(何より、筆者が Go でツールを作りたかったからということも大いにあります。)

ツールの I/F

ツールの入出力のイメージとしては、下記のイメージを持っていました。

[イメージ]

検証するテキストファイル + 仕様定義ファイル => gkeeper => 検証結果出力

ツールは開発者が利用することもあり、CLI 上で実行されることを想定しています。
標準入力で csv データを受け取って、標準出力に結果を出力するようにしました。
これは、ツール単体で実行した際に速度が出なかった場合に、Hadoop Streaming で分散処理できることを
見越した I/F となっています。(実際は単体で十分な速度が出たので不要でした。)

仕様定義ファイルに関しては、yaml で実施するようにしました。

[実装結果]

cat exapmle.csv | gk -s spec.yml > result.txt

実装の設計

検証 interface について

設計するにあたってまず重視したのは、 仕様の 2 番と 5番 です。

2.テストのテストを実施することができる
5.データ検証観点の追加を容易に実施できる

この仕様を Go で実装するための具体化として、 各検証を 1 つの struct に対応させて、特定の interface を満たすように実装 すれば上手くいくのでは?と考えました。
さらに、interface で抽象化されているので、実行する部分の実装も複雑化しないので良さそうだとも思いました。
実際には下記のような interface を定義しました。

// Checker はチェック処理の最小単位の振る舞いです。
type Checker interface {

  // Name は実装struct名の文字列を返却します。
  Name() string

  // LoadArg はspec.yamlの設定から、Examineで利用する条件を生成します。
  LoadArg(col spec.Gate, colType ColType) (CheckerParam, error)

  // Examine は実際にチェックしたい処理を実装します。
  Examine(c CheckerParam, elements ...string) error
}

肝となる実装は Examine メソッドで、こちらの実装内に検証処理が含まれます。
検証の成否は、error value によって判断します。error が発生しなかった(=nil)場合に検証成功で、error がある場合は失敗としています。
ここは Go らしさを出して実装した部分かと思います。

例えば、 要素の数値が特定の数値以上である という検証をしたい場合は、下記のように実装します。


// min は、整数値が特定の値以上であることを保証します。
//
// Spec Example:
//  columns:
//    - name: "id"
//      type: "int"
//      gates:
//        - name: "min"
//          nums:
//            - 5
//
type min struct {
  name string
}

func (m *min) Examine(c CheckerParam, elements ...string) error {
  num, err := strconv.Atoi(elements[0])
  if err != nil {
    return errors.Wrap(err, "指定された要素がint型ではありません")
  }

  if num < c.Num {
    return fmt.Errorf("指定された最小値より小さな値です。 n:%v min:%v", n, c.Num)
  }
  return nil
}

func (m *min) Name() string {
  return "min"
}

func (m *min) LoadArg(col spec.Gate, colType ColType) (CheckerParam, error) {
  // 後述
}

検証の観点を追加したい場合は、 Checker interface を満たすような struct を新たに定義するだけで可能です。
Checker interface を定義することで、5.データの検証観点の追加を容易の実施できる は十分に満たせました。

また、仕様 2 のテストに関しては、 Examine 関数として検証処理を切り出せたので、下記のような単体テストを実装することでテスト可能です。
Go らしく Table Driven Test で実装しています。また、error の比較をする必要があったため、 stretchr/testify を利用しています。

func TestMinExamine(t *testing.T) {
  tests := []struct {
    name    string
    cond    spec.Gate
    colType check.ColType
    in      []string
    want    error
  }{
    {
      name:    "正常系",
      cond:    spec.Gate{Nums: []int{3}},
      colType: check.Int,
      in:      []string{"4"},
      want:    nil,
    },
    {
      name:    "最小値以下の場合",
      cond:    spec.Gate{Nums: []int{5}},
      colType: check.Int,
      in:      []string{"4"},
      want:    errors.New("指定された最小値より小さな値です。 n:4 min:5"),
    },
  }

  tested := checker.NewMin()
  for _, c := range tests {
    t.Run(c.name, func(t *testing.T) {
      a, err := tested.LoadArg(c.cond, c.colType)
      got := tested.Examine(a, c.in...)
      assert.Equal(t, c.want, got, "not expected error")
    })
  }
}

(記事を書きながら気づいたのですが、LoadArgメソッドに関する処理が混ざっているため、
きれいな単体テストになっていないです。CheckerParam を入力値に入れるようなテストにしたほうが良さそうです。)

LoadArg メソッドに対する補足

Checker interface には、 LoadArg メソッドも定義しています。
LoadArg メソッドの役割としては、仕様定義データ(yml)から必要なパラメータを抽出して返却することです。
返却値である、 CheckerParam は下記のような struct にしています。
パラメータとして指定されうる、複数の型のデータを定義しています。(正直、この実装はいまいちかと思ってます。)

type CheckerParam struct {
  Num   int
  Nums  []int
  FNum  float64
  FNums []float64
  Str   string
  Strs  []string
}

CheckerParam struct を経由させることで、仕様定義ファイルと Checker interface を疎結合にして、
仕様変更に対応できるようしました。

チェック実行計画の作成

仕様定義ファイルから、チェックの実行計画を作成します。
仕様定義ファイルは、下記のような記述をします。
この yml 定義をパースできるように、そのまま Spec struct を定義しています。

spec.yml

# 区切り文字を指定
delimiter: ","
# データ定義を指定
columns:
  - name: "id"
    type: "string"
    key: true
  - name: "name"
    type: "string"
    key: true
  - name: "age"
    type: "int"
    # 歴史的な経緯により gateになっている (= check)
    gates:
      - name: "min"
        nums:
          # 最小値を指定
          - 0

Spec

type Spec struct {
    Delimiter string   `yaml:"delimiter"`
    Columns   []Column `yaml:"columns"`
}

type Column struct {
    Name  string `yaml:"name"`
    Type  string `yaml:"type"`
    Key   bool   `yaml:"key"`
    Gates []Gate `yaml:"gates"`
}

チェックの実行計画は、 Plan struct ととして 定義しています。
実際のチェックに関しては、 Constraint 内にあります。

Plan

type Plan struct {
  Columns []Column
  KeyIdxs []int
}

type Column struct {
    Name        string
    Type        string
    Idx         int
    Constraint  Constraint
}

type Constraint struct {
  // チェック関数
  Checker check.Checker

  // パラメータ
  Arg     check.CheckerParam
}

Constraint 内で、 上で述べた CheckerParam を保持しています。
これは、検証条件を検証ごとに生成するコストがもったいなく、一度の生成にしたかったためここに持たせています。

チェック実行

チェックの実行は run メソッド以下で実施しています。
簡易化のため、実装を少々変更して記載しています。


// 一部実装を省略しています

func run(plan plan.Plan) {
  // csv 読み込み
  cr = csv.NewReader(os.Stdin)

  for {
    elements, err := cr.Read()
    if err != nil {
      break
    }

    challenge(os.Stdout, plan, elements)
  }
}

// challenge は各行ごとのチェックを実施します
func challenge(
  w io.Writer,        // 出力先
  p plan.Plan,        // チェック計画
  elements []string,  // 1行要素
) {
  for _, column := range p.Columns {
    challengeGate(w, column, elements[p.Idx])
  }
}

// challengeGate は単一要素のチェックを実施します
func challengeGate(
  w io.Writer,        // 出力先
  column plan.Column, // チェック計画
  element string,     // 単一要素
) {
  constraint = column.Constraint
  // チェック実行!!
  if err := constraint.Checker.Examine(constraint.CheckerParam, element); err != nil {
    fmt.Fprintf(w, "Check Failed:  %v", err)
  }
}

以上で、チェックを実行することができました。

その他の機能

詳細は割愛しますが、仕様を満たすために下記機能も実装しています。

  • 1 カラム複数のチェックの実施
    • 上記実装例では 1 カラムに対して 1 チェックのみの実施しかできませんが、実際の実装では複数チェック可能です
    • Constraint を Slice 化して実現しています
  • デフォルトチェック実施
    • 仕様定義ファイルに記載のない場合も、特定の条件を満たすと自動でチェック処理を発火させます
    • 基本的にはデータ型をみて発火します
  • 行間チェックの実施
    • 行の key を指定することで、同一 key 間での検査を実施できるようにしています
    • 1 行前のデータを保持することで実現しています
    • 速度劣化 と 実行時のメモリサイズの増加 が課題となっています

所感

Go で CUI ツール作成にあたっての、内部設計/実装について記載いたしました。
interface を利用して抽象化することで、チェック処理を分離することができ、かつ簡単に新規追加できるように設計してみました。
他のメンバーにチェック関数の追加を依頼したところ違和感なく実装されていましたので、ある程度は成功したのではないかと思っています。
ですが、行間チェック や Checkerの実装が型の成約を受ける(例: min が int 型に依存しているため float 型が必要な場合は別に実装を定義する必要がある) 点等で課題はいくつかあり、どんどん改善して使いやすくメンテナンスしやすいツールにしていきたいと思います。

9
4
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
9
4