ディレクトリ構造から学ぶ dbt ベストプラクティスのすゝめ

2023-04-21

Overview

データエンジニアやデータ分析者にとって、dbt はとても便利なツールであることは言うまでもありません。しかし、dbt は使い方を誤るとデータ品質の低下や開発プロセスの遅延を引き起こす可能性があります。Belong では 2022 年にデータプラットフォームチームを立ち上げ 1 、同じ年の後半から dbt を使い始めました。 本記事では、ディレクトリ構造を題材に、開発を通して実感した dbt のベストプラクティスを遵守することの重要性をお伝えします。

ベストプラクティスのすゝめ

ベストプラクティスとは最良の方法である

dbt の開発だけに限った話ではないですが、守破離の「守」をマスターすることはとても大事なことだと言えます。そのことを今回は、ディレクトリ構造を題材に考えてみます。 dbt に限らず開発において、ディレクトリ構造は非常に重要な要素です。何故ならばディレクトリ構造はプロジェクトの可読性、保守性、拡張性に直結するからです。守破離を守らないと具体的にどういう問題に直面するか、私達の例を元に解説していきたいと思います。

まずは私達のデータ基盤の構成図について紹介します。どのような試行錯誤の元このような構成になったかは、以前、弊社 CTO が公開した記事「Belong のデータプラットフォームチーム立ち上げ」を御覧ください。

dbt structure

jaffle shop をお借りして、ディレクトリ構造がどのようになるのか今回は /models に限定して見ていきます。

上記構成を実現するために、私達が取ったディレクトリ構成は下記の形となります。ポイントとしては、/models 直下のサブディレクトリ( dbt におけるレイヤー)と Dataset を一致させるように構成してるところです。レイヤーはそれぞれ、DataSource からの抽出先を raw もしくは master、加工済み時系列データの蓄積を warehouse、ユーザーに提供するデータを report に置くこととしています。 この構成で起こった問題として、プロジェクト内のファイルやディレクトリの役割が分かりにくくなり、理解するために時間がかかったりメンバー間で認識がずれる要因にもなりました。結果として、データの関連性が複雑になるため保守性が低下し、拡張性も制限されました。

jaffle_shop
(省略)
├── models
│   ├── master
│   │   ├── [テーブル].sql
│   │   ├── schema.yml
│   │   └── sources.yml
│   ├── raw
│   │   ├── [テーブル].sql
│   │   ├── schema.yml
│   │   └── sources.yml
│   ├── report
│   │   ├── [テーブル].sql
│   │   └── schema.yml
│   └── warehouse
│       ├── [テーブル].sql
│       └── schema.yml
(省略)

上記構成に対し、ベストプラクティスでは大きく staging, intermediate, marts の 3 つのレイヤーに分けています。( utilities というフォルダもありますが、今回は割愛します) 以下は dbt のベストプラクティスから拝借した /models 配下の一例です。

jaffle_shop
(省略)
├── models
│   ├── intermediate
│   │   └── [エンティティ]
│   │       ├── _int_[エンティティ]__models.yml
│   │       └── [テーブル].sql
│   ├── marts
│   │   └── <エンティティ名>
│   │       ├── _<エンティティ名>__models.yml
│   │       └── <テーブル名>.sql
│   ├── staging
│   │   └── [ソース]
│   │       ├── _[ソース]__models.yml
│   │       ├── _[ソース]__sources.yml
│   │       ├── base
│   │       │   └── base_[ソース]__[コンセプト].sql
│   │       └── stg_[ソース]__[エンティティ]s.sql
│   └── utilities
│       └── all_dates.sql
(省略)

パット見ただけでも使われている名称や規則が違うことがわかります。ベストプラクティスではどういう意図があるのでしょうか? 次に、各フォルダにおける役割やルールを簡単に説明します。

変換プロセスの先陣を担う staging

staging は、DataSource からデータを取り込むために使用されます。通常、各 DataSource ごとにサブディレクトリが切られ、データのクレンジング、変換、整形が行われます。注意するポイントは、2 つあります。1 つ目は、staging では DataSource からデータを「抽出するだけではない」ということ。2 つ目は「View Table として定義すること」です。DRY 原則を意識することが staging では特に重要となります。

コードを DRY に保つために、同じ変換を複数回しているようなケースは staging に記載します。オーソドックスな変換として、リネーム、キャスト、計算、分類(集約はしません)があります。例えば、公式に記載されている下記のようなコードを書きます。

-- stg_stripe__payments.sql

with

source as (

    select * from {{ source('stripe','payment') }}

),

renamed as (

    select
        -- ids
        id as payment_id,
        orderid as order_id,

        -- strings
        paymentmethod as payment_method,
        case
            when payment_method in ('stripe', 'paypal', 'credit_card', 'gift_card') then 'credit'
            else 'cash'
        end as payment_type,
        status,

        -- numerics
        amount as amount_cents,
        amount / 100.0 as amount,

        -- booleans
        case
            when status = 'successful' then true
            else false
        end as is_completed_payment,

        -- dates
        date_trunc('day', created) as created_date,

        -- timestamps
        created::timestamp_ltz as created_at

    from source

)

select * from renamed

コード引用元: https://docs.getdbt.com/guides/best-practices/how-we-structure/2-staging#staging-models

DataSource 上に存在する複数のテーブルを結合することで、必要なデータが取れるようなケースの場合はどうするのでしょうか。その答えは、base モデルを構築することで解消されます。解説やサンプルコードが長くなるので今回は説明しませんが、気になった方は公式ドキュメントを参照していただければと思います。

これにより、staging 上でデータの最小構成のモジュールが作成されます。私たちが適用していたモデルと比較すると、raw (+ subject という、DataSource 上に作成した特定のコンテキストでデータをまとめたもの) が近いように思えます。しかし、どこで変換を行うのか責務が明確であること、コードが DRY に保たれること、そして開発が全てデータプラットフォームに集約することから、ベストプラクティスを採用すると保守性と拡張性が高いことがわかります。

ビジネスドメインごとの明確な目的を実現する intermediate

ビジネスドメインごとの明確な目的を実現するために intermediate は存在します。intermediate の目的は、marts から複雑さを解消することです。intermediate にモデルを作成する場合は、ビジネス上の関心領域ごとにサブディレクトリを切ります。staging とは異なり、モデルを DataSource ではなくビジネス上の関心領域に適合するように構築することに注意してください。また、intermediate は少数のモデルを扱う場合は不要です。公式では、marts のモデルが 10 個未満でそれらの開発と使用に問題がない場合は、intermediate は不要であると記述されています。

intermediate モデルを構築する場合、公式に記載されているコード例は下記となります。 構築する利点は複雑あるいは理解が難しいロジック部分を marts から分離することで、保守性が上がることです。私たちが適用していたモデルと比較する構成が存在しないため説明は省略します。詳しく気になる方は公式ドキュメントを見ていただければと思います。

-- int_payments_pivoted_to_orders.sql

{%- set payment_methods = ['bank_transfer','credit_card','coupon','gift_card'] -%}

with

payments as (

   select * from {{ ref('stg_stripe__payments') }}

),

pivot_and_aggregate_payments_to_order_grain as (

   select
      order_id,
      {% for payment_method in payment_methods -%}

         sum(
            case
               when payment_method = '{{ payment_method }}' and
                    status = 'success'
               then amount
               else 0
            end
         ) as {{ payment_method }}_amount,

      {%- endfor %}
      sum(case when status = 'success' then amount end) as total_amount

   from payments

   group by 1

)

select * from pivot_and_aggregate_payments_to_order_grain

コード引用元: https://docs.getdbt.com/guides/best-practices/how-we-structure/3-intermediate#intermediate-models

特定のエンティティを表現する marts

marts は、ユーザーから唯一参照可能なレイヤーで、特定のエンティティ(コンセプトとも呼ばれます)概念を独自の粒度で表現します。エンティティレイヤーやコンセプトレイヤーとも呼ばれます。marts のモデルは、wide-table 2 によって非正規化されたモデルで設計され、誰かが必要とするすべての情報を提供できるようにします。

公式に記載されているコード例は下記となります。

-- customers.sql

with

customers as (

    select * from {{ ref('stg_jaffle_shop__customers')}}

),

orders as (

    select * from {{ ref('orders')}}

),

customer_orders as (

    select
        customer_id,
        min(order_date) as first_order_date,
        max(order_date) as most_recent_order_date,
        count(order_id) as number_of_orders,
        sum(amount) as lifetime_value

    from orders

    group by 1

),

customers_and_customer_orders_joined as (

    select
        customers.customer_id,
        customers.first_name,
        customers.last_name,
        customer_orders.first_order_date,
        customer_orders.most_recent_order_date,
        coalesce(customer_orders.number_of_orders, 0) as number_of_orders,
        customer_orders.lifetime_value

    from customers

    left join customer_orders on customers.customer_id = customer_orders.customer_id

)

select * from customers_and_customer_orders_joined

コード引用元: https://docs.getdbt.com/guides/best-practices/how-we-structure/4-marts#marts-models

また、マテリアライゼーションに関する一般的な経験則として、View Table から始め、次第に実テーブルまたは増分モデルへシフトするのが良いとされています。具体的な目安としては、クエリを実行するのに時間がかかりすぎたら実テーブルとして構築し、その後、テーブルの構築に時間がかかりすぎて実行が遅くなる場合は、増分モデルとして構成する、といった流れになります。

私たちが適用していたモデルと比較すると、warehouse と report を組み合わせた形が近いように思えます。しかし、ユーザーが参照できる範囲が明確であることやマテリアライゼーションが明示的であること、またどのような変換をどこに入れるのかが明確なところから、ベストプラクティスを採用するほうが保守性が高いことがわかります。

私たちが適用していたモデルが全て悪いということではないですが、ベストプラクティスを採用することで明確な基準や先人たちの知見を活かすことができるため、守破離の「守」を明確に破る必要が出るフェーズに入るまでは、ベストプラクティスに準拠することが多くのメリットを享受できるということがわかりました。

Conclusion

本記事では、dbt を利用してデータパイプラインを構築する際には、公式のベストプラクティスを理解しておくことの重要性について紹介しました。各フォルダにはそれぞれ守るべきルールが他にもありますが、今回詳細は割愛しております。詳細が気になった方は是非、ベストプラクティスを見ていただければと思います。

データプラットフォームチームでは、よりスピーディーにより高品質なデータを提供できるようにするメンバーを更に募集中です。一緒にデータ分析基盤を作りたいと思っていただけた方はもちろん、上のアーキテクチャをみて気になった方、など話してみたいといった方のカジュアル面談などもお待ちしています...!

エンジニアリングチームの紹介

データエンジニアの求人

Footnotes

  1. Belong のデータプラットフォームチーム立ち上げ

  2. https://developer.alibabacloud.com/tech-news/dws/giaeccylu2-data-warehouse-design-wide-tables