Files

8 lines
21 KiB
JSON
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{
"index": 231,
"slug": "editorial-2021-08-practice-storage-contracts",
"title": "Контракт хранилища: как менять profile/settings без поломки старых читателей",
"excerpt": "JSON задаёт форму, но не объясняет смысл поля, различие между absent и null и границы совместимости. Разбираем контракт profile/settings, матрицу writer/reader и безопасный порядок изменения.",
"contentHtml": "<p>Сбой начинается после обычного релиза. Новый writer добавляет <code>timezone</code> в <code>profile.settings</code>. Строгий reader может отвергнуть неизвестное поле, а permissive reader — молча его проигнорировать. Отдельная ошибка возникает, когда отсутствие <code>timezone</code> принимают за явную очистку. Пользователь видит сброшенную настройку или ошибку загрузки профиля. Команда тратит время на восстановление уже записанных данных.</p>\n<p>Цена ошибки выше, чем один неудачный запрос. Новая версия может записать данные, которые ещё не умеют читать все потребители. Если поле уже меняет смысл, откат кода не возвращает старый смысл автоматически. Поэтому формат записи нельзя считать полным контрактом. Нужны правила владельца, наличия ключа, типов, значений, версий и отката.</p>\n<h2>Главный вопрос: что именно обещает запись</h2>\n<p>В этой статье договор описывает запись <code>profile.settings</code>. Он не зависит от конкретной базы, файла или очереди. Объект имеет стабильный идентификатор <code>profile.id</code>. Поле <code>settings.emailDigest</code> хранит частоту сводки: <code>off</code>, <code>weekly</code> или <code>daily</code>. Новое поле <code>settings.timezone</code> необязательно. Для него различаются три состояния: ключ отсутствует, ключ содержит <code>null</code>, ключ содержит непустую строку.</p>\n<p>Это учебная модель. Она работает на объектах в памяти и показывает границу между writer и reader. Она не проверяет выбранную БД, репликацию, миграционный job, сеть или реальный rollout. Такие проверки появляются после привязки правил к конкретной системе.</p>\n<h2>Сначала фиксируем форму и смысл</h2>\n<p>Владелец записи отвечает не только за названия ключей. Он фиксирует обязательные поля, допустимые значения, смысл каждого значения и список совместимых потребителей. Для <code>profile.settings</code> это можно записать небольшой декларацией:</p>\n<pre><code>const profileSettingsContract = {\n owner: 'profile-settings',\n identity: 'profile.id',\n required: ['id', 'displayName', 'settings.emailDigest'],\n optional: { 'settings.timezone': 'absent | null | non-empty string' },\n values: { 'settings.emailDigest': ['off', 'weekly', 'daily'] },\n compatibility: [\n 'writer-v1 -&gt; reader-v2',\n 'writer-v2 -&gt; reader-v1',\n 'writer-v2 -&gt; reader-v2'\n ]\n};</code></pre>\n<p>Это не схема конкретного провайдера. Поле типа «строка» всё ещё может нарушать договор. Значение <code>weekly</code> должно сохранять смысл режима сводки. Если тот же текст начинают использовать как метку маркетингового сегмента, структурный валидатор не заметит проблему. Это semantic break: тип прежний, а поведение потребителя изменилось.</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><code>id</code></td><td>Непустая строка, одна запись профиля</td><td>Проверить тип и связь с profile</td><td>Не доказывает существование профиля в БД</td></tr><tr><td><code>emailDigest</code></td><td><code>off</code>, <code>weekly</code> или <code>daily</code></td><td>Проверить множество значений</td><td>Строка сама не раскрывает смысл</td></tr><tr><td><code>settings.timezone</code></td><td>Absent, <code>null</code> или непустая строка</td><td>Проверить собственный ключ в settings</td><td>Нельзя без правила заменить absent на <code>null</code></td></tr><tr><td>Версии</td><td>Старые и новые пары читают поддерживаемые записи</td><td>Прогнать матрицу samples</td><td>Не покрывает неизвестного consumer</td></tr></tbody></table></div>\n<h2>Почему absent не равно null</h2>\n<p>Старый writer, который не знает о <code>timezone</code>, не передаёт этот ключ. Такое состояние сообщает только об отсутствии данных в версии writer. Оно не означает, что пользователь очистил часовой пояс. Явное <code>null</code> может означать очистку, если именно это установил владелец контракта. Значение-строка означает заданный часовой пояс. Пустая или состоящая только из пробелов строка запрещена: для неё нет определённого смысла.</p>\n<pre><code>const hasOwn = (object, key) =&gt;\n Object.prototype.hasOwnProperty.call(object, key);\n\nfunction readTimezone(settings) {\n if (!hasOwn(settings, 'timezone')) return { state: 'absent' };\n if (settings.timezone === null) return { state: 'explicit-null' };\n if (typeof settings.timezone === 'string'\n &amp;&amp; settings.timezone.trim().length !== 0) {\n return { state: 'value', value: settings.timezone };\n }\n throw new Error('settings.timezone violates profile/settings contract');\n}\n\nreadTimezone({ emailDigest: 'weekly' });\nreadTimezone({ emailDigest: 'weekly', timezone: null });</code></pre>\n<p>Проверка <code>if (settings.timezone)</code> здесь ошибочна. Она смешивает отсутствие ключа, <code>null</code> и пустую строку. Проверка собственного ключа сохраняет наблюдаемое состояние. Reader должен либо вернуть известный результат, либо остановить интерпретацию. Молчаливый fallback прячет нарушение и записывает неверное решение в следующий слой.</p>\n<figure><img src=\"/assets/editorial/2021/storage-contract-compatibility-2021.svg\" alt=\"Матрица совместимости profile.settings: writer v1 и v2, reader v1 и v2; v2 добавляет timezone, reader v2 различает absent, null и значение\" loading=\"lazy\" /><figcaption>Дополнительное поле безопасно только для проверенной пары writer и reader. Схема иллюстрирует правила статьи, а не гарантии конкретного хранилища.</figcaption></figure>\n<h2>Совместимость проверяют в обе стороны</h2>\n<p>Пусть writer v1 пишет только обязательные поля. Writer v2 добавляет <code>settings.timezone</code>, не меняя существующие значения. Reader v1 читает известные обязательные поля и игнорирует неизвестное необязательное поле. Reader v2 понимает старую запись, возвращает для неё состояние <code>absent</code> и умеет обработать значение и явный <code>null</code>.</p>\n<p>Режим permissive нельзя подразумевать по умолчанию. Если reader v1 построен на строгом валидаторе, правило «игнорировать неизвестные необязательные поля» должно быть явно включено в его договор. Иначе additive-изменение безопасным считать нельзя.</p>\n<pre><code>const compatibility = [\n ['writer v1', 'reader v1', 'accept'],\n ['writer v1', 'reader v2', 'accept: timezone absent'],\n ['writer v2 additive', 'reader v1', 'accept: unknown optional'],\n ['writer v2', 'reader v2', 'accept: value and explicit null'],\n ['writer v1 daily', 'narrowed reader', 'reject before release']\n];</code></pre>\n<p>Неизвестное поле можно игнорировать только тогда, когда оно не влияет на старое обязательное поведение. Если новый writer добавил <code>settings.timezone</code>, а старый reader теперь должен изменить расчёт уведомлений, изменение уже не additive. Если новый reader перестал принимать старое <code>daily</code>, он сузил множество допустимых значений. Обе ситуации требуют остановки выпуска и отдельного переходного договора.</p>\n<h2>Минимальный воспроизводимый fixture</h2>\n<p>Проверим именно ту матрицу, о которой говорим. Функции ниже не ходят в базу: writer возвращает обычный объект, reader читает известные поля, а три утверждения фиксируют направления миграции и различие состояний.</p>\n<pre><code>const hasOwn = (object, key) =&gt;\n Object.prototype.hasOwnProperty.call(object, key);\n\nconst readTimezone = (settings) =&gt; {\n if (!hasOwn(settings, 'timezone')) return { state: 'absent' };\n if (settings.timezone === null) return { state: 'explicit-null' };\n if (typeof settings.timezone === 'string'\n &amp;&amp; settings.timezone.trim().length !== 0) {\n return { state: 'value', value: settings.timezone };\n }\n throw new Error('invalid settings.timezone');\n};\n\nconst writeV1 = () =&gt; ({\n id: 'profile-17',\n displayName: 'Лена',\n settings: { emailDigest: 'weekly' }\n});\n\nconst writeV2 = (timezone) =&gt; {\n const record = writeV1();\n if (timezone !== undefined) record.settings.timezone = timezone;\n return record;\n};\n\nconst readV1 = ({ id, settings }) =&gt; ({\n id,\n emailDigest: settings.emailDigest\n});\n\nconst readV2 = ({ id, settings }) =&gt; ({\n ...readV1({ id, settings }),\n timezone: readTimezone(settings)\n});\n\nconsole.assert(readV1(writeV2('Europe/Moscow')).emailDigest === 'weekly');\nconsole.assert(readV2(writeV1()).timezone.state === 'absent');\nconsole.assert(readV2(writeV2(null)).timezone.state === 'explicit-null');</code></pre>\n<p>Здесь <code>undefined</code> означает, что writer v2 не добавил ключ, а <code>null</code> передаётся явно. Поэтому reader v2 возвращает три разных результата, а reader v1 сохраняет прежний путь. Это ещё не тест реальной БД или сериализатора: для них нужно дополнительно проверить миграцию, дубликаты ключей, ограничения размера и поведение конкретного драйвера.</p>\n<h2>Симптом → причина → проверка → действие</h2>\n<div class=\"table-scroll\"><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>Профиль не читается после записи</td><td>Reader отвергает новое поле или тип</td><td>Сравнить record, версии и путь ошибки</td><td>Добавить reader, который понимает старый record; writer не включать раньше него</td></tr><tr><td>Часовой пояс сбросился</td><td>Absent трактовали как явный clear</td><td>Проверить собственный ключ и исходный writer</td><td>Развести ветви absent и <code>null</code>, добавить samples обоих состояний</td></tr><tr><td>Старая сводка перестала работать</td><td>Сузили допустимые значения или сменили смысл</td><td>Прогнать все старые значения через новый reader</td><td>Отклонить narrowing или выпустить отдельный перевод смысла</td></tr><tr><td>После отката остаются неверные настройки</td><td>Writer уже записал новый смысл</td><td>Найти записи новой версии и сравнить семантику</td><td>Остановить producer, сохранить samples и планировать контролируемое преобразование</td></tr></tbody></table></div>\n<h2>Порядок безопасного изменения</h2>\n<ol><li>Зафиксируйте текущий договор: owner, обязательные поля, допустимые значения и смысл каждого значения.</li><li>Соберите несколько обезличенных старых records. Для каждого укажите writer и ожидаемый результат чтения.</li><li>Разделите новые состояния. Для <code>settings.timezone</code> отдельно опишите absent, <code>null</code> и строку.</li><li>Проверьте новый reader на старых records. Он не должен превращать отсутствие нового ключа в другое состояние.</li><li>Добавьте новый writer только после проверки старого reader. Новое поле должно быть необязательным для старого пути.</li><li>Проверьте новую пару и отрицательные случаи: неправильный тип, пустую строку, narrowing и смену смысла.</li><li>Перед удалением legacy-пути подтвердите, что поддерживаемых старых consumers и records больше нет. Одной новой версии схемы для этого недостаточно.</li></ol>\n<h2>Отрицательный путь и откат</h2>\n<p>Удачная запись не доказывает совместимость. Нужны примеры, которые должны быть отклонены. <code>settings.timezone: 42</code> нарушает тип. Пустая строка нарушает ограничение значения. Reader, который принимает только <code>off</code> и <code>weekly</code>, нарушает совместимость со старым writer, если тот законно писал <code>daily</code>. Reader, который читает <code>weekly</code> как маркетинговую метку, нарушает смысл.</p>\n<p>При additive-изменении откат обычно ограничивается остановкой нового writer: reader уже понимает старые и новые записи. При semantic change такой откат недостаточен. Старые байты уже несут новое значение. Сначала остановите producer, зафиксируйте затронутые records и определите обратимое преобразование. Учебный код статьи не выполняет такое преобразование и не подтверждает, что оно безопасно для конкретной системы.</p>\n<h2>Границы модели</h2>\n<p>JSON не описывает владельца поля, срок поддержки версии или бизнес-смысл строки. Валидатор схемы может проверить структуру, тип и часть ограничений, но не узнает, что <code>weekly</code> нельзя переиспользовать. JSON Schema задаёт правила проверки экземпляра, а не автоматически устанавливает поведение приложения. Поэтому semantic break должен быть частью прикладного договора и отрицательных тестов.</p>\n<p>Порядок ключей объекта не используется как сигнал версии. Reader обращается к именам, а не к позиции. Нельзя полагаться и на дубликаты имён: разные реализации могут оставить последнее значение, вернуть ошибку или обработать несколько пар. Если системе нужен канонический текст для подписи или хеша, это отдельный договор сериализации. Статья также не утверждает наличие SLA, метрик, успешной миграции или production-результата: приведённые записи и проверки учебные.</p>\n<h2>Проверяемый критерий готовности</h2>\n<p>Изменение готово к выпуску, если владелец может назвать поддерживаемые пары writer/reader, новый reader проходит все старые samples, старый reader не зависит от нового optional поля, а отрицательные samples останавливают type break, narrowing и semantic break. Для <code>settings.timezone</code> проверка должна различать absent, явный <code>null</code> и непустую строку. Для отката должен существовать понятный стоп-сигнал producer и способ обнаружить уже записанные новые значения.</p>\n<h2>Проверяемые источники</h2>\n<ul><li><a href=\"https://www.rfc-editor.org/rfc/rfc8259.html\" target=\"_blank\" rel=\"noopener noreferrer\">RFC 8259: The JavaScript Object Notation (JSON) Data Interchange Format</a> — стандарт IETF описывает object как набор пар имя/значение, допускает <code>null</code>, рекомендует уникальные имена и предупреждает о различиях в видимости порядка членов.</li><li><a href=\"https://json-schema.org/draft/2020-12/json-schema-validation\" target=\"_blank\" rel=\"noopener noreferrer\">JSON Schema 2020-12: Validation vocabulary</a> — официальная спецификация задаёт структурные assertions, включая <code>type</code>, <code>required</code> и тип <code>null</code>; смысл поля и прикладная семантика остаются отдельным правилом.</li></ul>"
}