Диск под памятью: перезапуск и миграция схемы
Здесь будет встроен видео-разбор темы (5–7 минут, YouTube / RuTube / VK Видео) — тем, кто лучше воспринимает на слух, текст ниже не обязателен.
В прошлой статье у каждого изменения появилось имя, а хранилище стало свёрткой журнала. Но журнал жил в памяти процесса JavaScript, а с этой памятью телефон не церемонится: свёрнутое приложение система выгружает, когда ей нужна память. Эта статья кладёт под память диск. На диск ляжет журнал, а не строки — строки после запуска соберёт свёртка. «Убить процесс» в примерах значит выбросить всё, что лежало в памяти, и поднять её с диска заново. Потом приложение обновится, форма данных сменится, и выяснится, что у журнала при миграции своя судьба. Платформа встречается здесь дважды. У телефона нет localStorage. А асинхронный ящик AsyncStorage — не React Native и не Expo, а пакет сообщества со своим нативным модулем.
Всё, что собрано в двух прошлых статьях, — хранилище, журнал, счётчики имён — живёт в памяти одного процесса JavaScript. Пока приложение на экране, это не беда. Но свёрнутое приложение система выгружает, когда ей нужна память, и последнего слова ему не даёт: AppState сообщит об уходе в фон, а об убийстве процесса не сообщит никто (статья «Свернули и вернулись» в главе 7). Следующий запуск начинается с пустой памяти — ни журнала, ни подходов из зала.
Под память нужен диск. Рука, прошедшая часть II, тянется к localStorage из статьи «localStorage и sessionStorage». Рядом у браузера есть и настоящие базы — IndexedDB и Cache. Прогноз: что скажет компилятор, что напечатает пример и что он напечатает, если запустить его ещё раз?
Компилятор находит четыре ошибки: «Не удается найти имя "localStorage".» — дважды, — и такие же про indexedDB и caches. Пример всё равно выполняется: печатает «запуск №1», а обе базы бросают SecurityError. Второй запуск снова напечатает «запуск №1», и десятый тоже. Здесь правы все трое, но каждый о своём.
Компилятор говорит о телефоне. У набора rn нет библиотеки DOM, а Web Storage, IndexedDB и Cache — это API браузера. React Native 0.86 ставит свои глобали — fetch, XMLHttpRequest, WebSocket и другие, список был в прошлой статье у crypto, — и хранилищ среди них нет. На телефоне первая же строка упадёт с ReferenceError.
Песочница говорит о себе. Кадр примера живёт в уникальном происхождении, и настоящий localStorage в нём запрещён — обращение бросило бы SecurityError. Чтобы примеры части II работали, учебник подставляет вместо него хранилище в памяти кадра. Оно ведёт себя как настоящее, но каждый запуск создаёт новый кадр, и память начинается заново: заглушка работает, а перезапуск не переживает. IndexedDB и Cache учебник не подменял, и браузер честно отказал: у кадра нет флага allow-same-origin.
В веб-версии «Подхода», собранной Expo для браузера, localStorage настоящий. Так устроено и в приложении автора: session-storage.web.ts в браузере пишет в globalThis.localStorage, а session-storage.ts на телефоне — в SecureStore, и сборщик выбирает файл по расширению. На телефоне же для JavaScript нужен свой ящик.
Песочница может изобразить диск, но не может его иметь: всё, что в ней «сохранено», исчезает вместе с кадром.
Обычный ящик для данных JavaScript на телефоне — пакет @react-native-async-storage/async-storage. Когда-то он входил в сам React Native, потом его вынесли в пакет сообщества; в react-native 0.86 от него остался только отладочный геттер, который бросает «AsyncStorage has been removed from react-native core». К Expo SDK он тоже не относится, но Expo знает, какая его версия совместима с каждым SDK: в bundledNativeModules.json пакета expo 57 записано 2.2.0, и ставится он так же, как модули Expo:
Интерфейс знаком по localStorage: getItem, setItem, removeItem, внутри только строки — объект придётся превращать в JSON, как в разделе «Внутри только строки» части II. Разница одна, зато главная: каждый вызов возвращает промис. Значение лежит не в памяти JavaScript, а у нативного модуля, и тот ходит на диск в своём потоке. Где именно лежат данные:
| Платформа | Где лежит | Что важно знать |
|---|---|---|
| Android | база SQLite с именем RKStorage (ReactDatabaseSupplier.java) | по умолчанию не больше 6 МБ на всё (android/config.gradle); запросы выполняются по одному, в порядке вызова (SerialExecutor в AsyncStorageModule.java) |
| iOS | папка RCTAsyncLocalStorage_V1 в Application Support приложения, с файлом manifest.json (RNCAsyncStorage.mm) | значения до 1024 символов лежат прямо в manifest.json, длиннее — отдельными файлами; запросы идут через последовательную очередь |
| веб | window.localStorage | каждый вызов обёрнут в промис (src/AsyncStorage.ts) |
Веб-строка таблицы — приём из статьи «Одно приложение, три платформы». В пакете рядом лежат AsyncStorage.native.ts и AsyncStorage.ts, а index.ts импортирует просто ./AsyncStorage. Сборщик для телефона берёт файл с .native, и тот через RCTAsyncStorage.ts ищет нативный модуль в TurboModuleRegistry. Для веба берётся второй файл:
Пакетов в песочнице нет, а будь этот там, сборщик взял бы веб-файл — то есть ту же заглушку в памяти кадра. Поэтому ящик для примеров лежит в подложке: disk с методами getItem и setItem, строками по ключу и промисами, как у AsyncStorage. Запросы он выполняет по одному и по порядку, как настоящий, а ответ отдаёт следующей задачей. Главное его свойство — он живёт вне «процесса» примера. Внутри одного запуска можно выбросить всё, что пример держал в памяти, а диск останется. На телефоне на его место встаёт файл той же формы:
Что класть в ящик, решено прошлой статьёй. Строки из журнала собираются заново всегда, журнал из строк — никогда: по строке не видно ни того, что вес правили, ни того, что правка ещё не отправлена. Значит, на диск ложится журнал. Строки там не нужны вовсе — при запуске их соберёт fold.
Второе решение — сколько ключей. Транзакций у ящика нет, и два ключа — это две записи, между которыми процесс может умереть. Прошлая статья разбирала этот сбой в разделе «Строка и операция — одним шагом». А одно значение под одним ключом ложится одним setItem — это и есть один шаг. Поэтому на диске одно значение: журнал вместе со счётчиками имён. Задача 1 прошлой статьи советовала хранить их именно так — рядом с журналом и в том же шаге записи.
Процесс теперь — функция boot. Она читает диск, собирает строки свёрткой и отдаёт хранилище и журнал. «Убить процесс» значит выбросить всё, что вернула boot, и позвать её снова: память пуста, диск на месте. Лента подписана на хранилище, как в статье «Главная копия — в телефоне». Сценарий: первый запуск записывает подход и правит вес. Второй поднимается с диска и монтирует ленту, а человек добавляет ещё один подход. Прогноз: сколько операций поднимется с диска, что покажет лента и какое имя получит новый подход?
С диска поднялись две операции, свёртка собрала из них строку с поправленным весом, и лента показала её в первой же отрисовке. boot закончилась до mount, поэтому экрану нечего ждать: ни «загрузка…», ни мелькнувшей пустоты. Новый подход получил имя A-2, а не A-1: счётчик приехал с диска вместе с журналом. Не будь счётчика на диске, имена после перезапуска повторились бы, как в задаче прошлой статьи.
Экран диска не ждёт. change возвращает промис записи, но onPress его не ждёт: правка уже в памяти, лента уже перерисовалась. Первую change сценарий тоже не ждёт, и это безопасно по двум причинам. Каждое значение — журнал целиком, а ящик выполняет запросы по одному в порядке вызова, как AsyncStorage на обеих платформах, так что последняя запись несёт всё. Окно, в котором убийство процесса отнимет правку, — миллисекунды между нажатием и ответом нативного модуля. Не «пока приложение не свернули»: запись на диск начинается в том же обработчике, что и запись в память.
Цена тоже видна: каждое нажатие переписывает весь журнал. На iOS журнал быстро перерастёт 1024 символа и станет отдельным файлом, который пишется целиком. На Android все ключи вместе упираются в 6 МБ. Для первых месяцев дневника этого хватит, для лет — нет. Настоящая база пишет одну строку на операцию — это последняя статья главы, expo-sqlite.
Пока сервера нет, журнал хранит всё, и строки собираются из него целиком. В главе 7 из журнала будут уходить операции, на которые сервер вынес вердикт, а чужие операции, пришедшие с сервера, будут применяться к строкам мимо журнала. Тогда одной свёртки журнала станет мало: рядом с журналом на диск ляжет снимок подтверждённых строк и курсор, а строки будут собираться как снимок плюс свёртка неотправленного. Правило при этом останется прежним. Снимок и журнал пишутся одним шагом — одним значением или одной транзакцией. А неотправленное хранится только журналом, потому что из строк его не восстановить.
Приложение обновляется — из магазина или по воздуху (глава 11), — а диск остаётся от прошлой версии. В версии 1.1 «Подхода» в настройках появятся фунты, и поле веса переименовано: было weight, стало weightKg, с единицей в имени. Код 1.1 читает weightKg, а на диске лежит журнал 1.0. Поэтому в значении на диске хранится номер схемы, а в приложении — лестница миграций: шаг с версии 1 на 2, с 2 на 3 и дальше. Телефон мог пропустить обновления: человек поставил 1.0 и через полгода сразу 1.3. Лестница проходит все ступени подряд, и старые шаги из неё не удаляют, пока где-то могут жить старые данные.
Лестница идёт до первого кадра — внутри boot, до mount. Иначе экран 1.1 успеет прочитать строки 1.0, не найдёт в них weightKg и покажет подход без веса. Что показывать человеку, пока она идёт, решает следующая статья.
Кеш при смене версии сносят и загружают заново: оригинал лежит на сервере. У «Подхода» оригинал здесь. Неотправленное существует только на этом телефоне, и снести базу — значит стереть его навсегда.
Миграцию обычно формулируют про таблицу: «в таблице подходов колонка weight стала weightKg». Пусть первая лестница так и делает — соберёт строки свёрткой и переименует поле в строках. Сценарий: на диске журнал 1.0 — подход и неотправленная правка веса, — и его открывает версия 1.1. Прогноз: что будет в таблице, что останется в журнале и что покажет второй запуск той же 1.1?
Первый запуск выглядит безупречно: в таблице weightKg: 82, экран прав. Но журнал — тот самый, что уедет на сервер, — хранит правку { weight: 82 } с полем, которого схема 2 не знает. А второй запуск той же версии вернул таблице форму 1.0. Номер схемы на диске уже 2, лестница больше не идёт, а строки снова собраны свёрткой из старого журнала. Таблица здесь производная, и мигрировать её бесполезно: она пересобирается при каждом запуске. Мигрировать надо то, что лежит на диске, — журнал.
В базе из последней статьи главы строки будут лежать на диске таблицей, и её миграция перезапуск переживёт. Но журнал рядом — те же частичные правки. Если поля правки лежат в нём строкой JSON, ALTER TABLE до них не дотянется, и шаг журнала понадобится всё равно.
Шаг журнала работает с операциями, а операция — не строка: в ней только поля, которые изменились. Правка { weight: 82 } — кусок строки, правка { reps: 6 } — кусок совсем без веса. Поэтому шаг журнала переименовывает поле только там, где оно есть, и не трогает остальное. Лестница — объект шагов, где ключ — версия, с которой шаг поднимает, и цикл от версии на диске до версии приложения. Поднятый журнал ложится на диск вместе с новым номером одним значением: иначе номер и содержимое разойдутся так же, как строка и операция. Сценарий тот же. Прогноз: что изменится в трёх строках вывода?
Таблица та же, но теперь она собрана из журнала в форме 1.1, и второй запуск её не теряет. Неотправленная правка стала { weightKg: 82 }: в главе 7 она уедет на сервер в форме, которую схема 2 знает. Шаг таблицы — функция «строка 1.0 → строка 1.1» — сюда не подходит: он считает, что в строке есть все поля, а в правке их нет. Что именно он ломает и почему это видно не при каждом запуске, покажет задача 1.
Лестница идёт только вверх. Схема на диске бывает и новее приложения: обновление по воздуху откатили, а диск остался от новой версии. Читать такой журнал приложение не умеет, дописывать в него — значит смешать две формы, снести — потерять неотправленное. Остаётся не открывать базу и сказать об этом человеку. Это третье состояние открытия из следующей статьи, а здесь — задача 2. Все правила лестницы вместе:
| Правило | Почему |
|---|---|
| номер схемы лежит в том же значении, что и журнал | номер и содержимое меняются одним шагом |
лестница идёт в boot, до mount | экран новой версии не должен увидеть данные старой |
| шаги идут подряд с любой старой версии | телефон мог пропустить обновления |
| у журнала свой шаг | правка несёт только изменённые поля |
| схема новее приложения — база не открывается | её нельзя ни прочитать, ни дописать, ни снести |
| «снести и загрузить заново» — никогда | неотправленное существует только на этом телефоне |
Ящик, boot и лестница с шагом журнала переезжают в подложку следующей статьи. Там выяснится, что показать человеку, пока boot читает диск и поднимает журнал по лестнице, и чем «не открылась» отличается от «пусто».
- У React Native нет Web Storage: компилятор набора rn не находит
localStorage, на телефоне этоReferenceError. В песочницеlocalStorage— заглушка в памяти кадра: работает, но каждый запуск начинается заново. IndexedDB и Cache бросаютSecurityError. - Диск для JavaScript на телефоне — асинхронный ящик AsyncStorage: пакет сообщества с нативным модулем, строки по ключу, каждый вызов — промис. На Android это база SQLite до 6 МБ, на iOS —
manifest.jsonи файлы, в вебе —localStorage. - На диск ложится журнал вместе со счётчиками — одним значением, то есть одним шагом. Строки после запуска собирает свёртка. «Убить процесс» — выбросить память и снова вызвать
boot. - Номер схемы лежит в том же значении. Лестница миграций идёт в
bootдо первого кадра и проходит все ступени подряд. Снести базу и загрузить заново нельзя: неотправленное существует только на этом телефоне. - Мигрировать надо журнал: таблица, собранная свёрткой, после перезапуска вернётся в старую форму. Шаг журнала трогает только поля, которые в правке есть, — шаг таблицы затирает остальные
undefined. - Схема новее приложения — база не открывается: её нельзя ни прочитать, ни дописать, ни снести.
1/4Пример rn с localStorage: компилятор пишет «Не удается найти имя "localStorage".», а пример при каждом запуске печатает «запуск №1». Что будет на телефоне?
2/4Почему на диск кладут журнал, а не готовые строки?
3/4Лестница 1.1 переименовала weight в weightKg в строках, собранных свёрткой, и записала на диск номер схемы 2. Что покажет второй запуск той же версии?
4/4На диске схема 3, а запущена версия со схемой 2: обновление по воздуху откатили. Как поступить?
Вес пропал, а после перезапуска вернулся
Кто-то решил не писать шаг журнала отдельно и взял для него шаг таблицы: «колонка weight стала weightKg». На диске журнал 1.0 из трёх операций, последняя — правка повторов. После обновления вес подхода на экране пропал, а после следующего перезапуска вернулся сам. Объясните, откуда взялось «вернулся», и почините шаг.
Шаг таблицы разворачивает правку так, будто в ней есть все поля: из { reps: 6 } получается { reps: 6, weightKg: undefined }. Свёртка накладывает поля по порядку, и undefined затирает 82 — вес пропал. А на диск журнал уезжает через JSON.stringify, который свойства со значением undefined выбрасывает (раздел «Что теряется по дороге» статьи «JSON: обмен данными» части I). На диске правка снова { reps: 6 }, и после перезапуска вес «вернулся». Компилятор промолчал: у Record<string, …> каждое поле для него «есть». Шаг журнала трогает только поля, которые в правке есть:
Вывод: вес 82 и после обновления, и после перезапуска. Ошибка, которую чинит перезапуск, опаснее постоянной: на ней нельзя поймать себя, проверив один раз, а в очередь на сервер она успеет попасть.
Схема из будущего
Обновление 1.2 по воздуху откатили, и на телефоне снова работает 1.1, а диск остался от 1.2 со схемой 3. boot версии 1.1 видит незнакомую схему и начинает с чистого листа. Сделайте так, чтобы база не открылась, а на диске всё осталось как было.
Вместо сноса — отказ, и до диска он не дотрагивается:
Вывод: не открылась: схема 3 новее приложения (2) и на диске: схема 3 | операций 2. Схема старше своей идёт по лестнице из раздела «У журнала свой шаг лестницы» — эта проверка встаёт перед ней. Исключение из boot — не падение приложения, а третье состояние открытия базы, и следующая статья покажет его отдельным экраном. Когда человек снова поставит 1.2, журнал откроется целиком, вместе с неотправленным. А снос из условия стёр бы две операции, которых нет больше нигде.