2022 年 5 月 18 日,星期三 ·14分鐘閱讀

海伯利亞驚人的序列化與綱要

最初發布於 techblog.babyl.ca

過去兩年,我是一群勇猛冒險者的一份子,他們每週四晚上透過 Discord 的傳送魔法集結,勉強地在《海伯利亞驚奇劍客與巫師》這個粗俗且不饒恕的世界中盡力不慘死,這是龍與地下城的通俗表親。這場遊戲是由邪惡的地下城主腦 Gizmo Mathboy 策劃的,而且非常有趣。

但海伯利亞的世界不僅受到怪物的圍攻。哦不。它也是一個充滿規則、統計數據和各種決定命運的擲骰子的領域。而捕捉許多這些神秘法則的關聯點是——對於所有熟悉該類型的人來說並不意外——角色表。

身為優秀的小書呆子,我們通常會好好地保持角色表的更新。但我們都是會犯錯的生物;錯誤會悄悄出現。這讓我想到...當然有一些方法可以自動驗證這些角色表。事實上,我們已經將表格保存為 YAML 文件。JSON 綱要完全可以用來定義文件綱要...當然可以稍微扭曲一下以適應遊戲的奇異邏輯嗎?

答案是,當然,只要咒語夠黑暗,一切都可以扭曲。這個部落格文章及其相關的 專案儲存庫,雖然不是一個詳盡的解決方案(還不是),但旨在展示 JSON 綱要可以帶來的優點,以及生態系統的工具。

所以...有興趣嗎,各位冒險者?那麼就束緊腰帶、收好刀劍,跟著我來:進入 JSON 綱要的叢林吧!

準備工作

首先,讓我們介紹一下這個專案將使用的核心工具。

對於所有 JSON 綱要的事物,我們將使用 ajv(以及 ajv-cli 用於 cli 互動)。它是 JSON 綱要規範的快速、穩健的實作,具有許多額外功能,而且更棒的是,它提供了一種簡單的機制來加入自訂驗證關鍵字,我們很快就會濫用它。

由於我們將進行很多命令列操作,我將引入 Task,一個基於 YAML 的任務執行器——基本上是 Makefile,將基於空白的瘋狂語法替換為,呃,我習慣的不同基於空白的瘋狂語法。

順便一提,我在本文中討論的所有程式碼的最終形式都在這個儲存庫中。

JSON 最爛

好的,這太過分了。JSON 是一種很棒的序列化格式,但手動編輯是很沒靈魂的苦差事。但這並不是什麼大問題,因為 JSON 綱要有點用詞不當:目標文件和綱要本身最終都只是純粹的舊資料結構——JSON 恰好是它的典型序列化方式。好吧,別管典型了,我們將使用 YAML 作為我們的來源。為了方便後續的其他部分,我們將透過 [transerialize][] 將這些 YAML 文件轉換為 JSON。

1# in Taskfile.yml
2tasks:
3    schemas: fd -e yml -p ./schemas-yaml -x task schema SCHEMA='{}'
4
5    schema:
6        vars:
7            DEST:
8                sh: echo {{.SCHEMA}} | perl -pe's/ya?ml/json/g'
9        sources: ["{{.SCHEMA}}"]
10        generates: ["{{.DEST}}"]
11        cmds: transerialize {{.SCHEMA}} {{.DEST}}

噢,是的,task 在循環方面非常糟糕,所以我使用 fd 和重新輸入來處理所有個別的綱要轉換。

設置驗證流程

在我們對綱要本身進行任意變更之前,我們需要弄清楚如何調用這些東西。為了做到這一點,讓我們以最枯燥、最簡約的方式來設定我們的綱要和範例文件。

1# file: schemas-yaml/character.yml
2$id: https://hyperboria.babyl.ca/character.json
3title: Hyperboria character sheet
4type: object
1# file: samples/verg.yml
2
3# Verg is my character, and always ready to face danger,
4# so it makes sense that he'd be volunteering there
5name: Verg-La

我們有一個綱要,我們有一個文件,而驗證它的直接方法是執行以下操作。

1⥼ ajv validate -s schemas-yaml/character.yml -d samples/verg.yml
2samples/verg.yml valid

太棒了。現在我們只需要在 Taskfile 中將其稍微形式化一下,我們就可以開始了。

1# file: Taskfile.yml
2# in the tasks
3validate:
4    silent: true
5    cmds:
6        - |
7            ajv validate  \\
8                --all-errors \\
9                --errors=json \\
10                --verbose \\
11                -s schemas-yaml/character.yml \\
12                -d {{.CLI_ARGS}}

開始制定綱要

為了熱身,讓我們從一些簡單的欄位開始。角色顯然有姓名和玩家。

1# file: schemas-yaml/character.json
2$id: https://hyperboria.babyl.ca/character.json
3title: Hyperboria character sheet
4type: object
5additionalProperties: false
6required:
7    - name
8    - player
9properties:
10    name: &string
11        type: string
12    player: *string

那裡沒什麼特別的,除了 YAML 錨點和別名,因為我是一個懶惰的傢伙。

1⥼ task validate -- samples/verg.yml
2samples/verg.yml invalid
3[
4    ...
5    "message": "must have required property 'player'",
6    ...
7]

哇!驗證正在對我們尖叫!此處省略了輸出,因為我在 taskfile 中將其配置為額外詳細。但要點很清楚:我們應該有一個玩家名稱,但我們沒有。所以讓我們添加它。

1# file: samples/verg.yml
2name: Verg-La
3player: Yanick

加入玩家名稱後,一切又恢復正常了。

1⥼ task validate -- samples/verg.yml
2samples/verg.yml valid

加入屬性和定義

接下來是核心屬性!所有屬性都遵循相同的規則(1 到 20 之間的數字)。為所有屬性複製貼上綱要是不恰當的。使用上一節中的錨點是一個選項,但在這種情況下,最好使用綱要定義,使事情更正式一點。

1# file: schemas-yaml/character.yml
2# only showing deltas
3required:
4    # ...
5    - statistics
6properties:
7    # ...
8    statistics:
9        type: object
10        allRequired: true
11        properties:
12            strength: &stat
13                $ref: "#/$defs/statistic"
14            dexterity: *stat
15            constitution: *stat
16            intelligence: *stat
17            wisdom: *stat
18            charisma: *stat
19$defs:
20    statistic:
21        type: number
22        minimum: 1
23        maximum: 20

請注意,allRequiredajv-keywords 提供的自訂關鍵字,要使用它,我們必須修改我們在 taskfile 中對 ajv validate 的調用

1# file: Taskfile.yml
2validate:
3    silent: true
4    cmds:
5        - |
6            ajv validate \\
7                --all-errors \\
8                --errors=json \\
9                --verbose \\
10                -c ajv-keywords \\
11                -s schemas-yaml/character.yml \\
12                -d {{.CLI_ARGS}}

為了符合綱要,我們也將統計資料加入我們的範例角色

1# file: samples/verg.yml
2statistics:
3    strength: 11
4    dexterity: 13
5    constitution: 10
6    intelligence: 18
7    wisdom: 15
8    charisma: 11

我們檢查一下,是的,我們的表格仍然有效。

1⥼ task validate -- samples/verg.yml
2samples/verg.yml valid

一個樣本無法構成嚴謹的測試

到目前為止,我們已經使用 Verg 作為我們的測試對象。我們調整綱要,針對表格執行,調整表格,沖洗、起泡、重複。但是隨著綱要越來越複雜,我們可能想在我們的小專案中加入一個真正的測試套件。

一種方法是使用 ajv test,它的好處是不需要額外的程式碼。

1⥼ ajv test -c ajv-keywords \\
2    -s schemas-yaml/character.yml \\
3    -d samples/verg.yml \\
4    --valid
5samples/verg.yml passed test
6# bad-verg.yml is like verg.yml, but missing the player name
7⥼ ajv test -c ajv-keywords \\
8    -s schemas-yaml/character.yml \\
9    -d samples/bad-verg.yml \\
10    --invalid
11samples/bad-verg.yml passed test

但它在簡單性方面的優勢,在模組化方面就缺乏了。這些綱要將會變得更加複雜,並且針對它們的片段會很好。所以我們將改為使用傳統的單元測試,透過 [vitest][]

例如,讓我們測試統計數據。

1// file: src/statistics.test.js
2import { test, expect } from "vitest";
3
4import Ajv from "ajv";
5
6import characterSchema from "../schemas-json/character.json";
7
8const ajv = new Ajv();
9// we just care about the statistic schema here, so that's what
10// we take
11const validate = ajv.compile(characterSchema.$defs.statistic);
12
13test("good statistic", () => {
14    expect(validate(12)).toBeTruthy();
15    expect(validate.errors).toBeNull();
16});
17
18test("bad statistic", () => {
19    expect(validate(21)).toBeFalsy();
20    expect(validate.errors[0]).toMatchObject({
21        message: "must be <= 20",
22    });
23});

我們在 taskfile 中加入一個 test 任務

1# file: Taskfile.yml
2test:
3    deps: [schemas]
4    cmds:
5        - vitest run

就這樣,我們有了測試。

1⥼ task test
2task: [schemas] fd -e yml -p ./schemas-yaml -x task schema SCHEMA='{}'
3task: [schema] transerialize schemas-yaml/test.yml schemas-json/test.json
4task: [schema] transerialize schemas-yaml/character.yml schemas-json/character.json
5task: [test] vitest run
6
7 RUN  v0.10.0 /home/yanick/work/javascript/hyperboria-character-sheet
8
9 √ src/statistics.test.js (2)
10
11Test Files  1 passed (1)
12     Tests  2 passed (2)
13      Time  1.41s (in thread 5ms, 28114.49%)

更多綱要!

下一步:角色職業。雖然我們可以在主綱要中直接加入一個 enum 並稱其完成,但它是一個可能在其他地方重複使用的列表,因此最好在它自己的綱要中定義它,並在角色表綱要中引用它。

額外的挑戰!在海伯利亞,您可以擁有一個通用職業,或一個職業和子職業。可以像這樣明確地架構它

1oneOf:
2    - enum: [ magician, figher ]
3    - type: object
4      properties:
5        generic: { const: fighter }
6        subclass: { enum: [ barbarian, warlock, ... ] }
7    ...

但這是很多重複的輸入。相反,如果來源更簡潔,儘管少了一點 JSON 綱要,那會更好。例如,像這樣

1$id: https://hyperboria.babyl.ca/classes.json
2title: Classes of characters for Hyperborea
3$defs:
4    fighter:
5        - barbarian
6        - berserker
7        - cataphract
8        - hunstman
9        - paladin
10        - ranger
11        - warlock
12    magician: [cryomancer, illusionist, necromancer, pyromancer, witch]

然後在我們將 YAML 轉換為 JSON 時,讓一個小腳本處理資料。幸運的是(真是幸運!),transerialize 確實允許在過程中嵌入一個轉換腳本。所以我們可以將我們的 taskfile 綱要任務變更為

1schema:
2    vars:
3        TRANSFORM:
4            sh: |
5                echo {{.SCHEMA}} | \\
6                    perl -lnE's/yml$/pl/; s/^/.\//; say if -f $_'
7        DEST:
8            sh: echo {{.SCHEMA}} | perl -pe's/ya?ml/json/g'
9    cmds:
10        - transerialize {{.SCHEMA}} {{.TRANSFORM}} {{.DEST}}

然後我們插入一個看起來像這樣的轉換腳本

1# file: schemas-yaml/classes.pl
2sub {
3    my $schema = $_->{oneOf} = [];
4
5    push @$schema, { enum => [ keys $_->{'$defs'}->%* ] };
6
7    for my $generic ( keys $_->{'$defs'}->%* ) {
8        push @$schema, {
9            type => 'object',
10            properties => {
11                generic => { const => $generic },
12                subclass => { enum => $_->{'$defs'}{$generic} }
13            }
14        }
15    }
16
17    return $_;
18}

有了它,輸出綱要就被膨脹到我們想要的樣子。我們正在享用我們簡潔的蛋糕,同時也吃著大的鬆軟蛋糕。太棒了!

所以剩下的就是將綱要連結在一起。我們從角色綱要中引用職業綱要

1# file: schemas-yaml/character.yml
2required:
3    # ...
4    - class
5properties:
6    # ...
7    class: { $ref: "/classes.json" }

我們還需要告訴 ajv 這個新綱要的存在

1validate:
2    silent: true
3    cmds:
4        - |
5            ajv validate \\
6                --all-errors \\
7                --errors=json \\
8                --verbose \\
9                -c ajv-keywords \\
10                -r schemas-json/classes.json \\
11                -s schemas-json/character.json \\
12                -d {{.CLI_ARGS}}

最後,我們將 Verg 的職業加入他的表格

1# file: samples/verg.yml
2class:
3  generic: magician
4  subclass: cryomancer

就這樣,Verg(和我們的角色綱要)變得很有格調了。

參考綱要的其他部分

到目前為止,我們可以設定我們的角色表格綱要,以確保我們擁有我們想要的欄位,以及我們想要的類型和值。但我們想要做的另一件事是驗證屬性之間的關係。

例如,角色有一個生命值統計數據。每次角色升級時,玩家都會擲骰子並相應地增加生命值。如您所想,忘記獲得獎勵可能會是一個致命的錯誤,因此最好確保永遠不會發生。

我們將透過 JSON 指標和 avj 的 $data 來做到這一點,如下所示

1# file: schemas-yaml/character.yml
2level: { type: number, minimum: 1 }
3health:
4    type: object
5    required: [ max ]
6    properties:
7        max: { type: number }
8        current: { type: number }
9        log:
10            type: array
11            description: history of health rolls
12            items: { type: number }
13            minItems: { $data: /level }
14            maxItems: { $data: /level }

基本上(一旦我們在 ajv 中加入 --data 旗標來啟用該功能),任何提及 { $data: '/path/to/another/value/in/the/schema' } 的地方,都會被替換為 JSON 指標在被驗證的文件中解析到的值。這不是 JSON Schema 本身的一部分,但它是一種非常實用的方式來連接 schema 和被驗證的文件。

不過,請注意:我說「任何提及 $data 的地方」,但這有點誇大。有些情況下,$data 欄位不會被解析。如果您要使用該功能,請務必花幾分鐘閱讀 AJV 相關文件。相信我,它會讓您避免一些「這到底是什麼鬼?」的時刻。

自定義關鍵字

在前一節中,我們檢查了健康值的擲骰次數是否等於角色的等級。這已經很不錯了。但接下來的邏輯步驟是確保這些擲骰的總和等於我們擁有的最大健康點數。我們需要類似這樣的東西

1# file: schemas-yaml/character.yml
2health:
3    type: object
4    properties:
5        max:
6            type: number
7            sumOf: { list: { $data: 1/log } }
8        log:
9            type: array
10            items: { type: number }

這就是自定義關鍵字登場的地方。AJV 允許我們使用新的關鍵字來擴充 JSON Schema 的詞彙。

定義自定義關鍵字的方法有幾種。我選擇的方法是將其定義為 JavaScript 函數(這裡因為我們內部處理 JSON 指標而使其稍微複雜一些)。

1// file: src/sumOf.cjs
2
3const _ = require("lodash");
4const ptr = require("json-pointer");
5
6function resolvePointer(data, rootPath, relativePath) {
7    if (relativePath[0] === "/") return ptr.get(data, relativePath);
8
9    const m = relativePath.match(/^(\d+)(.*)/);
10    relativePath = m[2];
11    for (let i = 0; i < parseInt(m[1]); i++) {
12        rootPath = rootPath.replace(/\/[^\/]+$/, "");
13    }
14
15    return ptr.get(data, rootPath + relativePath);
16}
17
18module.exports = (ajv) =>
19    ajv.addKeyword({
20        keyword: "sumOf",
21        $data: true,
22        errors: true,
23        validate: function validate(
24            { list, map },
25            total,
26            _parent,
27            { rootData, instancePath }
28        ) {
29            if (list.$data)
30                list = resolvePointer(rootData, instancePath, list.$data);
31
32            if (map) data = _.map(data, map);
33
34            if (_.sum(list) === total) return true;
35
36            validate.errors = [
37                {
38                    keyword: "sumOf",
39                    message: "should add up to sum total",
40                    params: {
41                        list,
42                    },
43                },
44            ];
45
46            return false;
47        },
48    });

像往常一樣,我們必須告訴 ajv 通過 -c ./src/sumOf.cjs 來包含這段新的程式碼。但除此之外,恭喜,我們有了一個新的關鍵字!

更多相同的操作

到目前為止,我們已經擁有了大部分所需的工具,剩下的就是開始運作。

經驗值?與健康值的邏輯大致相同

1# file: schemas-yaml/character.yml
2experience:
3    type: object
4    properties:
5        total:
6            type: number
7            sumOf:
8                list: { $data: '1/log' }
9                map: amount
10        log:
11            type: array
12        items:
13            type: object
14            properties:
15                date: *string
16                amount: *number
17                notes: *string

其他基本屬性都很簡單

1# file: schemas-yaml/character.yml
2gender: *string
3age: *number
4height: *string
5appearance: *string
6alignment: *string

基於列表的欄位?我們已經做過了

1# file: schemas-yaml/character.yml
2  race: { $ref: /races.json }
3  languages:
4    type: array
5    minItems: 1
6    items:
7      $ref: /languages.json

法術只適用於魔法師?沒問題。

1# file: schemas-yaml/character.yml
2type: object
3properties:
4    # ...
5    spells:
6      type: array
7      items: { $ref: /spells.json }
8      maxSpells:
9        class: { $data: /class }
10        level: { $data: /level }

使用新的關鍵字 maxSpells

1// file: src/maxSpells.cjs
2
3const _ = require("lodash");
4const resolvePointer = require('./resolvePointer.cjs');
5
6module.exports = (ajv) =>
7    ajv.addKeyword({
8        keyword: "maxSpells",
9        validate: function validate(
10            schema,
11            data,
12            _parent,
13            { rootData, instancePath }
14        ) {
15            if (schema.class.$data) {
16                schema.class = resolvePointer(
17                    rootData, instancePath, schema.class.$data
18                );
19            }
20
21            if( schema.class !== 'magician'
22                && schema.class?.generic !== 'magician'
23                && data.length ) {
24                validate.errors = [
25                    {
26                        message: "non-magician can't have spells",
27                    },
28                ];
29                return false;
30            }
31
32            return true;
33        },
34        $data: true,
35        errors: true,
36    });

裝備?哼!當然。

1# file: schemas-yaml/character.yml
2properties:
3    # ...
4    gear: { $ref: '#/$defs/gear' }
5$defs:
6  gear:
7    type: array
8    items:
9      oneOf:
10        - *string
11        - type: object
12          properties:
13            desc:
14              type: string
15              description: description of the equipment
16            qty:
17              type: number
18              description: |
19                quantity of the item in the
20                character's possession
21          required: [ desc ]
22          additionalProperties: false
23          examples:
24            - { desc: 'lamp oil', qty: 2 }

到目前為止,您應該了解了。許多約束都可以通過原生的 JSON Schema 關鍵字來表達。對於比較奇怪的事情,可以添加新的關鍵字。而對於任何繁瑣的輸入,我們必須記住,它底層都是 JSON,而且我們很清楚如何處理 JSON。