Community

Refinementsを使ってLookMLのコードを整理する

本記事はこちらの英語記事:us: の翻訳です。

Looker 7.6 より LookML に refinements が追加されました。本記事では refinements がどのように動作するのかを説明したいと思います。また、関連するコードを一緒に管理するために、どのようにLookMLコードを整理、または「レイヤー化」すれば良いのかについて書いてみたいと思います。

Refinements とは?

Refinementsは、ベースとなるLookMLのオブジェクトに対して変更を加えることができると言う意味で Extends(継承) とよく似ていますが、新しい名前のオブジェクトを新規に作成するのではなく、既存のオブジェクトに対して直接上書きして変更を加える言う点が異なります。

基本的な例を見てみましょう。プラス(+)記号に注意してください。

view: fact {
  sql_table_name: fact ;;
}

view: +fact {
  label: "Facts"
  dimension: date { type:date }
}

view: +fact {
  label: "Quantified Facts"
  dimension: amount { type:number }
}

これら3つの独立した宣言は、Extendsと同一のマージ規則にしがって効率的に結合されます。これは、以下の記述と同等です:

view: fact {
  sql_table_name: fact ;;
  label: "Quantified Facts"
  dimension: date { type:date }
  dimension: amount { type:number }
}

これは非常に小さな変更であると同時に、非常に大きな変更でもあります!Refinementsの使い所はたくさんあるでしょう。例えば、LookML Blocks やプロジェクトをまたいでインポートされたLookMLのモデルなどです。ただ、ここでは、もう少し面白い、かつ広く適用可能な使用方法に着目してみたいと思います:チームで開発しているときに、LookMLプロジェクトをより管理しやすくする方法です。

LookML のレイヤー化

通常、LookMLはLookerのオブジェクトに沿ってコードを管理する必要があります。つまり、ViewやModelです。

例を挙げますと、例えば共通ロジックをより簡潔に理解できるように、二つの隣り合う関連ディメンションを記述したいと言う場合があるとします。これを実現するには、これら二つのディメンションが同じViewファイルに存在していなければなりません。

ではこれを、Refinementsを使って書いてみましょう。非常に柔軟な書き方ができることがわかると思います。Viewを一箇所で定義し、その後に別の場所にディメンションを定義します。これは複数の既存Viewにまたがっていたとしても問題ありません。

実務的には、これはコードをいくつもの「レイヤー(層)」に整理していると言うことに他なりません。

補足: `.layer.lkml` ファイル形式って何?

.lkmlファイル形式のファイルは、フィールドピッカー上のアイコンを変えている場合をのぞいて、.model.lkmlファイル形式だけが具体的な機能を備えています。.view.lkml を含むその他のファイル形式は、純粋に整理のためだけに使用されます。

ここで.layer.lkmlを使用するのは、潜在的に複数のRefinementsレイヤーを組み合わせてLookMLの機能をレイヤー化している、ということを表すためです。とは言え、このファイル形式には何の魔法もありません。これはその他の .lkml ファイルと同じように振る舞います。なのでこう言う場合は、このファイル形式を使っていただくことをお勧めします。

ここで、レイヤーの概念を適用する場合、完全に有効なファイルの命名規則とフォルダー構造がたくさんあるので、プロジェクトに適した方法で概念を適用できるように、各レイヤーが「なぜこうなっているのか」について、もう少し深掘りしてみたいと思います。

Modelファイル

Lookerは開始点を定義するためのModelファイルが一つ必要になります。このファイルには、そのほかのファイルをincludeする他はほとんど何も書かなくて大丈夫です。

“基礎”あるいは“生の”レイヤー (_base.layer.lkml)

このレイヤーは自動生成されたLookMLコードを維持するために使用されます。この場合、データベーススキーマで変更が発生した場合、LookMLジェネレーターを再実行するだけで、これらの更新をすべて取得できます。続くレイヤーに加えた手書きの変更に影響を与えることはありません。

このレイヤーでは多くのファイルが作成される可能性がありますが、自動生成されたコードはなるべく1つのファイルにまとめてしまった方が便利だと考えています。このワークフローをより有効にするために、LookMLで自動生成されるLookMLは、Viewごとに1つのファイルではなく、まとめて1つのファイルに出力できるように取り組んでいます。

ファイルの命名規則はそれぞれ異なることがありますが、先頭にアンダースコアをつけて、フィールドピッカーで他のファイルよりも先頭になるようにすることをお勧めします。

“通常”または“標準”レイヤー (_basic.layer.lkml)

このレイヤーでは、自動生成されたLooKMLコードを拡張するためにRefinementsを使用して、生成されたスキーマから成る構造に沿って標準的な宣言を使っていきます。ここでの考え方は、特定のビジネスロジックやユースケースには着目するのではなくて、データの構造により密接に関連したものだけを含めることです。プライマリーキー宣言や、ビジネスに関連しないフィールドをHiddenにしたり、基本的なラベル付けや説明書きの追加などがそれにあたります。外部キーを使ってmany_to_one結合を使用した基本的なExploreですら、ここに含まれるようになります。

一つのファイルであっても、複数のファイルであっても、両方とも有効です。1つのモノリシックファイルをメンテしていくこともできますし、「スキーマ」または「データセット」によってそれらを複数のファイルに分離したり、従来のビューごとに1ファイルのアプローチをそのまま使うことも可能です。

論理レイヤー

ここは魔法が起きる部分です。いくつものレイヤーを持つことができ、それぞれは関連するビジネスロジックを含めるようにします。

このコンセプトは全く新しいものなので、例がとても長くなってしまうと思います!

さて、注文に対する利益を価格マイナスコストと定義します。このディメンションはデータベーススキーマから直で発生していないフィールドになので、これを標準レイヤーから分解して、それぞれ独立したレイヤーに、関連するロジックとともに加えていきます。さらに、一つのファイルの中に、複数のビューにまたがる関連ロジックを定義することだってできます。

# Profit Logic

view: +orders {
  dimension: profit { type:number sql: ${price} - ${cost} ;; }
  measure: total_profit { type:sum sql: ${profit} ;; }
}

view: user_profit {
  derived_table:{
    explore_source: orders {
      column: user_id {field: orders.user_id}
      column: user_profit {field: orders.total_profit}
    }
  }
  dimension: user_id { primary_key: yes hidden: yes }
  dimension: user_profit { hidden: yes }
}

explore: +users {
  join: user_profit {
    view_label: "Users"
    sql_on: ${user_profit.user_id} = ${users.id} ;;
    relationship: one_to_one
  }
}
サンプルプロジェクト全体をみて比較してみましょう

同じUsersとOrdersスキーマを使用して、プロジェクト全体が従来のアプローチで記述された場合と、このレイヤー化アプローチとでどのように違うのかをみてみましょう。

特に、利益(Profit)と顧客(Customer)の部分の関係性がどのように分離されているかに注意してください。ただし、それぞれの関連性に適用される複数のオブジェクトはグループ化されています。

簡潔にするために、LookMLはよりコンパクトなスタイルで記述されており、かなり最小限のスキーマが想定されています。また、自動生成された「ベース」レイヤーは単なる例であり、生成に使用したプロセスによって異なる場合があります。

伝統的な記述例

thelook.model.lkml

connection: "..."

include: "users.view.lkml"
include: "orders.view.lkml"
include: "user_summaries.view.lkml"

explore: orders {
  join: users {
    sql_on: ${users.id} = ${orders.user_id} ;;
    relationship: many_to_one
  }
  join: user_summaries {
    sql_on: ${user_summaries.user_id} = ${users.id} ;;
    relationship: one_to_one
  }
}

explore: users {
  join: user_summaries {
    sql_on: ${user_summaries.user_id} = ${users.id} ;;
    relationship: one_to_one
  }
  always_filter: {
    filters: [user_summaries.is_customer: "Yes"]
  }
}

explore: _orders {
  from: orders
  view_name: orders
  hidden: yes # Un-joined version just used for NDTs
}

users.view.lkml

view: users {
  sql_table_name: thelook.users ;;

  dimension: id      {primary_key: yes }
  dimension: created { type:date }
}

orders.view.lkml

view: orders {
  sql_table_name: thelook.orders ;;

  dimension: id      { primary_key: yes }
  dimension: user_id { hidden: yes }
  dimension: cost    { type:number }
  dimension: price   { type:number }
  dimension: profit  { type:number sql:${price} - ${cost};; }
  dimension: date    { type:date }

  measure: count         { type:count }
  measure: total_revenue { type:sum    sql:${price};; }
  measure: total_cost    { type:sum    sql:${cost};; }
  measure: total_profit  { type:sum    sql:${profit};; }
  measure: total_margin  { type:number sql:${total_profit}/${total_revenue};; }
  measure: any_orders    { type:yesno  sql:MAX(${id} IS NOT NULL);; }
}

user_summaries.view.lkml

view: user_summaries {
  derived_table: {
    explore_source: _orders {
      column: user_id     { field: orders.user_id }
      column: is_customer { field: orders.any_orders }
      column: user_profit { field: orders.total_profit }
    }
  }
  dimension: user_id         { primary_key:yes hidden: yes}
  dimension: is_customer     { type:yesno }
  dimension: user_profit     { type:number }
  measure: total_user_profit { type:sum   sql:${user_profit};;}
  measure: any_customer      { type:yesno sql:MAX(${is_customer});; }
}

レイヤー化アプローチ

thelook.model.lkml

connection: "..."

include: "_basic.layer.lkml"
include: "profit.layer.lkml"
include: "customer.layer.lkml"

_base.layer.lkml

# Machine-generated. Do not edit by hand.

explore: orders { hidden:yes }
view: orders {
  sql_table_name: thelook.orders ;;
  dimension: id      { type:string}
  dimension: user_id { type:string}
  dimension: cost    { type:number }
  dimension: price   { type:number }
  dimension: date    { type:date }
  measure: count     {type:count}
}

explore: users { hidden:yes }
view: users {
  sql_table_name: thelook.users ;;
  dimension: id      { type:string }
  dimension: created { type:date }
}

_basic.layer.lkml

include: "_base.layer.lkml"

explore: +orders {
  hidden:no
  join: users {
    sql_on: ${users.id} = ${orders.user_id} ;;
    relationship: many_to_one
  }
}

explore: +users { hidden:no }

view: +orders {
  dimension: id      { primary_key:yes }
  dimension: user_id { hidden:yes }
}

customer.layer.lkml

include: "_basic.layer.lkml"

view: +orders {
  measure: any_orders    { type:yesno  sql:MAX(${id} IS NOT NULL);; }
}

view: user_is_customer {
  derived_table: {
    explore_source: orders {
      column: user_id     { field: orders.user_id }
      column: is_customer { field: orders.any_orders }
    }
  }
  dimension: user_id         { primary_key:yes hidden: yes}
  dimension: is_customer     { type:yesno }
  measure: any_customer      { type:yesno sql:MAX(${is_customer});; }
}

explore: +users {
  join: user_is_customer {
    view_label: "Users"
    sql_on: ${user_is_customer.user_id} = ${users.id} ;;
    relationship: one_to_one
  }
  always_filter: {
    filters: [user_is_customer.is_customer: "Yes"]
  }
}

explore: +orders {
  join: user_is_customer {
    view_label: "Users"
    sql_on: ${user_is_customer.user_id} = ${orders.user_id} ;;
    relationship: one_to_one
  }
}

profit.layer.lkml

include: "_basic.layer.lkml"

view: +orders {
  dimension: profit      { type:number sql:${price}-${cost};; }
  measure: total_revenue { type:sum    sql:${price};; }
  measure: total_cost    { type:sum    sql:${cost};; }
  measure: total_profit  { type:sum    sql:${profit};; }
  measure: total_margin  { type:number sql:${total_profit}/${total_revenue};; }
}

view: user_profit {
  derived_table:{
    explore_source: orders {
      column: user_id {field: orders.user_id}
      column: user_profit {field: orders.total_profit}
    }
  }
  dimension: user_id { primary_key: yes hidden: yes }
  dimension: user_profit { type: number}
  measure: total_user_profit { type:sum sql:${user_profit};; }
}

explore: +users {
  join: user_profit {
    view_label: "Users"
    sql_on: ${user_profit.user_id} = ${users.id};;
    relationship: one_to_one
  }
}

explore: +orders {
  join: user_profit {
    view_label: "Users"
    sql_on: ${user_profit.user_id} = ${orders.user_id} ;;
    relationship: one_to_one
  }
}

早速やってみましょう

この、とても小さな、しかしとても強力な新しい文法によって生み出されるRefinementsが、本質的な転換になっていることを皆さんにご理解いただければ幸いです。

まったく新しいアプローチとして、すべてのLookML開発者チームがすぐにそれを採用するわけではありません。ただし、他のフレームワークや言語で見られた同様の変化、たとえば、クラスベースの宣言からフックベースの宣言に移行するReactのrationaleなど、説得力のある類似点は見られます。

要するに何が言いたいかというと、レイヤー化を試してみて、それがプロジェクトにどのように役立つかをやってみて欲しいということです!

このアプローチについてどう思いますか?是非試してみてください!