{ "index": 231, "slug": "editorial-2021-08-practice-storage-contracts", "title": "Контракт хранилища: как менять profile/settings без поломки старых читателей", "excerpt": "JSON задаёт форму, но не объясняет смысл поля, различие между absent и null и границы совместимости. Разбираем контракт profile/settings, матрицу writer/reader и безопасный порядок изменения.", "contentHtml": "
Сбой начинается после обычного релиза. Новый writer добавляет timezone в profile/settings. Старый reader получает запись и либо падает на неизвестном поле, либо принимает отсутствие timezone за явную очистку. Пользователь видит сброшенную настройку или ошибку загрузки профиля. Команда тратит время на восстановление уже записанных данных.
Цена ошибки выше, чем один неудачный запрос. Новая версия пишет данные, которые ещё не умеют читать все потребители. Если поле уже меняет смысл, откат кода не возвращает старый смысл автоматически. Поэтому формат записи нельзя считать полным контрактом. Нужны правила владения, presence, типов, значений, версий и отката.
\nВ этой статье договор описывает запись profile.settings. Он не зависит от конкретной базы, файла или очереди. Объект имеет стабильный идентификатор profile.id. Поле settings.emailDigest хранит режим частоты сводки: off, weekly или daily. Новое поле timezone необязательно. Для него различаются три состояния: ключ отсутствует, ключ содержит null, ключ содержит непустую строку.
Это учебная модель. Она работает на объектах в памяти и показывает границу между writer и reader. Она не проверяет выбранную БД, репликацию, миграционный job, сеть или реальный rollout. Такие проверки появляются только после привязки правил к конкретной системе.
\nСначала назовите владельца записи. Владелец отвечает за смысл полей и список поддерживаемых потребителей. Затем запишите обязательные ключи, допустимые типы и наборы значений. Отдельной строкой опишите отсутствие ключа и null. Наконец, перечислите пары версий, которые могут работать одновременно.
const profileSettingsContract = {\n owner: 'profile-settings',\n identity: 'profile.id',\n required: ['id', 'displayName', 'settings.emailDigest'],\n optional: { timezone: 'absent | null | non-empty string' },\n values: { emailDigest: ['off', 'weekly', 'daily'] },\n compatibility: [\n 'writer-v1 -> reader-v2',\n 'writer-v2 -> reader-v1',\n 'writer-v2 -> reader-v2'\n ]\n};\nПоле типа «строка» всё ещё может нарушать договор. Значение weekly должно сохранять смысл режима сводки. Если тот же текст начинают использовать как метку маркетингового сегмента, структурный валидатор не заметит проблему. Это semantic break: тип прежний, а поведение потребителя изменилось.
| Часть | Правило | Проверка | Граница |
|---|---|---|---|
id | Непустая строка, одна запись профиля | Проверить тип и связь с profile | Не доказывает существование профиля в БД |
emailDigest | off, weekly или daily | Проверить множество значений | Строка сама не раскрывает смысл |
timezone | Absent, null или непустая строка | Проверить наличие собственного ключа | Нельзя без правила заменить absent на null |
| Версии | Старые и новые пары читают поддерживаемые записи | Прогнать матрицу samples | Не покрывает неизвестного consumer |
Старый writer, который не знает о timezone, не передаёт этот ключ. Такое состояние сообщает только об отсутствии данных в версии writer. Оно не означает, что пользователь очистил часовой пояс. Явное null может означать очистку, если именно это установил владелец контракта. Значение-строка означает заданный часовой пояс. Пустая строка запрещена: для неё нет определённого смысла.
const hasOwn = (object, key) =>\n Object.prototype.hasOwnProperty.call(object, key);\n\nfunction readTimezone(record) {\n if (!hasOwn(record, 'timezone')) return { state: 'absent' };\n if (record.timezone === null) return { state: 'explicit-null' };\n if (typeof record.timezone === 'string' && record.timezone.length > 0) {\n return { state: 'value', value: record.timezone };\n }\n throw new Error('timezone violates profile/settings contract');\n}\n\nreadTimezone({ id: 'profile-17' });\nreadTimezone({ id: 'profile-17', timezone: null });\nПроверка if (record.timezone) здесь ошибочна. Она смешивает отсутствие ключа, null и пустую строку. Проверка собственного ключа сохраняет наблюдаемое состояние. Reader должен либо вернуть известный результат, либо остановить интерпретацию. Молчаливый fallback прячет нарушение и записывает неверное решение в следующий слой.
Пусть writer v1 пишет только обязательные поля. Writer v2 добавляет timezone, не меняя существующие значения. Reader v1 читает известные обязательные поля и игнорирует неизвестное необязательное поле. Reader v2 понимает старую запись, возвращает для неё состояние absent и умеет обработать значение и явный null.
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];\nНеизвестное поле можно игнорировать только тогда, когда оно не влияет на старое обязательное поведение. Если новый writer добавил timezone, а старый reader теперь должен изменить расчёт уведомлений, изменение уже не additive. Если новый reader перестал принимать старое daily, он сузил множество допустимых значений. Обе ситуации требуют остановки выпуска и отдельного переходного договора.
| Симптом | Причина | Проверка | Действие |
|---|---|---|---|
| Профиль не читается после записи | Reader отвергает новое поле или тип | Сравнить record, версии и путь ошибки | Добавить reader, который понимает старый record; writer не включать раньше него |
| Часовой пояс сбросился | Absent трактовали как явный clear | Проверить наличие собственного ключа и исходный writer | Развести ветви absent и null, добавить samples обоих состояний |
| Старая сводка перестала работать | Сузили допустимые значения или сменили смысл | Прогнать все старые значения через новый reader | Отклонить narrowing или выпустить отдельный перевод смысла |
| После отката остаются неверные настройки | Writer уже записал новый смысл | Найти записи новой версии и сравнить семантику | Остановить producer, сохранить samples и планировать контролируемое преобразование |
timezone отдельно опишите absent, null и строку.Удачная запись не доказывает совместимость. Нужны примеры, которые должны быть отклонены. timezone: 42 нарушает тип. Пустая строка нарушает ограничение значения. Reader, который принимает только off и weekly, нарушает совместимость со старым writer, если тот законно писал daily. Reader, который читает weekly как маркетинговую метку, нарушает смысл.
При additive-изменении откат обычно ограничивается остановкой нового writer: reader уже понимает старые и новые записи. При semantic change такой откат недостаточен. Старые байты уже несут новое значение. Сначала остановите producer, зафиксируйте затронутые records и определите обратимое преобразование. Учебный код статьи не выполняет такое преобразование и не подтверждает, что оно безопасно для конкретной системы.
\nJSON не описывает владельца поля, срок поддержки версии или бизнес-смысл строки. Валидатор схемы может проверить структуру, тип и часть ограничений, но не узнает, что weekly нельзя переиспользовать. Не каждый reader должен игнорировать неизвестные поля: это решение зависит от критичности данных и правил формата. В некоторых системах безопаснее отклонить запись, чем потерять неизвестное обязательное поведение.
Порядок ключей объекта не используется как сигнал версии. Reader обращается к именам, а не к позиции. Если системе нужен канонический текст для подписи или хеша, это отдельный договор сериализации. Статья также не утверждает наличие SLA, метрик, успешной миграции или production-результата: приведённые записи и проверки учебные.
\nИзменение готово к выпуску, если владелец может назвать поддерживаемые пары writer/reader, новый reader проходит все старые samples, старый reader не зависит от нового optional поля, а отрицательные samples останавливают type break, narrowing и semantic break. Для timezone проверка должна различать absent, явный null и непустую строку. Для отката должен существовать понятный стоп-сигнал producer и способ обнаружить уже записанные новые значения.
null; семантика приложения остаётся отдельным правилом.