Знаете ли вы тот момент, когда вы находитесь в середине ниоткуда, ваша 4G превращается в «E», и ваше мобильное приложение решает стать очень дорогим бумажным весом? Представьте себе драйвера доставки, который смотрит на загрузочный спинер, потому что приложение не может загрузить свой маршрут без интернета, или менеджера склада, который не может вытащить список запасов, потому что сервер опустошен. Группа наших клиентов попала в эти пробелы в связи — удаленные рабочие места, внештатные события, склады, логистические узлы. В основном, везде, где Wi-Fi умирает. In this little adventure, I’ll show you how I built a local-first mobile app with Вы будете видеть: React Native + RxDB Как двухсторонняя синхронизация на самом деле работает без таяния вашего сервера. Как справиться с «забавными» ситуациями, такими как конфликты с данными. О чем не стоит забывать при проектировании одного из этих животных. Также — я буду делиться одним «последним обновлением выигрывает» трюк.Не всегда правильный выбор, но в нашем случае... поцелуй повара. The Stack (a.k.a. My Chosen Weapons) The Stack (а.к.а. Мое выбранное оружие) Для этой постройки, я ролл с: React Native — кроссплатформенная магия: одна кодовая база, iOS + Android. React-native-nitro-sqlite — потому что местное хранение без SQLite похоже на пиццу без сыра. RxDB — offline-first, реактивный DB, который хорошо играет с синхронизацией. NestJS + TypeORM + PostgreSQL — команда мечты. Конечный результат: приложение работает в автономном режиме, синхронизируется позже и выживает в недоступных соединениях, не испытывая срыва. Step 1 — Local Storage via SQLite Шаг 1 — Местное хранение через SQLite SQLite является очевидным выбором, но я упаковал его с некоторыми дополнительными полномочиями - валидацией и шифрованием - потому что целостность данных и конфиденциальность имеют значение (а также потому, что мое будущее я буду благодарить). //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'; const sqliteBasics = getSQLiteBasicsQuickSQLite(open); const storage = getRxStorageSQLiteTrial({ sqliteBasics }); const validatedStorage = wrappedValidateAjvStorage({ storage }); const encryptedStorage = wrappedKeyEncryptionCryptoJsStorage({ storage: validatedStorage, }); export { encryptedStorage }; Да, это три слоя обертывания, как лук, или корпоративное приложение с слишком большим количеством слоев среднего оборудования. Step 2 — Creating the Database Instance Шаг 2 — Создание базы данных Затем я построил а Потому что если вы думаете, что иметь несколько экземпляров вашего DB - это хорошая идея... вы, вероятно, также наслаждаетесь конфликтами в производстве. RxDatabaseManager Вот класс во всей его славе: //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'; // for support query.update method addRxPlugin(RxDBUpdatePlugin); // for support chained query methods addRxPlugin(RxDBQueryBuilderPlugin); // for enabling data migration 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__) { // needs to be added in dev mode addRxPlugin(RxDBDevModePlugin); } this.db = await createRxDatabase<MyDatabaseCollections>({ name: 'myDb', storage: encryptedStorage, multiInstance: false, // No multi-instance support for React Native closeDuplicates: true, // Close duplicate database instances }); 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, }, push: { async handler(changeRows) { console.log('push'); const response = await api.push({ changeRows }); return response.data; }, }, }); replicationState.active$.subscribe(v => { console.log('Replication active$:', v); }); replicationState.canceled$.subscribe(v => { console.log('Replication canceled$:', v); }); replicationState.error$.subscribe(async error => { console.error('Replication error$:', error); }); } private async startReplication() { const db = this.getDb(); this.replicateCollection<UserType>({ collection: db.users, replicationId: '/users/sync', api: { push: usersApi.push, pull: usersApi.pull, }, }); } private setupConnectivityListener() { NetInfo.addEventListener(state => { const wasOffline = !this.isOnline; this.isOnline = Boolean(state.isConnected); if (this.isOnline && wasOffline) { this.onReconnected(); } }); } private async onReconnected() { this.startReplication(); } } Этот маленький зверь: Встаньте на DB. Часы для интернета как клинги бывший. Синхронизируем, как только мы снова онлайн. И да, он записывает все. Будущее меня будет благодарно при следующем дебютировании. Step 3 — Bootstrapping the DB When the App Starts Шаг 3 — загрузка DB при запуске приложения Когда приложение запускается, я поднимаю свой Нет волшебства здесь — только хороший оль’ Делать свое дело. RxDatabaseManager useEffect Если что-то взрывается во время init, я записываю его.Потому что притворяться, что ошибок не существует, это то, как вы получаете пристрастия к приложениям. //App.tsx useEffect(() => { const init = async () => { const dbManager = RxDatabaseManager.getInstance(); dbManager .init() .then(() => { setAppStatus('ready'); }) .catch((error) => { console.log('Error initializing database:', error); setAppStatus('error'); }); }; init(); }, []); Step 4 — Data Replication (a.k.a. Syncing Without Tears) Шаг 4 — Репликация данных (так называемый синхронизация без слез) Когда приложение переходит из «оффлайн-пещерного режима» обратно в онлайн, Это отключает синхронизацию данных между локальным DB и сервером через . onReconnected() replicateRxCollection Вот базовый драйвер потягивания — RxDB отправляет контрольную точку ( , ) так что сервер знает, где мы остановились. потому что никто не хочет брать DB каждый раз. updatedAt id Целый //instance.ts 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, }; }, На сервере я спрашиваю только новые/обновленные вещи с последнего контрольного пункта, потому что пропускная способность ценна, а также мое терпение. //users.query-repository.ts async pull(dto: PullUsersDto): Promise<UserViewDto[]> { const { id, updatedAt, batchSize } = dto; const users = await this.users .createQueryBuilder('user') .where('user.updatedAt > :updatedAt', { updatedAt }) .orWhere('user.updatedAt = :updatedAt AND user.id > :id', { updatedAt, id, }) .orderBy('user.updatedAt', 'ASC') .addOrderBy('user.id', 'ASC') .limit(batchSize) .getMany(); return users.map(UserViewDto.mapToView); } Затем сервер отправляет обратно как документы, так и блестящую новую контрольную точку: //user.service.ts async pull(dto: PullUsersDto) { const users = await this.usersRepository.pull(dto); const newCheckpoint = users.length === 0 ? { id: dto.id, updatedAt: dto.updatedAt } : { id: users.at(-1)!.id, updatedAt: users.at(-1)!.updatedAt, }; return { documents: users, checkpoint: newCheckpoint, }; } Step 5 — Pushing Local Changes Back to the Server Шаг 5 — Передача локальных изменений обратно на сервер RxDB также отслеживает то, что изменилось локально с момента последнего синхронизации, и подталкивает его, думая о нем как о Dropbox для данных вашего приложения — без случайных «конфликтных копий» файлов (хорошо... если вы плохо справляетесь с конфликтами). //instance.ts async handler(changeRows) { const response = await api.push({ changeRows }); return response.data; }, На заднем плане я проверяю каждое входящее изменение: Если конфликта нет, он идет прямо вниз. Если версия сервера более новая, я бросаю ее в столб конфликтов. //user.service.ts async push(dto: PushUsersDto) { const changeRows = dto.changeRows; const existingUsers = await this.usersRepository.findByIds( changeRows.map((changeRow) => changeRow.newDocumentState.id), ); const existingMap = new Map(existingUsers.map((user) => [user.id, user])); const toSave: UserViewDto[] = []; const conflicts: UserViewDto[] = []; for (const changeRow of changeRows) { const newDoc = changeRow.newDocumentState; const existing = existingMap.get(newDoc.id); const isConflict = existing && existing.updatedAt > newDoc?.updatedAt; if (isConflict) { conflicts.push(existing); } else { toSave.push(newDoc); } if (toSave.length > 0) { await this.usersRepository.save(toSave); } } return conflicts; } Step 6 — Conflict Resolution (The “Last Update Wins” Gambit) Шаг 6 — Решение конфликтов (побеждает «Последнее обновление» Gambit) Вот где люди обычно слишком усложняют вещи. да, вы Или... вы можете пойти с «последним обновлением выигрывает» и назвать его днем. могла Это не всегда правильный шаг, но в нашем случае — простой, быстрый, достаточно хороший. //utills.ts export const defaultConflictHandler: RxConflictHandler<{ updatedAt: number; }> = { isEqual(a, b) { return a.updatedAt === b.updatedAt; }, resolve({ assumedMasterState, realMasterState, newDocumentState }) { return Promise.resolve(realMasterState); }, }; Step 7 — Keeping the UI in Sync Шаг 7 — Поддержание интерфейса в синхронизации После init статус приложения переключается на Никаких странных ручных кнопок обновления, никакого «нажатия на перезагрузку» глупости. Ready //UsersScreen.tsx export const UsersScreen = () => { const users = useUsersSelector({ sort: [{ updatedAt: 'desc' }], }); const { createUser, deleteUser, updateUser } = useUsersService(); return ( <View style={styles.container}> {users.map(user => ( <Text key={user.id}>{user.name}</Text> ))} <Button title={'Create new user'} onPress={createUser} /> <Button disabled={users.length === 0} title={'Update user'} onPress={() => updateUser(users[0].id)} /> <Button disabled={users.length === 0} title={'Delete user'} onPress={() => deleteUser(users[0].id)} /> </View> ); }; подписывается на изменения в DB, поэтому интерфейс пользователя обновляется сам. Это один из тех «действует как магия» моментов, о которых вы не должны слишком сильно думать — просто наслаждайтесь им. useUsersSelector //user.selector.tsx export const useUsersSelector = (query?: MangoQuery<UserType>) => { const userModel = RxDatabaseManager.getInstance().getDb().users; const [users, setUsers] = useState<UserType[]>([]); useEffect(() => { const subscription = userModel.find(query).$.subscribe(result => { setUsers(result); }); return () => subscription.unsubscribe(); }, [userModel, query]); return users; }; Final Thoughts Окончательные мысли Мы в основном создали приложение, которое: Работает в оффлайн. Синхронизируется автоматически Не важно, имеет ли ваш Wi-Fi экзистенциальный кризис. Мы использовали ту же схему для Теперь ездители могут завершить заказы, сделать доказательства доставки фотографий и отмечать поставки, завершенные без интернета. Sizl’s dark kitchen riders in Chicago Разумеется, реальные случаи могут стать более сложными — несколько устройств обновляют один и тот же рекорд, удаляют связанные данные, массивные наборы данных. Абсолютно — если вам не нравятся билеты на гнев пользователей, которые начинаются с «Я был оффлайн и...» Click the link in my bio for more info on this one & other projects! To check out our open source admin panel on React see our . Github ГИТБУБ