JSON Toolkit

Переиспользование JSON Schema: $ref, $defs и композиция allOf/oneOf

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

Средний~11 мин чтенияОпубликовано 2026-10-31Обновлено 2026-10-31
Схема переиспользования и композиции JSON Schema с $ref, $defs и allOf/oneOf

Главное: сначала извлеките общие структуры в $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"] }
}

Три детали определяют, ведёт ли он себя как задумано:

  1. Запись properties внутри if не падает, когда ключ отсутствует. Сочетайте её с required, чтобы выразить «kind существует и равен image».
  2. Сам if никогда не производит ошибку валидации. Провал просто маршрутизирует к else, поэтому он не может блокировать плохие данные сам по себе.
  3. Одинокий 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, вычисляемый один раз после всех веток.