このポストについて

Data Contract CLI を触ってみたところ、面白かったのとこれからのデータパイプライン開発について思うところがあったので書いてみる。

Data Contract CLI とは?

datacontract/datacontract-cli

Data Contract CLI は data contracts を運用するためのオープンソースのコマンドラインツールである。

data contracts の概念については以前の記事で詳しく書いているのでそちらをご参考いただければと。
ただしこちらの記事は1年前のものであり、今回取り上げる Data Contract CLI の登場などを含めて現在では data contracts を取り巻く状況も変わっている可能性があることに注意。

Data Contract CLI は Python で開発されており、pip でインストールすることができる。
この記事を書いている時点では v0.10.3 が最新であり、この記事の内容はこのバージョンに基づいている。

Data Contract CLI で扱う data contracts は YAML で定義される前提となっており、その仕様は datacontract/datacontract-specification で決められている。
この data contracts に対して Data Contract CLI では次のようなことが行える。

  • lint によるフォーマットチェック
  • データソースに接続した上での schema やデータ品質のテスト
  • data contracts の破壊的な変更の検出
  • JSON Schema や dbt など、他の形式からの/へのインポートとエクスポート

以下の図がイメージしやすい。

datacontract/datacontract-cli より

datacontract/datacontract-cli より

Data Contract CLI の開発者が何を考えているか、この図から推測できる部分があるので後ほど考察したい。

Data Contract CLI を使ってみる

前提

手元に個人開発用の BigQuery の table があったので、これについて data contract を用意して Data Contract CLI を使ってみることにした。
個人の MoneyForward の情報を取り込んでいる table であり、収支詳細のカテゴリを扱う table である。

data contract (YAML ファイル) の作成は基本的には best practice に従う。
data contract の作成後は export や変更の検出などを試してみる。

準備

今回は Poetry で Python 環境を用意した。
datacontract-cli および BigQuery を扱うのに必要となる google-cloud-bigquery-storage を install しておく。

[tool.poetry.dependencies]
python = "^3.10"
datacontract-cli = "^0.10.3"
google-cloud-bigquery-storage = "^2.24.0"

バージョンを確認。

$ datacontract --version
0.10.3

DDL からの import して data contract を作成

まず data contract を用意する必要がある。
data contract は YAML ファイルなので仕様を見ながら一から書くこともできるが、既存の table などがある場合は import を使うことができる。
ちなみに v0.10.3 の時点で対応している import のソースは sql, avro, glue の3つ。

BigQuery では INFORMATION_SCHEMA.TABLES から既存 table の DDL を得ることができるため、それを利用して money_forward_main_group.sql を用意した。

CREATE TABLE `<PROJECT_ID>.<DATASET_ID>.money_forward_main_group`
(
  mf_main_group_id INT64,
  name STRING,
  is_income BOOL
);

この DDL ファイルを使って import サブコマンドにより data contract を作成する。

$ datacontract import --format sql --source money_forward_main_group.sql > datacontract_v1.yml

作成された datacontract_v1.yml の中は次のようになっている。

dataContractSpecification: 0.9.3
id: my-data-contract-id
info:
  title: My Data Contract
  version: 0.0.1
models:
  money_forward_main_group`:
    type: table
    fields:
      mf_main_group_id:
        type: integer
      name:
        type: string
      is_income:
        type: boolean

この時点で一度 lint サブコマンドでフォーマットチェックしておく。

datacontract lint datacontract_v1.yml 
WARNING:root:Data Contract YAML is invalid. Validation error: data.models must be named by propertyName definition
ERROR:root:Run operation failed: [lint] Check that data contract YAML is valid - None - failed - data.models must be named by propertyName definition - datacontract
╭────────┬────────────────────────────────────────┬───────┬────────────────────────────────────────────────────╮
│ Result │ Check                                  │ Field │ Details                                            │
├────────┼────────────────────────────────────────┼───────┼────────────────────────────────────────────────────┤
│ failed │ Check that data contract YAML is valid │       │ data.models must be named by propertyName          │
│        │                                        │       │ definition                                         │
╰────────┴────────────────────────────────────────┴───────┴────────────────────────────────────────────────────╯
🔴 data contract is invalid, found the following errors:
1) data.models must be named by propertyName definition

エラーになってしまった。
実はこの時点で datacontract_v1.yml にいくつか問題があり、修正することに。

  • 不要な “`” が含まれている
  • servers ブロックがない
    • (確かに DDL を与えるだけだと BigQuery なのか Snowflake なのかわからんな…)

次のように修正して datacontract_v2.yml を作った。

@@ -3,8 +3,13 @@
 info:
   title: My Data Contract
   version: 0.0.1
+servers:
+  dev:
+    type: bigquery
+    project: <PROJECT_ID>
+    dataset: <DATASET_ID>
 models:
-  money_forward_main_group`:
+  money_forward_main_group:
     type: table
     fields:
       mf_main_group_id:

再度 lint サブコマンドを実行すると成功した。
ただし description がない旨の warning が出ている。
table や column の description や制約事項を追加して datacontract_v3.yml とする。

@@ -3,6 +3,9 @@
 info:
   title: My Data Contract
   version: 0.0.1
+  description: |
+    MoneyForward の収支における分類 (項目)。
+    ダウンロードできる収支詳細に記載される情報を元に作成されている。
 servers:
   dev:
     type: bigquery
@@ -11,10 +14,18 @@
 models:
   money_forward_main_group:
     type: table
+    description: MoneyForward の収支における大項目
     fields:
       mf_main_group_id:
+        description: 大項目 ID
         type: integer
+        required: true
+        primary: true
+        unique: true
       name:
+        description: 大項目の項目名
         type: string
+        required: true
       is_income:
+        description: 大項目が収入の場合は true, 支出の場合は false
         type: boolean

lint サブコマンドが成功するようになった。

$ datacontract lint datacontract_v3.yml          
╭────────┬──────────────────────────────────────────┬───────┬─────────╮
│ Result │ Check                                    │ Field │ Details │
├────────┼──────────────────────────────────────────┼───────┼─────────┤
│ passed │ Data contract is syntactically valid     │       │         │
│ passed │ Linter 'Field pattern is correct regex'  │       │         │
│ passed │ Linter 'Objects have descriptions'       │       │         │
│ passed │ Linter 'Field references existing field' │       │         │
│ passed │ Linter 'Example(s) match model'          │       │         │
│ passed │ Linter 'Fields use valid constraints'    │       │         │
│ passed │ Linter 'Quality check(s) use model'      │       │         │
│ passed │ Linter 'noticePeriod in ISO8601 format'  │       │         │
╰────────┴──────────────────────────────────────────┴───────┴─────────╯
🟢 data contract is valid. Run 8 checks. Took 0.304237 seconds.

初回テスト

次に test サブコマンドでテストを実行する。
ちなみに test では BiqQuery にアクセスするため、環境変数 DATACONTRACT_BIGQUERY_ACCOUNT_INFO_JSON_PATH に認証情報の JSON ファイルを指定しておく必要がある。

$ datacontract test datacontract_v3.yml
Testing datacontract_v3.yml
Column,Event,Details
is_income,:icon-fail: Type Mismatch, Expected Type: boolean; Actual Type: BOOL

Type Mismatch, Expected Type: boolean; Actual Type: BOOL
╭────────┬──────────────────────────────────────────────────────────────────┬──────────────────┬────────────────────────────────────────────────────╮
│ Result │ Check                                                            │ Field            │ Details                                            │
├────────┼──────────────────────────────────────────────────────────────────┼──────────────────┼────────────────────────────────────────────────────┤
│ passed │ Check that field mf_main_group_id is present                     │                  │                                                    │
│ passed │ Check that field mf_main_group_id has type integer               │                  │                                                    │
│ passed │ Check that field name is present                                 │                  │                                                    │
│ passed │ Check that field name has type string                            │                  │                                                    │
│ passed │ Check that field is_income is present                            │                  │                                                    │
│ failed │ Check that field is_income has type boolean                      │                  │ Type Mismatch, Expected Type: boolean; Actual      │
│        │                                                                  │                  │ Type: BOOL                                         │
│ passed │ Check that required field mf_main_group_id has no null values    │ mf_main_group_id │                                                    │
│ passed │ Check that unique field mf_main_group_id has no duplicate values │ mf_main_group_id │                                                    │
│ passed │ Check that required field name has no null values                │ name             │                                                    │
╰────────┴──────────────────────────────────────────────────────────────────┴──────────────────┴────────────────────────────────────────────────────╯
🔴 data contract is invalid, found the following errors:
1) Type Mismatch, Expected Type: boolean; Actual Type: BOOL

エラーになってしまった。
column is_income の型が boolean ではなく BOOL だと言われている。
確かに BiqQuery としての型は BOOL であるが、一方で data contracts の仕様としては boolean しか指定できない。
バグっぽいのでいったん is_income の型指定をはずしておく…

出力された内容を見るとどういったことがチェックされているかがわかる。
この時点では column の存在および型がチェックされていて、さらに制約事項を記載した column についてはその制約を満たしているかがチェックされている。

データ例の追加

次に data contract に examples ブロックを追加する。
名前のとおりでデータの例を記載することができる。
まず data contract を宣言して開発することを考えると、例が示されているのはとても助かる。

次のように修正して datacontract_v4.yml を作った。

@@ -28,4 +28,14 @@
         required: true
       is_income:
         description: 大項目が収入の場合は true, 支出の場合は false
-        type: boolean
+        # type mismatch になるバグ?があるため boolean 型のチェックは除外
+        # type: boolean
+examples:
+  - type: csv
+    description: money_forward_main_group のレコードの例。
+    model: money_forward_main_group
+    data: |
+      mf_main_group_id,name,is_income
+      1,"収入",true
+      2,"食費",false
+      3,"日用品",false

オプション --examples をつけて test サブコマンドを実行すると、examples ブロックに追加したデータに対してテストを行うことができる。

$ datacontract test --examples datacontract_v4.yml
Testing datacontract_v4.yml
╭────────┬──────────────────────────────────────────────────────────────────┬──────────────────┬─────────╮
│ Result │ Check                                                            │ Field            │ Details │
├────────┼──────────────────────────────────────────────────────────────────┼──────────────────┼─────────┤
│ passed │ Check that field mf_main_group_id is present                     │                  │         │
│ passed │ Check that field name is present                                 │                  │         │
│ passed │ Check that field is_income is present                            │                  │         │
│ passed │ Check that required field mf_main_group_id has no null values    │ mf_main_group_id │         │
│ passed │ Check that unique field mf_main_group_id has no duplicate values │ mf_main_group_id │         │
│ passed │ Check that required field name has no null values                │ name             │         │
╰────────┴──────────────────────────────────────────────────────────────────┴──────────────────┴─────────╯
🟢 data contract is valid. Run 6 checks. Took 0.232013 seconds.

データ品質チェックの追加

quality ブロックを追加してデータ品質チェックを行うことができる。
v0.10.3 の時点では SodaCL, Monte Carlo, greate expectation の記法で品質チェックを書くことができる。

ここでは SodaCL の記法で table の行数が1件以上あることを確認する簡単なチェックを追加して datacontract_v5.yml とする。

@@ -39,3 +39,8 @@
       1,"収入",true
       2,"食費",false
       3,"日用品",false
+quality:
+  type: SodaCL
+  specification:
+    checks for money_forward_main_group:
+      - row_count > 0

examples と BigQuery それぞれに対して test サブコマンドを実行。

$ datacontract test --examples datacontract_v5.yml
Testing datacontract_v5.yml
╭────────┬──────────────────────────────────────────────────────────────────┬──────────────────┬─────────╮
│ Result │ Check                                                            │ Field            │ Details │
├────────┼──────────────────────────────────────────────────────────────────┼──────────────────┼─────────┤
│ passed │ Check that field mf_main_group_id is present                     │                  │         │
│ passed │ Check that field name is present                                 │                  │         │
│ passed │ Check that field is_income is present                            │                  │         │
│ passed │ row_count > 0                                                    │                  │         │
│ passed │ Check that required field mf_main_group_id has no null values    │ mf_main_group_id │         │
│ passed │ Check that unique field mf_main_group_id has no duplicate values │ mf_main_group_id │         │
│ passed │ Check that required field name has no null values                │ name             │         │
╰────────┴──────────────────────────────────────────────────────────────────┴──────────────────┴─────────╯
🟢 data contract is valid. Run 7 checks. Took 0.48302 seconds.
$ datacontract test datacontract_v5.yml  
Testing datacontract_v5.yml
╭────────┬──────────────────────────────────────────────────────────────────┬──────────────────┬─────────╮
│ Result │ Check                                                            │ Field            │ Details │
├────────┼──────────────────────────────────────────────────────────────────┼──────────────────┼─────────┤
│ passed │ Check that field mf_main_group_id is present                     │                  │         │
│ passed │ Check that field mf_main_group_id has type integer               │                  │         │
│ passed │ Check that field name is present                                 │                  │         │
│ passed │ Check that field name has type string                            │                  │         │
│ passed │ Check that field is_income is present                            │                  │         │
│ passed │ row_count > 0                                                    │                  │         │
│ passed │ Check that required field mf_main_group_id has no null values    │ mf_main_group_id │         │
│ passed │ Check that unique field mf_main_group_id has no duplicate values │ mf_main_group_id │         │
│ passed │ Check that required field name has no null values                │ name             │         │
╰────────┴──────────────────────────────────────────────────────────────────┴──────────────────┴─────────╯
🟢 data contract is valid. Run 9 checks. Took 5.690055 seconds.

行数 (row_count) のチェックが増えていることが確認できる。

ここまでのところで data contract (YAML) はいったん完成とする。
この時点での全体像を記載しておく。

dataContractSpecification: 0.9.3
id: my-data-contract-id
info:
  title: My Data Contract
  version: 0.0.1
  description: |
    MoneyForward の収支における分類 (項目)。
    ダウンロードできる収支詳細に記載される情報を元に作成されている。    
servers:
  dev:
    type: bigquery
    project: <PROJECT_ID>
    dataset: <DATASET_ID>
models:
  money_forward_main_group:
    type: table
    description: MoneyForward の収支における大項目
    fields:
      mf_main_group_id:
        description: 大項目 ID
        type: integer
        required: true
        primary: true
        unique: true
      name:
        description: 大項目の項目名
        type: string
        required: true
      is_income:
        description: 大項目が収入の場合は true, 支出の場合は false
        # type mismatch になるバグ?があるため boolean 型のチェックは除外
        # type: boolean
examples:
  - type: csv
    description: money_forward_main_group のレコードの例。
    model: money_forward_main_group
    data: |
      mf_main_group_id,name,is_income
      1,"収入",true
      2,"食費",false
      3,"日用品",false      
quality:
  type: SodaCL
  specification:
    checks for money_forward_main_group:
      - row_count > 0

ここで作った data contract はフルの記載ではない。
例えば servicelevels ブロックを追加して SLA を記載することもできる。
data contract のすべての仕様を知りたい場合は仕様ドキュメントを参照のこと。

export を試す

JSON Schema

data contract ができたところで export を試していきたい。
v0.10.3 では14種類のフォーマットに対しての export がサポートされており、ここではそのうちのいくつかを使ってみる。

まずは JSON Schema で出力してみる。

$ datacontract export --format jsonschema datacontract_v5.yml
{
  "$schema": "http://json-schema.org/draft-07/schema#",
  "type": "object",
  "properties": {
    "mf_main_group_id": {
      "type": "integer",
      "unique": true
    },
    "name": {
      "type": "string"
    },
    "is_income": {}
  },
  "required": [
    "mf_main_group_id",
    "name"
  ]
}

JSON Schema の記法でモデルが表現されたものが標準出力された。
同様に Avro, Protobuf などの形式でも export することができる。

データ基盤的な話をすると、取り込みの対象であるアプリケーション (data provider) から送られてくるログは JSON 形式になっていることがよくある。
data contract をアプリケーション側とデータ基盤側で合意しておけば、アプリケーション側でログ生成時に JSON Schema でログに validation をかけることができる。
これによりログの schema の意図しない変更を防ぐ運用が可能になるだろう。

dbt

dbt の形式で export することもできる。
dbt の YAML ドキュメントを出力してみる。

$ datacontract export --format dbt --server bigquery datacontract_v5.yml
version: 2
models:
- name: money_forward_main_group
  config:
    meta:
      data_contract: my-data-contract-id
    materialized: table
    contract:
      enforced: true
  description: MoneyForward の収支における大項目
  columns:
  - data_type: NUMBER
    description: 大項目 ID
    constraints:
    - type: not_null
    - type: unique
    name: mf_main_group_id
  - data_type: STRING
    description: 大項目の項目名
    constraints:
    - type: not_null
    name: name
  - description: 大項目が収入の場合は true, 支出の場合は false
    name: is_income

data_type が微妙で BigQuery の型定義になっていない。
--server bigquery を与えているので配慮してほしいところではある。

sources 用のドキュメントの形式でも export できる。

$ datacontract export --format dbt-sources --server bigquery datacontract_v5.yml
version: 2
sources:
- name: my-data-contract-id
  tables:
  - name: money_forward_main_group
    description: MoneyForward の収支における大項目
    columns:
    - tests:
      - dbt_expectations.dbt_expectations.expect_column_values_to_be_of_type:
          column_type: NUMBER
      - not_null
      - unique
      description: 大項目 ID
      name: mf_main_group_id
    - tests:
      - dbt_expectations.dbt_expectations.expect_column_values_to_be_of_type:
          column_type: STRING
      - not_null
      description: 大項目の項目名
      name: name
    - description: 大項目が収入の場合は true, 支出の場合は false
      name: is_income
  description: 'MoneyForward の収支における分類 (項目)。

    ダウンロードできる収支詳細に記載される情報を元に作成されている。

    '

staging 用の SQL の形式の export もある。

$ datacontract export --format dbt-staging-sql --server bigquery datacontract_v5.yml
    select 
        mf_main_group_id, name, is_income
    from {{ source('my-data-contract-id', 'money_forward_main_group') }}

このようにいくつかの dbt の形式で出力できるが、微妙な部分があるのでこのまま data contract を SSoT として dbt のファイルを CI で自動生成するという運用は現時点では難しいだろう。
またリッチなテストなども表現できないため、dbt をがっつり使っているチームには物足りなさがあると思われる。
data contract をまず作り、それをもとに dbt モデルを構築していくときのベースを作る用途なら現時点でもありかもしれない。

HTML

HTML 形式で export することもできる。
ホスティングして data contract を web で見せられるようにすることもできるだろう。

$ datacontract export --format html datacontract_v5.yml > money_forward_main_group.html
HTML での export 例

HTML での export 例

頑張ればこれで data catalog を作ることもできそう。
また今回の data contract には含まれていないが、前述の servicelevels ブロックがあれば web 上で SLA を示せたりできて良さそうだ。

考察

Data Contract CLI で何ができるかがわかったところで最初の図を再掲。

datacontract/datacontract-cli より

datacontract/datacontract-cli より

この図から Data Contract CLI としては data contracts ファーストな運用を考えていることがわかる。
まず data provider と data consumer の合意のもとに宣言的に data contracts を作成する。
それに基づき、それぞれ data provider と data consumer で使われるツール用に data contracts から必要なファイルを自動生成していく。
例えば data provider である application 向けにはログの validation 用に JSON Schema のファイルを作成する。
data consumer である DWH 向けには dbt のファイルを用意する、など。

また、data contract に記載の情報からデータに対してテストを実行することができる。
簡単な column の制約から、SodaCL などを使ったより高度な品質チェックまで。
データについてのドキュメントも data contract から生成される。

このようにまず data contract ありきで、モデルのメタ情報としてはそれを SSoT とし、data provider / consumer で必要なファイルを CI で自動生成するという未来が見えてくるような気がする。
ただしその未来を実現するための課題も現状では多い。
Data Contract CLI の相互運用性 (interoperatbility) が最も大きなボトルネックになるだろう。
例えば想定する dbt ドキュメントが一発で作れるか、package を使ったリッチなテストが書けるか、etc.

そもそも前述したようにバグっぽい挙動や微妙な挙動もあり、このツール自体がまだ発展途上という感がある。
これから普及していくかというのは今のところ何とも言えない。
ちなみに作者の1人は Data Mesh Manager というサービスの制作者であり、(ここまでのところでは触れていなかったが) Data Contract CLI にはこの Data Mesh Manager との連携機能が組み込まれている。
data contract ファーストの世界のビジョンはとても面白いと思ったので、いい方向でツールが発展していくとうれしい。