8 lines
18 KiB
JSON
8 lines
18 KiB
JSON
{
|
||
"index": 230,
|
||
"slug": "editorial-2021-08-mechanism-storage-contracts",
|
||
"title": "Контракт хранилища: как менять запись, не ломая старого reader",
|
||
"excerpt": "Совместимость хранилища зависит не только от JSON-синтаксиса. Разбираем presence, тип и смысл поля, безопасный порядок rollout и отрицательные проверки для writer/reader.",
|
||
"contentHtml": "<p>Сбой reader после обычной записи часто выглядит как проблема базы: объект сохранился, но приложение не может его прочитать. Лог сообщает о неожиданном типе, отсутствующем поле или недопустимом значении. Ошибка кажется локальной. На деле она может затронуть все записи, которые создал новый 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 может понимать её иначе. Schema может разрешать отсутствие поля, но приложение обязано знать, означает ли оно «старый writer не сообщил значение», «значение неизвестно» или «пользователь очистил его».</p>\n<h2>Absent, null и значение — разные состояния</h2>\n<p>В учебном договоре отсутствие <code>timezone</code> означает, что v1 writer о нём не знал. <code>null</code> означает явную очистку. Непустая строка означает заданный часовой пояс. Эти состояния нельзя свести к одному JavaScript default.</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<p>Порядок ключей также не является контрактом. Reader должен обращаться к именам полей. Учебная проверка с переставленными ключами должна дать тот же результат:</p>\n<pre><code>const reordered = {\n settings: { emailDigest: 'weekly' },\n timezone: 'Europe/Moscow',\n displayName: 'Ada',\n id: 'profile-17',\n};\n\nconst result = readProfile(reordered);\n// result.id === 'profile-17'</code></pre>\n<p>Если библиотека или протокол специально фиксирует порядок, это отдельное правило конкретного формата. Его нельзя вывести из обычного JSON-объекта.</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 только при tolerant reader</td><td>Старый reader должен игнорировать unknown optional key</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» не исправляет эту проблему.</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».</li><li>Если изменение additive, выпустить tolerant reader до writer. Проверить чтение старых записей и новой записи старым reader.</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 и граница применимости порядка членов объекта.</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> — официальный vocabulary для структурных assertions, а не готовая политика 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>"
|
||
}
|