こういうツイートを見た。
Scala (or Java) で、jsonのdiffをpatchファイルみたいな感じでわかりやすいテキストで出力してくれるライブラリないかなあ。そしてjacksonに依存してないといいな
— Arthur (@Arthur1__) 2024年1月13日
現代のプログラミングではJSONの差分を取ったり、逆にパッチを当てるということがよくある。可能ならそれがPretty Printできると良い。 JSONの差分をScalaで取る方法についていくつか調べてみたのでメモ。
JSONの差分をどう表現する?
JSONの差分は単純なテキストの差分で表現できない。というのも、フィールドの順序はJSONでは順不同となっているため*1、パース結果が同一になるような異なるJSONを考えることができるからだ。例えば以下の2つのJSONはテキスト表現が異なるにもかかわらず、等価なものとみなされる:
{"foo": "bar", "fizz": "buzz"}
{"fizz": "buzz", "foo": "bar"}
このため、単純にテキスト差分を生成してもうまくいかない可能性がある。もちろん意図的にフィールドをソートした場合はこの限りではないが、JSONの規約で要求されていないことをJSONに求めることになってしまうので、設計上あまり良くない。
JSON Patch
この問題に対する答えとして、JSON Patch(RFC 6902)をはじめとするいくつかのRFCが提案された。JSON Patchのうち、JSONの特定のフィールドを指定するポインタを規定する仕様はJSON Pointer(RFC 6901)として別に定義されている。
例えば、{"a": "b", "c": "buzz"}
から{"a": "foo", "b": "bar"}
への差分はJSON Patchを用いて以下のように表現される:
[ { "op" : "replace", "path" : "/a", "value" : "foo" }, { "op" : "add", "path" : "/b", "value" : "bar" }, { "op" : "remove", "path" : "/c" } ]
加えて、JSON Merge Patch(RFC 7396)という類似のRFCも存在する。 JSON Merge PatchはJSON Patchよりも後発だが、異なるセマンティクスを持つ。前述と同様の差分をJSON Merge Patchで表現すると以下のようになる:
{ "a" : "foo", "c" : null, "b" : "bar" }
二者の違いをおおまかに表現すると:
- JSON Patch
- あるJSON a から別のJSON b に編集するまでの編集操作を列挙することで差分を表現する
- 先発
- サイズが大きくなりがちだがpatchを実装しやすそう
- JSON Merge Patch
- あるJSON a から別のJSON b への差分を直接表現する
- よりシンプルだが表現力に劣る(フィールドを
null
にするような操作を記述できない)
この二者の差分については以下のページに詳しい。具体例も載っている。
いずれの仕様も、JSON間の差分をJSONで表現するという面白いコンセプトを達成している。テキスト形式でポータブルだし、筋が良さそう。
diffson
このJSON Patch / JSON Merge Patchを実装したライブラリのうち、Scala実装として有名なものがghieh/diffson
だ:
diffson
はspray-json
、play-json
、circe
をJSONライブラリとしてサポートしている。今回のサンプルではとりあえずcirce
を使っている。
以下のサンプルはScala Scriptで記述してある。共通のimport
などは以下の通り:
//> using scala 3.3.1 //> using dep "io.circe::circe-core:0.14.6" //> using dep "io.circe::circe-parser:0.14.6" //> using dep "org.gnieh::diffson-circe:4.5.0" val x = """{"a": "b", "c": "buzz"}""" val y = """{"a": "foo", "b": "bar"}""" import io.circe.*, io.circe.parser.*, io.circe.syntax.* val xj = parse(x).right.get val yj = parse(y).right.get
diffson
でJSON Patchを生成する
JSON Patchを生成するには、2つのJSONオブジェクトに対してdiff
メソッドを呼び出せばよい。diff
メソッドは利用しているJSONライブラリでシリアライズできる。
import diffson.* import diffson.circe.* import diffson.jsonpatch.simplediff.* println(diff(xj, yj).asJson.spaces2)
実行結果は以下の通り:
[ { "op" : "replace", "path" : "/a", "value" : "foo" }, { "op" : "add", "path" : "/b", "value" : "bar" }, { "op" : "remove", "path" : "/c" } ]
ちなみに、diffson
は差分検出に用いるアルゴリズムをいくつか選択できるようになっている。上掲の例では高速でシンプルなsimplediff
を利用した。よりスマートで小さい差分(ただし、巨大な配列などでは低速になる可能性がある)を得るには、LCSアルゴリズムを用いる:
import diffson.* import diffson.circe.* import diffson.lcs.Patience import diffson.jsonpatch.lcsdiff.* // LCSアルゴリズムとしてPatienceアルゴリズムを利用する // given(implicit)を利用してアルゴリズムを渡す given Patience[Json] = new Patience[Json] println(diff(xj, yj).asJson.spaces2)
LCSを使うと配列の差分がかしこくなる。使わない場合は配列の挿入/削除は単純に置換で表現されるようになる。
diffson
でJSON Patchを適用する
さて、生成したJSON Patchを適用するにはdiff
した結果のapply
を呼び出せばよい(つまり、関数を呼び出すように呼び出せばよい)。こちらも簡単だ(変換に失敗する可能性があるため型パラメータでTry
などを指定する*2):
import diffson.* import diffson.circe.* import diffson.jsonpatch.simplediff.* import scala.util.Try val patch = diff(xj, yj) println(patch[Try](xj).get.asJson.spaces2) // xjにpatchが適用され、yjになる assert(patch[Try](xj).get.asJson == yj)
既にあるJSON Patchをもとにパッチを当てるには以下のようにする:
import scala.util.Try import diffson.* import diffson.circe.* import diffson.jsonpatch.JsonPatch val p = """[ { "op" : "replace", "path" : "/a", "value" : "foo" }, { "op" : "add", "path" : "/b", "value" : "bar" }, { "op" : "remove", "path" : "/c" } ]""" val pj = parse(p).right.get val patch = pj.as[JsonPatch[Json]].right.get // JsonPatchオブジェクトに変換する println(patch[Try](xj).get.asJson.spaces2) assert(patch[Try](xj).get.asJson == yj)
JSONオブジェクトにcirceが提供するas
メソッドを使ってJsonPatch
に変換するのがミソだ。
diffson
でJSON Merge Patchを生成する
さて、JSON Merge Patchも生成してみよう。JSON Merge PatchはシンプルなぶんLCSなどのアルゴリズムを利用しない。
import diffson.* import diffson.circe.* import diffson.jsonmergepatch.* import scala.util.Try val patch = diff(xj, yj) println(patch.asJson.spaces2)
diffson
でJSON Merge Patchを適用する
パッチの適用はJSON Patchのときと同じだ。パッチオブジェクトに対してapply
すればよい。
println(patch[Try](xj).get.asJson.spaces2)
JSON Patchで前の値も表示してもらう
import diffson.jsonpatch.simplediff.*
するかわりにimport diffson.jsonpatch.simplediff.remembering.*
(LCSを使う場合はsimplediff
ではなくlcsdiff
のを呼ぶ)すると、前の値が出てくるようになる:
import diffson.* import diffson.circe.* import diffson.jsonpatch.simplediff.remembering.* println(diff(xj, yj).asJson.spaces2)
[ { "op" : "replace", "path" : "/a", "value" : "foo", "old" : "b" }, { "op" : "remove", "path" : "/c", "old" : "buzz" }, { "op" : "add", "path" : "/b", "value" : "bar" } ]
これはもともとの規格にはない便利機能だ。ちょっとしたユーティリティを作るときに重宝しそうだ。
JSONのdiffをpretty printする
Patchを生成できるのなら、具体的にどこが変更されたのかわかりやすく知りたいのが人の常だ。しかし今のところdiffsonにはpretty-print機能は搭載されていない。あくまで差分を出して適用することに特化したライブラリだ。
ちょっとトリッキーだが、生成されたJSON Patchを変形して、Pretty Printを生成するようなJSON Patchを生成するという方法で簡易的なdiffを生成できる。
import diffson.* import diffson.circe.* import diffson.jsonpatch.simplediff.remembering.* import diffson.jsonpatch.{Add, Remove, Replace} import diffson.jsonpointer.Pointer // 文字色を変更するためのユーティリティ import scala.io.AnsiColor def red(s: String): String = AnsiColor.RED + s + AnsiColor.RESET def green(s: String): String = AnsiColor.GREEN + s + AnsiColor.RESET // そのポインタの末尾の文字列に関数を適用するユーティリティ def mapBasePointer(p: Pointer, f: String => String): Pointer = { val parent = Pointer(p.parts.toList.dropRight(1).map { case Left(s) => s case Right(idx) => idx.toString }*) val last = p.parts.toList.last last match case Left(s) => parent / f(s) case Right(idx) => parent / f(idx.toString) } // まず普通にpatchを生成する val patch = diff(xj, yj) // patchを変形する val patch2 = patch.copy(ops = patch.ops.flatMap { case Replace( p, j, o ) => // replaceする操作は、古いキーを赤色で、新たなキーを緑色で挿入する操作に変形できる List( Remove(p), Add(mapBasePointer(p, red), o.get), Add(mapBasePointer(p, green), j) ) case Add(p, j) => // addする操作は、新たなキーを緑色で挿入する操作に変形できる List(Add(mapBasePointer(p, green), j)) case Remove(p, o) => // removeする操作は、削除されたキーを赤色で挿入する操作に変形できる List(Remove(p), Add(mapBasePointer(p, red), o.get)) }) import scala.util.Try // 変形したpatchを適用するとpretty printになる val patched = patch2[Try](xj).get.spaces2SortKeys // そのままだとescape sequenceを出力できないので、StringContext.processEscapesを適用して // エスケープシーケンスリテラル`\u001b`を実際のエスケープ文字に書き換える println(StringContext.processEscapes(patched))
するとこういう出力が得られる:
キーはソートできていないが、簡易的なPretty Printができた。どうしてキーがうまくソートされていないかというと、Circeのソート関数がエスケープシーケンスを巻き込んでいるせいだ。
あくまで簡易的なPretty Printなので複雑なケースでは多分壊れているが、ちゃんと実装すればうまく動くはずだ。