設定ファイル(JSON)のdiffをjson patchでやってみようという試み

設定ファイル(JSON)のdiffを手軽に分かるようになるには?ということについての探訪の1つ。文字列的なdiffの場合に意図しない余分なdiffが発生したりしていた(sort keysしてpretty printしたJSONを使えば幾文かはマシになるけれど)。それ以外の表現は無いものかとそれなりに定期的に探していたりする。

あと、最近、APIリクエストのsnapshot testingをgoでできないかなーと思っていて、それのエラー時の表示でもJSON同士を比較したくなったりしていた。

そのdiffの表現にjson patchを使ってみるのはどうだろう?という話。

json patch?

json patch(日本語訳してくれている人もいた)という仕様がある。これはJSONの値を変更するための命令をJSONで記述しようという試みの1つ。類似のものとしてjson merge patchというものもある。

json patchに存在している命令

json patchに存在している命令は以下。用途や機能の大雑把な部分は名前でおおよそ見当が付くと思う(細かい挙動についてはまじめに仕様を読む必要があるかもしれない)。

  • add
  • remove
  • replace
  • copy
  • move
  • test

json patchを実装しているライブラリ

json patchを実装してくれているライブラリを探してみた所幾つか存在していた。

jiffはnode.js製、python-json-patchはpython製。

json patchの適用例

例えば以下の様なJSON

{
  "name": "foo",
  "age": 20
}

以下のようなJSONに変更された場合には、

{
  "name": "foo",
  "nickname": "F",
  "age": 21
}

以下のようなjson patchが出力される。

[
  {
    "op": "replace",
    "path": "/age",
    "value": 21
  },
  {
    "op": "add",
    "path": "/nickname",
    "value": "F"
  }
]

大雑把に言えば、pathで変更位置を指定して(json pointerの表現が使われる)、幾つかの対応した命令(op)が記述されるという形。

(json pointerということで細かい話をすると、/を含んだ部分は~1になる(e.g. swaggerのpaths部分)。あるいはそのドキュメント全体を意味するのは""("/"{"": "xxx"}みたいなオブジェクトに対応する))。

ここで気づいてしまったのだけれど。情報が欠損してしまっている。例えば、先程のドキュメントでage20から21に変わったことがdiffとして欲しいのだけれど。json patchはJSONを操作する命令でしか無いので、ageの位置が21で上書きされているということしかわからない。

なので完全なdiffという意味ではjson patchは不適切かもしれない。

json patch の拡張

json patchの仕様として、命令として解釈する部分以外の余分な部分は無視されるらしい。 なので、けっこう自由に値を追加しても良いかもしれない。

A.11. Ignoring Unrecognized Elements

An example target JSON document:

{ "foo": "bar" }

A JSON Patch document:

[ { "op": "add", "path": "/baz", "value": "qux", "xyz": 123 } ]

The resulting JSON document:

{ "foo": "bar", "baz": "qux" }

例えば先程の年齢が20から21になった例については以下の様な形でも良い。

  {
    "op": "replace",
    "path": "/age",
    "value": 21,
    "x-from": 20  // x-fromが追加されている
  },

x-from というフィールドを設けた(x-*という形式の理由はswagger(OpenAPI)のイメージで)。

これを仮にjson patch extと呼ぶとすると、json patch extはreversibleにできたりdiffとして完全になったりしそう。

json patch diffの実装

ついでにjson patchによるdiffの実装について考えてみた。まじめに最適化したような表現をつくるのはけっこうめんどくさいけれど。以下3つの命令だけで作られたjson patchのsubsetで構成されるものならけっこう手軽に実装できる気がする(コーディング試験?の問題などとしてちょうど良いかもしれない(golang.tokyo方面でtreeコマンドを実装したリポジトリのリンクを貼るなどの行為が流行っていた模様。何かしら再帰的な操作が入るものが良いかもというのはそんな気もする))。

  • add
  • replace
  • remove

たまにはどういうふうに実装するかという頭の中を文章にしてみても面白いかもしれない(文章のトレーニング?)。そんなわけでちょっと文章を書いてみる。

add,replace,removeだけの場合

渡された2つのJSONをsrcとdstと呼ぶとして、オブジェクトがdict(object)だったら、keyの存在の有無で「追加分」、「更新分」、「削除分」に分ける。「更新部分」についてはこの操作を再帰的に呼び出せば終わり。オブジェクトがlist(array)だったときには添字の扱いがちょっとめんどくさいけれどほぼdictのときと同様。primitiveな値については等値か調べれば良い感じだと思う。

copy,moveも追加された場合

それでもやっぱりmoveとかcopyがあったほうが読みやすかったりするのだろうか?とも思ったりする。

move,copyの対応はちょっと骨で、ぱっと思いつく限りではtwo pathな形に変わりそう。先程の再帰的な操作の前段階で値のsignature的なものを取ったmapを用意しておく、変換後(dst)の値に対して同じ形状の値を持っているものがあればカウントしていき、その頻度によってmoveとcopyを使い分けることになりそう。moveなどの対象になった部分については先程の再帰的な部分に関する操作をスキップする。という感じのイメージ。

swagger 2.0とswagger 3.0の差分

JSONのdiffの確認の例として、ドキュメントの例の2.0の部分と3.0の部分のdiffを取ってみた。これくらいだとまだ通常の文字列ベースのdiff(unified diff)の方が見やすい気もする。

[
  {
    "path": "/swagger",
    "op": "remove",
    "x-from": "2.0"
  },
  {
    "path": "/info/description",
    "op": "replace",
    "value": "Optional multiline or single-line description in [CommonMark](http://commonmark.org/help/) or HTML.",
    "x-from": "API description in Markdown."
  },
  {
    "path": "/info/version",
    "op": "replace",
    "value": "0.1.9",
    "x-from": "1.0.0"
  },
  {
    "path": "/host",
    "op": "remove",
    "x-from": "api.example.com"
  },
  {
    "path": "/basePath",
    "op": "remove",
    "x-from": "/v1"
  },
  {
    "path": "/schemes",
    "op": "remove",
    "x-from": [
      "https"
    ]
  },
  {
    "path": "/paths/~1users/get/description",
    "op": "replace",
    "value": "Optional extended description in CommonMark or HTML.",
    "x-from": "Optional extended description in Markdown."
  },
  {
    "path": "/paths/~1users/get/produces",
    "op": "remove",
    "x-from": [
      "application/json"
    ]
  },
  {
    "path": "/paths/~1users/get/responses/200",
    "op": "remove",
    "x-from": {
      "description": "OK"
    }
  },
  {
    "path": "/paths/~1users/get/responses/200",
    "op": "add",
    "value": {
      "description": "A JSON array of user names",
      "content": {
        "application/json": {
          "schema": {
            "type": "array",
            "items": {
              "type": "string"
            }
          }
        }
      }
    }
  },
  {
    "path": "/openapi",
    "op": "add",
    "value": "3.0.0"
  },
  {
    "path": "/servers",
    "op": "add",
    "value": [
      {
        "url": "http://api.example.com/v1",
        "description": "Optional server description, e.g. Main (production) server"
      },
      {
        "url": "http://staging-api.example.com",
        "description": "Optional server description, e.g. Internal staging server for testing"
      }
    ]
  }
]

元となるファイルなどがないとどのような変更が行われたかなどわからないと思うので比較結果のgistをリンクとして貼っておく。

比較結果のgist

追記

swaggerのdiffの例をjson patch ext(?)的な表現に変更