DarkRiDDeR15 мин

Под капотом: совместимость контракта хранилища без догадок о JSON

ДанныеАрхитектураОтладка

Сбой reader после обычной записи часто выглядит как ошибка хранения: объект прочитан, но поле пришло «не таким». Цена неверного диагноза выше одного exception. Разработчик подставляет default, вторая версия writer закрепляет эту догадку, а старые record начинают означать другое. Через несколько изменений уже невозможно ответить, была ли timezone очищена, не передана старым writer или испорчена при преобразовании.

Механизм совместимости начинается с простого разделения. Формат отвечает, как передать значения. Schema описывает разрешённую форму. Контракт добавляет presence и смысл. Consumer отвечает за интерпретацию конкретной версии. В этой статье я не запускаю parser, storage или migration framework: есть только Array, Map и объекты в одной Node fixture. Она показывает, какие assertions нужны до того, как формат или хранилище выбраны как решение.

Четыре слоя, которые нельзя смешивать

Первый слой — синтаксис JSON: есть object, member и literal null. Второй — structural validation: обязательность ключа, допустимый тип, набор значений. Третий — compatibility rule между writer и reader. Четвёртый — semantic rule: что означает строка weekly и можно ли переиспользовать её как другой признак. Если вопрос попал не на свой слой, решение оказывается слишком сильным или слишком слабым: например, schema разрешила строку, а consumer уже не может сказать, что эта строка обозначает.

Где живёт каждое правило profile.settings
СлойПример правилаКакой дефект ловитЧего не ловит сам
форматобъект содержит пары имя/значениенеразбираемый текстсмысл и поддерживаемые версии
shapeid — непустая строка; timezone — absent, null или строкаtype и presence breakпереосмысление старого значения
compatibilityv2 writer добавляет только unknown optional полеразрыв пары writer/readerфактический rollout вне теста
semanticweekly — cadence сводкита же строка с новым значениемправо owner принять бизнес-решение

Такое разделение делает ошибку локализуемой. Если timezone: 3, это type break: reader обязан отвергнуть record. Если ключа нет, это presence case: reader v2 возвращает absent, но не «очищено». Если новый reader перестал принимать старое daily, это narrowing: у writer v1 было законное значение, а новый reader сузил договор. Если weekly сохранило тип, но стало обозначать маркетинговый флаг, это semantic break. Ни один JSON parser не может угадать последнее правило.

Absent, null и значение должны пройти разными ветками

Отсутствующий key и key со значением null различаются уже в JSON-представлении. Но их бизнес-граница выбирается приложением. В учебном договоре absent означает «writer этой версии не сообщил timezone», а null — «writer явно очистил timezone». Поэтому reader v2 не имеет права свести оба случая к одной переменной без состояния. Иначе переход от v1 к v2 будет выглядеть успешным, но потеряет информацию, которую позже нельзя восстановить.

const owns = (value, key) => Object.prototype.hasOwnProperty.call(value, key);

function timezoneState(record) {
  if (!owns(record, 'timezone')) return 'absent';
  if (record.timezone === null) return 'explicit-null';
  if (typeof record.timezone === 'string' && record.timezone) return 'value';
  throw new Error('timezone violates contract');
}

timezoneState({ id: 'profile-17' });                 // absent
timezoneState({ id: 'profile-17', timezone: null }); // explicit-null

Обратите внимание на проверку собственного свойства. У объекта могут быть прототип, вычисляемый default или неудачный merge; договор читает конкретный record, а не всё, что JavaScript способен вернуть по цепочке. Для этого примера пустая строка тоже отвергается: она не является ни старым absent, ни явным clear, ни полезным часовым поясом. В реальном проекте допустимые строки и их нормализацию задаёт owner отдельно; fixture не притворяется справочником временных зон.

Последовательность эволюции profile.settings: зафиксированный v1, reader v2 с поддержкой absent и null, additive writer v2, compatibility matrix и красная граница для narrowing либо переиспользования семантики
У reader появляется способность понять старые и новые record раньше, чем writer начинает выпускать новый optional key.

Порядок ключей не является договором record

RFC 8259 не даёт переносимого права использовать порядок членов object как смысл. Реальная библиотека может сохранить insertion order, другая — показать свои структуры иначе; reader, который зависит от позиции, перестаёт быть договором по именам. В fixture функция reorderV2Record() меняет порядок тех же key. Reader v2 получает тот же id, emailDigest и timezone value, потому что читает имя key, а не его место. Это assertion о нашем reader, не обещание одинакового поведения всех библиотек.

const reordered = {
  settings: record.settings,
  timezone: record.timezone,
  displayName: record.displayName,
  id: record.id,
};

const before = readProfileByConsumerV2(record);
const after = readProfileByConsumerV2(reordered);
// before.id === after.id; reader обращается к именам полей

Это правило имеет исключение только там, где формат сам фиксирует порядок: например, позиционный массив или отдельный бинарный protocol. Тогда порядок должен быть назван в contract и иметь собственный тест. Для обычного object такой перенос смысла создаёт невидимый coupling: producer переставил поля ради читаемости, а consumer вдруг прочитал другой record. Вместо позиции используйте name, explicit discriminator или отдельный массив, если порядок действительно является данными.

Compatibility matrix проверяет направление, а не слово «v2»

Версия не гарантирует совместимость. Нужна направленная проверка: может ли конкретный reader прочитать record конкретного writer. В нашей matrix четыре ожидаемо зелёные пары: v1 → v1, v2 additive → v1, v1 → v2, v2 → v2. Пара v2 writer → v1 reader разрешена только потому, что timezone описано как optional, а v1 reader явно игнорирует unknown optional fields. Если бы v1 reader запрещал все незнакомые ключи, та же запись стала бы несовместимой.

const result = compatibilityIssues(writerV1, proposedReader);
if (result.some((issue) => issue.startsWith('narrowing break'))) {
  throw new Error('do not release a reader that rejects legacy daily');
}

// Same JSON type is still not enough:
// emailDigest: 'weekly' must keep summary-cadence semantics.
Migration matrix и ожидаемый verdict fixture
WriterReaderОжиданиеПочему
v1 без timezonev1acceptодинаковый core-договор
v2 с optional timezonev1acceptreader игнорирует только неизвестный optional key
v1 без timezonev2acceptv2 выдаёт состояние absent
v1 с dailyproposed narrowed v2rejectreader удалил прежнее допустимое значение
v2 с прежней формойproposed semantic v2rejectстрока сохраняется, но meaning изменился
v1 без timezoneproposed required-timezone readerrejectстарый writer вправе не присылать key

Матрица не должна быть составлена из одних «зелёных» examples. Плохие пары важнее: они доказывают, что тест способен остановить опасную правку. В нашем коде proposal с narrowed emailDigest отвергается, потому что legacy sample содержит daily. Proposal с другой semantic меткой также отвергается, хотя набор JavaScript-типов прежний. Это делает обсуждение точным: нужно не «аккуратно менять схему», а решить, поддерживается ли старое значение и старый смысл.

Schema validation не заменяет migration test

JSON Schema draft 2019-09 позволяет выражать structural assertions, включая типы и ограничения. Это полезно для входа, но schema не знает сама по себе, какие версии writer ещё существуют, кто может удалить old path и что означает weekly. Даже форматно-зависимый механизм вроде Avro schema resolution не переносится автоматически на JSON objects: его правила относятся к конкретной паре writer/reader schema и выбранному format. Поэтому migration test содержит samples и направленную matrix рядом с документом, а не прячется в названии версии.

Для нашей модели есть ещё одна граница. Assertion genericJsonDoesNotBypassTheContract проверяет строку emailDigest: "hourly": JSON-форма корректна, но значение отсутствует в договоре и reader fixture его отвергает. Он не доказывает свойства любой JSON-библиотеки, persistence layer или schema registry. Если выбранный инструмент валидирует вход иначе, его поведение нужно добавить отдельным integration test. Маленькая fixture полезна тем, что сначала фиксирует собственное правило и не выдаёт локальную проверку за системную гарантию.

Рабочая fixture и её assertions

Один запуск создаёт producer v1, producer v2, v2 с clear и legacy sample с daily. Затем он строит Map matrix, читает record двумя consumer и намеренно подаёт missing id, числовой timezone, narrowed reader, semantic reader и required timezone reader. Assertion не просто перечисляет слова: он проверяет конкретную пару, состояние или факт rejection. Если новый change проходит только потому, что fixture ничего о нём не знает, это не совместимость, а отсутствующая проверка.

const fixture = runStorageContractFixture();
for (const [name, passed] of Object.entries(fixture.assertions)) {
  if (!passed) throw new Error('failed invariant: ' + name);
}

// Fixture использует только Array, Map и объекты в памяти.
// Она не проверяет выбранное хранилище, репликацию или реальный rollout.

Управление изменением остаётся простым: добавили правило — добавили sample и assertion; изменили meaning — изменили semantic marker и решили, нужен ли отдельный field; сделали поле required — доказали, что старые writer больше не входят в матрицу. Такой ход не даёт универсального migration framework, зато сохраняет техническую границу рядом с кодом. В 2021 году это важнее громкого названия: следующий reader сможет объяснить, почему он принимает record, а не просто «как-то переживает v2».

Маршрут: симптом → причина → проверка → действие

  1. Взять один record, на котором reader упал, и выписать key names, schema label, путь ошибки и ожидаемую пару writer/reader. Значения профиля в диагностический вывод не добавлять.
  2. Классифицировать разрыв: type, presence, narrowing или semantic. Не называть отсутствие key ошибкой типа.
  3. Проверить absent и null через наличие собственного key. Если оба состояния сейчас сливаются, остановить изменение до выбора семантики.
  4. Переставить key в локальном object. Если result меняется, consumer читает порядок, который JSON object не обязан гарантировать.
  5. Добавить пару в migration matrix и требовать ожидаемый accept либо reject. Negative case должен оставаться в fixture после исправления.
  6. Только затем выбрать validator, storage или форматный механизм. Их integration test дополняет, но не заменяет contract test.

Ограничения и проверяемые источники

Эта модель не утверждает, что JSON Schema, Avro или любое хранилище автоматически сохраняют совместимость. RFC 8259 задаёт синтаксическую рамку и предостерегает от зависимости от порядка object members. JSON Schema draft 2019-09 задаёт vocabulary для structural validation, а не готовую политику rollout. Avro 1.10.2 показывает, что schema resolution требует конкретных writer и reader schema. В статье эти источники используются для границы терминов, а не для заявления о запущенной production-инфраструктуре.

Fixture не запускает БД, broker, файлы, SDK, реальную миграцию, сеть, browser, CI или deployment. Она не проверяет rights, backfill, retention и скорость обработки. Следующий шаг для проекта — перенести ровно эту matrix на реальные samples выбранного формата, добавить integration test его validator и отдельно описать owner решения о старом reader. До этого «v2» остаётся названием, а не доказательством совместимости.

Проверяемые источники

  • RFC 8259: The JavaScript Object Notation (JSON) Data Interchange Format — первичный стандарт JSON: объект состоит из пар имя/значение; порядок членов объекта не следует использовать как межсистемный договор
  • JSON Schema draft 2019-09: Validation vocabulary — официально опубликованный Draft 2019-09 (17 сентября 2019, не IETF RFC) разделяет assertions для структуры и допускает тип null; конкретный валидатор и режим проверки остаются выбором приложения
  • Apache Avro 1.10.2: Specification, schema resolution — официальная спецификация формата описывает resolution writer и reader schema; это пример форматно-зависимого правила, а не свойство произвольного JSON-объекта