JSON Toolkit

Ponowne użycie JSON Schema: $ref, $defs i kompozycja allOf/oneOf

Podsumowanie: $ref rozwiązuje ponowne użycie, allOf i oneOf rozwiązują kompozycję, a ich łączenie to miejsce, w którym wszystko się psuje. Omawia $id, $defs, warunki, rekurencję, bundling i debugowanie.

Średniozaawansowany~11 min czytaniaOpublikowano 2026-10-31Zaktualizowano 2026-10-31
Diagram ponownego użycia i kompozycji JSON Schema z $ref, $defs i allOf/oneOf

Najważniejsze: najpierw wyciągnij wspólne struktury do $defs z $ref, a dopiero potem sięgaj po allOf/oneOf; gdy gałęzie mogą pasować jednocześnie, użyj anyOf lub znacznika const, a oneOf zarezerwuj dla przypadków naprawdę wymagających dokładnie jednego dopasowania.

Kopiuj-wklej oszczędza dziesięć minut i kosztuje cichy rozłam trzy miesiące później, gdy nikt nie pamięta, która kopia jest poprawna.

1. Trzy warstwy ponownego użycia — wybierz jedną, zanim zaczniesz dzielić pliki

Pierwszą intuicją, gdy pola zaczynają się mnożyć, jest wepchnięcie każdego ograniczenia do jednego pliku. To najszybszy sposób na wdrożenie, ale powyżej kilkuset linii każda zmiana pola musi być powtórzona w trzech miejscach. Pomiń jedno i dostajesz „przechodzi lokalnie, zwraca 500 na produkcji”. Ponowne użycie znaczy napisać fakt raz i odwoływać się do niego wszędzie indziej.

JSON Schema oferuje trzy warstwy, każdą o innym zasięgu:

Warstwa Nośnik Zasięg Typowe użycie
Podstruktura $defs + $ref Jeden plik schematu Adres, pieniądze, paginacja, kształty błędów
Plik Samodzielny schemat + $id Kilka plików w jednej usłudze Encje użytkownika, zamówienia, produktu
Biblioteka kontraktów Repozytorium schematów + artefakt w paczce Wiele usług, wiele języków Komponenty OpenAPI, publikowane kontrakty

Dla większości projektów wystarczy pierwsza warstwa. Dzielenie na pliki opłaca się tylko wtedy, gdy dwie usługi naprawdę dzielą ten sam kontrakt albo gdy schemat jest publikowanym artefaktem konsumowanym przez inny język. Zbyt wczesne dzielenie utrudnia debugowanie rozwiązywania $id i ścieżek względnych, a ten koszt zwykle przewyższa oszczędności z uniknięcia duplikacji.

Prosta zasada praktyczna: jeśli struktura występuje w dwóch miejscach, jedna kopia jest do zniesienia; gdy pojawia się w trzecim, awansuj ją do $defs; tylko gdy inny język lub repozytorium musi ją czytać, zasługuje na samodzielny plik z własnym $id.

2. $id ustanawia tożsamość, $ref wykonuje skok

$id to nie komentarz. Deklaruje bazowy URI schematu. Wartość $ref to referencja URI: jest rozwiązywana względem obecnego bazowego URI do bezwzględnego URI, a następnie fragment po # lokalizuje węzeł. Jeśli bazowy URI jest zły, każda względna referencja wskazuje gdzie indziej, a błąd zwykle mówi tylko „can't resolve reference”, nie nazywając złego hosta.

{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "$id": "https://bbdrug.com/schemas/order.json",
  "$defs": {
    "address": {
      "$anchor": "address",
      "type": "object",
      "required": ["city"],
      "properties": {
        "city": { "type": "string", "minLength": 1 },
        "detail": { "type": "string" }
      }
    }
  },
  "type": "object",
  "required": ["shipTo"],
  "properties": {
    "shipTo": { "$ref": "#/$defs/address" },
    "billTo": { "$ref": "#address" }
  }
}

shipTo używa JSON Pointer, a billTo używa kotwicy; oba lądują na tym samym węźle. Poniższa tabela mapuje każdą formę na jej rozwiązanie:

Forma Wskazuje Draft Uwagi
# Korzeń obecnego schematu Wszystkie Częsta dla rekurencji
#/$defs/address Węzeł pod $defs 2019-09 i późniejsze Najczystsza; preferowana
#address Nazwana kotwica $anchor w 2020-12 Draft-07 pisał $id: "#address"
common.json Plik względem bazowego URI Wszystkie Musi być zarejestrowany lub dołączony
https://example.com/x.json Bezwzględny URI Wszystkie Unikaj pobierania w czasie wykonania w produkcji

Jedna subtelna pułapka: jeśli podschemat wewnątrz $defs deklaruje własne $id, staje się ono nowym bazowym URI dla wszystkiego poniżej. Wklej schemat skopiowany z innego pliku, a pozostawione $id przekieruje jego względne referencje na oryginalny host — a walidator zauważy to dopiero, gdy faktycznie spróbuje je rozwiązać.

3. $defs to kontener, a nie miejsce walidacji

$defs to zwykła mapa. Nic w niej nie działa, dopóki $ref tam nie wskaże. Lista required siedząca na górze $defs bez przychodzącej referencji nie waliduje dokładnie niczego, niezależnie od tego, jak surowo wygląda. To specyfikacja działająca zgodnie z zamiarem, a nie bug walidatora.

{
  "$defs": {
    "money": {
      "type": "string",
      "pattern": "^[0-9]+\\.[0-9]{2}$",
      "description": "A two-decimal money string that avoids floating-point drift"
    },
    "page": { "type": "integer", "minimum": 1 }
  },
  "properties": {
    "total": { "$ref": "#/$defs/money" },
    "page": { "$ref": "#/$defs/page" }
  }
}

Kilka konwencji warto ustalić w zespole:

  • Klucze wewnątrz $defs nie niosą semantyki. address, Address i addr są wszystkie legalne, więc ustal jeden styl.
  • Przed 2019-09 słowem kluczowym było definitions. Oba można wskazać przez $ref, ale tryb ścisły może zgłosić definitions jako nieznane słowo kluczowe.
  • $defs może trzymać dowolny schemat, w tym fragment allOf albo niezależny podschemat z własnym $id.
  • Nie traktuj $defs jako dziedziczenia szablonów. Przechowuje tylko definicje; znaczenie nadal pochodzi z kontekstu odwołującego się.

4. allOf, anyOf i oneOf nie są wymienne

Wszystkie trzy obejmują wiele gałęzi, ale ich reguły akceptacji się różnią. Gdy dane pasują do dwóch gałęzi, allOf i anyOf przechodzą, a oneOf zawodzi — to najczęstszy powód, dla którego ludzie dochodzą do wniosku „schemat jest zepsuty”.

Słowo kluczowe Minimalne dopasowania Maksymalne dopasowania Dane pasujące do dwóch gałęzi Jakość błędów Typowe użycie
allOf Wszystkie Bez limitu Przechodzą Dobra, jedno ograniczenie naraz Łączenie fragmentów ograniczeń
anyOf 1 Bez limitu Przechodzą Słaba, gałęzie się piętrzą „E-mail lub telefon, którykolwiek”
oneOf 1 1 Zawodzą Słaba Unie z dyskryminatorem
not 0 0 Zawodzą Najgorsza Wykluczanie kilku wartości; rzadko używane
{
  "allOf": [
    { "$ref": "#/$defs/auditable" },
    { "$ref": "#/$defs/softDeletable" },
    { "required": ["email"] }
  ],
  "unevaluatedProperties": false
}

allOf nie scala definicji obiektów; wymaga tylko, by każda gałąź obowiązywała. Każda gałąź jest oceniana niezależnie, więc additionalProperties: false w jednej gałęzi zna tylko klucze zadeklarowane przez tę gałąź i oznaczy pola zadeklarowane przez inną gałąź jako zbędne. W 2020-12 poprawką jest umieszczenie polityki nieznanych właściwości na poziomie głównym i użycie unevaluatedProperties: false, które jest oceniane raz po wszystkich gałęziach.

Używaj anyOf do „kilku legalnych kształtów”, a nie do „kilku kształtów wzajemnie wyłączających się”. Jeśli dwie gałęzie mogą pasować jednocześnie, oneOf produkuje słynnie bezużyteczne must match exactly one schema in oneOf.

5. if/then/else i dyskryminator: uczyń warunki rozstrzygalnymi

Warunkowe ograniczenia zwykle dają lepsze błędy niż oneOf, bo ścieżka decyzyjna jest jawna: if przechodzi, stosuje się then; inaczej stosuje się else.

{
  "type": "object",
  "required": ["kind"],
  "properties": { "kind": { "enum": ["text", "image"] } },
  "if": {
    "properties": { "kind": { "const": "image" } },
    "required": ["kind"]
  },
  "then": { "required": ["url", "width", "height"] },
  "else": { "required": ["content"] }
}

Trzy szczegóły decydują, czy zachowuje się zgodnie z zamiarem:

  1. Wpis properties wewnątrz if nie zawodzi, gdy klucza brakuje. Połącz go z required, aby wyrazić „kind istnieje i równa się image”.
  2. Sam if nigdy nie produkuje błędu walidacji. Jego porażka po prostu kieruje do else, więc nie potrafi samodzielnie zablokować złych danych.
  3. Samotny then lub else jest bezwładny. Działają tylko obok if.

Gdy gałęzie unii rosną, łańcuch warunków staje się nieutrzymywalnym gniazdem. Solidniejszy wzorzec to zablokowanie pola znacznika wewnątrz każdej gałęzi, aby gałęzie były wzajemnie wyłączające się z konstrukcji:

{
  "oneOf": [
    {
      "properties": { "kind": { "const": "card" } },
      "required": ["kind", "cardNumber"]
    },
    {
      "properties": { "kind": { "const": "account" } },
      "required": ["kind", "accountId"]
    }
  ]
}

discriminator to rozszerzenie OpenAPI 3.x, a nie słowo kluczowe JSON Schema. Ajv wspiera je po włączeniu opcji discriminator: true, ale jego mapowanie żyje w mapping i nie jest przenośne między łańcuchami narzędzi. Jeśli schemat musi działać w kilku walidatorach, preferuj wzorzec znacznika const, który nie zależy od żadnego rozszerzenia.

6. Referencje rekurencyjne: drzewa, listy powiązane i pliki cytujące się wzajemnie

Struktury rekurencyjne nie wymagają specjalnej składni — wystarczy $ref do siebie. Walidatory nie rozwijają referencji nieskończenie w czasie kompilacji; rekurencja podąża za rzeczywistą głębokością danych, więc kilkadziesiąt linii opisuje drzewo dowolnej wysokości.

{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "$id": "https://bbdrug.com/schemas/tree-node.json",
  "$defs": {
    "node": {
      "type": "object",
      "required": ["name"],
      "properties": {
        "name": { "type": "string", "minLength": 1 },
        "children": {
          "type": "array",
          "items": { "$ref": "#/$defs/node" }
        }
      }
    }
  },
  "$ref": "#/$defs/node"
}

Trzy granice warto ustalić z góry:

  • Głębokość danych: schemat nie narzuca limitu, ale walidacja i serializacja zużywają stos proporcjonalnie do głębokości. Publiczne API powinny dodać limit biznesowy, powiedzmy osiem poziomów, inaczej złośliwie głęboki ładunek może przepełnić stos.
  • Referencje wzajemne: A odwołuje się do B, a B do A jest legalne, ale oba schematy muszą być najpierw zarejestrowane; inaczej kolejność rozwiązywania decyduje o wyniku.
  • Gałęzie nullowalne: jeśli pole rekurencyjne może być null, zapisz "type": ["object", "null"] albo opakuj w oneOf. $ref nie dziedziczy nullowalności.

7. Referencje między plikami i bundling: nigdy nie pobieraj schematów w produkcji

Referencje między plikami często „po prostu działają” w rozwoju, bo walidator pobiera cel przez $id z sieci. Zmień politykę sieciową przy wdrożeniu, a cały łańcuch walidacji się psuje. Reguła produkcji jest prosta: każdy przywoływany schemat musi być zarejestrowany w procesie lub dołączony z góry.

import Ajv2020 from 'ajv/dist/2020';
import addressSchema from './address.json' with { type: 'json' };
import orderSchema from './order.json' with { type: 'json' };

const ajv = new Ajv2020({ allErrors: true, strict: true });

// Register the referenced file first; the key must equal its $id, or relative refs won't resolve
ajv.addSchema(addressSchema, 'https://bbdrug.com/schemas/address.json');

// Compile the entry schema once, at module scope
export const validateOrder = ajv.compile(orderSchema);

Jeśli order.json ma $id: "https://bbdrug.com/schemas/order.json", jego "$ref": "address.json" rozwiązuje się do https://bbdrug.com/schemas/address.json, co pasuje do zarejestrowanego klucza. Jedna różniąca się litera daje can't resolve reference.

Trzy praktyczne podejścia porównują się tak:

Podejście Jak Mocna strona Koszt
Rejestracja w czasie wykonania addSchema per plik Brak etapu budowania, pliki pozostają niezależne Kolejność rejestracji i klucze są ręczne
Bundling w czasie budowania Scalanie parserem referencji w jeden plik Zero zależności w czasie wykonania Większy artefakt, hałaśliwe diffy
Ładowanie asynchroniczne loadSchema + compileAsync Obsługuje często zmieniające się schematy Problemy sieci i cache; unikaj na ścieżkach krytycznych

Dla projektów frontendowych bundling w czasie budowania jest bezpieczniejszy. Na serwerze rejestracja wystarczy, o ile test dowodzi, że każda referencja się rozwiązuje. Niezależnie od wyboru uruchamiaj compile w CI, aby błędy referencji ujawniały się przed scaleniem, a nie przy pierwszym produkcyjnym żądaniu.

8. Kolejność debugowania: rozwiąż, skompiluj, potem zbadaj gałęzie

Niemal każdy problem z $ref można zlokalizować w trzech etapach, a kolejność ma znaczenie:

Objaw Prawdopodobna przyczyna Poprawka
can't resolve reference ... from id ... Literówka w $id, zła względna podstawa, cel niezarejestrowany Wypisz $id i rozwiązany bezwzględny URI, a potem porównaj z zarejestrowanymi kluczami
schema with key or id already exists Ten sam $id zarejestrowany dwukrotnie albo compile wywołane wielokrotnie Przenieś rejestrację do inicjalizacji modułu i uruchamiaj raz
strict mode: unknown keyword: "definitions" Mieszanie składni draft-07 Zmień nazwę na $defs; nie wyłączaj trybu ścisłego
Dane pasują do dwóch gałęzi oneOf Nakładające się ograniczenia gałęzi, brak pola znacznika Dodaj const do każdej gałęzi albo przejdź na anyOf
Ograniczenia wewnątrz $defs nie działają Nic się do nich nie odwołuje Sprawdź ścieżkę referencji; $defs samo nic nie waliduje

Debugując, czytaj schemaPath przed message: nazywa gałąź i słowo kluczowe, które zawiodły, i jest znacznie bardziej niezawodne niż angielska prosa. Cache’uj skompilowany walidator na poziomie modułu, zamiast rekompilować per żądanie. Dla każdej zmiany schematu pokryj zarówno przypadek przechodzący, jak i zawodzący — testowanie tylko ścieżki sukcesu nie testuje niczego. O wyborze walidatora i projektowaniu komunikatów błędów przeczytasz w JSON Schema: od podstaw do produkcji; o podłączeniu schematu do generowania typów w Dwukierunkowej synchronizacji schematu i typów TypeScript.

9. Często zadawane pytania

Czy $ref może współistnieć z type lub description?

2020-12 pozwala na $ref obok sąsiednich słów kluczowych, a te sąsiednie są oceniane normalnie. W draft-07 sąsiednie są ignorowane i stosuje się tylko $ref. To największa różnica behawioralna, gdy ten sam schemat jest używany w różnych draftach, więc potwierdź, który draft wspiera twój walidator, zanim zmigrujesz.

Czy używać $defs czy definitions?

Używaj $defs w nowej pracy; pojawiło się z 2019-09. definitions nie jest słowem kluczowym w 2020-12. Wskaźnik taki jak $ref: "#/definitions/foo" nadal się rozwiąże, bo to zwykły JSON Pointer, ale tryb ścisły zgłosi nieznane słowo kluczowe. Mieszanie obu stylów zostawia minę, która wybucha tylko w CI.

Dlaczego oneOf odrzuca dane wyglądające poprawnie?

Bo dane spełniają dwie gałęzie naraz. oneOf wymaga dokładnie jednej, a gałąź ograniczająca tylko część obiektu jest spełniona nawet wtedy, gdy brakuje pól innej gałęzi. Dodaj znacznik const do każdej gałęzi, aby były wzajemnie wyłączające się.

Czy referencje rekurencyjne zapętlą się na zawsze?

Nie. Walidatory nie rozwijają referencji w czasie kompilacji; rekurencja podąża za rzeczywistymi danymi. Prawdziwym ryzykiem są dane na tyle głębokie, by przepełnić stos, i zła kolejność rozwiązywania między plikami odwołującymi się wzajemnie. Limit głębokości na poziomie biznesowym dla pól rekurencyjnych jest skuteczniejszy niż jakikolwiek trik schematu.

Czy produkcja może polegać na zdalnym $ref?

Nie. Zdalne referencje wiążą dostępność walidacji z siecią: zmiany domen, wygasłe certyfikaty i izolacja sieci wewnętrznej psują łańcuch. Dołącz przywoływane schematy do artefaktu albo zarejestruj je przy starcie, jeśli chcesz przewidywalnego zachowania.

Jak zdecydować między $defs a osobnymi plikami?

Strukturę występującą w dwóch miejscach można skopiować raz; gdy pojawia się w trzecim, awansuj ją do $defs. Tylko gdy inny język lub repozytorium musi ją czytać, zasługuje na samodzielny plik z własnym $id.

Dlaczego ograniczenia wewnątrz $defs nie działają?

$defs to tylko kontener, a węzły, do których żaden $ref nie wskazuje, nigdy nie biorą udziału w walidacji. Lista required na górze $defs bez przychodzącej referencji nadal przepuszcza dane — to specyfikacja, a nie bug walidatora.

Czy umieszczać additionalProperties: false w gałęziach allOf?

Nie. Każda gałąź jest oceniana niezależnie, więc jedna gałąź traktuje pola zadeklarowane przez inną jako zbędne. Podejście 2020-12 przenosi politykę nieznanych pól na poziom główny i używa unevaluatedProperties: false, ocenianego raz po wszystkich gałęziach.