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.

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
$defsnie niosą semantyki.address,Addressiaddrsą 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ćdefinitionsjako nieznane słowo kluczowe. $defsmoże trzymać dowolny schemat, w tym fragmentallOfalbo niezależny podschemat z własnym$id.- Nie traktuj
$defsjako 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:
- Wpis
propertieswewnątrzifnie zawodzi, gdy klucza brakuje. Połącz go zrequired, aby wyrazić „kind istnieje i równa się image”. - Sam
ifnigdy nie produkuje błędu walidacji. Jego porażka po prostu kieruje doelse, więc nie potrafi samodzielnie zablokować złych danych. - Samotny
thenlubelsejest bezwładny. Działają tylko obokif.
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 woneOf.$refnie 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.