Pull to refresh

Ktor как HTTP клиент для Android

Reading time 7 min
Views 36K
Retrofit2 мне, как Android разработчику, нравится, но как на счет того, чтобы попробовать к качестве HTTP клиента Ktor? На мой взгляд, для Android разработки он не хуже и не лучше, просто один из вариантов, хотя если всё немного обернуть, то может получиться очень неплохо. Я рассмотрю базовые возможности с которыми можно будет начать пользоваться Ktor как HTTP клиентом — это создание запросов разных видов, получение raw ответов и ответов в виде текста, десериализация json в классы через конвертеры, логирование.



Если в общем, то Ktor — это фреймворк, который может выступать в роли HTTP клиента. Я рассмотрю его со стороны разработки под Android. Вряд ли вы увидите ниже очень сложные кейсы использования, но базовые возможности точно. Код из примеров ниже можно посмотреть на GitHub.

Ktor использует корутины из Kotlin 1.3, список доступных артефактов можно найти здесь, текущая версия — 1.0.1.
Для запросов я буду использовать HttpBin.

Простое использование


Для начала работы понадобятся базовые зависимости для Android клиента:

implementation "io.ktor:ktor-client-core:1.0.1"
implementation "io.ktor:ktor-client-android:1.0.1"

Не забываем в Manifest добавить информацию о том, что вы используете интернет.

<uses-permission android:name="android.permission.INTERNET"/>

Попробуем получить ответ сервера в виде строки, что может быть проще?

private const val BASE_URL = "https://httpbin.org"
private const val GET_UUID = "$BASE_URL/uuid"

fun simpleCase() {
    val client = HttpClient()

    GlobalScope.launch(Dispatchers.IO) {
        val data = client.get<String>(GET_UUID)
        Log.i("$BASE_TAG Simple case ", data)
    }
}

Создать клиент можно без параметров, просто создаем экземпляр HttpClient(). В этом случае Ktor сам выберет нужный движок и использует его с настройками по-умолчанию (движок у нас подключен один — Android, но существуют и другие, например, OkHttp).
Почему корутины? Потому что get() — это suspend функция.

Что можно сделать дальше? У вас уже есть данные с сервера в виде строки, достаточно их распарсить и получить классы, с которыми уже можно работать. Вроде бы просто и быстро при таком случае использования.

Получаем сырой ответ


Иногда бывает нужно и набор байт получить вместо строки. Заодно поэкспериментируем с асинхронностью.

fun performAllCases() {
    GlobalScope.launch(Dispatchers.IO) {
        simpleCase()
        bytesCase()
    }
}

suspend fun simpleCase() {
    val client = HttpClient()
    val data = client.get<String>(GET_UUID)
    Log.i("$BASE_TAG Simple case", data)
}

suspend fun bytesCase() {
    val client = HttpClient()
    val data = client.call(GET_UUID).response.readBytes()
    Log.i("$BASE_TAG Bytes case", data.joinToString(" ", "[", "]") { it.toString(16).toUpperCase() })
}

В местах вызова методов HttpClient, таких как call() и get(), под капотом будет вызываться await(). Значит в данном случае вызовы simpleCase() и bytesCase() всегда будут последовательны. Нужно параллельно — просто оберните каждый вызов в отдельную корутину. В этом примере появились новые методы. Вызов call(GET_UUID) вернет нам объект, из которого мы можем получить информацию о запросе, его конфигурации, ответе и о клиенте. Объект содержит в себе много полезной информации — от кода ответа и версии протокола до канала с теми самыми байтами.

А закрывать как-то нужно?


Разработчики указывают, что для корректного завершения работы HTTP движка нужно вызвать у клиента метод close(). Если же вам нужно сделать один вызов и сразу закрыть клиент, то можно использовать метод use{}, так как HttpClient реализует интерфейс Closable.

suspend fun closableSimpleCase() {
    HttpClient().use {
        val data: String = it.get(GET_UUID)
        Log.i("$BASE_TAG Closable case", data)
    }
}

Примеры помимо GET


В моей работе второй по популярности метод — POST. Рассмотрим на его примере установку параметров, заголовков и тела запроса.

suspend fun postHeadersCase(client: HttpClient) {
    val data: String = client.post(POST_TEST) {
        fillHeadersCaseParameters()
    }
    Log.i("$BASE_TAG Post case", data)
}

private fun HttpRequestBuilder.fillHeadersCaseParameters() {
    parameter("name", "Andrei") // + параметр в строку запроса
    url.parameters.appendAll(
        parametersOf(
            "ducks" to listOf("White duck", "Grey duck"), // + список параметров в строку запроса
            "fish" to listOf("Goldfish") // + параметр в строку запроса
        )
    )

    header("Ktor", "https://ktor.io") // + заголовок
    headers /* получаем доступ к билдеру списка заголовков */ {
        append("Kotlin", "https://kotl.in")
    }
    headers.append("Planet", "Mars") // + заголовок
    headers.appendMissing("Planet", listOf("Mars", "Earth")) // + только новые заголовки, "Mars" будет пропущен
    headers.appendAll("Pilot", listOf("Starman"))  // ещё вариант добавления заголовка

    body = FormDataContent( // создаем параметры, которые будут переданы в form
        Parameters.build {
            append("Low-level", "C")
            append("High-level", "Java")
        }
    )
}

Фактически, в последнем параметре функции post() у вас есть доступ к HttpRequestBuilder, с помощью которого можно сформировать любой запрос.
Метод post() просто парсит строку, преобразует её в URL, явно задает тип метода и делает запрос.

suspend fun rawPostHeadersCase(client: HttpClient) {
    val data: String = client.call {
        url.takeFrom(POST_TEST)
        method = HttpMethod.Post
        fillHeadersCaseParameters()
    }
        .response
        .readText()

    Log.i("$BASE_TAG Raw post case", data)
}

Если выполнить код из двух последних методов, то результат будет аналогичный. Разница не велика, но пользоваться обертками удобнее. Ситуация аналогичная для put(), delete(), patch(), head() и options(), поэтому их рассматривать не будем.

Однако, если присмотреться, то можно заметить, что разница есть в типизации. При вызове call() вы получаете низкоуровневый ответ и сами должны считывать данные, но как же быть с автоматической типизацией? Ведь все мы привыкли в Retrofit2 подключить конвертер (типа Gson) и указывать возвращаемый тип в виде конкретного класса. О конвертации в классы мы поговорим позже, а вот типизировать результат без привязки к конкретному HTTP методу поможет метод request.

suspend fun typedRawPostHeadersCase(client: HttpClient) {
    val data = client.request<String>() {
        url.takeFrom(POST_TEST)
        method = HttpMethod.Post
        fillHeadersCaseParameters()
    }
    Log.i("$BASE_TAG Typed raw post", data)
}

Отправляем form данные


Обычно нужно передавать параметры либо в строке запроса, либо в теле. Мы в примере выше уже рассматривали как это сделать с помощью HttpRequestBuilder. Но можно проще.

Функция submitForm принимает url в виде строки, параметры для запроса и булевый флаг, который говорит о том как передавать параметры — в строке запроса или как пары в form.

suspend fun submitFormCase(client: HttpClient) {

    val params = Parameters.build {
        append("Star", "Sun")
        append("Planet", "Mercury")
    }

    val getData: String = client.submitForm(GET_TEST, params, encodeInQuery = true) // параметры в строке запроса
    val postData: String = client.submitForm(POST_TEST, params, encodeInQuery = false) // параметры в form

    Log.i("$BASE_TAG Submit form get", getData)
    Log.i("$BASE_TAG Submit form post", postData)
}

А как быть с multipart/form-data?


Помимо строковых пар можно передать как параметры POST запроса числа, массивы байт и разные Input потоки. Отличия в функции и формировании параметров. Смотрим как:

suspend fun submitFormBinaryCase(client: HttpClient) {

    val inputStream = ByteArrayInputStream(byteArrayOf(77, 78, 79))

    val formData = formData {
        append("String value", "My name is") // строковый параметр
        append("Number value", 179) // числовой
        append("Bytes value", byteArrayOf(12, 74, 98)) // набор байт
        append("Input value", inputStream.asInput(), headersOf("Stream header", "Stream header value")) // поток и заголовки
    }

    val data: String = client.submitFormWithBinaryData(POST_TEST, formData)
    Log.i("$BASE_TAG Submit binary case", data)
}

Как вы могли заметить — можно ещё к каждому параметру набор заголовков прицепить.

Десериализуем ответ в класс


Нужно получить какие-то данные из запроса не в виде строки или байт, а сразу преобразованные в класс. Для начала в документации нам рекомендуют подключить фичу работы с json, но хочу оговориться, что для jvm нужна специфическая зависимость и без kotlinx-serialization всё это не взлетит. В качестве конвертера предлагаю использовать Gson (ссылки на другие поддерживаемые библиотеки есть в документации, ссылки на документацию будут в конце статьи).

build.gradle уровня проекта:

buildscript {
    dependencies {
        classpath "org.jetbrains.kotlin:kotlin-serialization:$kotlin_version"
    }
}

allprojects {
    repositories {
        maven { url "https://kotlin.bintray.com/kotlinx" }
    }
}

build.gradle уровня приложения:

apply plugin: 'kotlinx-serialization'

dependencies {
    implementation "io.ktor:ktor-client-json-jvm:1.0.1"
    implementation "io.ktor:ktor-client-gson:1.0.1"
}

А теперь выполним запрос. Из нового будет только подключение фичи работы с Json при создании клиента. Использовать буду открытый погодный API. Для полноты покажу модель данных.

data class Weather(
    val consolidated_weather: List<ConsolidatedWeather>,
    val time: String,
    val sun_rise: String,
    val sun_set: String,
    val timezone_name: String,
    val parent: Parent,
    val sources: List<Source>,
    val title: String,
    val location_type: String,
    val woeid: Int,
    val latt_long: String,
    val timezone: String
)

data class Source(
    val title: String,
    val slug: String,
    val url: String,
    val crawl_rate: Int
)

data class ConsolidatedWeather(
    val id: Long,
    val weather_state_name: String,
    val weather_state_abbr: String,
    val wind_direction_compass: String,
    val created: String,
    val applicable_date: String,
    val min_temp: Double,
    val max_temp: Double,
    val the_temp: Double,
    val wind_speed: Double,
    val wind_direction: Double,
    val air_pressure: Double,
    val humidity: Int,
    val visibility: Double,
    val predictability: Int
)

data class Parent(
    val title: String,
    val location_type: String,
    val woeid: Int,
    val latt_long: String
)

private const val SF_WEATHER_URL = "https://www.metaweather.com/api/location/2487956/"

suspend fun getAndPrintWeather() {

    val client = HttpClient(Android) {
        install(JsonFeature) {
            serializer = GsonSerializer()
        }
    }

    val weather: Weather = client.get(SF_WEATHER_URL)

    Log.i("$BASE_TAG Serialization", weather.toString())
}    

А что еще можно


Например, сервер возвращает ошибку, а код у вас как в предыдущем примере. В таком случае вы получите ошибку сериализации, но можно настроить клиент так, чтобы при коде ответа <300 бросалась ошибка BadResponseStatus. Достаточно устаносить при сборке клиента expectSuccess в true.

    val client = HttpClient(Android) {
        install(JsonFeature) {
            serializer = GsonSerializer()
        }
        expectSuccess = true
    }

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

implementation "io.ktor:ktor-client-logging-jvm:1.0.1"

    val client = HttpClient(Android) {
        install(Logging) {
            logger = Logger.DEFAULT
            level = LogLevel.ALL
        }
    }

Указываем DEFAULT логгер и всё будет попадать в LogCat, но можно переопределить интерфейс и сделать свой логгер при желании (хотя больших возможностей я там не увидел, на входе есть только сообщение, а уровня лога нет). Также указываем уровень логов, которые нужно отражать.

Ссылки:


Что не рассмотрено:

  • Работа с OkHttp движком
  • Настройки движков
  • Mock движок и тестирование
  • Модуль авторизации
  • Отдельные фичи типа хранения cookies между запросами и др.
  • Всё что не относится к HTTP клиенту для Android (другие платформы, работа через сокеты, реализация сервера и т.п.
Tags:
Hubs:
+6
Comments 1
Comments Comments 1

Articles