Мобильная разработка

REST, GraphQL и Offline-first архитектура

В метро на втором уровне сеть пропадает каждые две минуты. Пользователь в Telegram пишет сообщение, нажимает 'Отправить', и сообщение мгновенно появляется в чате с серым кружком (отправляется). Через минуту, когда сеть появится на станции, кружок становится зелёной галочкой. Без хорошей offline-first архитектуры пользователь увидел бы ошибку, потерял текст и закрыл приложение. С правильной - даже не заметил, что был офлайн. Эта разница - между приложением, которое люди используют, и тем, которое они удаляют.

  • **Notion mobile** - канонический offline-first; редактирование документов работает без сети, синхронизация в фоне
  • **Linear** - mobile-приложение полностью synchronizes через WebSocket + outbox; ощущается мгновенным даже на медленной сети
  • **Figma** - real-time collaborative editing через CRDT (Yjs-подобный custom CRDT) для одновременной работы команды

Кэширование: REST vs GraphQL

Мобильный пользователь открывает Instagram в метро. Сеть нестабильна, latency 800ms, пакеты теряются. Без кэша приложение показывает белый экран и пользователь его закрывает. С правильным кэшем - приложение мгновенно показывает последние посты из предыдущей сессии, фоном пытается обновить ленту, и пользователь спокойно листает. Разница - в архитектуре сетевого слоя.

REST использует HTTP-кэш на уровне URL: ответ от GET /users/42 кэшируется по URL + заголовкам, обновление одного поля профиля инвалидирует весь объект. GraphQL кэширует на уровне entities (через __typename + id): запрос меняет имя пользователя - кэш обновляет именно user.name везде, где он используется. Apollo Client и Relay делают это автоматически через normalized cache. Для REST аналог - RTK Query или TanStack Query, но требует ручной настройки invalidation.

Какое ключевое преимущество normalized cache (Apollo, Relay) над URL-based кэшированием REST?

Offline-first: outbox pattern и фоновая синхронизация

Notion mobile - канонический пример offline-first. Пользователь редактирует страницу в самолёте, изменения сохраняются мгновенно. Когда соединение появляется, изменения автоматически уходят на сервер. Технически это - **outbox pattern**: каждое изменение сначала записывается в локальную SQLite-таблицу operations с полем status='pending', UI обновляется на основе локального состояния (read-your-writes), фоновый воркер периодически отправляет pending operations и помечает их status='sent'.

Тонкости реализации: операции должны быть идемпотентными (повторная отправка не ломает данные) и иметь уникальный client_op_id для дедупликации на сервере. При обрыве соединения посередине отправки - повторяем, сервер возвращает 200 на уже применённую операцию благодаря client_op_id. SQLite-таблица operations - persistent across app restarts, что критично: пользователь может закрыть приложение и открыть через час, операции должны дойти.

Зачем в outbox pattern генерировать client_op_id на клиенте, а не получать его от сервера?

Оптимистичный UI и rollback

Лайк в Instagram. Пользователь нажимает сердечко, и оно сразу окрашивается в красный - до того, как сервер подтвердил запрос. Это **оптимистичный UI**: клиент предполагает, что операция пройдёт успешно, и обновляет состояние немедленно. Если сервер ответит ошибкой (например, пользователь забанен) - клиент откатывает изменение. Преимущество: UI ощущается мгновенным, особенно при latency 500ms+. Сложность: нужно правильно откатывать состояние, если что-то пошло не так.

Не каждая операция должна быть оптимистичной. Правило: оптимизм работает для операций с очень высокой вероятностью успеха (>95%). Для финансовых транзакций, оплат, удаления данных - наоборот, лучше показать спиннер и дождаться подтверждения. Для UX-операций (лайки, комментарии, реакции) - оптимизм обязателен. Apollo и React Query предоставляют API для оптимистичных обновлений с автоматическим rollback при ошибке.

Когда оптимистичный UI становится антипаттерном?

Разрешение конфликтов: LWW, CRDT, manual merge

Два пользователя одновременно редактируют одну и ту же страницу Notion. Алиса меняет заголовок на 'Plan A', Боб - на 'Plan B'. Кто выиграет? Это - **проблема разрешения конфликтов**. Три классических подхода: Last-Write-Wins (LWW, кто последний - тот прав, простой но теряет данные), Manual Merge (попросить пользователя выбрать, как в Git), CRDT (Conflict-free Replicated Data Types - математически гарантированное автоматическое слияние без потерь).

LWW работает для простых полей (заголовки, статусы), где потерять одно из изменений приемлемо. Manual merge - для важных данных типа документов (Google Docs показывает оба варианта). CRDT - для real-time collaborative editing: текст представляется как граф операций (insert character at position X by user Y), который сходится к одному состоянию у всех клиентов независимо от порядка применения операций. Yjs и Automerge - две популярные CRDT-библиотеки для JavaScript.

Offline-first - это просто 'сохранить ответ API в кэш и показать из кэша при отсутствии сети'

Полноценная offline-first архитектура включает четыре уровня: persistent operations outbox для исходящих мутаций, normalized cache для входящих данных, оптимистичный UI для мгновенных обновлений, и стратегия разрешения конфликтов (LWW, CRDT, manual). Простой кэш ответов не покрывает мутации

Кэш ответов решает только read-сценарий 'покажи, что было'. Реальная задача - 'продолжай работать без сети': писать, редактировать, удалять, а потом синхронизироваться без конфликтов и потерь. Это - архитектурная проблема, не библиотечная

Почему CRDT - правильное решение для real-time collaborative editing, а не LWW?

Ключевые идеи

  • **REST vs GraphQL caching** - URL-based в REST требует ручной инвалидации, normalized cache в GraphQL обновляет всё автоматически
  • **Outbox pattern** - persistent SQLite-таблица pending operations плюс фоновый воркер; основа надёжной синхронизации
  • **Оптимистичный UI** - мгновенное обновление состояния с автоматическим rollback при ошибке; работает для high-success операций
  • **Разрешение конфликтов** - LWW для простых случаев, CRDT для collaborative editing, manual merge для важных данных

Связанные темы

Возврат к мотивации: offline-first - это не одна технология, а композиция приёмов. Связь с предыдущими уроками:

  • Локальное хранилище: SQLite и Realm — Persistent outbox таблица - частный случай локального хранилища, ключевой для offline-first
  • Состояние приложения и Redux/MobX — Оптимистичный UI требует чёткой работы со state; reducers и actions удобно расширяются на outbox
  • WebSocket и push-уведомления — Server push дополняет outbox: входящие изменения из сети попадают в normalized cache в реальном времени

Вопросы для размышления

  • Если outbox pattern настолько надёжен, почему многие приложения по-прежнему показывают ошибку 'Нет соединения' вместо тихой синхронизации? Какие ограничения мешают повсеместному применению?
  • Команда хочет реализовать collaborative editing для своего mobile-приложения. С чего стоит начать: с CRDT-библиотеки или с outbox pattern? Как принять решение?
  • Возврат к мотивации: пользователь в метро отправил сообщение, сеть пропала, телефон выключился. Через час сеть появилась, но сообщение не доставилось. Какой компонент архитектуры дал сбой и как это починить?

Связанные уроки

  • mob-13 — Local storage для offline-first требует понимания memory
  • mob-10 — Clean Architecture определяет слои для REST/GraphQL
  • mob-05 — State management для offline-first данных
  • mob-18 — Offline-first тестируется в CI/CD pipeline
  • net-21-http-basics — REST строится поверх HTTP протокола
  • ds-02-cap-theorem — Offline-first - это CAP: consistency vs availability при offline
  • net-15-tcp-basics
REST, GraphQL и Offline-first архитектура

0

1

Войти