Представьте: вы создали приложение, которое работает ровно тогда, когда у пользователя есть интернет. Нет интернета? Поздравляю, у вас мёртвое приложение и куча недовольных пользователей. Ну или курьер, который стоит как дурак и не может выполнять свою работу, потому что приложение зависло. Бизнес стоит, а вы сидите и ждёте, что всё само решится (нет).
Если хотите перестать выглядеть полными профанами и дать юзерам что-то, что не падает при первом же обрыве связи — welcome to local-first apps. Здесь всё про то, чтобы сделать локальную базу, а синхронизация — это такая себе приятная бонусная функция, а не священный грааль.
Технологический стэк для тех, кто не хочет сойти с ума
-
React Native — потому что «разрабатывать сразу под всё» звучит круто, пока не начнёшь дебажить 15 платформенных багов;
-
react-native-nitro-sqlite — потому что единственное, что реально работает локально и не вызывает желание забить всё молотком;
-
RxDB — чтобы локальное хранение не было кошмаром и можно было хоть как-то мутить реактивные штуки;
-
NestJS + TypeORM + PostgreSQL — чтобы на сервере никто не писал SQL руками, а всё было «круто» и «типа» удобно.
Настройка локалки. Не забудь про шифрование, чтобы скрыть, как плохо всё устроено
//storage.ts import { getRxStorageSQLiteTrial, getSQLiteBasicsQuickSQLite, } from 'rxdb/plugins/storage-sqlite'; import { open } from 'react-native-nitro-sqlite'; import { wrappedValidateAjvStorage } from 'rxdb/plugins/validate-ajv'; import { wrappedKeyEncryptionCryptoJsStorage } from 'rxdb/plugins/encryption-crypto-js'; // Вот тут магия — берём SQLite, валидируем и шифруем. // Потому что кто-то думает, что шифрование — это круто, а не очередной геморрой. const sqliteBasics = getSQLiteBasicsQuickSQLite(open); const storage = getRxStorageSQLiteTrial({ sqliteBasics }); const validatedStorage = wrappedValidateAjvStorage({ storage }); const encryptedStorage = wrappedKeyEncryptionCryptoJsStorage({ storage: validatedStorage, }); export { encryptedStorage };
Инициализация базы и мучения с синглтоном
//Instance.ts import { addRxPlugin, createRxDatabase, RxDatabase, WithDeleted } from 'rxdb'; import { RxDBDevModePlugin } from 'rxdb/plugins/dev-mode'; import { replicateRxCollection } from 'rxdb/plugins/replication'; import NetInfo from '@react-native-community/netinfo'; import { CheckPointType, MyDatabaseCollections, ReplicateCollectionDto, } from './types.ts'; import { encryptedStorage } from './storage.ts'; import { defaultConflictHandler } from './utills.ts'; import { usersApi, userSchema, UserType } from '../features/users'; import { RxDBMigrationSchemaPlugin } from 'rxdb/plugins/migration-schema'; import { RxDBQueryBuilderPlugin } from 'rxdb/plugins/query-builder'; import { RxDBUpdatePlugin } from 'rxdb/plugins/update'; // Для тех, кто любит читать документацию: здесь плагин для апдейтов, чтобы можно было обновлять документы, и плагин для цепочек запросов — кому-то же надо мучиться. addRxPlugin(RxDBUpdatePlugin); addRxPlugin(RxDBQueryBuilderPlugin); addRxPlugin(RxDBMigrationSchemaPlugin); export class RxDatabaseManager { private static instance: RxDatabaseManager; private db: RxDatabase<MyDatabaseCollections> | null = null; private isOnline = false; private constructor() {} public static getInstance(): RxDatabaseManager { if (!RxDatabaseManager.instance) { // О, волшебство синглтона — единственный способ избежать ещё большего хаоса RxDatabaseManager.instance = new RxDatabaseManager(); } return RxDatabaseManager.instance; } public async init(): Promise<RxDatabase<MyDatabaseCollections>> { if (this.db) return this.db; if (__DEV__) { // В деве включаем режим разработчика, чтобы ловить все баги и падения, которых в продакшене и так хватает. addRxPlugin(RxDBDevModePlugin); } this.db = await createRxDatabase<MyDatabaseCollections>({ name: 'myDb', storage: encryptedStorage, multiInstance: false, // Потому что React Native и мульти-инстансы — это сродни пытке. closeDuplicates: true, // Закрываем дубликаты, чтобы база не рвала ваши нервы. }); await this.db.addCollections({ users: { schema: userSchema, conflictHandler: defaultConflictHandler, // Когда два пользователя обновляют одно и то же, и начинается веселье. migrationStrategies: { // Миграции для тех, кто любит откладывать проблемы на потом. // 1: function (oldDoc: UserType) {}, }, }, }); this.setupConnectivityListener(); // Чтобы хоть как-то отслеживать ваше «всегда онлайн». return this.db; } public getDb(): RxDatabase<MyDatabaseCollections> { if (!this.db) { throw new Error('Database not initialized. Call init() first.'); // Вот тут мы вам честно говорим — инициализируйте сначала, а не запускайте приложение и надейтесь. } return this.db; } private replicateCollection<T>(dto: ReplicateCollectionDto<T>) { const { collection, replicationId, api } = dto; const replicationState = replicateRxCollection<WithDeleted<T>, number>({ collection: collection, replicationIdentifier: replicationId, pull: { async handler(checkpointOrNull: unknown, batchSize: number) { // Пуллим пачками изменения, чтобы не упасть от нагрузки, когда сервер ещё жив. const typedCheckpoint = checkpointOrNull as CheckPointType; const updatedAt = typedCheckpoint ? typedCheckpoint.updatedAt : 0; const id = typedCheckpoint ? typedCheckpoint.id : ''; const response = await api.pull({ updatedAt, id, batchSize }); return { documents: response.data.documents, checkpoint: response.data.checkpoint, }; }, batchSize: 20, // Потому что 20 — это не слишком много, и не слишком мало, а просто «как обычно». }, push: { async handler(changeRows) { console.log('push'); // Вот тут мы пытаемся залить свои локальные обновления на сервер, // а сервер может быть либо в ударе, либо мёртв — пофиг. const response = await api.push({ changeRows }); return response.data; }, }, });
Короче, если хотите, чтобы ваше приложение не сдохло при первом же обрыве связи — учитесь локальному хранению. Если не хотите тратить время на понимание всей этой каши — ну, вы всегда можете послать всех и просто ждать, пока клиенты уйдут к конкурентам.
Настройка синхронизации и ловля ошибок (aka «почему всё ломается»)
private setupConnectivityListener() { NetInfo.addEventListener((state) => { this.isOnline = state.isConnected ?? false; if (this.isOnline) { // Когда наконец-то интернет появился, запускаем синхронизацию. this.startReplication(); } else { // Нет интернета — значит можно спокойно игнорировать жалобы пользователей. console.warn('Нет связи, работаем в оффлайне. Пользователь доволен? Нет.'); } }); } private startReplication() { if (!this.db) { console.error('База не инициализирована, а вы уже решили синхронизироваться? Молодец.'); return; } // Синхронизация для коллекции users — это такой мини-перформанс с ошибками. const usersCollection = this.db.users; if (!usersCollection) { console.error('Коллекция users не найдена, привет багам.'); return; } this.replicateCollection<UserType>({ collection: usersCollection, replicationId: 'users-replication', api: usersApi, }); }
Обработка конфликтов — или как выжить, если два пользователя обновили один и тот же документ одновременно
export const defaultConflictHandler = async (input: { realMasterState: any; newDocumentState: any; realMasterStateLastWriteTime: number; }) => { // Тут мы просто говорим: "Держи, вот твоя последняя версия, с которой уже никто не спорит" // Потому что пытаться решить конфликты вручную — это для слабаков. return { isEqual: false, documentData: input.realMasterState, }; };
Немного о миграциях, которые никто не пишет
migrationStrategies: { 1: function (oldDoc: UserType) { // В этом месте мы просто делаем вид, что помним о миграциях, // но на самом деле откладываем этот вопрос до следующего апдейта, когда всё снова сломается. return oldDoc; }, }
Если вы ещё не столкнулись с миграциями, то поздравляю — вы ещё не начали серьёзно мучиться. Рано или поздно придётся писать, и тогда вы вспомните нас с благодарностью.
Совет от старшего разработчика с горьким опытом
-
Никогда не надейтесь на то, что интернет будет всегда.
-
Никогда не думайте, что пользователь подождёт, пока ваши данные наконец загрузятся.
-
Никогда не пытайтесь сделать всё идеально с первого раза — баги и конфликты — это часть игры, особенно с RxDB.
В общем, если вы тут ещё — значит не боитесь жёстких реалий локального хранилища и синхронизации. Наш совет: выстраивайте архитектуру с запасом прочности и готовьтесь к тому, что придётся постоянно допиливать код, чтобы хоть как-то удовлетворить требования бизнес-юзера, который понятия не имеет, что такое база данных и оффлайн.
ссылка на оригинал статьи https://habr.com/ru/articles/935798/
Добавить комментарий