Lambdaカクテル

京都在住Webエンジニアの日記です

Invite link for Scalaわいわいランド

JSONの差分を取ってJSON Patchを得るにはdiffsonがおすすめ

こういうツイートを見た。

現代のプログラミングでは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 PatchJSON 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にするような操作を記述できない)

この二者の差分については以下のページに詳しい。具体例も載っている。

erosb.github.io

いずれの仕様も、JSON間の差分をJSONで表現するという面白いコンセプトを達成している。テキスト形式でポータブルだし、筋が良さそう。

diffson

このJSON Patch / JSON Merge Patchを実装したライブラリのうち、Scala実装として有名なものがghieh/diffsonだ:

github.com

diffsonspray-jsonplay-jsoncirceを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なので複雑なケースでは多分壊れているが、ちゃんと実装すればうまく動くはずだ。

*1:RFC 4627による

*2:CatsのMonadErrorのインスタンスであればなんでもよい

★記事をRTしてもらえると喜びます
Webアプリケーション開発関連の記事を投稿しています.読者になってみませんか?