Перейти к содержимому

Практика 10. Практическая работа 10. Слой данных: репозиторий и кеширование

Цели работы

  • Построить слой данных по принципам Clean Architecture: домен не знает ни о сети, ни о хранилище.
  • Реализовать репозиторий-фасад, который скрывает за единым интерфейсом два источника — удалённый (API, mock) и локальный (AsyncStorage/память).
  • Реализовать и сравнить две политики кэширования: Cache-First и Network-First.
  • Показать, что UI-слой работает только с репозиторием и ничего не знает об источниках данных.
  • Набросать архитектуру offline-first: очередь несинхронизированных изменений и синхронизацию при «появлении сети».

Коротко о теории

Связь с лекцией 10. Главное правило Clean Architecture — Dependency Rule: зависимости направлены только внутрь, к домену. Поэтому интерфейс репозитория объявляется в домене, а его реализация живёт в слое данных. Это инверсия зависимостей: деталь (HTTP, AsyncStorage) зависит от абстракции, а не наоборот.

Репозиторий — это фасад: снаружи устойчивый интерфейс в терминах домена, внутри — выбор источника, кеш, маппинг ошибок. Политики кэширования из лекции:

  • Cache-First — сначала кеш; если данные есть, отдаём их, сеть можем не трогать. Быстро, но данные могут устареть.
  • Network-First — сначала сеть; при ошибке откатываемся на кеш. Свежо, но медленнее и зависит от соединения.
  • Stale-While-Revalidate — отдаём кеш сразу и параллельно обновляем из сети (на практике обычно через TanStack Query/SWR).

Offline-first делает локальную базу источником истины, копит изменения в очереди (outbox) и синхронизирует их при восстановлении сети. Из CAP-теоремы следует, что мобильный клиент выбирает доступность (AP) и итоговую согласованность.


Задание

Реализуйте слой данных для сущности Note (заметка). Каркас структуры папок:

src/
domain/
entities/Note.ts
repositories/NoteRepository.ts # интерфейс (порт)
data/
sources/RemoteNoteSource.ts # mock API
sources/LocalNoteSource.ts # AsyncStorage / память
repositories/CachedNoteRepository.ts
presentation/
useNotes.ts # хук
NotesScreen.tsx # экран

Шаг 1. Доменная сущность и интерфейс репозитория

Сущность чистая: никаких импортов React, сети или хранилища. Поле updatedAt понадобится для синхронизации.

domain/entities/Note.ts
export interface Note {
id: string;
title: string;
body: string;
updatedAt: number; // метка времени для разрешения конфликтов
}
// domain/repositories/NoteRepository.ts
import { Note } from "../entities/Note";
export interface NoteRepository {
getAll(policy?: "cache-first" | "network-first"): Promise<Note[]>;
save(note: Note): Promise<void>;
remove(id: string): Promise<void>;
}

Шаг 2. Два источника данных

Удалённый источник имитирует сеть (задержка и случайные сбои). Локальный источник кеширует данные в AsyncStorage.

data/sources/RemoteNoteSource.ts
import { Note } from "../../domain/entities/Note";
const delay = (ms: number) => new Promise((r) => setTimeout(r, ms));
let server: Note[] = [
{ id: "1", title: "Купить хлеб", body: "...", updatedAt: Date.now() },
];
export class RemoteNoteSource {
async fetchAll(): Promise<Note[]> {
await delay(700);
if (Math.random() < 0.3) throw new Error("Network error"); // имитация офлайна
return server;
}
async push(note: Note): Promise<void> {
await delay(400);
server = [note, ...server.filter((n) => n.id !== note.id)];
}
}
data/sources/LocalNoteSource.ts
import AsyncStorage from "@react-native-async-storage/async-storage";
import { Note } from "../../domain/entities/Note";
const KEY = "notes_cache";
export class LocalNoteSource {
async getAll(): Promise<Note[]> {
const raw = await AsyncStorage.getItem(KEY);
return raw ? (JSON.parse(raw) as Note[]) : [];
}
async replaceAll(notes: Note[]): Promise<void> {
await AsyncStorage.setItem(KEY, JSON.stringify(notes));
}
}

Шаг 3. Репозиторий-фасад с двумя политиками

Вся логика выбора источника спрятана здесь. Снаружи виден только интерфейс из домена.

data/repositories/CachedNoteRepository.ts
import { Note } from "../../domain/entities/Note";
import { NoteRepository } from "../../domain/repositories/NoteRepository";
import { RemoteNoteSource } from "../sources/RemoteNoteSource";
import { LocalNoteSource } from "../sources/LocalNoteSource";
export class CachedNoteRepository implements NoteRepository {
constructor(
private readonly remote: RemoteNoteSource,
private readonly local: LocalNoteSource,
) {}
async getAll(policy: "cache-first" | "network-first" = "cache-first") {
return policy === "cache-first"
? this.cacheFirst()
: this.networkFirst();
}
// Cache-First: есть кеш — отдаём сразу; иначе идём в сеть и заполняем кеш.
private async cacheFirst(): Promise<Note[]> {
const cached = await this.local.getAll();
if (cached.length > 0) return cached;
const fresh = await this.remote.fetchAll();
await this.local.replaceAll(fresh);
return fresh;
}
// Network-First: пробуем сеть; при ошибке — fallback на кеш.
private async networkFirst(): Promise<Note[]> {
try {
const fresh = await this.remote.fetchAll();
await this.local.replaceAll(fresh); // обновляем кеш
return fresh;
} catch {
return this.local.getAll();
}
}
async save(note: Note): Promise<void> {
const notes = await this.local.getAll();
const next = [note, ...notes.filter((n) => n.id !== note.id)];
await this.local.replaceAll(next); // пишем локально мгновенно
await this.remote.push(note); // затем в сеть
}
async remove(id: string): Promise<void> {
const notes = await this.local.getAll();
await this.local.replaceAll(notes.filter((n) => n.id !== id));
}
}

Шаг 4. UI работает только с репозиторием

Экран и хук не знают ни про RemoteNoteSource, ни про AsyncStorage — только про интерфейс NoteRepository. Реализацию подставляем в одном месте (сборка зависимостей).

presentation/useNotes.ts
import { useEffect, useState } from "react";
import { Note } from "../domain/entities/Note";
import { NoteRepository } from "../domain/repositories/NoteRepository";
export function useNotes(repo: NoteRepository) {
const [notes, setNotes] = useState<Note[]>([]);
const [loading, setLoading] = useState(true);
async function load() {
setLoading(true);
setNotes(await repo.getAll("network-first"));
setLoading(false);
}
useEffect(() => { load(); }, []);
return { notes, loading, reload: load };
}
presentation/NotesScreen.tsx
import { View, Text, FlatList, Button } from "react-native";
import { useNotes } from "./useNotes";
import { CachedNoteRepository } from "../data/repositories/CachedNoteRepository";
import { RemoteNoteSource } from "../data/sources/RemoteNoteSource";
import { LocalNoteSource } from "../data/sources/LocalNoteSource";
// Сборка зависимостей (DI) — единственное место, знающее об источниках.
const repo = new CachedNoteRepository(new RemoteNoteSource(), new LocalNoteSource());
export default function NotesScreen() {
const { notes, loading, reload } = useNotes(repo);
if (loading) return <Text>Загрузка…</Text>;
return (
<View style={{ flex: 1, padding: 16 }}>
<Button title="Обновить" onPress={reload} />
<FlatList
data={notes}
keyExtractor={(n) => n.id}
renderItem={({ item }) => <Text>{item.title}</Text>}
/>
</View>
);
}

Шаг 5 (доп.). Набросок offline-first: очередь и синхронизация

Идея: при save мгновенно пишем в локальный источник, а операцию для сервера кладём в очередь (outbox). Когда NetInfo сообщает о появлении сети — «проигрываем» очередь.

data/sync/Outbox.ts
type Op = { type: "save" | "remove"; payload: any };
let queue: Op[] = []; // в реальном проекте — в AsyncStorage/SQLite
export const Outbox = {
enqueue(op: Op) { queue.push(op); },
// sync вызывается при восстановлении сети
async sync(remote: RemoteNoteSource) {
for (const op of [...queue]) {
if (op.type === "save") await remote.push(op.payload);
queue = queue.slice(1); // удаляем успешно применённую операцию
}
},
};
// presentation: подписка на сеть
import NetInfo from "@react-native-community/netinfo";
NetInfo.addEventListener((state) => {
if (state.isConnected) Outbox.sync(new RemoteNoteSource());
});

Конфликты разрешаем по updatedAt (Last-Write-Wins): побеждает запись с большей меткой времени. Это соответствует выбору AP и итоговой согласованности из лекции.

Что сдать

  1. Код всех файлов из шагов 1–4 (рабочий проект на Expo).
  2. Скриншот/видео: запуск с пустым кешем (идёт сеть) и повторный запуск (Cache-First отдаёт мгновенно).
  3. Короткий ответ (3–5 предложений): почему интерфейс репозитория лежит в домене и какую политику вы выбрали для экрана списка и почему.
  4. (Доп.) Демонстрация очереди: сохранение в офлайне и отправка после «появления сети».

Критерии оценки

КритерийВес
Доменная сущность и интерфейс репозитория вынесены в domain, без импортов сети/хранилища15%
Два рабочих источника (remote-mock и local/AsyncStorage)15%
Репозиторий-фасад реализует интерфейс и инкапсулирует выбор источника20%
Корректно работают обе политики: Cache-First и Network-First (с fallback)20%
UI/хук зависят только от интерфейса репозитория (источники не импортируются в presentation)15%
Доп.: очередь изменений и синхронизация при появлении сети10%
Оформление, README, ответы на вопросы из «Что сдать»5%

Вопросы для самопроверки

  1. Почему интерфейс NoteRepository объявлен в domain, а CachedNoteRepository — в data? Как это связано с Dependency Rule?
  2. Чем отличается поведение Cache-First и Network-First при недоступной сети? Какие данные увидит пользователь в каждом случае?
  3. Для какого экрана вы бы выбрали Network-First, а для какого — Cache-First? Приведите пример.
  4. Что произойдёт с UI, если заменить AsyncStorage на SQLite? Какие файлы придётся менять?
  5. Зачем нужна очередь (outbox) и почему операциям нужны стабильные id и метка updatedAt?
  6. Почему мобильный клиент обычно выбирает доступность (AP) и итоговую согласованность, а не строгую согласованность?

Ресурсы