2022年7月23日(土) 11約 分の読み物

JSON スキーマ出力の修正

私には問題があります。GitHub の issue を読むと、時々共感し、解決されるまで執着してしまうのです。これは一部の人にとっては問題ないように聞こえるかもしれませんが、その解決策が JSON スキーマ実装開発者に3年間も基本的な設計上の質問をさせる原因になる場合…それは問題です。

そして、それはまさにドラフト 2019-09 から発生したことです。このバージョンの仕様書では、最初の公式出力形式をリリースしました。実際には、複数のニーズに対応する複数の形式でした。

  • ほとんどの人はエラーを知りたがっていましたが、パス/失敗の結果だけを知りたがる人もいたので、flag形式を作成しました。
  • 実際に何が失敗したかについてより詳細を知りたい人のうち、フラットリストを好む人もいれば、スキーマに一致する階層の方が良いと考える人もいました。そこで、リストを好む人向けにbasicを作成しました。
  • 最後に、階層を望んだ人のうち、簡潔なバージョン(detailedとなった)を望む人もいれば、完全に実現された階層(verbose)を望む人もいました。

補足 インスタンスデータに似た階層を望む人もいましたが、現実的な方法でそれを実現する方法が分からなかったので、単に棚上げして先に進みました。

仕様への要件の追加

当時、私は仕様に大きな貢献をしたことはありませんでしたが、方向性の決定にはかなり関与していたため、執筆に挑戦することにしました。これは、私が仕様にテキストを全く貢献していなかったという意味ではありません。単に、セクション全体ほど重要なものではなかったということです。

そこで、数週間かけて、数週間(数ヶ月?)の議論から生まれた長い GitHub の issue に基づいて、出力の新しい要件を書き上げました。

もう、全て網羅したと思いました!プロパティ、全体的な構造、検証例を定義し、私たちみんなが大好きな素晴らしい仕様書風の記述で全てを書きました。

仕様がリリースされる前に、私のライブラリ Manatee.Json で実装して、動作することを確認しました。

しかし、アノテーションを見落としていました。アノテーションを検討し、その要件も提示しました。しかし、アノテーションを生成した合格インスタンスの結果の例を示しませんでした。verboseの例の中に深くネストして埋もれてはいましたが、大きすぎて仕様書とは別のファイルにする必要があると判断したためです。(そうです、誰もそれを読むとは思いませんでしたが…)

その後の数年間のハイライトは、出力に関する混乱、主にアノテーションの表現方法に関する多くの質問を他の実装者から受けたことでしょう。そして、これらの質問に対する私の一般的な回答もあまり良くありませんでした。「エラーと同じです。」些細なことだと思っていました。

幸いなことに、出力全体を「SHOULD」要件としてリストしていたため、実装にそれを実行する必要はありませんでした。これは、定義の初期段階であり、将来のリリースで調整する可能性が高いことを知っていたため、実装に過度の負担を課したくなかったという考えに基づいていました。

自らの薬を味わう

Manatee.Json の廃止を決めて JsonSchema.Net を構築するまで、なぜ誰もが質問をしていたのかが分かりませんでした。出力を再実装することで、目を開かされました。

すごい。たくさんのことを省いていました!

当初の意図を理解していたことは大きな助けになりましたが、自分自身も書いていないものを実装しようとした場合、どのような状況だったのか想像もできません。

そこで、メモを取り始めました。

更新の必要性

ドラフト 2020-12 は1年以上公開されており、出力について何かする必要があると判断しました。私がこの混乱を引き起こし、それを片付けるのは私の責任だと感じています。(今では実際にそれが私の仕事です!😁)すべてのメモを整理し、改善案に関する大規模な最初のディスカッションコメントを投稿しました。

最初に全員が同意したのは、出力単位のプロパティの一部を目的別に分離し、名前を変更することでした。これらのプロパティは明確な目的を果たしていましたが、ネーミングは難しいので、もちろんこれらの名前は改善の余地がありました。いくつかのやり取り、提案された代替案、改良の後、これは既にマージされている簡単で迅速な PR になりました。これで一つ片付きました。

  • keywordLocation ➡️ evaluationPath
  • absoluteSchemaLocation(ほとんどオプション)➡️ schemaLocation(必須)
  • errors/annotations ➡️ details

提案された変更の残りの部分についてはディスカッションを読むことができますが、特に1つのことに焦点を当てたいと思います。ディスカッションのある時点で、ひらめきがありました。

なぜ出力は、最終的にエラーとアノテーションを収集して最終結果を提供するサブスキーマではなく、個々のキーワードからのエラーとアノテーションをキャプチャするように設計されているのでしょうか?

現状

これがどういう意味なのかを理解するために、既存の出力を見てみましょう。簡単な例から始め、簡潔にするためにbasic、つまりリスト形式のみを扱います。

スキーマ
{ "$schema": "https://json-schema.dokyumento.jp/draft/2020-12/schema", "$id": "example-schema", "type": "object", "title": "foo object schema", "properties": { "foo": { "title": "foo's title", "description": "foo's description", "type": "string", "pattern": "^foo ", "minLength": 10, } }, "required": [ "foo" ], "additionalProperties": false}
// instance (passing){ "foo": "foo isn't a real word"}

ご覧の通り、このスキーマはJSON値が、単一の文字列値のプロパティ`foo`を持つオブジェクトでなければならないことを定義しており、インスタンスはこの要件を満たしています。さらに、このスキーマはいくつかのアノテーションを定義しています。

2019-09/2020-12の仕様では、この評価に対して以下の出力が要求されます。

データ
{ "valid": true, "keywordLocation": "", "instanceLocation": "", "annotations": [ { "valid": true, "keywordLocation": "/title", "instanceLocation": "", "annotation": "foo object schema" }, { "valid": true, "keywordLocation": "/properties", "instanceLocation": "", "annotation": [ "foo" ] }, { "valid": true, "keywordLocation": "/properties/foo/title", "instanceLocation": "/foo", "annotation": "foo's title" }, { "valid": true, "keywordLocation": "/properties/foo/description", "instanceLocation": "/foo", "annotation": "foo's description" } ]}

何が問題なのか?

  • アノテーションが完全なノードとしてレンダリングされています。これにより、多くの不要な情報や重複情報が生じます。これは、すべてが位置によってグループ化される階層形式では、繰り返される位置プロパティが冗長になるため、さらに顕著になります。
  • すべてのノードは`valid`プロパティを持っています。そのため、アノテーションの結果と検証の結果を区別することが困難です。
  • トップレベルノードには、ノードの配列を持つ複数形の`annotations`プロパティがありますが、内部ノードにはそれぞれ、アノテーション値を持つ単数の`annotation`プロパティがあります。これは単に混乱を招きます。

これは単純な例です。スキーマのサイズと複雑さが増すにつれて、これがどれほど大きくなるかは容易に想像できます。

もっと良い方法があるはずだ。

あります。キーワードではなく、サブスキーマによる出力レポートです。

上記の例では、これはルートスキーマと`foo`プロパティサブスキーマの2つのノードが得られることを意味します。(前述のプロパティ名の変更にも注意してください。)

データ
{ "valid": true, "evaluationPath": "", "instanceLocation": "", "details": [ { "valid": true, "evaluationPath": "/properties/foo", "instanceLocation": "/foo" } ]}

これはかなりシンプルに見えます。しかし、アノテーションはどうでしょうか?新しいプロパティにグループ化できます。そして、任意のキーワードは単一のアノテーション値しか生成しないことを知っているため、キーワードをプロパティ名として使用して、オブジェクトを使用してそれらのアノテーションを報告できます。

データ
{ "valid": true, "evaluationPath": "", "instanceLocation": "", "annotations": { "title": "foo object schema", "properties": [ "foo" ] }, "details": [ { "valid": true, "evaluationPath": "/properties/foo", "instanceLocation": "/foo", "annotations": { "title": "foo's title", "description": "foo's description" } } ]}

あるいは、リストであるはずのbasic形式の場合、ルートスキーマの結果は、下記のようにルート出力ノード内に移動できます。これはあくまで提案です。PRのコメントで、どちらの方法が好ましいかお知らせください。現在提案されている形式を使用するため、投稿の残りの部分ではこの形式を使用します。

データ
{ "valid": true, "details": [ { "valid": true, "evaluationPath": "", "instanceLocation": "", "annotations": { "title": "foo object schema", "properties": [ "foo" ] } }, { "valid": true, "evaluationPath": "/properties/foo", "instanceLocation": "/foo", "annotations": { "title": "foo's title", "description": "foo's description" } } ]}

最後の点は、サブスキーマの絶対URIが必須になったことです。そのため、追加しましょう。

注記 これらの例(旧および新)はすべて、https://json-everything/baseのデフォルトのベースURIを使用する私の実装によって生成されています。この新しい出力は実験ブランチに実装されており、その変更が私のライブラリスイートに与える影響はこちらで確認できます。

データ
{ "valid": true, "details": [ { "valid": true, "evaluationPath": "", "schemaLocation": "https://json-everything/example-schema#", "instanceLocation": "", "annotations": { "title": "foo object schema", "properties": [ "foo" ] } }, { "valid": true, "evaluationPath": "/properties/foo", "schemaLocation": "https://json-everything/example-schema#/properties/foo", "instanceLocation": "/foo", "annotations": { "title": "foo's title", "description": "foo's description" } } ]}

以上です!以前はより簡潔なパッケージで提供されていたすべての情報です。さらに、関連するすべてのアノテーションがグループ化されているため、可読性が向上しています。

エラーへの影響

以前の反復処理で見逃していたため、アノテーションから始めたいと思いました。次に、いくつかの失敗したインスタンスがどのように報告されるかを見てみましょう。すぐに明らかにならない興味深いニュアンスがあり、それが正しいことを確認するために、二度、三度確認する必要がありました。

最初の失敗したインスタンス

データ
{ "baz": 42}

これは失敗します。なぜなら

  • fooは必須ですが、欠落しています。
  • bazは許可されていません。

現在のエラー出力には、現在のアノテーション出力と同じ問題があります。

データ
{ "valid": false, "keywordLocation": "#", "instanceLocation": "#", "errors": [ { "valid": false, "keywordLocation": "#/required", "instanceLocation": "#", "error": "Required properties [\"foo\"] were not present" }, { "valid": false, "keywordLocation": "#/additionalProperties", "instanceLocation": "#/baz", "error": "All values fail against the false schema" } ]}

すべてのエラーが実際にはルートスキーマに起因するにもかかわらず、子ノードの位置から報告されていることに注意してください。これは正しくないように見えます。

新しい出力を見てみましょう。

データ
{ "valid": false, "details": [ { "valid": false, "evaluationPath": "", "schemaLocation": "https://json-everything/example-schema#", "instanceLocation": "", "errors": { "required": "Required properties [\"foo\"] were not present" } }, { "valid": false, "evaluationPath": "/additionalProperties", "schemaLocation": "https://json-everything/example-schema#/additionalProperties", "instanceLocation": "/baz", "errors": { "": "All values fail against the false schema" } } ]}

再び、エラーは単一のerrorsプロパティとして存在し、サブスキーマレベルで報告されています。

また、前述のニュアンスが現れます。additionalPropertiesの下のfalseは、別々のサブスキーマとして報告されます(技術的にはサブスキーマであるため)。そして、エラーは空文字列のキーワードとして報告されます。しかし、評価パスを見ると、依然としてキーワードレベルで報告されているように見えます。これがニュアンスです。実際にはサブスキーマレベルで報告していますが、サブスキーマがキーワードに位置しているというだけです。これをより明確にするために、別の失敗インスタンスを見てみましょう。

データ
{ "foo": "baz"}
データ
{ "valid": false, "details": [ { "valid": false, "evaluationPath": "/properties/foo", "schemaLocation": "https://json-everything/example-schema#/properties/foo", "instanceLocation": "/foo", "errors": { "pattern": "The string value was not a match for the indicated regular expression", "minLength": "Value is not longer than or equal to 10 characters" } } ]}

ここで、評価パスが/properties/fooにあるサブスキーマの位置を示していることがわかります。これは、/additionalPropertiesの位置にあるサブスキーマfalseを評価した前の例と比較すると、類似点がわかります。

まとめ

これが、出力の更新方法と、その背後にある理由の1つです。これについてご意見がございましたら、ディスカッションまたはPRでコメントを残してください。

この変更を私の実装で加えた場合の影響を確認したい場合は、このPRをご覧ください。これらの変更はすべて出力の更新によって行われましたが、そのほとんどは私のアーキテクチャ特有のものであり、新しい出力を実装しなくてもライブラリに変更を加えることができるものもあります。ただし、簡潔に言うと、コード行数が-343行削減されました!

表紙写真は、Daria NepriakhinaさんによるUnsplashからのものです 😁