8 lines
20 KiB
JSON
8 lines
20 KiB
JSON
{
|
||
"index": 230,
|
||
"slug": "editorial-2021-08-mechanism-storage-contracts",
|
||
"title": "Контракт хранилища: как менять запись, не ломая старого reader",
|
||
"excerpt": "Совместимость хранилища зависит не только от JSON-синтаксиса. Разбираем presence, тип и смысл поля, безопасный порядок rollout и отрицательные проверки для writer/reader.",
|
||
"contentHtml": "<p>Представим выкладку версии v2: новый writer уже сохранил запись, а один из старых reader всё ещё разбирает её по правилам v1. Объект физически лежит в хранилище, но приложение получает ошибку неожиданного типа, отсутствующего поля или недопустимого значения. Сбой выглядит локальным, хотя затрагивает все записи, которые создал новый writer.</p>\n<p>Цена неверного исправления выше одного исключения. Если reader молча подставит default, команда потеряет различие между старой записью без поля и новой записью, которая явно очистила его. Если writer переиспользует старое значение в новом смысле, откат кода не вернёт смысл уже записанных данных. Следующий сервис увидит ту же строку и интерпретирует её по-своему.</p>\n<p>Контракт хранилища нужно проверять как договор между writer и reader. JSON задаёт форму передачи, но не описывает совместимость, presence и бизнес-смысл. Для безопасного изменения сначала расширяют reader, затем writer. Любое сужение допустимых значений, обязательности или смысла проходит отдельную проверку и не считается additive-изменением.</p>\n<h2>Что именно входит в контракт</h2>\n<p>Рассмотрим учебную запись профиля. Её ядро существует в версии v1:</p>\n<pre><code>{\n \"id\": \"profile-17\",\n \"displayName\": \"Ada\",\n \"settings\": {\"emailDigest\": \"weekly\"}\n}</code></pre>\n<p>В v2 команда хочет добавить часовой пояс. На первый взгляд достаточно дописать <code>timezone</code>. Но нужно зафиксировать четыре разных правила.</p>\n<div class=\"table-scroll\"><table><caption>Четыре слоя контракта profile.settings</caption><thead><tr><th scope=\"col\">Слой</th><th scope=\"col\">Правило</th><th scope=\"col\">Что проверяет</th><th scope=\"col\">Чего не доказывает</th></tr></thead><tbody><tr><td>Формат</td><td>Объект состоит из пар имя/значение</td><td>Текст можно разобрать</td><td>Смысл и версии</td></tr><tr><td>Форма</td><td><code>id</code> — непустая строка; <code>timezone</code> — строка, <code>null</code> или отсутствует</td><td>Тип и presence</td><td>Смысл старого значения</td></tr><tr><td>Совместимость</td><td>Новый optional key не ломает v1 reader</td><td>Пару writer/reader</td><td>Реальный rollout</td></tr><tr><td>Семантика</td><td><code>weekly</code> означает периодичность сводки</td><td>Переиспользование значения</td><td>Решение владельца предметной области</td></tr></tbody></table></div>\n<p>Эти слои нельзя заменять друг другом. Валидный JSON может нарушать контракт. Строка может иметь правильный тип, но новый reader может понимать её иначе. В JSON Schema <code>required</code> отвечает за наличие имени, а <code>type</code> — за допустимый тип его значения. Эти keywords не решают, означает ли отсутствие поля «старый writer не сообщил значение», «значение неизвестно» или «пользователь очистил его».</p>\n<p>RFC 8259 описывает JSON object как набор пар и рекомендует уникальные имена. При дубликатах разные реализации могут оставить последнее значение, сообщить об ошибке или сохранить все пары. Поэтому граница контракта должна отдельно решить, как обнаруживаются дубликаты; одного успешного разбора JSON недостаточно.</p>\n<h2>Absent, null и значение — разные состояния</h2>\n<p>В учебном договоре отсутствие <code>timezone</code> означает, что v1 writer о нём не знал. <code>null</code> означает явную очистку. Непустая строка означает заданный часовой пояс. Это правило данного договора, а не универсальное значение <code>null</code> во всех системах.</p>\n<pre><code>function readTimezone(record) {\n if (!Object.prototype.hasOwnProperty.call(record, 'timezone')) {\n return { state: 'absent' };\n }\n\n if (record.timezone === null) {\n return { state: 'explicit-null' };\n }\n\n if (typeof record.timezone === 'string' && record.timezone.length > 0) {\n return { state: 'value', value: record.timezone };\n }\n\n throw new Error('timezone must be absent, null, or a non-empty string');\n}</code></pre>\n<p>Проверка собственного свойства важна: значение из прототипа не является частью сохранённой записи. Пустая строка тоже не становится корректным значением только потому, что имеет тип <code>string</code>. В реальной системе допустимый справочник часовых поясов и его нормализация принадлежат владельцу поля. Этот пример проверяет границу договора, а не справочник и не реальное хранилище.</p>\n<h2>Проверяем порядок и смысл чтения</h2>\n<p>Порядок ключей также не является контрактом для обычного JSON object. Reader должен обращаться к именам полей. Выполните этот фрагмент после функции <code>readTimezone</code> из предыдущего примера:</p>\n<pre><code>function readProfile(record) {\n if (!record || typeof record !== 'object' || Array.isArray(record)) {\n throw new Error('record must be an object');\n }\n\n if (typeof record.id !== 'string' || record.id.length === 0) {\n throw new Error('id must be a non-empty string');\n }\n\n return { id: record.id, timezone: readTimezone(record) };\n}\n\nconst reordered = {\n settings: { emailDigest: 'weekly' },\n timezone: 'Europe/Moscow',\n displayName: 'Ada',\n id: 'profile-17',\n};\n\nconst result = readProfile(reordered);\nconsole.assert(result.id === 'profile-17');\nconsole.assert(result.timezone.state === 'value');</code></pre>\n<p>Два <code>console.assert</code> проверяют именно этот пример: функция находит <code>id</code> и различает заданное значение часового пояса, хотя ключи записаны в другом порядке. Если библиотека или протокол специально фиксирует порядок, это отдельное правило конкретного формата. Его нельзя вывести из обычного JSON object.</p>\n<figure><img src=\"/assets/editorial/2021/storage-contract-evolution-2021.svg\" alt=\"Схема эволюции контракта profile.settings: v1, tolerant reader v2, additive writer v2 и красные границы для narrowing и смены смысла\" loading=\"lazy\" /><figcaption>Сначала reader учится читать старые и новые записи. Только после этого writer добавляет новый optional key.</figcaption></figure>\n<h2>Безопасное additive-изменение</h2>\n<p>Совместимым считается изменение, которое не запрещает ни одно прежнее корректное состояние и не меняет смысл существующего значения. Добавление optional <code>timezone</code> может быть таким изменением, если v1 reader игнорирует неизвестные optional fields, а v2 reader умеет читать запись без этого key.</p>\n<p>Направление важно. Новый writer может встретиться со старым reader. Старый writer может встретиться с новым reader. Мобильное приложение, очередь и фоновый job часто обновляются в разное время. Поэтому одного теста «новый код читает новую запись» недостаточно.</p>\n<table><caption>Минимальная compatibility matrix для учебного контракта</caption><thead><tr><th scope=\"col\">Writer</th><th scope=\"col\">Reader</th><th scope=\"col\">Ожидаемый результат</th><th scope=\"col\">Почему</th></tr></thead><tbody><tr><td>v1</td><td>v1</td><td>accept</td><td>Базовая пара</td></tr><tr><td>v1</td><td>v2</td><td>accept, <code>absent</code></td><td>Новый reader допускает отсутствие optional key</td></tr><tr><td>v2</td><td>v2</td><td>accept</td><td>Обе версии знают поле</td></tr><tr><td>v2</td><td>v1</td><td>accept, если v1 reader игнорирует unknown optional key</td><td>Иначе старый reader должен отклонить новую форму</td></tr><tr><td>v1</td><td>v2 с required timezone</td><td>reject</td><td>Новый reader сузил старый договор</td></tr></tbody></table>\n<p>В последней строке форма записи ещё похожа на прежнюю, но совместимость уже нарушена. Старый writer не мог передать обязательный для нового reader key. Название «v2» не исправляет эту проблему. Для Avro похожий вопрос решается специальным алгоритмом schema resolution; это свойство Avro, а не общее обещание JSON.</p>\n<h2>Отрицательный путь нельзя прятать</h2>\n<p>Положительный тест показывает, что happy path работает. Отрицательный тест показывает, где система должна остановиться. Для этого контракта нужны как минимум четыре отказа.</p>\n<ul><li><strong>Type break:</strong> <code>timezone: 3</code> не превращается в случайную строку через fallback.</li><li><strong>Presence break:</strong> reader не требует <code>timezone</code> от v1 записи.</li><li><strong>Narrowing:</strong> новый reader не удаляет старое допустимое значение <code>daily</code>.</li><li><strong>Semantic break:</strong> строка <code>weekly</code> не получает новый смысл «маркетинговый сегмент» без нового поля или явного перехода.</li></ul>\n<p>Последний случай особенно опасен. Тип, имя и набор символов могут остаться теми же. Форматный валидатор даст зелёный результат, хотя два потребителя примут разные решения. Такой дефект нельзя исправить выбором другого JSON parser.</p>\n<h2>Симптом → причина → проверка → действие</h2>\n<table><caption>Маршрут первичной диагностики</caption><thead><tr><th scope=\"col\">Симптом</th><th scope=\"col\">Причина</th><th scope=\"col\">Проверка</th><th scope=\"col\">Действие</th></tr></thead><tbody><tr><td><code>timezone</code> имеет число</td><td>Type break</td><td>Сверить фактический type и версию writer</td><td>Остановить создание новых неправильных записей; исправить validator</td></tr><tr><td>Старая запись не содержит key</td><td>Presence break</td><td>Прогнать v1 sample через v2 reader</td><td>Вернуть ветку <code>absent</code>; не записывать <code>null</code> поверх evidence</td></tr><tr><td><code>daily</code> отвергнут</td><td>Narrowing</td><td>Сравнить старый список значений с новым</td><td>Расширить reader или остановить rollout; не переписывать legacy массово</td></tr><tr><td><code>weekly</code> даёт другой эффект</td><td>Semantic break</td><td>Сверить owner и описание смысла поля</td><td>Остановить producer; ввести новое поле или отдельный переход</td></tr><tr><td>Новая запись читается, старая — нет</td><td>Reader проверяет только v2 shape</td><td>Проверить обе стороны matrix</td><td>Сначала сделать reader tolerant, затем менять writer</td></tr></tbody></table>\n<h2>Порядок действий</h2>\n<ol><li>Сохранить безопасное evidence: id записи, версии writer и reader, список ключей, путь ошибки и состояние спорного поля. Не писать в общий лог весь профиль.</li><li>Назвать владельца поля и зафиксировать смысл. Для <code>timezone</code> отдельно описать absent, <code>null</code>, непустую строку и неверный type.</li><li>Собрать samples старого writer, нового writer, старого reader и нового reader. Проверить направления «старый writer → новый reader» и «новый writer → старый reader».</li><li>Если изменение additive, выпустить tolerant reader до writer. Проверить чтение старых записей и новой записи старым reader, если тот обязан игнорировать unknown optional key.</li><li>Добавить отрицательные samples для type break, required field, narrowing и semantic change. У каждого sample должен быть ожидаемый accept или reject.</li><li>Если producer продолжает создавать записи, которые активный reader не понимает, остановить producer доступным в конкретной системе способом. Это может быть release, конфигурация, очередь или право записи.</li><li>Разделить rollback кода и rollback данных. Для нового optional key возврат writer не обязан удалять уже записанное поле. Для смены смысла одного key простой откат бинарника не восстанавливает прежнюю семантику.</li><li>После локального теста добавить integration-проверку выбранного validator, storage или broker. Учебная fixture сама по себе не доказывает их поведение.</li></ol>\n<h2>Ограничения и критерий готовности</h2>\n<p>Эта модель не выбирает базу, брокер или schema registry. Она не обещает, что любой storage engine игнорирует неизвестные поля. Она не учитывает retention, backfill, права, размер записи, транзакции, репликацию и задержки. Эти свойства проверяются отдельно на реальном выбранном компоненте.</p>\n<p>Примеры в статье учебные. Они не запускают production reader, не выполняют миграцию, не останавливают сервис и не содержат production-метрик. Поэтому нельзя заявлять, что конкретный rollout уже безопасен. Безопасность появляется после проверки реальных samples и матрицы поддерживаемых пар.</p>\n<p>Критерий готовности проверяем: каждая поддерживаемая пара writer/reader имеет явный verdict, v1 запись проходит новый reader, optional v2 поле не ломает старый reader, отрицательные samples отклоняются в ожидаемых местах, а для смены смысла назначен отдельный владелец перехода. Если хотя бы одно условие неизвестно, изменение ещё не готово к rollout.</p>\n<h2>Проверяемые источники</h2>\n<ul><li><a href=\"https://www.rfc-editor.org/rfc/rfc8259.html\" target=\"_blank\" rel=\"noopener noreferrer\">RFC 8259: JSON Data Interchange Format</a> — JSON object, уникальность имён и различия реализаций при порядке членов.</li><li><a href=\"https://json-schema.org/draft/2019-09/draft-handrews-json-schema-validation-02\" target=\"_blank\" rel=\"noopener noreferrer\">JSON Schema draft 2019-09: Validation vocabulary</a> — официальные assertions <code>required</code> и <code>type</code>; это не готовая политика rollout.</li><li><a href=\"https://avro.apache.org/docs/1.10.2/spec.pdf\" target=\"_blank\" rel=\"noopener noreferrer\">Apache Avro 1.10.2 Specification</a> — форматно-зависимое разрешение writer и reader schema; его правила нельзя автоматически переносить на произвольный JSON.</li></ul>"
|
||
}
|