現在 Scala で実装されたサービスの開発に携わっているのですが、そこで定義される Entity のほとんどは ID を case class で表現しています (e.g. Application クラスに対し case class ApplicationId(value: Long) で ID を表現する)。

ID を 1 つのクラスとして表現するというのはよくあるモデリングだと思うのですが、見ているとそうしたクラスには Iso 型クラスの定義が与えられていることに気が付きました。

Iso という概念に触れるのは初めてだったのですが、しばらく触っていてこれが地味に便利な表現だなと感じたので少し ID と Iso について整理してみたいと思いました。

使用しているサンプルは こちら で管理しています。

  1. Iso とは
  2. ID trait
  3. e.g. Application Entity
  4. ScalaCheck と Iso な ID
  5. SkinnyORM と Iso な ID

1. Iso とは

拾い物ですが こちらのスライド の 4 枚目あたりの解説がわかりやすいと思います。

平易な日本語で表現すると、2 つの集合間で相互変換が保証されているという感じでしょうか。

Scala では trait を使用して以下のように表現できそうです。 (上の定義を見ると本当は reverseGet(get(a)) == a のような保証が必要な気がします -> あとで ScalaCheck でテストする)

// 相互に変換する関数から Iso を定義する
// trait の不変条件として reverseGet(get(a)) == a, get(reverseGet(b)) == b
trait Iso[A, B] {
  def get(a: A): B
  def reverseGet(b: B): A
}

object Iso {
  // 相互に変換する関数を与えることで Iso を生成する
  def apply[A, B](_get: (A) => B)(_reverseGet: (B) => A): Iso[A, B] =
    new Iso[A, B] {
      override def get(a: A): B = _get(a)
      override def reverseGet(b: B): A = _reverseGet(b)
    }
}

2. ID trait

ドメインモデルにおける各 Entity が ID をクラスとして表現すると想定します。

例えば Application entity に対する ID ApplicationId は以下のように定義できます。

case class ApplicationId(value: Long)

ApplicationId というのは名前で ID っぽいというのはわかるかもしれませんが、それを明示的に示すために IdLike という trait を定義します。

trait IdLike {
  type Value
  def value: Value
}

trait LongIdLike extends IdLike {
  override type Value = Long
}

ApplicationIdLongIdLike です。

case class ApplicationId(value: Long) extends LongIdLike

IdLike を実装した ApplicationId のような case class は、IdLike#Value 型との間で相互変換が行える、つまり Iso であると言えるはずです。 そのため以下のような trait で Iso が導出されるようにできます。

trait IsoIdCompanion[A <: IdLike] {
  def apply(a: A#Value): A

  implicit lazy val iso: Iso[A#Value, A] =
    Iso[A#Value, A](apply)(_.value)
}

最終的な ApplicationId の定義は以下の感じになります。

case class ApplicationId(value: Long) extends LongIdLike

object ApplicationId extends IsoIdCompanion[ApplicationId]

3. e.g. Application Entity

上で定義した ApplicationId を使用した簡単な Application entity の定義は以下のようになります。

case class ApplicationId(value: Long) extends LongIdLike
object ApplicationId extends IsoIdCompanion[ApplicationId]

case class Application(
    id: ApplicationId,
    name: String
) extends Entity[ApplicationId]

ここで Entity[T] は 文字通り entity を表すための trait で、id を持つこと、また id のみで同値を判断することが定義されています。

trait Entity[ID] {
  def id: ID

  override def equals(obj: Any): Boolean = obj match {
    case e: Entity[ID] => this.id == e.id
    case _             => false
  }

  override def hashCode(): Int = 31 * id.##
}

4. ScalaCheck と Iso な ID

IdLikeIso を定義すると何が嬉しいのかという話なのですが、簡単にいうと様々な型クラスや定義の導出がシンプルかつ一貫したやり方で行えるというところかなと思います。

上で Iso trait を定義した際に、trait の不変条件として reverseGet(get(a)) == a, get(reverseGet(b)) == b を設定しました。 ここではその性質を保証できるようなテストを ScalaCheck で書いてみます。

Iso に対する上の不変条件のチェックは以下の isoLaws のようにして表現できると思います。

package com.tiqwab.example.app

import com.tiqwab.example.modeling.Iso
import org.scalacheck.{Gen, Prop}

trait PropUtils {

  def isoLaws[A: Gen, B: Gen](implicit iso: Iso[A, B]): Prop = {
    val x = Prop.forAll(implicitly[Gen[A]]) { (a: A) =>
      iso.reverseGet(iso.get(a)) == a
    }
    val y = Prop.forAll(implicitly[Gen[B]]) { (b: B) =>
      iso.get(iso.reverseGet(b)) == b
    }
    x && y
  }

}

object PropUtils extends PropUtils

上の isoLaws は Iso が定義される型 A, B それぞれに対し Gen 定義されていないといけないため、下記の Gens trait でそれを用意します。

package com.tiqwab.example.app

import com.tiqwab.example.modeling.{Iso, LongIdLike}
import org.scalacheck.Gen
import org.scalacheck.Arbitrary.arbitrary

trait Gens {

  def positiveLongGen: Gen[Long] =
    Gen.chooseNum(0, Long.MaxValue)

  // Long は Long でも ID 値として使用されるものは 正の数であるはず
  def longIdGen[A <: LongIdLike](implicit iso: Iso[Long, A]): Gen[A] =
    positiveLongGen.map(iso.get(_))

  lazy val applicationIdGen: Gen[ApplicationId] =
    longIdGen[ApplicationId]

}

object Gens extends Gens

ここでは Gen[ApplicationId]longIdGen という LongIdLike なものから Gen[A] を定義できる関数によって簡単に定義できるというのが嬉しいと思います。

例えば新たに UserId のようなものが出てくる場合でも、同様に longIdGen から導出できるはずです。 (というかうまくやれば xxxIdGen の宣言すらいらなくて longIdGen だけでいけるのでしょうか)

Gen[A] を定義したので、以下のようにして isoLaws による ApplicationId の Iso な性質のチェックを行うことができます。

package com.tiqwab.example.app

import org.scalatest.FunSuite
import org.scalatest.prop.Checkers._

class ApplicationIdTest extends FunSuite {
  import Gens._
  import PropUtils._

  test("iso check") {
    implicit val longGen = Gens.positiveLongGen
    implicit val appIdGen = applicationIdGen
    check(isoLaws[Long, ApplicationId])
  }

}

5. SkinnyORM と Iso な ID

Scala の ORM ライブラリ SkinnyORM には上記の ApplicationId のようにドメインモデルとして表現される ID と実際の RDB 上での id カラムとのマッピングを行うための trait が提供されています (e.g. SkinnyCRUDMapperWithId)。

ID モデルに Iso を定義することでこういった実装も repository 間である程度共通化することができます。

まずドメインモデルとして entity の永続化を行う Repository を定義します。

trait Repository[ID, E <: Entity[ID], Context] {
  def findById(id: ID)(implicit ctx: Context): Option[E]
  def store(entity: E)(implicit ctx: Context): E
  def deleteById(id: ID)(implicit ctx: Context): Int
}

次に永続化先を RDB とした RepositoryOnJdbc を定義します。 ここでは Context の型を scalikejdbc.DBSession に具体化しています。

またこのクラスは CRUDFeatureWithId[ID, E] を自分型アノテーションとして宣言しているので、SkinnyORM を知っているクラスということになります。

import scalikejdbc.{AsIsParameterBinder, DBSession}
import scalikejdbc.interpolation.SQLSyntax
import skinny.orm.feature.CRUDFeatureWithId

trait RepositoryOnJdbc[ID, E <: Entity[ID]]
    extends Repository[ID, E, DBSession] {
  this: CRUDFeatureWithId[ID, E] =>

  protected def namedValuesWithoutId(entity: E): Seq[(SQLSyntax, Any)]

  protected def namedValues(entity: E): Seq[(SQLSyntax, Any)] =
    idNamedValue(entity) +: namedValuesWithoutId(entity)

  protected def idNamedValue(entity: E): (SQLSyntax, Any) =
    column.id -> AsIsParameterBinder(idToRawValue(entity.id))

  override def store(entity: E)(implicit ctx: DBSession): E = {
    if (findById(entity.id).isDefined) {
      updateById(entity.id).withNamedValues(namedValuesWithoutId(entity): _*)
    } else {
      createWithNamedValues(namedValues(entity): _*)
    }
    entity
  }
}

CRUDMapperIsoIdRepository では SkinnyORM が提供する SkinnyMapperWithId[ID, E] trait, CRUDFeatureWithId[ID, E] trait を継承して、SkinnyORM による ORM Mapper を表現する抽象クラスを定義します。

SkinnyORM の上記 2 trait は idToRawValue(id: ID): Any, rawValueToId(value: Any): ID の実装を要求しますが、これらは Iso の持つ関数から定義することができます。

import skinny.orm.SkinnyMapperWithId
import skinny.orm.feature.CRUDFeatureWithId

abstract class CRUDMapperIsoIdRepository[ID, DbID, E <: Entity[ID]](
    implicit val iso: Iso[DbID, ID]
) extends SkinnyMapperWithId[ID, E]
    with CRUDFeatureWithId[ID, E]
    with RepositoryOnJdbc[ID, E] {

  override def useAutoIncrementPrimaryKey: Boolean = false

  override def idToRawValue(id: ID): Any =
    iso.reverseGet(id)

  override def rawValueToId(value: Any): ID =
    iso.get(value.asInstanceOf[DbID])

}

あとは Iso な ID を持つ Entity に関しては CRUDMapperIsoIdRepository を継承していい感じに Repository を実装することができます。

import com.tiqwab.example.app.{Application, ApplicationId}
import com.tiqwab.example.modeling.CRUDMapperIsoIdRepository
import scalikejdbc.WrappedResultSet
import scalikejdbc.interpolation.SQLSyntax
import skinny.orm.Alias

class ApplicationRepositoryOnJdbc(override val tableName: String)
    extends CRUDMapperIsoIdRepository[ApplicationId, Long, Application] {

  override def defaultAlias: Alias[Application] = createAlias("AP")

  override protected def namedValuesWithoutId(
      entity: Application): Seq[(SQLSyntax, Any)] = Seq(
    column.name -> entity.name
  )

  override def extract(rs: WrappedResultSet,
                       n: scalikejdbc.ResultName[Application]): Application =
    Application(
      id = ApplicationId(rs.get(n.id)),
      name = rs.get(n.name)
    )

}

細々と

はじめに述べた通り、ここで行っている modeling のベースは現在仕事で使用しているコードベースを参考にしたものでした。

他にも play-json の Format 定義でも同様に導出ができたりするので、Iso という概念は色々なところで便利に使える表現に思います。

ただいくつか実装上でなぜこうなっているのか理解ができないという部分もあり、Scala でモデルを考える経験が足りないなというのを実感します (コードの良い悪いではなく、そうしている判断や必然性に自分の理解が追いついていない)。

  • IdLike は総称性で定義してもいいのでは
    • クラスに型メンバを定義することと総称性を持たせることは基本的には同じことができると思っている
    • 型メンバでないとできないこととしては共変まわりの話はあるはず
    • ただ今回の場合はどちらでも実装できそうには思う
  • CRUDMapperIsoIdRepository[ID, DbID, E <: Entity[ID]] が trait ではなく抽象クラスなわけ
    • implicit に Iso[DbID, ID] を受け取りたいためでは
    • SkinnyORM から継承するメソッドもあるので、各メソッドに implicit に受け取らせるわけにはいかない
  • RepositoryOnJdbc[ID, E <: Entity[ID]] って自分型アノテーションを使用する必然性があるのか
    • 継承ではなく自分型アノテーションを使うモチベーションは?