Привет!

Ktor постепенно становится полноценной альтернативой классической связке OkHttp + Retrofit. Его ключевые преимущества — кроссплатформенность, чистый Kotlin, асинхронность и корутины, а также высокая гибкость и управляемость. Кроме того, ожидается поддержка HTTP/3, чего, судя по всему, не планируется в OkHttp.

Ktorfit же — это обёртка над Ktor, которая предоставляет более удобное API, практически идентичное Retrofit, что особенно удобно при миграции Retrofit —> Ktorfit. Короче, Ktorfit — это Retrofit для Kotlin Multiplatform.

В этой статье я продемонстрирую алгоритм написания простого кэша запросов для Ktorfit, используя механизм Ktor Plugins.

Что мы получим в итоге:

// Ktorfit интерфейс для выполнения запросов
interface TasksService {

    // В случае успешного запроса кэшируем избранные задачи на один час
    @Cacheable(lifetime = Cacheable.TimeUnit.Hour)
    @GET("tasks/favorites")
    suspend fun getFavoriteTasks(
        @Tag("IgnoreCache") ignoreCache: Boolean
    ): List<TaskResponse>

    // При успешном запросе очищаем кэш избранных задач 
    // или вообще любой другой кэш (или несколько) по регулярке
    @CacheEvict(patterns = ["^tasks/favorites"])
    @PUT("tasks/favorites")
    suspend fun changeTaskFavorite(
        @Body request: ChangeTaskFavoriteRequest
    )
}

Ktor Client Plugins

Одна из сильных сторон Ktor — модульная архитектура, важной частью которой являются плагины. Они позволяют встраиваться на любой этап цепочки запроса или ответа и добавлять собственную логику. Это даёт разработчикам приложений и библиотек больше контроля и возможностей для реализации нестандартных решений.

Ktor включает набор встроенных плагинов, среди которых, например:

  • Logging для логирования;

  • ContentNegotiation для сериализации/десериализации;

  • Auth для авторизации.

И это далеко не полный список. Есть даже готовый плагин для кэширования, работающий на заголовках. Можно было бы использовать и его, но наше решение будет гибче.

Подготовка

Управление кэшем

Аннотацией @Cacheable будем помечать запросы, ответ которых необходимо кэшировать в случае успеха. В качестве идентификатора кэша будет выступать строка key. Пустая строка (по умолчанию) будет означать полный URL запроса, однако оставим потребителям возможность передавать любой строковый ключ.


@Target(AnnotationTarget.FUNCTION)
@Retention(AnnotationRetention.RUNTIME)
annotation class Cacheable(
    val key: String = "",
    val lifetime: Long = Day
) {
    companion object TimeUnit {
        const val Minute = 60 * 1000L
        const val Hour = 60 * Minute
        const val Day = 24 * Hour
        const val Week = 7 * Day
    }
}

@CacheEvict — очистка кэша.

Название для аннотации украдено у Spring. Если запрос помечен этой аннотацией, то в случае его успешного выполнения, мы очистим кэш по переданным ключам или регулярным выражениям.

@Target(AnnotationTarget.FUNCTION)
@Retention(AnnotationRetention.RUNTIME)
annotation class CacheEvict(
    // Передать ключи явно — быстрее
    val keys: Array<String> = [],

    // Можно передать регулярные выражения, 
    // например ^tasks/favorites (из примера) снесёт весь кэш ручек /tasks/favorites
    val patterns: Array<String> = []
)

Хранение

По сути нам нужно хранить базовые данные HTTP ответа: заголовки, тело, статус и версию протокола, а также время жизни кэша для проверки его актуальности.

Вот примерно такая модель данных получится:

@Serializable
class CachedResponse(
    val key: String,
    val body: ByteArray,
    val expiresAt: Long,
    val requestTime: GMTDate,
    val responseTime: GMTDate,
    val headers: HeadersMap,

    @Serializable(with = HttpStatusCodeSerializer::class)
    val statusCode: HttpStatusCode,

    @Serializable(with = HttpProtocolVersionSerializer::class)
    val version: HttpProtocolVersion
)
Сериализаторы
class HttpStatusCodeSerializer : KSerializer<HttpStatusCode> {
    override val descriptor = PrimitiveSerialDescriptor(
        "HttpStatusCodeSerializer",
        PrimitiveKind.INT
    )

    override fun serialize(encoder: Encoder, value: HttpStatusCode) {
        encoder.encodeInt(value.value)
    }

    override fun deserialize(decoder: Decoder): HttpStatusCode {
        return fromValue(decoder.decodeInt())
    }
}

class HttpProtocolVersionSerializer : KSerializer<HttpProtocolVersion> {
    override val descriptor = PrimitiveSerialDescriptor(
        "HttpProtocolVersionSerializer",
        PrimitiveKind.STRING
    )

    override fun serialize(encoder: Encoder, value: HttpProtocolVersion) {
        encoder.encodeString(value.toString())
    }

    override fun deserialize(decoder: Decoder): HttpProtocolVersion {
        return HttpProtocolVersion.parse(decoder.decodeString())
    }
}

А вот такой будет интерфейс хранилища:

interface ResponseCacheStorage {

    // Сохранить запись кэша
    suspend fun put(responses: Collection<CachedResponse>)

    // Получить по конкретному ключу
    suspend fun get(key: String): CachedResponse?

    // Очистить по конкретному ключу
    suspend fun clear(keys: Collection<String>)

    // Очистить по паттернам
    suspend fun clear(patterns: Collection<Regex>)
    
    // Очистить просроченный
    suspend fun clearExpired() 

    // Очистить весь (например, при логауте)
    suspend fun clearAll()
}

Для производительности я настоятельно рекомендую использовать в качестве имплементации локального хранилища Room, так как поиск по реляционной БД с помощью регулярных выражений будет в разы быстрее, чем итерациия по хранилищу с сериализованными данными.

❗❗ И пожалуйста, не кладите кэш в оперативку. Тело ответа может быть больших размеров, и рано или поздно вы получите OOM.

Реализация

Создать плагин в Ktor довольно просто, нужно объявить его конфигурацию, а затем и сам плагин:

class CachingConfiguration() {
    var storage: ResponseCacheStorage? = null
}

val Caching = createClientPlugin("Caching", ::CachingConfiguration) {
    // Получаем хранилище из конфигурации
    val cacheStorage = requireNotNull(pluginConfig.storage) {
        "Cache storage not initialized!"
    }
    
    // ...
}

Осталось встроить логику кэширования в цепочку обработки запросов Ktor.

Для этого предусмотрен механизм пайплайнов. Каждый пайплайн характеризует то или иное логическое состояние запроса: подготовка запроса, отправка запроса в сеть, получение ответа. У каждого пайплайна есть несколько фаз. Ниже представлен полный перечень пайплайнов и их фаз, взятый из офф. документации.

Пайплан

Описание

Фазы

HttpRequestPipeline

An HttpClient's pipeline used for executing HttpRequest.

Before, State, Transform, Render, Send

HttpSendPipeline

An HttpClient's pipeline used for sending HttpRequest to a remote server.

Before, State, Monitoring, Engine, Receive

HttpResponsePipeline

HttpClient Pipeline used for executing HttpResponse.

Receive, Parse, Transform, State, After

HttpReceivePipeline

HttpClient Pipeline used for receiving HttpResponse without any processing.

Before, State, After

Мы хотим реализовать проверку кэша до реального похода в сеть.

Из таблицы следует, что подойдёт уютный слот после HttpSendPipeline.State, когда на вход подаётся уже готовый запрос, и перед HttpSendPipeline.Monitoring, чтобы итоговые метрики собирались с учётом вероятного кэша.

Весь дальнейший код пишем в лямбде createClientPlugin { }

// ...
// Создаём фазу проверки актуального кэша
val checkCachePhase = PipelinePhase("CheckCache")

// Вставляем фазу проверку актуального кэша в нужное место
client.sendPipeline.insertPhaseAfter(HttpSendPipeline.State, checkCachePhase)

// Когда запрос доходит до этой фазы, перехватываем его и делаем всё, 
// что нам необходимо 
client.sendPipeline.intercept(checkCachePhase) { content ->    
    // Поддерживаем кэш только если в запросе нет тела
    if (content !is OutgoingContent.NoContent) {
        return@intercept
    }

    // Поддерживаем кэш только идемпотетных HTTP GET запросов
    if (context.method != HttpMethod.Get || !context.url.protocol.isHttp()) {
        return@intercept
    }

    // Бывают ситуации, когда нам необходимо принудительно пойти в сеть. 
    // Если аттрибут "IgnoreCache" передан в запрос и равен true, пропускаем проверку.
    val shouldIgnoreCache: Boolean = context.attributes.getOrNull(
        AttributeKey<Boolean>("IgnoreCache")
    ) == true

    if (shouldIgnoreCache) {
        return@intercept
    }

    // Находим аннотацию @Cacheable
    context.annotations.filterIsInstance<Cacheable>()
        .firstOrNull()
        ?.let { cacheable ->
            // Идентификатор ключа — переданная строка или URL запроса.
            val key: String = cacheable.key.ifEmpty { context.url.toString() }

            // Поиск существующего и актуального кэша
            val response: CachedHttpResponse = cacheStorage.get(key)
            if (response != null && response.expiresAt < GMTDate().timestamp) {
                // Восстаналиваем реальный ответ из кэшированного *
                val restoredResponse = cachedResponse.restoreResponse(
                    client = this@createClientPlugin.client,
                    request = CacheRequest(data = context.build()),
                    responseContext = context.executionContext
                )

                finish()
                proceedWith(restoredResponse.call)
            }
        }
    }

// Фаза сохранения и очистки кэша кэша
val manageCachePhase = PipelinePhase("ManageCache")
client.receivePipeline.insertPhaseAfter(HttpReceivePipeline.State, manageCachePhase)

client.receivePipeline.intercept(manageCachePhase) { response ->
    // Пропускаем неуспешные запросы
    if (!response.request.url.protocol.isHttp() || !response.status.isSuccess()) {
        return@intercept
    }

    val annotations = response.request.annotations
    annotations.filterIsInstance<CacheEvict>().forEach { cacheEvict ->
        // Очищаем по ключам
        if (cacheEvict.keys.isNotEmpty()) {
            launch {
                cacheStorage.clear(cacheEvict.keys)
            }
        }

        // Очищаем по регуляркам
        if (cacheEvict.patterns.isNotEmpty()) {
            launch {
                cacheStorage.clear(cacheEvict.patterns.map { it.toRegex() })
            }
        }
    }

    if (response.request.method != HttpMethod.Get) {
        return@intercept
    }
    
    // Если есть аннотация @Cacheable, то сохраняем результат запроса в кэш
    annotations.filterIsInstance<Cacheable>().firstOrNull()
        ?.let { cacheable ->
            // Получаем CachedResponse, соединяя аннотацию и реальный ответ ***
            val cacheEntry: CachedResponse = cacheable.join(response)
            cacheStorage.put(cacheEntry)

            // Восстанавливаем ответ
            val reusableResponse = cacheEntry.restoreResponse(
                client = this@createClientPlugin.client,
                request = response.request,
                responseContext = response.coroutineContext
            )
            
            proceedWith(reusableResponse)
        }
    }
}

Ниже приведены примеры функций маппинга CachedResponse <—> HttpResponse

// *
class CacheRequest(data: HttpRequestData) : HttpRequest {
    override val call: HttpClientCall
        get() = throw IllegalStateException("This is fake request")

    override val method: HttpMethod = data.method
    override val url: Url = data.url
    override val attributes: Attributes = data.attributes
    override val content: OutgoingContent = data.body
    override val headers: Headers = data.headers
}

// **
fun ResponseCacheEntry.restoreResponse(
    client: HttpClient,
    request: HttpRequest,
    responseContext: CoroutineContext
): HttpResponse {
    val response = object : HttpResponse() {
        override val call: HttpClientCall get() = throw IllegalStateException("This is a fake response")
        override val status: HttpStatusCode = statusCode
        override val version: HttpProtocolVersion = this@restoreResponse.version
        override val requestTime: GMTDate = this@restoreResponse.requestTime
        override val responseTime: GMTDate = this@restoreResponse.responseTime

        @InternalAPI
        override val rawContent: ByteReadChannel get() = throw IllegalStateException("This is a fake response")
        override val headers: Headers = this@restoreResponse.headers.toHeaders()
        override val coroutineContext: CoroutineContext = responseContext
    }

    return SavedHttpCall(client, request, response, body)
        .response
}

// ***
suspend fun Cacheable.join(response: HttpResponse): CachedResponse {
    val body = response.rawContent.readRemaining()
        .readByteArray()

    return CachedResponse(
        key = key.ifEmpty { response.request.url.toString() },
        statusCode = response.status,
        requestTime = response.requestTime,
        headers = response.headers.toHeadersMap(),
        version = response.version,
        body = body,
        responseTime = response.responseTime,
        expiresAt = response.requestTime.plus(lifetime)
            .timestamp
    )
}

Готово. Сверху — максимально упрощённая выжимка из реального боевого решения, которая показала себя лучше, чем кэширование где-то на уровне репозитория за кучей абстракций и тем более, чем полное отсутствие кэширования.

Пара моментов/идей, которые не попали в статью:

  1. Scope жизни кэша (на уровне приложения, сессии, пользователя и т.д.);

  2. Кэширование запросов с request body.

Буду рад почитать конструктивные комментарии и замечания, а также любую постиронию или смешной хейт-спич.