Bạn có biết khoảnh khắc đó khi bạn đang ở giữa không có nơi nào, 4G của bạn biến thành "E", và ứng dụng di động của bạn quyết định trở thành một trọng lượng giấy rất đắt tiền? Hãy tưởng tượng một trình điều khiển giao hàng nhìn vào một spinner tải vì ứng dụng không thể tải tuyến đường của họ mà không có internet. hoặc một người quản lý kho không thể kéo danh sách hàng tồn kho vì máy chủ đang xuống. Một loạt các khách hàng của chúng tôi đã gặp những lỗ hổng kết nối này - các trang web làm việc từ xa, các sự kiện ngoài trang web, tầng kho, trung tâm hậu cần. Về cơ bản, bất cứ nơi nào Wi-Fi sẽ chết. Trong cuộc phiêu lưu nhỏ này, tôi sẽ cho bạn thấy cách tôi xây dựng một ứng dụng di động địa phương đầu tiên với Bạn sẽ thấy: React Native + RxDB Làm thế nào hai chiều đồng bộ thực sự hoạt động mà không làm tan chảy máy chủ của bạn. Làm thế nào để đối phó với các tình huống "vui vẻ" như xung đột dữ liệu. Những gì không nên quên khi thiết kế một trong những con thú này. Ngoài ra - tôi sẽ chia sẻ một "cập nhật cuối cùng chiến thắng" thủ thuật. không phải lúc nào cũng lựa chọn đúng, nhưng trong trường hợp của chúng tôi ... nụ hôn đầu bếp. The Stack (a.k.a. My Chosen Weapons) Lời bài hát: My Chosen Weapons (A.K.A.) Đối với xây dựng này, tôi đã cuộn với: React Native – Magic Cross-Platform: One Codebase, iOS + Android. react-native-nitro-sqlite - bởi vì lưu trữ địa phương mà không có SQLite giống như pizza mà không có phô mai. RxDB - offline-first, phản ứng DB mà chơi tốt với đồng bộ. NestJS + TypeORM + PostgreSQL – nhóm giấc mơ backend. Kết quả cuối cùng: ứng dụng hoạt động ngoại tuyến, đồng bộ hóa sau đó và tồn tại các kết nối khó hiểu mà không có sự sụp đổ. Step 1 — Local Storage via SQLite Bước 1 – Lưu trữ cục bộ thông qua SQLite Đầu tiên, tôi cần một cơ sở dữ liệu địa phương mà RxDB có thể lạm dụng. SQLite là sự lựa chọn rõ ràng, nhưng tôi đã đóng gói nó với một số quyền lực bổ sung - xác nhận và mã hóa - bởi vì tính toàn vẹn dữ liệu và quyền riêng tư quan trọng (và cũng bởi vì bản thân tương lai của tôi sẽ cảm ơn tôi). //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 }; Có, đó là ba lớp bao bì. Giống như một hành tây. Hoặc một ứng dụng doanh nghiệp với quá nhiều lớp middleware. Step 2 — Creating the Database Instance Bước 2: Tạo cơ sở dữ liệu Sau đó, tôi xây dựng một Bởi vì nếu bạn nghĩ rằng có nhiều phiên bản của DB của bạn là một ý tưởng tốt ... bạn có thể cũng thích xung đột hợp nhất trong sản xuất. RxDatabaseManager Đây là lớp học trong tất cả vinh quang của nó: //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(); } } Con thú nhỏ này: Đặt DB lên. Đồng hồ cho internet như một ex clingy. Syncs ngay khi chúng tôi trở lại trực tuyến. Và vâng, nó ghi lại mọi thứ.Tương lai tôi sẽ biết ơn khi xử lý lần sau. Step 3 — Bootstrapping the DB When the App Starts Bước 3 – Bootstrapping DB khi ứng dụng khởi động Khi ứng dụng khởi động, tôi xoay lên Không có ma thuật ở đây - chỉ có ol tốt làm việc của mình. RxDatabaseManager useEffect Nếu một cái gì đó bùng nổ trong init, tôi ghi lại nó.Bởi vì giả vờ lỗi không tồn tại là cách bạn nhận được các ứng dụng bị ám ảnh. //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) Bước 4 – Lặp lại dữ liệu (còn gọi là đồng bộ hóa mà không có nước mắt) Khi ứng dụng chuyển từ "chế độ hang ngoại tuyến" trở lại trực tuyến, Điều này kích hoạt đồng bộ dữ liệu giữa DB cục bộ và máy chủ thông qua . onReconnected() replicateRxCollection Dưới đây là những thông tin cơ bản về RxDB (RxDB gửi điểm kiểm tra) , ) để máy chủ biết nơi chúng tôi dừng lại. bởi vì không ai muốn lấy DB mỗi lần updatedAt id Toàn bộ //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, }; }, Trên máy chủ, tôi chỉ truy vấn những thứ mới / cập nhật kể từ điểm kiểm tra cuối cùng. bởi vì băng thông là quý giá, và cũng vậy là sự kiên nhẫn của tôi. //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); } Và sau đó máy chủ gửi lại cả hai tài liệu và một điểm kiểm tra mới rực rỡ: //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 Bước 5 - Đẩy thay đổi địa phương trở lại máy chủ RxDB cũng theo dõi những gì đã thay đổi cục bộ kể từ lần đồng bộ cuối cùng và đẩy nó lên. nghĩ về nó như Dropbox cho dữ liệu của ứng dụng của bạn - không có các tệp ngẫu nhiên "một bản sao xung đột" (tốt ... trừ khi bạn xử lý xung đột tồi tệ). //instance.ts async handler(changeRows) { const response = await api.push({ changeRows }); return response.data; }, Trên backend, tôi kiểm tra từng thay đổi đến: Nếu không có xung đột, nó đi thẳng vào. Nếu phiên bản của máy chủ là mới hơn, tôi ném nó vào đống xung đột. //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) Bước 6 – Giải quyết xung đột (The “Last Update Wins” Gambit) Đây là nơi mọi người thường quá phức tạp mọi thứ. Hoặc... bạn có thể đi với “cập nhật cuối cùng chiến thắng” và gọi nó là một ngày. Có thể Nó không phải lúc nào cũng là động thái đúng đắn, nhưng trong trường hợp của chúng tôi - đơn giản, nhanh chóng, đủ tốt. //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 Bước 7 - Giữ UI trong Sync Sau init, trạng thái ứng dụng sẽ chuyển sang , và chúng tôi chỉ ... sử dụng nó. không có nút làm mới thủ công kỳ lạ, không có "chạm vào để tải lại" vô nghĩa. 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> ); }; Đăng ký thay đổi trong DB, vì vậy UI tự cập nhật.Đây là một trong những khoảnh khắc "làm việc như ma thuật" mà bạn không nên suy nghĩ quá nhiều - chỉ cần tận hưởng nó. 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 Suy nghĩ cuối cùng Về cơ bản, chúng tôi đã xây dựng một ứng dụng mà: Hoạt động offline Sync tự động Không quan tâm nếu Wi-Fi của bạn đang trải qua một cuộc khủng hoảng hiện sinh. Chúng tôi đã sử dụng cùng một thiết lập cho , nơi kết nối là ... chỉ cần nói "chế độ phiêu lưu đô thị." Người lái xe bây giờ có thể hoàn thành đơn đặt hàng, chụp ảnh chứng minh giao hàng, và đánh dấu giao hàng hoàn thành mà không cần internet. Sizl’s dark kitchen riders in Chicago Chắc chắn, các trường hợp thực tế có thể trở nên tồi tệ hơn – nhiều thiết bị cập nhật cùng một bản ghi, xóa dữ liệu liên quan, tập hợp dữ liệu khổng lồ. Tôi có khuyên bạn nên đi địa phương đầu tiên không? tuyệt đối - trừ khi bạn thích vé người dùng bắt đầu với "Tôi đã ngoại tuyến và ..." 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