JavaRush /Курси /Swift SELF /Кеш у памʼяті: ключ → відповідь, TTL та інвалідація

Кеш у памʼяті: ключ → відповідь, TTL та інвалідація

Swift SELF
Рівень 67 , Лекція 1
Відкрита

1. Вступ

Коли ми говоримо про «надійність мережі», легко подумати, що йдеться лише про помилки та повторні спроби. Але на практиці найнадійніший запит — той, який ви взагалі не надсилали. Саме тут і зʼявляється кеш: замість того щоб знову й знову ходити до однієї й тієї самої кінцевої точки (endpoint), особливо в CLI, де користувач може повторювати одну й ту саму команду, ми інколи просто повертаємо вже отриманий результат.

Важливо не плутати кеш із «безсмертною істиною». Кеш — це компроміс: ми свідомо погоджуємося на те, що певний час повертатимемо трохи застарілі дані, зате зменшимо затримку, навантаження на API та ризик мережевої помилки. Такий компроміс часто абсолютно виправданий: наприклад, для списку жанрів, картки книги за ID чи довідкових даних.

І ще один неочікуваний бонус: кеш допомагає навіть там, де retry недоречний. Якщо запит завершився помилкою мережі, повторна спроба може допомогти. Якщо ж запит успішний, але «дорогий» за часом, лімітами API або кількістю запитів, кеш заощаджує ресурси ще до появи проблем.

2. Що саме ми будемо кешувати в нашому проєкті LibraryCLI

Щоб кеш був зрозумілим і передбачуваним, ми оберемо дуже просту й «навчальну» модель: кешуємо успішну відповідь транспортного рівня за ключем «метод + URL». Тобто на рівні HTTPClient, який повертає (Data, HTTPURLResponse). Це зручно: кеш не знає про DTO, доменні моделі та декодування. Він просто каже: «Ось байти відповіді, які ви вже отримували».

Такий кеш легко вбудувати як обгортку навколо наявного клієнта:

URLSessionHTTPClient (реальний транспорт) → CachingHTTPClient (наша політика) → ApiClient (інтерпретація відповіді, decode, NetworkError).

Це важливо: ми не ламаємо архітектуру і не розкидаємо Dictionary по всьому проєкту. Ми додаємо окремий компонент, який робить рівно одну річ.

Одразу фіксуємо обмеження курсу: кеш — компонент зі змінним станом. У сьогоднішній версії він передбачає послідовний доступ до одного екземпляра (single‑writer): тобто ми не розраховуємо, що хтось одночасно тицятиме send() із кількох паралельних завдань.

3. Мінісхема: як кеш «перехоплює» запит

Перш ніж писати код, корисно побачити очима, що саме відбуватиметься. У нас зʼявиться розвилка: «є запис у кеші — немає запису».

flowchart TD
    A["send(request)"] --> B{Ключ сформовано?}
    B -- ні --> Z[Переходимо до base.send]
    B -- так --> C{Є запис у кеші?}
    C -- так --> D{Не прострочився?}
    D -- так --> E["Повертаємо кешовані (Data, Response)"]
    D -- ні --> F[Видаляємо прострочений запис]
    F --> Z
    C -- ні --> Z
    Z --> G[Отримали результат]
    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: ми змінюємо внутрішній стан структури. Це і є компонент зі станом. Саме тому ми постійно наголошуємо на обмеженні single‑writer: якщо два місця одночасно почнуть викликати get/set в одного екземпляра без дисципліни, буде біда.

TTL на практиці: чому кеш — це не витік памʼяті, але може виглядати як витік

Кеш зберігає дані в памʼяті. Тому графік памʼяті застосунку часто виглядає як «лінія, що повзе вгору», поки кеш поступово наповнюється. Це дуже схоже на витік — і люди починають панікувати, хоча робити висновки варто трохи пізніше.

У документації Swift прямо зазначається, що поступове зростання памʼяті не завжди означає витік: інколи це лише типовий профіль памʼяті застосунку, і кеш — один із найпоширеніших прикладів. За правильного налаштування (обмеження розміру, спливання, очищення) зростання має стабілізуватися й вийти на плато.

У нашій спрощеній моделі розмір кешу безпосередньо не обмежений: ми не реалізуємо LRU/LFU та не вводимо ліміти за кількістю записів. Тому TTL стає вашим головним «гальмом»: занадто великий TTL — кеш довго зберігає багато даних; занадто малий TTL — кеш майже не допомагає. У CLI-утилітах часто добре працюють малі TTL: 530 секунд, щоб згладити повтори команд користувача, але не перетворити кеш на «альтернативну базу даних».

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
    }
}

Зверніть увагу: cachevar, бо ми будемо його змінювати. І це ще раз нагадує про 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 для ручного очищення, щоб пізніше або в налагодженні це можна було зробити.

Проблема в тому, що cacheprivate всередині 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 як «чисте читання». Але ми видаляємо прострочені записи під час читання, а отже getmutating. Це ламає спроби зробити кеш let і взагалі корисно як сигнал: «цей компонент не є чистою функцією».

Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ