Привет!
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 ) }
Готово. Сверху — максимально упрощённая выжимка из реального боевого решения, которая показала себя лучше, чем кэширование где-то на уровне репозитория за кучей абстракций и тем более, чем полное отсутствие кэширования.
Пара моментов/идей, которые не попали в статью:
Scope жизни кэша (на уровне приложения, сессии, пользователя и т.д.);
Кэширование запросов с request body.
Буду рад почитать конструктивные комментарии и замечания, а также любую постиронию или смешной хейт-спич.
