당신이 아무데도 없을 때, 당신의 4G는 "E"로 변하고, 당신의 모바일 앱은 매우 비싼 종이 무게가되기로 결정하는 그 순간을 알고 있습니까? 애플리케이션이 인터넷없이 경로를 로드할 수 없기 때문에 로딩 스핀을 바라보는 배달 드라이버를 상상해보십시오.또는 서버가 다운되기 때문에 창고 관리자가 재고 목록을 끌 수 없기 때문입니다. 많은 고객들이 이러한 연결 구멍을 만났습니다 - 원격 작업 사이트, 오프 사이트 이벤트, 창고 바닥, 물류 허브. 기본적으로 Wi-Fi는 어디서나 사망합니다. 이 작은 모험에서, 나는 내가 어떻게 로컬 최초의 모바일 앱을 만들었는지 보여줄 것입니다. 당신은 보게 될 것입니다 : React Native + RxDB 두 방향 동기화가 실제로 서버를 녹이지 않고 작동하는 방법. 데이터 충돌과 같은 " 재미있는"상황을 다루는 방법. 이 짐승 중 하나를 디자인 할 때 잊지 말아야 할 것들. 또한 - 나는 하나의 "최근 업데이트 승리"트릭을 공유 할 것입니다.이 항상 올바른 선택은 아니지만 우리의 경우 ... 요리의 키스. The Stack (a.k.a. My Chosen Weapons) 나의 선택된 무기 (My Chosen Weapons) 이 빌딩을 위해, 나는 롤링 : 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 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단계 - 데이터베이스 인스턴스 만들기 다음으로, I built 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 를 설치하십시오. 인터넷을 위한 시계는 마치 클린지 전처럼. 우리는 온라인으로 돌아올 때 즉시 syncs. 그리고 예, 그것은 모든 것을 기록합니다.Future me will be grateful when debugging next time. 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 - 데이터 복제 (a.k.a. 눈물없이 동기화) 앱이 "오프라인 동굴 모드"에서 다시 온라인으로 이동하면, 즉, 로컬 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, }; }, 서버에서, 나는 마지막 체크 포인트 이후 새로운 / 업데이트 된 물건을 쿼리합니다.Because 대역폭은 귀중하고 내 인내심도 그렇습니다. //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) Step 6 – Conflict Resolution (The “Last Update Wins” Gambit) 이것은 사람들이 보통 과도하게 복잡한 곳입니다. NASA 수준의 융합 전략을 구축하거나 ... "최근 업데이트 승리"를 사용하여 하루를 호출 할 수 있습니다. 할 수 그것은 항상 올바른 움직임이 아니지만 우리의 경우 - 간단하고 빠르고 충분히 좋습니다. //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를 Sync에 유지 init 후에, app status flips to 이상한 수동 업그레이드 버튼이 없으며, "Tap to Reload"이 헛소리가 없습니다. 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 자체가 업데이트됩니다.이것은 당신이 너무 열심히 생각해서는 안되는 "마법처럼 작동하는"순간 중 하나입니다 - 그냥 즐기십시오. 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가 실존 위기를 겪고 있는지 신경 쓰지 마십시오. 우리는 같은 설계를 사용하여 커넥티브 기능이 있는 곳, 그냥 “도시 모드 모험”이라고 말해 봅시다.Riders can now finish orders, take proof-of-delivery photos, and mark deliveries complete without internet. 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