JSON Schema 的靜態分析
當我第一次實作 JSON Schema 時,我採取了一種對於 C# 開發人員來說可能很典型的做法:物件導向和程序式程式設計的混合。然而,在過去的一年左右,我有一個想法一直揮之不去。
一個 schema 定義了實例中已知位置值的約束。如果我可以捕捉到這些約束並以此方式建立 schema 模型,會怎麼樣?那麼我就只需要做一次這項工作,當我最終收到一個實例時,我只需要評估每個單獨的約束即可。
我嘗試了好幾次。我想是四次。每次我都更接近讓它運作,但我總是會遇到一個障礙,那就是我提出的任何設計都無法克服。
但過去幾週,我成功了!(而且效能提升相當顯著!)
在這篇文章中,我想分享我在這個過程中學到的一些更抽象的 JSON Schema 分析事項。這篇是寫給實作者的!
什麼是約束?
約束是 JSON Schema 的基石。它們是適用於 JSON 資料中特定位置的個別要求。
在這個 schema 中
我們有三個約束
- 根實例必須是一個物件。
- 如果有一個
foo
屬性,則其值必須是一個字串。 - 如果有一個
bar
屬性,則其值必須是一個數字。
每個約束都識別
- 我們在 schema 中的位置,即「schema 位置」
- 我們如何到達那裡的,即「評估路徑」
- 實例位置
- 一個關鍵字提出的特定要求
請注意,這一切實際上不需要一個實例,而且我們應該能夠預先計算很多內容。
這似乎很容易設置。我們可以為每個約束建模,當我們獲得一個實例時,我們測試每個約束。如果它們都通過,則驗證通過。
很簡單,對吧?我也是這麼想的。它很快就會在多個方面變得複雜。
請注意,我並不是將這些問題視為實作 JSON Schema 時需要克服的問題;它們只是存在的機制。
具有相依性的關鍵字
絕大多數關鍵字都完全獨立運作。這些關鍵字就像我們在上面看到的 type
和 properties
,以及 maximum
、minItems
、title
、format
和其他許多關鍵字。
但有一些關鍵字在運作時需要依賴其他關鍵字(或與其他關鍵字互動)。這些關鍵字是
關鍵字 | 相依性 |
---|---|
additionalProperties | properties patternProperties |
contains | minContains maxContains |
then else | if |
items | prefixItems |
unevaluatedItems * | prefixItems items unevaluatedItems |
unevaluatedProperties * | properties patternProperties additionalProperties unevaluatedProperties |
* 雖然大多數關鍵字只能在其同層級的項目中尋找相依性,但 unevaluated*
關鍵字也會將其相依性擴展到同層級項目之子模式(適用於相同實例位置)內的關鍵字。
if
/then
/else
關鍵字是關鍵字互動的一個很好的例子。
程序式的方法會使用類似以下的條件分支邏輯:
1if ( ... )
2then {
3 ...
4} else {
5 ...
6}
我們會評估 if
,其結果會決定我們是否要評估 then
或 else
。
但如果我們將這些視為約束,那麼這三者呈現的是一種相當特殊的布林邏輯:
1valid = (if && then) || (!if && else)
請注意,這與所有單獨的約束通過即表示驗證通過的概念不同。在這種情況下,如果 if
通過,那麼 else
是否通過並不重要,因為它會被跳過;反之,如果 if
失敗,則會跳過 then
。
雖然 if
/then
/else
的互動相當容易預先計算,但對某些其他關鍵字(例如 additionalProperties
)這樣做就行不通了。對於該關鍵字,您還會遇到其他複雜情況。
未知的實例位置
最開始我們將約束定義為應用於特定位置的要求。然而,有些關鍵字會更廣泛地應用它們的子模式。這些關鍵字包括:
關鍵字 | 實例位置 |
---|---|
patternProperties | 任何符合其正規表示式鍵值的屬性 |
additionalProperties unevaluatedProperties unevaluatedItems | 任何未被其相依性評估的物件屬性 |
contains | 陣列中的任何項目 |
items unevaluatedItems | 任何未被其相依性評估的陣列項目 |
對於所有這些,您都需要實例來決定哪些位置可用。只有這樣您才能完成約束。
此處的策略是建立一個「約束範本」。這個概念是指一種約束,其中包含一個需求和一些機制,一旦已知可用位置,即可確定需要在何處應用該需求。因此,雖然我們無法建立完整的約束,但仍然可以完成部分工作。
靜態引用
靜態引用,即 $ref
,可以在沒有實例的情況下預先解析。它們始終指向同一文件中相同的位置,無論實例如何。簡易模式。有時是這樣。
如果我們有一個遞迴的綱要,像是驗證連結列表或二元樹的綱要會如何?在這些情況下,當實例不再有任何需要驗證的資料時,遞迴會停止,也就是當您讀取到列表的結尾或節點上的葉子時。為了處理這種情況,我們可以採用與上一節相同的「約束範本」方法。
範本解決方案實際上適用於許多情況。真正的訣竅是弄清楚何時需要使用它。
動態引用
另一方面,動態引用通常歸結為一件事:動態範圍。動態範圍是評估進入和離開的資源 ID(通常由 $id
設定)的排序集合。(想像成一個堆疊,進入資源時將其推入,離開時將其彈出。)動態範圍受到兩個因素的影響:
- 評估的開始位置
- 實例資料
根綱要動態
去年我寫了一篇文章,描述如何在語言中使用 $dynamicRef
來建立泛型型別的模型。這個概念是這樣的:
- 首先定義一個泛型綱要,使用指向
$dynamicAnchor
的$dynamicRef
來識別未定義的「型別」參數。 - 定義多個參考泛型綱要的次要綱要,並使用它們自己的
$dynamicAnchor
來定義「型別」參數:每個型別一個。
使用此方法,如果您從泛型綱要開始評估,評估將會失敗,因為「型別」未定義。但是,從次要綱要開始評估會將 $dynamicRef
解析重新導向至次要綱要中定義的解析。這種不同的解析可以讓實例通過驗證。
這是一個動態引用的絕佳範例,它可以在沒有實例的情況下解析。您只需要評估的起點。具體來說,您需要知道動態範圍從何處開始,才能識別引用目標。
資料驅動的動態
動態範圍變化的另一種方式是透過某種條件邏輯。JSON Schema 測試套件中的這個測試 就是一個很好的例子。它使用了泛型文章中的相同概念,但沒有使用單獨的綱要,而是將所有內容都捆綁在一起。
在這種情況下,根據 kindOfList
實例屬性的值,陣列中的項目預期為數字或字串。在機制上,這由一組 if
/then
/else
關鍵字決定,這些關鍵字會將評估導向 numberList
或 stringList
定義,它們都定義了 $dynamicAnchor: itemType
,並引用包含 $dynamicRef: #itemType
的 genericList
定義。
當最終命中 $dynamicRef
時,評估必須經過 numberList
或 stringList
。這會識別要解析哪個 $dynamicAnchor
。
在這種情況下,您必須擁有實例才能完全定義約束,因為雖然您可能知道實例位置,但您不知道需要應用哪些要求。
我找不到任何適合用來隔離任何預先工作的好策略,因此,我仍然必須在評估時計算所有這些內容。
摘要
這些是我在嘗試改變綱要評估方法時發現的主要陷阱。JSON 綱要靜態分析對我來說已證明是一個非常有趣的研究領域,我希望我也激起了您的興趣。
如果您想進一步了解我是如何實現所有這些的,我已在 blog.json-everything.net 上做了摘要。它現在比程序式更具功能性,但仍然是非常物件導向的。
這裡可能還有很多東西可以探索。如果您想到什麼,請隨時在 Slack 上找到我。
封面圖片由 Google DeepMind 在 Unsplash 上提供