あなたは、あなたがどこにもいない真ん中にいるその瞬間、あなたの4Gが「E」に変わり、あなたのモバイルアプリは非常に高価な紙の重さになることを決めることを知っていますか? はい。 アプリがインターネットなしでルートをロードできないため、ロードスピナーを見つめている配送ドライバーをイメージしてください. または、サーバーがダウンしているため、在庫リストを引くことができない倉庫マネージャーをイメージしてください. That's not "quirky behavior," that's lost time, lost money, and very angry users. 多くのクライアントがこれらのコネクティビティ・ポットホールを打ち破った - リモートワークサイト、オフサイトイベント、倉庫の床、物流ハブ。 基本的に、Wi-Fiはどこでも死にます。 修正? ローカルファーストアプリ:データをローカルに処理し、可能な限り同期してください。 この小さな冒険では、私がどのように地元初のモバイルアプリを構築したかを示します。 あなたは見るでしょう: React Native + RxDB あなたのサーバーを溶かさずに実際の両方向同期がどのように機能するか。 データ紛争のような「楽しい」状況をどう対処するか。 これらの動物の1つをデザインするときに忘れてはいけないこと。 また、私は「最後のアップデートが勝つ」トリックをシェアします. 常に正しい選択ではなく、私たちのケースでは...シェフのキス。 The Stack (a.k.a. My Chosen Weapons) The Stack (A.K.A. My Chosen Weapons) この作り方で、私は、Rolled with: React Native — cross-platform magic: one codebase, iOS + Android react-native-nitro-sqlite — なぜなら、SQLiteなしのローカルストレージはチーズなしのピザのようなものだからです。 RxDB — オフラインファースト、反応型DBで、シンクで楽しい。 NestJS + TypeORM + PostgreSQL - バックエンド・ドリーム・チーム 最終結果:アプリはオフラインで動作し、後で同期し、バラバラな接続を崩壊することなく生き残ります。 Step 1 — Local Storage via SQLite STEP 1 - ローカルストレージ via SQLite 最初に、RxDBが喜んで悪用できるローカルデータベースが必要だった 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 }; はい、それは包装の3つのレイヤーです. シューブのように. または、あまりにも多くのミドルウェアレイヤーを持つエンタープライズアプリケーションです。 Step 2 — Creating the Database Instance ステップ2 - データベースインスタンスの作成 次に、Aを構築しました。 なぜなら、あなたがあなたの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 Step 3 — Bootstrapping the DB when the app starts (アプリが起動するときにDBを起動する) アプリが起動すると、I spin up my No magic here — just the good ol’ 自分の事をする。 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) Step 4 - Data Replication (a.k.a. Syncing Without Tears) アプリが「オフライン洞窟モード」からオンラインに戻ると、 これは、ローカル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 Step 5 — ローカル変更をサーバーに戻す RxDB はまた、最後の同期以来、ローカルに何が変更されたかを追跡し、それを押し上げます。 //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) Step 6 – Conflict Resolution (The “Last Update Wins” Gambit) ここは、人々が通常、物事を過剰に複雑にする場所です。 NASA級の合併戦略を構築するか・・・「最後の更新が勝利する」と行って、1日を呼ぶことができます。 できる それは常に正しい動きではありませんが、私たちのケースでは - シンプルで、速く、十分に良い。 //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 STEP 7 - UI in Sync を保持する init 後、アプリの状態が flips to , and we just... use it. No weird manual refresh buttons, no “tap to reload” nonsense. 奇妙な手動のリフレッシュボタンはなく、「タップしてリロードする」バカさはありません。 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の変更にサブスクリプトするので、UI自体が更新されます。これはあなたがあまり考えてはいけない「魔法のように働く」瞬間の1つです。 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 GitHub