React Native + RxDB: как сделать Local-First приложение, которое не сдохнет без связи

от автора

Представьте: вы создали приложение, которое работает ровно тогда, когда у пользователя есть интернет. Нет интернета? Поздравляю, у вас мёртвое приложение и куча недовольных пользователей. Ну или курьер, который стоит как дурак и не может выполнять свою работу, потому что приложение зависло. Бизнес стоит, а вы сидите и ждёте, что всё само решится (нет).

Если хотите перестать выглядеть полными профанами и дать юзерам что-то, что не падает при первом же обрыве связи — 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/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *