Переиспользование JSON Schema: $ref, $defs и композиция allOf/oneOf
Главное: $ref решает переиспользование, allOf и oneOf решают композицию, и именно их наложение ломает всё. Рассматривает $id, $defs, условия, рекурсию, объединение и отладку.

Главное: сначала извлеките общие структуры в $defs с помощью $ref, затем тянитесь к allOf/oneOf; когда ветки могут совпасть одновременно, используйте anyOf или тег const, и приберегайте oneOf для случаев, которые действительно требуют ровно одного совпадения.
Копирование-вставка экономит десять минут и стоит вам молчаливого форка три месяца спустя, когда никто не помнит, какая копия верна.
1. Три слоя переиспользования — выберите один, прежде чем разделять файлы
Первый инстинкт, когда поля начинают размножаться, — запихнуть каждое ограничение в один файл. Это самый быстрый способ выпустить, но после пары сотен строк каждое изменение поля нужно отражать в трёх местах. Пропустите одно, и получите «локально проходит, в продакшене 500». Переиспользование означает написать факт один раз и ссылаться на него везде.
JSON Schema предлагает три слоя, каждый с разной областью:
| Слой | Носитель | Область | Типичное применение |
|---|---|---|---|
| Подструктура | $defs + $ref |
Один файл схемы | Адрес, деньги, пагинация, формы ошибок |
| Файл | Отдельная схема + $id |
Несколько файлов в одном сервисе | Сущности пользователя, заказа, продукта |
| Библиотека контрактов | Репозиторий схем + объединённый артефакт | Несколько сервисов, несколько языков | Компоненты OpenAPI, опубликованные контракты |
Для большинства проектов первого слоя достаточно. Разделение на файлы окупается только когда два сервиса действительно разделяют один контракт или когда схема — опубликованный артефакт, потребляемый другим языком. Слишком раннее разделение усложняет разрешение $id и относительных путей, и эта цена обычно превышает то, что вы сэкономили, избежав дублирования.
Простое правило большого пальца: если структура появляется в двух местах, одна копия терпима; когда она появляется в третьем, повысьте её до $defs; только когда её должен читать другой язык или репозиторий, она заслуживает отдельного файла со своим $id.
2. $id устанавливает идентичность, $ref выполняет прыжок
$id — не комментарий. Он объявляет базовый URI схемы. Значение $ref — URI-ссылка: она разрешается относительно текущего базового URI в абсолютный URI, и затем фрагмент после # локализует узел. Если базовый URI неверен, каждая относительная ссылка указывает куда-то ещё, но ошибка обычно говорит лишь «can't resolve reference», не называя неверный хост.
{
"$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 использует JSON Pointer, а billTo — якорь; оба попадают на один узел. Таблица ниже отображает каждую форму на её разрешение:
| Форма | Указывает на | Драфт | Примечания |
|---|---|---|---|
# |
Корень текущей схемы | Все | Часто для рекурсии |
#/$defs/address |
Узел под $defs |
2019-09 и позже | Яснее всего; предпочтительно |
#address |
Именованный якорь | $anchor в 2020-12 |
draft-07 писал $id: "#address" |
common.json |
Файл относительно базового URI | Все | Должен быть зарегистрирован или объединён |
https://example.com/x.json |
Абсолютный URI | Все | Избегайте выборки во время выполнения в продакшене |
Одна тонкая ловушка: если подсхема внутри $defs объявляет свой собственный $id, он становится новым базовым URI для всего ниже него. Вставьте схему, скопированную из другого файла, и оставшийся $id перенаправит её относительные ссылки на исходный хост — и валидатор заметит это только когда действительно попытается их разрешить.
3. $defs — контейнер, а не место валидации
$defs — обычная карта. Ничто внутри него не действует, пока $ref не укажет туда. Список required, сидящий в начале $defs без входящей ссылки, не валидирует ровно ничего, каким бы строгим он ни выглядел. Это спецификация, работающая как задумано, а не баг валидатора.
{
"$defs": {
"money": {
"type": "string",
"pattern": "^[0-9]+\\.[0-9]{2}$",
"description": "Строка денег с двумя десятичными, избегающая дрейфа чисел с плавающей точкой"
},
"page": { "type": "integer", "minimum": 1 }
},
"properties": {
"total": { "$ref": "#/$defs/money" },
"page": { "$ref": "#/$defs/page" }
}
}
Несколько соглашений стоит зафиксировать в команде:
- Ключи внутри
$defsне несут семантики.address,Addressиaddrвсе легальны, поэтому договоритесь о стиле. - До 2019-09 ключевое слово было
definitions. На оба можно нацелить$ref, но строгий режим может сообщитьdefinitionsкак неизвестное ключевое слово. $defsможет держать любую схему, включая фрагментallOfили независимую подсхему со своим$id.- Не трактуйте
$defsкак наследование шаблонов. Он хранит только определения; смысл всё равно приходит из ссылающегося контекста.
4. allOf, anyOf и oneOf не взаимозаменяемы
Все три включают несколько веток, но их правила принятия различаются. Когда данные совпадают с двумя ветками, allOf и anyOf проходят, а oneOf падает — самая частая причина, по которой люди заключают «схема сломана».
| Ключевое слово | Минимум совпадений | Максимум совпадений | Данные, совпадающие с двумя ветками | Качество ошибок | Типичное применение |
|---|---|---|---|---|---|
allOf |
Все | Неограничен | Проходят | Хорошее, по одному ограничению за раз | Комбинировать фрагменты ограничений |
anyOf |
1 | Неограничен | Проходят | Плохое, ветки нагромождаются | «Email или телефон, любой из» |
oneOf |
1 | 1 | Падают | Плохое | Дискриминированные объединения |
not |
0 | 0 | Падают | Худшее | Исключение нескольких значений; редко используется |
{
"allOf": [
{ "$ref": "#/$defs/auditable" },
{ "$ref": "#/$defs/softDeletable" },
{ "required": ["email"] }
],
"unevaluatedProperties": false
}
allOf не слияет определения объектов; он только требует, чтобы каждая ветка выполнялась. Каждая ветка вычисляется независимо, поэтому additionalProperties: false внутри одной ветки знает только ключи, объявленные этой веткой, и пометит поля, объявленные другой веткой, как лишние. В 2020-12 исправление — поместить политику неизвестных свойств на верхний уровень и использовать unevaluatedProperties: false, который вычисляется один раз после всех веток.
Используйте anyOf для «нескольких легальных форм», а не для «нескольких взаимоисключающих форм». Если две ветки могут совпасть одновременно, oneOf производит пресловуто бесполезное must match exactly one schema in oneOf.
5. if/then/else и discriminator: сделайте условия разрешимыми
Условные ограничения обычно дают лучшие ошибки, чем oneOf, потому что путь решения явен: if проходит, then применяется; иначе применяется 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"] }
}
Три детали определяют, ведёт ли он себя как задумано:
- Запись
propertiesвнутриifне падает, когда ключ отсутствует. Сочетайте её сrequired, чтобы выразить «kind существует и равен image». - Сам
ifникогда не производит ошибку валидации. Провал просто маршрутизирует кelse, поэтому он не может блокировать плохие данные сам по себе. - Одинокий
thenилиelseинертен. Они применяются только рядом сif.
По мере роста веток объединения цепочка условий становится необслуживаемым гнездом. Более крепкий шаблон — закрепить тег-поле внутри каждой ветки, чтобы ветки были взаимоисключающими по построению:
{
"oneOf": [
{
"properties": { "kind": { "const": "card" } },
"required": ["kind", "cardNumber"]
},
{
"properties": { "kind": { "const": "account" } },
"required": ["kind", "accountId"]
}
]
}
discriminator — расширение OpenAPI 3.x, а не ключевое слово JSON Schema. Ajv поддерживает его при включённой опции discriminator: true, но его отображение живёт в mapping и не переносимо между инструментальными цепочками. Если схема должна работать в нескольких валидаторах, предпочитайте шаблон тега const, который не зависит ни от какого расширения.
6. Рекурсивные ссылки: деревья, связные списки и файлы, ссылающиеся друг на друга
Рекурсивным структурам не нужен специальный синтаксис — $ref обратно на себя достаточно. Валидаторы не разворачивают ссылки бесконечно во время компиляции; они рекурсируют по фактической глубине данных, поэтому несколько десятков строк описывают дерево любой высоты.
{
"$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"
}
Три границы стоит уладить заранее:
- Глубина данных: схема не накладывает ограничения, но валидация и сериализация обе потребляют стек пропорционально глубине. Публичные API должны добавить бизнес-ограничение, скажем восемь уровней, иначе злонамеренно глубокая полезная нагрузка может переполнить стек.
- Взаимные ссылки: A ссылается на B, а B ссылается на A — легально, но обе схемы должны быть зарегистрированы первыми; иначе порядок разрешения решает исход.
- Nullable-ветки: если рекурсивное поле может быть
null, пишите"type": ["object", "null"]или оборачивайте вoneOf.$refне наследует nullable.
7. Межфайловые ссылки и объединение: никогда не выбирайте схемы в продакшене
Межфайловые ссылки часто «просто работают» в разработке, потому что валидатор выбирает цель по $id по сети. Измените сетевую политику во время развёртывания, и вся цепочка валидации ломается. Правило продакшена простое: каждая ссылаемая схема должна быть зарегистрирована внутри процесса или объединена заранее.
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 });
// Сначала зарегистрируйте ссылаемый файл; ключ должен равняться его $id, иначе относительные ссылки не разрешатся
ajv.addSchema(addressSchema, 'https://bbdrug.com/schemas/address.json');
// Компилируйте входную схему один раз, на уровне модуля
export const validateOrder = ajv.compile(orderSchema);
Если order.json имеет $id: "https://bbdrug.com/schemas/order.json", его "$ref": "address.json" разрешается в https://bbdrug.com/schemas/address.json, что совпадает с зарегистрированным ключом. Один различающийся символ даёт can't resolve reference.
Три практических подхода сравниваются так:
| Подход | Как | Сила | Цена |
|---|---|---|---|
| Регистрация во время выполнения | addSchema на файл |
Без шага сборки, файлы остаются независимыми | Порядок регистрации и ключи вручную |
| Объединение во время сборки | Слить парсером ссылок в один файл | Ноль зависимостей времени выполнения | Больший артефакт, шумные различия |
| Асинхронная загрузка | loadSchema + compileAsync |
Обрабатывает часто меняющиеся схемы | Сетевые и кеш-заботы; избегайте на критических путях |
Для фронтенд-проектов объединение во время сборки безопаснее. На сервере регистрации достаточно при условии, что тест доказывает, что каждая ссылка разрешается. В любом случае запускайте compile в CI, чтобы ошибки ссылок всплывали до слияния, а не при первом производственном запросе.
8. Порядок отладки: разрешить, компилировать, затем осмотреть ветки
Почти каждую проблему $ref можно локализовать в три этапа, и порядок важен:
| Симптом | Вероятная причина | Исправление |
|---|---|---|
can't resolve reference ... from id ... |
Опечатка в $id, неверная относительная база, цель не зарегистрирована |
Выведите $id и разрешённый абсолютный URI, затем сравните с зарегистрированными ключами |
schema with key or id already exists |
Один и тот же $id зарегистрирован дважды, или compile вызывается повторно |
Переместите регистрацию в инициализацию модуля и запустите один раз |
strict mode: unknown keyword: "definitions" |
Смешивание синтаксиса draft-07 | Переименуйте в $defs; не отключайте строгий режим |
Данные совпадают с двумя ветками oneOf |
Перекрывающиеся ограничения веток, нет тег-поля | Добавьте const каждой ветке или перейдите на anyOf |
Ограничения внутри $defs не действуют |
Ничто на них не ссылается | Проверьте путь ссылки; $defs сам по себе ничего не валидирует |
При отладке читайте schemaPath до message: он называет провалившуюся ветку и ключевое слово и гораздо надёжнее английской прозы. Кешируйте скомпилированный валидатор на уровне модуля вместо повторной компиляции на каждый запрос. Для каждого изменения схемы покрывайте и проходящий, и падающий случай — тестирование только счастливого пути не тестирует ничего. О выборе валидатора и дизайне сообщений об ошибках см. JSON Schema: от основ до продакшена; о встраивании схемы в генерацию типов см. Двусторонняя синхронизация Schema и типов TypeScript.
9. Часто задаваемые вопросы
Может ли $ref жить рядом с type или description?
2020-12 допускает $ref рядом с соседними ключевыми словами, и эти соседи вычисляются нормально. В draft-07 соседи игнорируются, и применяется только $ref. Это самое большое поведенческое различие, когда одна схема используется в разных драфтах, поэтому подтвердите, какой драфт поддерживает ваш валидатор, перед миграцией.
Использовать $defs или definitions?
Используйте $defs для новой работы; он появился с 2019-09. definitions не является ключевым словом в 2020-12. Pointer вроде $ref: "#/definitions/foo" всё ещё может разрешаться, потому что это обычный JSON Pointer, но строгий режим сообщит о неизвестном ключевом слове. Смешивание обоих стилей оставляет мину, которая взрывается только в CI.
Почему oneOf отвергает выглядящие валидными данные?
Потому что данные удовлетворяют двум веткам одновременно. oneOf требует ровно одну, а ветка, ограничивающая только часть объекта, удовлетворяется даже когда поля другой ветки отсутствуют. Добавьте тег const каждой ветке, чтобы сделать их взаимоисключающими.
Зациклятся ли рекурсивные ссылки навсегда?
Нет. Валидаторы не разворачивают ссылки во время компиляции; они рекурсируют по фактическим данным. Реальные риски — данные, достаточно глубокие, чтобы переполнить стек, и неверный порядок разрешения между взаимно ссылающимися файлами. Бизнес-ограничение глубины на рекурсивных полях эффективнее любого трюка схемы.
Может ли продакшен полагаться на удалённый $ref?
Нет. Удалённые ссылки привязывают доступность валидации к сети: изменения домена, истёкшие сертификаты и изоляция внутренней сети все ломают цепочку. Объедините ссылаемые схемы в артефакт или зарегистрируйте их при запуске, если хотите предсказуемое поведение.
Как выбрать между $defs и отдельными файлами?
Структуру, появляющуюся в двух местах, можно копировать один раз; когда она появляется в третьем месте, повысьте её до $defs. Только когда её должен читать другой язык или другой репозиторий, она заслуживает отдельного файла со своим $id.
Почему ограничения внутри $defs не действуют?
$defs — только контейнер, и узлы, на которые не указывает никакой $ref, никогда не участвуют в валидации. Список required в начале $defs без входящей ссылки всё равно позволяет данным проходить — это спецификация, а не баг валидатора.
Стоит ли помещать additionalProperties: false внутрь веток allOf?
Нет. Каждая ветка вычисляется независимо, поэтому одна ветка трактует поля, объявленные другой веткой, как лишние. Подход 2020-12 перемещает политику неизвестных полей на верхний уровень и использует unevaluatedProperties: false, вычисляемый один раз после всех веток.