petitviolet_blog

@petitviolet blog

いい感じにオブジェクトのdiffを取るライブラリ作った

Scalaのオブジェクト同士を比較する際に、==を使って比較することは出来るが、同値かそうでないかしかわからない。 そこで、具体的に何がどう変わったのかをジェネリックに取得するライブラリを作ってみた。

こんな雰囲気で使える。

$ amm
Loading...
Welcome to the Ammonite Repl 1.6.7
(Scala 2.12.8 Java 1.8.0_192)
If you like Ammonite, please support our development at www.patreon.com/lihaoyi
@ import $ivy.`net.petitviolet::generic-diff:0.6.0-RC2`; import net.petitviolet.generic.diff._
import $ivy.$                                        ;
import net.petitviolet.generic.diff._

@ case class Hoge(i: Int, s: String)
defined class Hoge

@ Hoge(100, "foo") diff Hoge(100, "bar")
res: DiffResult[Hoge] = DiffResult(List(FieldSame("i", 100), FieldDiff("s", "foo", "bar")))

関係ないけどAmmonite便利。

case classのフィールドで異なる値があればそれがわかるようになっている

ソースコードpetitviolet/scala-generic-diffに公開してあってScala2.13.0-RC2まで無駄に対応してみた。

ユースケース

例えば、オブジェクトのフィールドを更新する処理の中で、実際に値が書き換わっていたらDBに保存するが、値が書き換わっていなければDBへの保存処理をスキップする、などが出来る。 あるいはバリデーションで何も更新されていないよと突き返すことも。

classのequalsをoverrideしてidによる同値チェックで実装しているような場合とか。 DDDのEntityでそういう処理をしていることは多そう。

@ case class User(id: String, name: String) {
    override def equals(obj: scala.Any): Boolean = obj match {
      case other: User => other.id == this.id
      case _ => false
    }
    def updateName(newName: String) = new User(this.id, newName)
  }
defined class User

@ val user = User("user-id", "alice")
user: User = User("user-id", "alice")

こういうuserがいた時に、==での比較するとこうなる。

@ user == user
res: Boolean = true

@ user == user.updateName("bob")
res: Boolean = true

これに対してdiffを取ると、

@ user diff user
res: DiffResult[User] = DiffResult(List(FieldSame("id", "user-id"), FieldSame("name", "alice")))

@ user diff user.updateName("bob")
res: DiffResult[User] = DiffResult(List(FieldSame("id", "user-id"), FieldDiff("name", "alice", "bob")))

このようにフィールドごとに同値判定をして、同じ値かどうかを結果として返却してくれる。

異なるフィールドが存在したかどうかはhasDiffを参照すれば可能。

@ (user diff user).hasDiff
res: Boolean = false

@ (user diff user.updateName("bob")).hasDiff
res: Boolean = true

技術的な話

ここからはライブラリの実装的な話でトピックは以下。

ジェネリックプログラミング

case classのコンストラクタ全体を参照するのにshapelessを使っている。 ジェネリックにフィールド名と値を取得している。

実装はdiff.scalaのこのあたり
case classをHListで表現しつつ、LabelledGenericでフィールド名と値の組を扱っている。

再帰的にフィールドを参照しつつdiffをとっていくような実装となっていて、アイデアcirceから学ぶ GenericProgramming入門パクった 参考にした。

macro

こんな感じにdiffした結果をフィールド名で参照できるようにするためにmacroを使っている。

@ val diffResult = user diff user.updateName("bob")
diffResult: DiffResult[User] = DiffResult(List(FieldSame("id", "user-id"), FieldDiff("name", "alice", "bob")))

@ diffResult.id
res: Field = FieldSame("id", "user-id") 

@ diffResult.name
res: Field = FieldDiff("name", "alice", "bob")

ScalaDynamicsを使ってフィールドを自由に指定することが出来るようにしつつ、内部的にはmacroを使って型安全性を担保している。

ここのアイデアscalikejdbc/SQLSyntaxSupportFeature.scalaを参考に実装した。

ここでフィールドとして存在しない名前を指定するとコンパイルで死ぬ。

@ diffResult.hoge
java.util.NoSuchElementException: field #hoge not found.
  net.petitviolet.generic.diff.DiffResult.$anonfun$field$2(GenericDiff.scala:29)
  scala.Option.getOrElse(Option.scala:138)
  net.petitviolet.generic.diff.DiffResult.field(GenericDiff.scala:29)
  ammonite.$sess.cmd15$.<init>(cmd15.sc:1)
  ammonite.$sess.cmd15$.<clinit>(cmd15.sc)

Dynamicだけどマクロで型安全に実装していてScala便利。

今後の展望

使うのかどうかよく分からないので展望とか今のところ特にない。