2022 年 7 月 23 日,星期六 ·11分鐘閱讀

修正 JSON Schema 輸出

我有一個問題:當我閱讀 GitHub 上的 issue 時,它們偶爾會引起我的共鳴,我會對它們耿耿於懷,直到它們得到解決。對某些人來說,這聽起來可能不是問題,但是當該解決方案導致 JSON Schema 實作開發人員針對基本設計問題提出三年的疑問時...是的,這就是一個問題。

這正是 2019-09 草案出現時發生的情況。對於這個版本的規格,我們發布了第一個官方輸出格式。實際上,它是多種格式,旨在滿足多種需求。

  • 雖然大多數人想知道錯誤是什麼,但有些人只想要一個通過/失敗的結果,因此我們創建了 flag 格式。
  • 在那些想要更多關於實際失敗原因的詳細資訊的人中,有些人喜歡平面列表,而另一些人則認為與 schema 匹配的層次結構會更好。因此,我們為列表使用者創建了 basic
  • 最後,在那些想要層次結構的人中,有些人想要一個濃縮版本(變成了 detailed),而另一些人想要完全實現的層次結構(verbose)。

題外話 有些人想要一個模仿實例資料的層次結構,但我們無法弄清楚如何以實際的方式使其工作,因此我們只是將其掩蓋起來並繼續前進。

在規格中加入要求

當時,我還沒有為規格做出任何重大貢獻,但我相當投入地參與了制定方向類型的決策,所以我認為我應該嘗試一下撰寫。這並不是說我沒有為規格貢獻任何文字;只是沒有像整個章節那樣重要。

因此,我花了幾個星期的時間,根據數週(數月?)的討論產生的漫長 github issue,寫出了新的輸出要求。

老天,我以為我擁有一切!我定義了屬性、整體結構、驗證範例,並且我用我們都喜歡的那種精彩的規格語言寫下了所有內容。

甚至在規格發布之前,我就在我的函式庫 Manatee.Json 中實作了它,以確保它正常運作。

但我遺漏了一些東西:註解。我的意思是,我考慮過它們,並為它們提供了要求。但我沒有提供生成註解的通過實例的結果範例。我想技術上我確實提供了,但它被埋沒了,嵌套在 verbose 範例的深處,而這個範例恰好非常大,因此我決定將它放在一個單獨的文件中,與規格文件分開。(是的,好像會有人讀那個一樣。)

接下來幾年的重點將是我從其他實作者那裡收到的大量關於輸出混亂的問題,主要是在註解應該如何表示方面。而我對這些問題的總體回應也不是很好:「它們就像錯誤一樣。」我認為這是一個微不足道的練習。

幸運的是,我們將輸出整體列為「應該」要求,因此實作並非必須執行。這樣做的想法是,我們正處於定義的早期階段,並且我們不想給實作帶來太大的負擔,因為我們知道我們可能會在未來的版本中對其進行調整。

嚐嚐自己的苦果

直到我決定棄用 Manatee.Json 來構建 JsonSchema.Net 時,我才意識到為什麼每個人都在提問。必須重新實作輸出讓我大開眼界。

哇。我遺漏了很多!

知道我最初的意圖對我幫助很大,但我無法想像在沒有同時編寫它的情況下,嘗試實作我寫的東西會是什麼樣子。

所以,我開始做筆記。

是時候更新了

2020-12 草案已經發布一年多了,我決定必須對輸出做些什麼。我製造了這個爛攤子,我覺得我有責任清理它。(現在清理它實際上是我的工作了!😁)我整理了所有的筆記,並在關於我認為可以對格式進行改進的 大量公開討論評論中傾倒了出來。

每個人首先同意的是隔離目的並重新命名一些輸出單元屬性。這些屬性有其獨特的目的,但命名事物很難,所以這些名稱當然可以更好。經過一些來回討論、提出的替代方案和改進,這得到了一個快速簡單的 PR,並且已經合併了,所以這件事完成了。

  • keywordLocation ➡️ evaluationPath
  • absoluteSchemaLocation(大多數情況下是可選的) ➡️ schemaLocation(必填)
  • errors/annotations ➡️ details

您可以閱讀討論以了解其餘的擬議變更,但我想特別關注其中一項。在討論的某個時刻,我突然頓悟

為什麼輸出設計為從單個關鍵字捕獲錯誤和註解,而不是從子 schema 捕獲錯誤和註解,而子 schema 最終會收集錯誤和註解並提供最終結果?

現狀

為了理解我的意思,讓我們看看現有的輸出。我們將從一個簡單的範例開始,為了簡潔起見,我們只會涵蓋 basic 或列表形式。

schema
{ "$schema": "https://json-schema.dev.org.tw/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"}

如你所見,這個 schema 定義了一個 JSON 值必須是一個物件,其中包含一個字串值的屬性 foo,並且這個實例符合這些要求。此外,這個 schema 還定義了一些註釋。

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 屬性,其中包含註釋值。這讓人感到困惑。

這只是一個簡單的例子。你可以看到,隨著 schema 的大小和複雜性增加,這會變得相當龐大。

一定有更好的方法

有的:依子 schema 而非關鍵字報告輸出。

在上面的範例中,這表示我們會得到兩個節點:一個用於根 schema,一個用於 foo 屬性的子 schema。(另請注意前面提到的屬性名稱變更。)

資料
{ "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,你可以看到它們的相似之處。

總結

這就是我想要更新輸出及其背後原因的一種方式。如果你對此有任何想法,請在討論區或在 PR 上留言告知我們。

再次強調,如果你想看看在我的實作中進行此變更的影響,請查看這個 PR。所有這些變更都是由更新輸出所驅動的,但我認為它們大多是針對我的架構,而且即使不實作新的輸出,也可以對該函式庫進行一些修改。不過,簡短的總結是程式碼淨減少了 -343 行!

封面照片由 Daria Nepriakhina 拍攝於 Unsplash 😁