Znovupoužití JSON Schema: $ref, $defs a kompozice allOf/oneOf
Shrnutí: $ref řeší znovupoužití, allOf a oneOf řeší kompozici, a právě jejich kombinování je místo, kde se věci rozbíjejí. Pokrývá $id, $defs, podmínky, rekurzi, bundling a ladění.

Stručně: nejprve extrahujte sdílené struktury do $defs s $ref, poté sáhněte po allOf/oneOf; když větve mohou odpovídat současně, použijte anyOf nebo const tag a oneOf si nechte pro případy, které skutečně vyžadují přesně jednu shodu.
Kopírovat a vkládat ušetří deset minut a stojí vás tichou vidličku o tři měsíce později, kdy si nikdo nepamatuje, která kopie je správná.
1. Tři vrstvy znovupoužití – zvolte jednu, než začnete rozdělovat soubory
První instinkt, jakmile pole začnou přibývat, je nacpat každé omezení do jediného souboru. Je to nejrychlejší cest k nasazení, ale po několika stovkách řádků se každá změna pole musí zrcadlit na třech místech. Vynechte jedno a dostanete „lokálně projde, v produkci vrátí 500“. Znovupoužití znamená napsat fakt jednou a odkazovat na něj všude ostatní.
JSON Schema nabízí tři vrstvy, každou s jiným rozsahem:
| Vrstva | Nosič | Rozsah | Typické použití |
|---|---|---|---|
| Podstruktura | $defs + $ref |
Jeden soubor schématu | Adresa, peníze, stránkování, tvary chyb |
| Soubor | Samostatné schéma + $id |
Několik souborů v jedné službě | Entity uživatele, objednávky, produktu |
| Knihovna kontraktů | Repozitář schémat + zabalený artefakt | Více služeb, více jazyků | Komponenty OpenAPI, publikované kontrakty |
Pro většinu projektů první vrstva stačí. Rozdělování do souborů se vyplatí jen tehdy, když dvě služby skutečně sdílejí stejný kontrakt, nebo když je schéma publikovaný artefakt konzumovaný jiným jazykem. Příliš brzké rozdělení činí $id rozlišení a relativní cesty hůř laditelné, a ta cena obvykle převyšuje úsporu z vyhnutí se duplikaci.
Jednoduché pravidlo palce: pokud se struktura objeví na dvou místech, jedna kopie je snesitelná; když se objeví třetí, povyšte ji na $defs; teprve když ji musí číst jiný jazyk nebo repozitář, zaslouží si samostatný soubor s vlastním $id.
2. $id stanoví identitu, $ref provádí skok
$id není komentář. Deklaruje základní URI schématu. Hodnota $ref je URI reference: rozliší se proti aktuálnímu základnímu URI na absolutní URI a poté fragment za # lokalizuje uzel. Pokud je základní URI špatné, každá relativní reference ukazuje jinam, přesto chyba obvykle jen řekne „can't resolve reference“, aniž by pojmenovala špatný hostitel.
{
"$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 používá JSON Pointer, zatímco billTo používá anchor; oba přistanou na stejném uzelu. Tabulka níže mapuje každou formu na její rozlišení:
| Forma | Ukazuje na | Draft | Poznámky |
|---|---|---|---|
# |
Kořen aktuálního schématu | Všechny | Běžné pro rekurzi |
#/$defs/address |
Uzel pod $defs |
2019-09 a novější | Nejjasnější; preferované |
#address |
Pojmenovaný anchor | $anchor v 2020-12 |
Draft-07 psal $id: "#address" |
common.json |
Soubor relativní k základnímu URI | Všechny | Musí být registrován nebo zabalen |
https://example.com/x.json |
Absolutní URI | Všechny | V produkci se vyhněte stahování za běhu |
Jedna jemná past: pokud podschema uvnitř $defs deklaruje vlastní $id, stane se novým základním URI pro všechno pod ním. Vložte schéma zkopírované z jiného souboru a zbylé $id přesměruje jeho relativní reference na původního hostitele – a validátor si to všimne, až když je skutečně zkusí rozlišit.
3. $defs je kontejner, nikoli validační místo
$defs je obyčejná mapa. Nic uvnitř neúčinkuje, dokud na to neukazuje $ref. Seznam required sedící na začátku $defs bez příchozí reference nevaliduje vůbec nic, ať vypadá sebepřísnější. Je to specifikace fungující tak, jak má, nikoli chyba validátoru.
{
"$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" }
}
}
Několik konvencí stojí za to v týmu zafixovat:
- Klíče uvnitř
$defsnenesou sémantiku.address,Addressiaddrjsou legální, takže se dohodněte na stylu. - Před 2019-09 bylo klíčové slovo
definitions. Oba lze cílit přes$ref, ale striktní režim můžedefinitionshlásit jako neznámé klíčové slovo. $defsmůže obsahovat libovolné schéma, včetně fragmentuallOfnebo nezávislého podschematu s vlastním$id.- Nepovažujte
$defsza dědičnost šablon. Jen ukládá definice; význam stále přichází z odkazujícího kontextu.
4. allOf, anyOf a oneOf nejsou vzájemně zastupitelné
Všechny tři zahrnují více větví, ale jejich pravidla přijetí se liší. Když data odpovídají dvěma větvím, allOf a anyOf projdou, zatímco oneOf selže – to je nejčastější důvod, proč lidé usoudí „schéma je rozbité“.
| Klíčové slovo | Minimální shody | Maximální shody | Data odpovídající dvěma větvím | Kvalita chyb | Typické použití |
|---|---|---|---|---|---|
allOf |
Všechny | Neomezené | Projde | Dobrá, jedno omezení naráz | Kombinace fragmentů omezení |
anyOf |
1 | Neomezené | Projde | Špatná, větve se kupí | „E-mail nebo telefon, jedno z toho“ |
oneOf |
1 | 1 | Selže | Špatná | Diskriminované unie |
not |
0 | 0 | Selže | Nejhorší | Vyloučení několika hodnot; používané zřídka |
{
"allOf": [
{ "$ref": "#/$defs/auditable" },
{ "$ref": "#/$defs/softDeletable" },
{ "required": ["email"] }
],
"unevaluatedProperties": false
}
allOf neslučuje definice objektů; jen vyžaduje, aby každá větve platila. Každá větve se vyhodnocuje nezávisle, takže additionalProperties: false uvnitř jedné větve zná jen klíče, které ta větve deklarovala, a pole deklarovaná jinou větví označí jako nadbytečná. V 2020-12 je oprava umístit politiku neznámých vlastností na nejvyšší úrovni a použít unevaluatedProperties: false, které se vyhodnotí jednou po všech větvích.
anyOf použijte pro „několik legálních tvarů“, nikoli pro „několik vzájemně výlučných tvarů“. Pokud dvě větve mohou odpovídat současně, oneOf produkuje pověstnou nepříliš užitečnou zprávu must match exactly one schema in oneOf.
5. if/then/else a diskriminátor: udělejte podmínky rozhodnutelné
Podmíněná omezení obvykle produkují lepší chyby než oneOf, protože rozhodovací cesta je explicitní: if projde, then se uplatní; jinak se uplatní 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"] }
}
Tři detaily rozhodují o tom, zda se chová zamýšleně:
- Položka
propertiesuvnitřifneselže, když klíč chybí. Spárujte ji srequired, abyste vyjádřili „kind existuje a rovná se image“. ifsamo nikdy neprodukuje validační chybu. Jeho selhání jen přesměruje naelse, takže samo nemůže blokovat špatná data.- Osamocené
thenneboelseje inertní. Uplatní se jen vedleif.
Jak větve unie rostou, podmíněný řetěz se stane neudržovatelným hnízdem. Robustnější vzor je uzamknout tag pole uvnitř každé větve, aby větve byly vzájemně výlučné už ze své konstrukce:
{
"oneOf": [
{
"properties": { "kind": { "const": "card" } },
"required": ["kind", "cardNumber"]
},
{
"properties": { "kind": { "const": "account" } },
"required": ["kind", "accountId"]
}
]
}
discriminator je rozšíření OpenAPI 3.x, nikoli klíčové slovo JSON Schema. Ajv ho podporuje, když je zapnutá volba discriminator: true, ale jeho mapování žije v mapping a není přenosné mezi nástrojovými řetězci. Pokud schéma musí fungovat ve více validátorech, preferujte vzor s const tagem, který nezávisí na žádném rozšíření.
6. Rekurzivní reference: stromy, spojové seznamy a soubory, které odkazují na sebe
Rekurzivní struktury nepotřebují zvláštní syntaxi – stačí $ref zpět na sebe. Validátory nerozbalují reference nekonečně při kompilaci; rekurzují podle skutečné hloubky dat, takže několik desítek řádků popíše strom libovolné výšky.
{
"$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"
}
Tři hranice stojí za to vyřešit předem:
- Hloubka dat: schéma neukládá žádný limit, ale validace i serializace spotřebovávají zásobník úměrně hloubce. Veřejná API by měla přidat obchodní limit, řekněme osm úrovní, jinak zlovolně hluboký payload může přetéci zásobník.
- Vzájemné reference: A odkazuje na B a B na A je legální, ale obě schémata musí být nejprve registrována; jinak o výsledku rozhodne pořadí rozlišení.
- Nullable větve: pokud rekurzivní pole může být
null, napište"type": ["object", "null"], nebo ho obalte dooneOf.$refnullability nezdědí.
7. Reference napříč soubory a bundling: nikdy nestahujte schémata v produkci
Reference napříč soubory často „prostě fungují“ ve vývoji, protože validátor stáhne cílové schéma podle $id přes síť. Změňte síťovou politiku při nasazení a celý validační řetěz se rozbije. Pravidlo pro produkci je jednoduché: každé referencované schéma musí být registrováno v procesu nebo předem zabalené.
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 });
// Nejprve registrujte referencovaný soubor; klíč musí odpovídat jeho $id, jinak relativní refs nebudou rozlišeny
ajv.addSchema(addressSchema, 'https://bbdrug.com/schemas/address.json');
// Zkompilujte vstupní schéma jednou, na úrovni modulu
export const validateOrder = ajv.compile(orderSchema);
Pokud order.json má $id: "https://bbdrug.com/schemas/order.json", jeho "$ref": "address.json" se rozliší na https://bbdrug.com/schemas/address.json, což odpovídá registrovanému klíči. Jediný odlišný znak dává can't resolve reference.
Tři praktické přístupy se srovnávají takto:
| Přístup | Jak | Přednost | Cena |
|---|---|---|---|
| Registrace za běhu | addSchema pro každý soubor |
Žádný build krok, soubory zůstávají nezávislé | Pořadí registrace a klíče jsou ruční |
| Bundling při sestavení | Sloučení pomocí ref parseru do jednoho souboru | Žádné závislosti za běhu | Větší artefakt, hlučné diffy |
| Asynchronní načítání | loadSchema + compileAsync |
Zvládá často se měnící schémata | Starosti o síť a cache; vyhněte se na kritických cestách |
Pro frontendové projekty je bezpečnější bundling při sestavení. Na serveru stačí registrace, pokud test prokáže, že se každá reference rozliší. V obou případech spouštějte compile v CI, aby se chyby referencí objevily před sloučením, nikoli při prvním produkčním požadavku.
8. Pořadí ladění: rozlišit, zkompilovat, poté prozkoumat větve
Téměř každý problém s $ref lze lokalizovat ve třech fázích a pořadí záleží:
| Příznak | Pravděpodobná příčina | Oprava |
|---|---|---|
can't resolve reference ... from id ... |
Překlep v $id, špatný relativní základ, cíl není registrován |
Vypište $id a rozlišenou absolutní URI a srovnejte s registrovanými klíči |
schema with key or id already exists |
Stejné $id registrováno dvakrát, nebo compile voláno opakovaně |
Přesuňte registraci do inicializace modulu a spusťte ji jednou |
strict mode: unknown keyword: "definitions" |
Míchání syntaxe draft-07 | Přejmenujte na $defs; nevypínejte striktní režim |
Data odpovídají dvěma větvím oneOf |
Překrývající se omezení větví, žádné tag pole | Přidejte const do každé větve, nebo přejděte k anyOf |
Omezení uvnitř $defs nemají účinek |
Nic na ně neodkazuje | Zkontrolujte cestu reference; $defs samo nevaliduje nic |
Při ladění čtěte schemaPath dřív než message: pojmenovává větve a klíčové slovo, které selhalo, a je mnohem spolehlivější než anglická próza. Cachujte zkompilovaný validátor na úrovni modulu, místo abyste rekompilovali při každém požadavku. Pro každou změnu schématu pokryjte procházející i selhávající případ – testovat jen šťastnou cestu netestuje nic. Výběru validátoru a návrhu chybových zpráv se věnuje JSON Schema: od základů k produkci; zapojení schématu do generování typů Obousměrná synchronizace schématu a typů TypeScript.
9. Často kladené otázky
Může $ref žít vedle type nebo description?
2020-12 povoluje $ref vedle souvisejících klíčových slov a tato související klíčová slova se vyhodnocují normálně. V draft-07 jsou související klíčová slova ignorována a platí jen $ref. To je největší behaviorální rozdíl, když se stejné schéma používá napříč návrhy, takže před migrací potvrďte, který návrh váš validátor podporuje.
Mám použít $defs, nebo definitions?
Pro novou práci použijte $defs; přišlo s 2019-09. definitions není v 2020-12 klíčové slovo. Pointer jako $ref: "#/definitions/foo" se stále rozliší, protože je to obyčejný JSON Pointer, ale striktní režim ohlásí neznámé klíčové slovo. Míchání obou stylů zanechává minu, která exploduje až v CI.
Proč oneOf odmítá data, která vypadají platně?
Protože data splňují dvě větve najednou. oneOf vyžaduje přesně jednu a větve omezující jen část objektu je splněna, i když pole druhé větve chybí. Přidejte do každé větve const tag, aby byly vzájemně výlučné.
Budou rekurzivní reference opakovat donekonečna?
Ne. Validátory nerozbalují reference při kompilaci; rekurzují nad skutečnými daty. Skutečná rizika jsou data dost hluboká na přetečení zásobníku a špatné pořadí rozlišení mezi soubory, které na sebe odkazují. Obchodní limit hloubky u rekurzivních polí je účinnější než jakýkoli trik se schématem.
Může produkce spoléhat na vzdálené $ref?
Ne. Vzdálené reference svazují dostupnost validace se sítí: změny domén, vypršelé certifikáty i izolace interní sítě rozbijí řetěz. Pokud chcete předvídatelné chování, zabalte referencovaná schémata do artefaktu nebo je registrujte při startu.
Jak rozhodnout mezi $defs a samostatnými soubory?
Strukturu, která se objeví na dvou místech, lze kopírovat jednou; když se objeví na třetím místě, povyšte ji na $defs. Teprve když ji musí číst jiný jazyk nebo jiný repozitář, zaslouží si samostatný soubor s vlastním $id.
Proč omezení uvnitř $defs nemají účinek?
$defs je jen kontejner a uzly, na které neodkazuje žádný $ref, se validace nikdy neúčastní. Seznam required na začátku $defs bez příchozí reference stále nechá data projít – je to specifikace, nikoli chyba validátoru.
Mám dát additionalProperties: false do větví allOf?
Ne. Každá větve se vyhodnocuje nezávisle, takže jedna větve považuje pole deklarovaná jinou větví za nadbytečná. Přístup 2020-12 přesouvá politiku neznámých polí na nejvyšší úroveň a používá unevaluatedProperties: false, vyhodnocené jednou po všech větvích.