1. Введение
Когда мы говорим «надёжность сети», хочется думать, что это только про ошибки и повторы. Но на практике самый надёжный запрос — это запрос, который вы вообще не отправили. И тут появляется кэш: вместо того чтобы снова и снова ходить на один и тот же endpoint (особенно в CLI, где пользователь может повторять команду), мы иногда можем вернуть уже полученный результат.
Важно не перепутать кэш с «бессмертной истиной». Кэш — это компромисс: мы сознательно принимаем, что некоторое время будем возвращать слегка устаревшие данные, зато уменьшим задержку, нагрузку на API и вероятность поймать сетевую ошибку. Такой компромисс часто абсолютно разумен: например, список жанров, карточка книги по ID, справочные данные.
И ещё один неожиданный бонус: кэш помогает даже там, где retry неуместен. Если запрос неудачен (ошибка сети) — retry может помочь. Если запрос успешен, но «дорогой» (по времени, по лимитам API, по количеству запросов) — кэш помогает заранее, до того как начались проблемы.
2. Что именно мы будем кэшировать в нашем проекте LibraryCLI
Чтобы кэш был понятным и предсказуемым, мы выберем очень простую и «учебную» модель: кэшируем успешный ответ транспорта по ключу «метод + URL». То есть на уровне HTTPClient, который возвращает (Data, HTTPURLResponse). Это удобно: кэш не знает про DTO, доменные модели и декодирование. Он просто говорит: «Вот байты ответа, которые ты уже получал».
Такой кэш легко встроить как обёртку вокруг существующего клиента:
URLSessionHTTPClient (реальный транспорт) → CachingHTTPClient (наша политика) → ApiClient (интерпретация ответа, decode, NetworkError).
Это важно: мы не ломаем архитектуру и не размазываем Dictionary по всему проекту. Мы добавляем отдельный компонент, который делает ровно одну вещь.
Сразу фиксируем ограничение курса: кэш — stateful компонент, он хранит изменяемое состояние. В сегодняшней версии он предполагает последовательный доступ к одному экземпляру (single‑writer): то есть мы не рассчитываем, что кто-то будет одновременно дергать send() из нескольких параллельных задач.
3. Мини‑схема: как кэш «перехватывает» запрос
Прежде чем писать код, полезно увидеть глазами, что вообще будет происходить. У нас появится развилка «есть запись в кэше — нет записи».
flowchart TD
A["send(request)"] --> B{Ключ получился?}
B -- нет --> Z[Идём в base.send]
B -- да --> C{Есть запись в кэше?}
C -- да --> D{Не протухла?}
D -- да --> E["Возвращаем cached (Data, Response)"]
D -- нет --> F[Удаляем протухшее]
F --> Z
C -- нет --> Z
Z --> G[Получили result]
G --> H{Можно кэшировать?}
H -- да --> I[Сохраняем с expiresAt]
H -- нет --> J[Просто возвращаем]
I --> J
Эта схема — и есть вся логика лекции. Мы просто аккуратно реализуем её в Swift так, чтобы код был читаемым, а правила — очевидными.
4. Реализация кэша: ключ, запись, хранилище и TTL
Ключ кэша: CacheKey
Если сказать «кэшируем запросы», новичок часто пытается засунуть в ключ весь URLRequest. Но URLRequest не очень удобен как ключ: он не Hashable, в нём много полей, а некоторые поля могут быть неважны или нестабильны.
Поэтому мы делаем свой маленький тип CacheKey, который содержит минимум нужного: HTTP‑метод и полный URL (включая query). Полный URL берём как строку absoluteString, чтобы не спорить о том, как сравнивать URL.
Пример: CacheKey
import Foundation
struct CacheKey: Hashable {
let method: String
let url: String
}
Теперь нужна функция, которая из URLRequest делает ключ. Тут есть важный момент: request.url — optional. Если URL нет, кэшировать нечего (и это нормально).
Пример: makeCacheKey(from:)
import Foundation
func makeCacheKey(from request: URLRequest) -> CacheKey? {
guard let url = request.url?.absoluteString else { return nil }
let method = request.httpMethod ?? "GET"
return CacheKey(method: method, url: url)
}
Обратите внимание на httpMethod ?? "GET". Это не «магия», а практичная страховка: если метод не выставили, Swift‑мир часто подразумевает "GET", а нам нужен какой-то текст для ключа.
Запись кэша: CacheEntry
Кэш без «срока годности» быстро превращается в «я помню всё» — а это уже хоррор, а не фича. Нам нужен TTL (time‑to‑live): сколько секунд запись считается актуальной.
Вместо того чтобы хранить «TTL = 10 секунд» и постоянно пересчитывать, удобнее хранить конкретный момент времени expiresAt: Date. Тогда проверка выглядит просто: Date() >= expiresAt.
Пример: CacheEntry
import Foundation
struct CacheEntry<Value> {
let value: Value
let expiresAt: Date
}
Заметьте, что CacheEntry — generic по Value. Мы не привязываемся к конкретному типу. Сегодня это будет (Data, HTTPURLResponse), но сама структура универсальна.
InMemoryCache: минимальный API и «протухание при чтении»
Сейчас мы соберём сам кэш как структуру с приватным словарём. Он будет поддерживать три базовые операции: get, set, remove/removeAll. Это как холодильник: «достать», «положить», «выкинуть всё, что подозрительно смотрит».
Важная часть поведения: что делать с протухшими значениями. Мы выберем самый простой и предсказуемый подход: протухание при чтении. То есть запись удаляется тогда, когда вы попытались её прочитать и обнаружили, что срок годности прошёл. Это не самый «эффективный» подход на больших системах, но для учебного проекта он идеален: минимум фоновой магии.
Пример: InMemoryCache
import Foundation
struct InMemoryCache<Key: Hashable, Value> {
private var storage: [Key: CacheEntry<Value>] = [:]
mutating func get(_ key: Key) -> Value? {
guard let entry = storage[key] else { return nil }
if Date() >= entry.expiresAt {
storage[key] = nil
return nil
}
return entry.value
}
}
Смотрите, насколько короткая логика: нашли запись → проверили дату → либо вернули, либо удалили и вернули nil.
Теперь добавим set. Мы принимаем ttl: TimeInterval (а это просто Double, «секунды»), и высчитываем expiresAt.
Пример: set
import Foundation
extension InMemoryCache {
mutating func set(_ value: Value, for key: Key, ttl: TimeInterval) {
let expiresAt = Date().addingTimeInterval(ttl)
storage[key] = CacheEntry(value: value, expiresAt: expiresAt)
}
}
И финальная часть — инвалидирование. Иногда нам нужно убрать один ключ (точечно) или очистить весь кэш (например, при подозрении на устаревшие данные).
Пример: remove/removeAll
import Foundation
extension InMemoryCache {
mutating func remove(_ key: Key) {
storage[key] = nil
}
mutating func removeAll() {
storage.removeAll()
}
}
Здесь важно заметить ключевое слово mutating: мы меняем внутреннее состояние структуры. Это и есть «stateful компонент». Именно поэтому мы постоянно подчёркиваем ограничение single‑writer: если два места одновременно начнут дергать get/set у одного экземпляра без дисциплины, будет беда.
TTL на практике: почему кэш — это не утечка памяти, но может выглядеть как утечка
Кэш хранит данные в памяти. Поэтому график памяти приложения часто выглядит как «ползущая вверх линия», пока кэш наполняется. Это очень похоже на утечку — и люди начинают паниковать, хотя паниковать надо чуть позже.
В документации Swift прямо отмечается, что постепенный рост памяти не всегда означает leak: иногда это «профиль памяти приложения», и кэш — типичный пример. При правильной настройке (ограничение размеров, протухание, очистка) рост должен стабилизироваться и выйти на плато.
В нашей упрощённой модели размер кэша напрямую не ограничен (мы не делаем LRU/LFU и лимиты по количеству записей). Поэтому TTL становится вашим главным «тормозом»: слишком большой TTL — кэш долго хранит много данных; слишком маленький TTL — кэш почти не помогает. В CLI‑утилитах часто подходят маленькие TTL: 5–30 секунд, чтобы сгладить повторы команд пользователя, но не превратить кэш в «альтернативную базу данных».
5. Встраиваем кэш в сетевой слой: CachingHTTPClient
Теперь самое интересное: мы превращаем наш кэш в «политику» поверх транспорта. Для этого делаем класс (именно класс, чтобы обёртка жила как один объект и хранила состояние между вызовами), который реализует протокол HTTPClient.
Контракт HTTPClient (напоминание)
Предположим, что ваш HTTPClient выглядит примерно так (вы его вводили раньше, на дне про ApiClient):
Пример: контракт HTTPClient (напоминание)
import Foundation
protocol HTTPClient {
func send(_ request: URLRequest) async throws -> (Data, HTTPURLResponse)
}
Каркас CachingHTTPClient
Теперь пишем обёртку. Она хранит base (реальный транспорт), TTL и сам кэш. Кэшируем только успешный результат, и в базовой версии — только "GET", чтобы не кэшировать действия, которые могут менять серверное состояние.
Пример: каркас CachingHTTPClient
import Foundation
final class CachingHTTPClient: HTTPClient {
private let base: HTTPClient
private let ttl: TimeInterval
private var cache = InMemoryCache<CacheKey, (Data, HTTPURLResponse)>()
init(base: HTTPClient, ttl: TimeInterval) {
self.base = base
self.ttl = ttl
}
}
Обратите внимание: cache — var, потому что мы будем его мутировать. И это ещё раз напоминает про single‑writer: один экземпляр CachingHTTPClient предполагает последовательные вызовы send.
Реализация send: чтение из кэша и запрос в сеть
Чтобы не раздувать один кусок на 30 строк, разобьём на маленькие шаги: сначала попробуем кэш, потом сходили в сеть, потом (если можно) положили в кэш.
Пример: чтение из кэша
import Foundation
extension CachingHTTPClient {
func send(_ request: URLRequest) async throws -> (Data, HTTPURLResponse) {
if request.httpMethod == "GET",
let key = makeCacheKey(from: request),
let cached = cache.get(key) {
return cached
}
let result = try await base.send(request)
// запись в кэш добавим ниже
return result
}
}
Реализация send: запись в кэш
Теперь добавим запись в кэш. Делать это надо после успешного base.send, иначе мы «закэшируем ошибку», а это почти всегда плохая идея в учебной модели. (Кэшировать ошибки иногда можно, но это отдельная политика и отдельные правила.)
Пример: запись в кэш
import Foundation
extension CachingHTTPClient {
func send(_ request: URLRequest) async throws -> (Data, HTTPURLResponse) {
if request.httpMethod == "GET",
let key = makeCacheKey(from: request),
let cached = cache.get(key) {
return cached
}
let result = try await base.send(request)
if request.httpMethod == "GET",
let key = makeCacheKey(from: request) {
cache.set(result, for: key, ttl: ttl)
}
return result
}
}
Да, тут два раза повторяется проверка "GET" и построение ключа. Это выглядит чуть «топорно», но для начинающих это часто лучше, чем попытка выжать всё в одну хитрую конструкцию. Код читается как история: «попробовали кэш → сходили в сеть → сохранили».
Инвалидирование: как «сбросить память»
Когда кэш уже встроен, возникает практический вопрос: «А как его очистить?» В нашей текущей лекции мы не добавляем команд CLI типа cache clear, но мы можем дать кэшу API для ручной очистки, чтобы позже (или в отладке) это можно было сделать.
Проблема в том, что cache — private внутри CachingHTTPClient. И это хорошо: наружу не должен торчать словарь. Но наружу можно дать аккуратный метод.
Пример: публичная очистка кэша
import Foundation
extension CachingHTTPClient {
func invalidateAll() {
cache.removeAll()
}
}
Тут есть тонкость: cache.removeAll() — mutating, а invalidateAll() находится в class, поэтому метод не помечается mutating, но всё равно меняет var cache. Всё законно.
Когда такое инвалидирование бывает нужно? Даже в простом приложении есть сценарии, где вы понимаете: «данные точно устарели» или «я подозреваю рассинхронизацию». Например, если вы сделали операцию, которая меняет данные на сервере (условный "POST"/"PUT"), логично сбросить кэш связанных "GET". Но тонкости «какие именно ключи сбрасывать» мы здесь не развиваем: это уже следующий уровень сложности.
6. Подключаем кэш в composition root
Самое приятное в подходе «обёртки вокруг HTTPClient» — вы добавляете кэш в одном месте, а остальной проект даже не знает, что он существует.
Представим, что у вас есть сборка зависимостей (условный composition root), где вы создаёте URLSessionHTTPClient, затем ApiClient.
Пример: сборка с кэшем
import Foundation
let transport: HTTPClient = URLSessionHTTPClient()
let cachedTransport: HTTPClient = CachingHTTPClient(base: transport, ttl: 10)
let api = ApiClient(http: cachedTransport)
Здесь ttl: 10 — просто пример. Хороший стиль — держать TTL в константе конфигурации, чтобы не искать «магические числа» по проекту.
7. Ограничение single‑writer: почему мы так упорно это повторяем
Когда кэш — это var storage: [Key: CacheEntry<Value>], любая операция get потенциально может мутировать состояние (удаление протухшего). Это значит, что даже «чтение» в нашей реализации — на самом деле «чтение + возможная запись».
Если два разных места одновременно вызовут send() у одного CachingHTTPClient, можно получить неконсистентное состояние: от пропущенных записей до повреждения структуры данных (в зависимости от режима исполнения и гарантий). Поэтому в сегодняшней версии мы честно говорим: один экземпляр — один последовательный поток вызовов.
Интуитивно это похоже на ситуацию «у вас одна тетрадь с заметками, и два человека одновременно пишут в неё ручками». Иногда получится, иногда нет, но проверять такое в продакшене — сомнительное развлечение.
Интересно, что в обсуждениях модели акторов в Swift подчёркивается идея: если доступ к общему состоянию (например, кэшу) сериализован, то оно не «ломается» от одновременных обращений, хотя и могут быть другие компромиссы вроде лишней работы. Мы пока не используем такие механизмы — сегодня фиксируем лишь ограничение.
8. Типичные ошибки при реализации in‑memory cache
Ошибка №1: слишком «широкий» ключ или слишком «узкий» ключ.
Если вы сделаете ключ только по url.path, игнорируя query, кэш начнёт путать разные запросы: ...?page=1 и ...?page=2 станут одним и тем же ключом. Если вы, наоборот, включите в ключ какие-нибудь случайные заголовки (например, Date или уникальный User-Agent), кэш перестанет попадать вообще. В учебной модели «метод + полный absoluteString URL» — хороший баланс.
Ошибка №2: кэшировать всё подряд, включая "POST", «потому что так быстрее».
Кэширование запросов изменения состояния может привести к очень странным эффектам, когда вы возвращаете «старый успешный ответ» вместо реального результата. Даже если это выглядит безопасно, вы почти всегда усложняете себе жизнь. Для старта кэшируем только "GET".
Ошибка №3: кэшировать ошибки как данные.
Очень хочется закэшировать «сервер упал» на 10 секунд, чтобы не долбить API. Но тогда вы рискуете 10 секунд показывать ошибку даже после того, как сервер поднялся. Это может быть уместно, но требует отдельной политики (например, негативный кэш с очень коротким TTL). В нашей модели кэшируем только успех.
Ошибка №4: путаница единиц времени (секунды vs миллисекунды).
TimeInterval — это секунды. Если вы мысленно думаете «TTL = 5000», ожидая 5 секунд, вы получите 5000 секунд (почти полтора часа). Введите константы вроде let ttlSeconds: TimeInterval = 5, и станет спокойнее.
Ошибка №5: забыть, что get у нас мутирует состояние.
Новички часто воспринимают get как «чистое чтение». Но мы удаляем протухшие записи при чтении, а значит get — mutating. Это ломает попытки сделать кэш let и вообще полезно как сигнал: «этот компонент не чистая функция».
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ