Chimney: 似てるけど少しだけ違うクラスへの変換を簡単に

ある程度の規模のシステムのプログラミングをしていると、「だいたい同じだけどちょっと違う」オブジェクトの間の変換コードを書くことがある。特に、素振り用のトイプログラムやソフトウェア治具的な小さなツールなどよりも、実プロジェクトのプロダクトコードで一般に多くなる。

今回は、こうしたボイラープレートをシンプルにする [Chimney](https://github.com/scalalandio/chimney) というライブラリを紹介する。

####バージョンなど
- Scala: 2.13.0
- avro4s: 3.0.1
- refined: 0.9.9
- chimney: 0.3.2
- sbt-avrohugger: 2.0.0-RC19 (plugins.sbt で指定)

[動くソース](https://gist.github.com/yasuabe/036be35530ac3d24a49fc26221a304b0)

## お題
以下のような状況で、似てるけどちょっとだけ異なるデータの形式をよく見かける。

1. DDD な開発などで、同じ対象をサブドメインごとにそれぞれ少し違った観点でとらえたデータ型の定義がある場合。
2. ドメインモデルの層と別に、UI や REST API に関連する層があって、それぞれ対応するクラスを持つ場合。
3. バージョンが異なるスキーマ間でのマイグレーション。
4. モデルの意図を型で表現したドメインのクラスに対応して、IDL から自動生成したような、属性の型に基本的にプリミティブを使っているクラスが別にある場合。

Chimney の使い方としてはどれでも変わりないが、今回は、4番目の自動生成コードで試してみる。

題材としては、[Apache Avro](https://avro.apache.org/)のScala用ライブラリである [avro4s](https://github.com/sksamuel/avro4s) のサンプルを使う。

## 試行
[avro4s](https://github.com/sksamuel/avro4s)の README に、[ピザのIDL](https://github.com/sksamuel/avro4s#schemas)の例がある。sbt プラグインの[sbt-avrohugger](https://github.com/julianpeeters/sbt-avrohugger)を使うと、この IDL から以下のような Scala コードが生成[^1]される。以降これを Avro のピザと呼ぶことにする。

```scala
package test.avro4s.json

//auto generated code by avro4s
case class Ingredient(
  name: String,
  sugar: Double,
  fat: Double
)

//auto generated code by avro4s
case class Pizza(
  name: String,
  ingredients: Seq[test.avro4s.json.Ingredient],
  vegetarian: Boolean,
  vegan: Boolean,
  calories: Int
)
```

これに対して、以下のようなよく似たクラスがドメイン層にもあるとする。以降、これをドメインのピザと呼ぶことにする。

```scala
case class Ingredient(
  name:  Name,
  sugar: Amount,
  fat:   Amount
)
case class Pizza(
  name:           Name,
  ingredients:    List[Ingredient],
  calories:       Int,
  vegetarianType: VegetarianType = VegetarianType.None
)
```

avro4s で自動生成された Avroのピザでは、各パラメータの型がプリミティブであるのに対し、ドメインのピザではモデルの意図を反映した以下のような型[^2]を使っている[^3]。

```scala
case class Amount(value: Double) extends AnyVal

type Name = String Refined NonEmpty

sealed trait VegetarianType
object VegetarianType {
  case object None       extends VegetarianType
  case object Vegetarian extends VegetarianType
  case object Vegan      extends VegetarianType
}
```

ここで、次のようなドメインのピザのインスタンスがあるとする。

```scala
val domainPizza = Pizza(
  name        = refineMV("pepperoni"),
  ingredients = List(//| name        | sugar     | fat
    Ingredient(refineMV("pepperoni"), Amount(12), Amount(4.4)),
    Ingredient(refineMV("onions"),    Amount(1),  Amount(0.4))
  ),
  calories    = 98
)
```
これを Avro のピザに変換する素朴なコードは、例えば以下のようになるが、、、

```scala
val avroPizza1 = avro.Pizza(
  name        = domainPizza.name.value,
  ingredients = domainPizza.ingredients.map { i =>
    avro.Ingredient(
      name  = i.name.value,
      sugar = i.sugar.value,
      fat   = i.fat.value)
  },
  calories   = domainPizza.calories,
  vegetarian = domainPizza.vegetarianType != VegetarianType.None,
  vegan      = domainPizza.vegetarianType == VegetarianType.Vegan
)
```
Chimney を使うと、こうしたボイラープレートを減らすことができる。

```scala
implicit val nameToString: Transformer[Name, String] = _.value

val avroPizza2 = domainPizza.into[avro.Pizza]
  .withFieldComputed(_.vegetarian, _.vegetarianType != VegetarianType.None)
  .withFieldComputed(_.vegan,      _.vegetarianType == VegetarianType.Vegan)
  .transform
```

以下、個別に見てみる(クラス内の定義順とは前後する)。
#### 同型同名のパラメータ
`calories: Int`のように、同じ型で同じ名前のパラメータが変換先にある場合、特に何も指定しなくても自然と転記される。

#### Value Class
`sugar`や`fat`で指定している`Amount`型のような、いわゆる value class も Chimney でサポートされていていて、自然に変換される（[参考](https://scalalandio.github.io/chimney/#Valueclasses)）。

#### コレクション型
`ingredients`のようなコレクションも、要素となる型の間で変換が可能であれば、特に`map`するコードを書かなくても変換先のコレクションに自然に転記される。

#### Chimney で変換が提供されない独自の型
上の例では、refined を使って`String`に非空文字列の制約を付与した`Name`型を使っているが、こうした Chemney で直接サポートされていない型を変換するには、スコープ内で暗黙の`Transformer`を定義する。ただし例のように一つ定義しておけば、Pizza の名前でも Ingredient の名前でもどちらでも使われる。

#### 個別に計算する場合など
上の例では、ドメインのピザの`vegetarianType`から、Avroのピザの`vegetarian`と`vegan`を導出している[^4]。このように計算結果をターゲットオブジェクトに反映する場合には、Chimney の`withFieldComputed`が使える。また、この例には含まれないが、変換元に含まれない値を使いたければ`withFieldConst`、パラメータの名前が違う場合には`withFieldRenamed`が使える。

## 所感
- 実プロジェクトでよくみられるような、パラメータ数がもっと多かったり構造がさらに複雑なデータの場合、ボイラープレートな変換コードをかなり減らせそうな気がする。
- ただし、ソースオブジェクトとターゲットオブジェクトの違いが大きくて、大半のパラメータについて計算やリネームが必要な場合には、ふつうにケースクラスのコンストラクタと名前付き引数を併用する方が、むしろ読みやすいかもしれない。

## 参考
- [Chimney: Scala library for boilerplate-free data transformations](https://scalalandio.github.io/chimney/#Gettingstarted)

---------------------

[^1]: src/main/avro/Pizza.avsc に Pizzaサンプルの IDL を書いて、sbt から`avroScalaGenerate`コマンドを実行すると、target/scala-2.13/src_managed/main/compiled_avro/test/avro4s/json/下に、Pizza.scala、Ingredient.scala が出力される。上の試行では、namespace だけ `test.avro4s.json` に変えた。
[^2]: DDDで言えば、*Supple Design* 下の *Intention-Revealing Interfaces*
[^3]: `calories`、`Amount#value` にも refined ライブラリを適用できるが、説明のためにあえてプリミティブとした。
[^4]: `VegetarianType`は、ベジタリアン用でなければ`None`、「厳密な」ベジタリアン用ならば`Vegan`、それ以外は`Vegetarian`と解釈している。