Lens の実用例

ご無沙汰しています。最近ヒトカラがマイブームのあおいの(@aoiroaoino)です。
そういえば ScalaMatsuri 2016 のスポンサー LT でこんなこと言っていたのを思い出しました。

「弊社では Lens(Monocle) を test で使用しています。」

今回はこれの話をしようと思います。

該当スライドはこの辺↓です。

モチベ

スライド中のコードは実際に使われていたコードを多少弄ったものです。ちょっとそのまま公開するわけにはいかないので、同じようなメソッドが定義できるよう適当なそれっぽいデータ型を用意します。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
case class CampaignStatus(
  id:            Long,
  monthlyStatus: MonthlyStatus,
  dailyStatus:   DailyStatus,
  urls:          List[String],
  readAt:        Date
)

case class MonthlyStatus(win: Win, bid: Bid)
case class DailyStatus(win: Win, bid: Bid)

case class Win(cpm: CPM)
case class Bid(cpm: CPM)

case class CPM(value: Long)

CampaignStatus は月/日それぞれの入札に使用した金額と落札した額を保持しているイメージです。ミドルウェアへアクセスして上記のデータを取得できるとしましょう。

しかし、例えば「monthlyStatus.win.value の値が別途設定された 数値の上限以下か判定する」等の validation を行うメソッドのテストを書きたい時に、わざわざミドルウェアに接続したくはないですね。とはいえ、

1
2
3
4
5
6
7
8
9
10
11
val data = CampaignStatus(
  999L,
  MonthlyStatus(
    Win(CPM(2000)),
    Bid(CPM(2000))),
  DailyStatus(
    Win(CPM(1000)),
    Bid(CPM(1000))),
  List("http://example.com"),
  new Date()
)

上記のように直接値を渡して想定するテスト条件(データ)を毎回書くのも面倒です。ってことで、このデータ構造に対する内部 DSL を用意してカッコ良くテストデータを作ろうって話です。結論を先に言ってしまうと、この DSL の中で Lens が活躍してくれます。

内部 DSL を作る

まずはエントリーポイントとなる最初のデータを作ります。今回は id と readAt は必須項目として引数で渡すようにし、残りは適当に zero を初期値としました。

以降、CampaignStatus から流れるようなスタイルで書きたいので、CampaignStatus から CampaignStatusBuilder への implicit conversion を定義しておきます。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
object CampaignStatusBuilder {

  def newCampaignStatus(id: Long, readAt: Date): CampaignStatus =
    CampaignStatus(
      id,
      MonthlyStatus(
        Win(CPM(0)),
        Bid(CPM(0))),
      DailyStatus(
        Win(CPM(0)),
        Bid(CPM(0))),
      List.empty[String],
      readAt
    )

  implicit def toCampaignStatusBuilder(status: CampaignStatus): CampaignStatusBuilder =
    new CampaignStatusBuilder(status)
}

で、変換先の CampaignStatusBuilder 内に必要な操作を定義します。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
class CampaignStatusBuilder(status: CampaignStatus) {

  def modifyMonthlyWinStatus(f: Long => Long): CampaignStatus =
    status.copy(
      monthlyStatus = status.monthlyStatus.copy(
        win = status.monthlyStatus.win.copy(
          cpm = status.monthlyStatus.win.cpm.copy(
            value = f(status.monthlyStatus.win.cpm.value)
          )
        )
      )
    )

  def setMonthlyWinStatus(v: Long): CampaignStatus =
    modifyMonthlyWinStatus(_ => v)

  // ...

  def addUrl(url: String): CampaignStatus =
    status.copy(
      urls = url :: urls
    )
}

そうすると、以下のように使うことができます。

1
2
3
4
5
6
7
8
9
val data1 = newCampaignStatus(999L, new Date())
  .setMonthlyWinStatus(1000)
  .setDailyWinStatus(100)
  .addUrl("http://example.com")
  .addUrl("http://example.org")

val data2 = data1
  .modifyMonthlyWinStatus(_ * 2)
  .modifyDailyWinStatus(_ * 2)

さて、ここまでで implicit conversion を使ってるくらいで特に難しいことはありません。が、copy メソッドの辛さが目につきますね。

Lens を使う

もうお気付きと思いますが、modify や set メソッドの中で Lens を活用することができます。

1
2
3
4
5
6
7
8
9
def modifyMonthlyWinStatus(f: Long => Long): CampaignStatus =
  GenLens[CampaignStatus](_.monthlyStatus.win.cpm.value).modify(f)(status)

// monocle.syntax.apply._ を使う
def setMonthlyWinStatus(v: Long): CampaignStatus =
  status &|-> GenLens[CampaignStatus](_.monthlyStatus.win.cpm.value) set v

def addUrl(url: String): CampaignStatus =
  status &|-> GenLens[CampaignStatus](_.urls) modify (url :: _)

上記の例では完全にラップしてしまいましたが、ある程度 Monocle に対しての理解があれば、各変数に対する Lens を定義しておくだけでもOKです。

1
2
  def dailyBidStatusLens =
    status &|-> _dailyStatus ^|-> _bid ^|-> _cpm ^|-> _value

そうすると setXXX や modifyXXX ってメソッドを網羅的に定義しなくても済みます。

1
2
3
val data3 = newCampaignStatus(999L, new Date())
  .dailyBidStatusLens.set(100)
  .dailyBidStatusLens.modify(_ + 200)

こんな感じで DSL を作るにあたり、実装をシンプルに書けるメリットがあります!ってことで、test 側に Monocle への依存を追加することが出来ました。(ぇ

まとめ

以上、実用例でした。サンプルコードは gist に置きましたのでどうぞ。