Ktor Client 那一兩件事情

Jast Lai
Jastzeonic
Published in
19 min readAug 30, 2022

使用 Kotlin 打網路的另一個選擇

前言

我們看到 Ktor 通常都會想到:「喔,那個用 Kotlin 來寫後端的玩意」,不過實際上這玩意是可以用來在 Client 使用的。

某種程度上,如果使用到 KMM ,希望可以搞到跨平台,需要純 Kotlin 的程度的話,那麼使用 Ktor 就會是一個選擇。

不過我這邊不打算聊得這麼深就是了,我這篇文章只是很單純想要聊聊 Ktor 的 Client 怎麼使用。

以我的本業 Android 來說,我們使用 Retrofit 的現況,其實沒有什麼問題,在 Android 上頭,也算是目前的主流。不過說實話用久了會膩,而且還是有一些大大小小的問題,那是不是有什麼有趣的新選擇, Ktor 就是其中一個,那這就來看看著個東西怎麼用吧。

設定

我其實一直在想要不要為了寫這一篇文章來開個 KMM 專案, 不過我只打算用到 Client ,那想想好像直接用 Android 專案來講最為通俗,用起來可能對我的受眾也比較實際一點,那就還是用一個 Android 專案來說明好了。

原則上開好一個 Android 專案就行了,畢竟預設這些 Gradle 的設定都有了,大多時候只需要直接在 Gradle file 上加上

implementation(“io.ktor:ktor-client-core:$ktor_version”)
implementation("io.ktor:ktor-client-cio:$ktor_version")

ktor_version 寫這篇文章的當下是 2.1.0,日後版本依該還會再往上長,有緣再修唄。

core 是必要的方法套件依賴(e.g. method Get、Post 那些),而 CIO 則是 Ktor 的 engines 層,engines 是 Ktor 需要為了多平台而設計的抽象層,那 Kotlin 會根據你使用的平台去產出對應的東西,細節部分可以參照文件,這邊只要知道多平台需要用到它即可。

val client = HttpClient(CIO) {
expectSuccess = true
}

使用上會類似這樣。那因為 Kotlin 幾乎是從 JVM 發根的,所以如果像是我使用 Android Project 的話,這裡可以把它省略掉。

val client = HttpClient {
expectSuccess = true
}

試著打一下

那我只是想要試試看他能不能跑,我還沒有想讓他跑在手機上,那很簡單,開一個 kt file 寫上:

fun main() {

val client = HttpClient {
expectSuccess = true
}
runBlocking {
val response: HttpResponse = client.get("https://ktor.io/")
println(response.status)
}
}

把對應的 import 加上就可以了,直接執行應該就可以看到 200 了。

要注意的是 client.get 方法是 suspend function ,所以會需要在 coroutines scope 下使用,否則會無法編譯。

看來沒什麼問題

不過上面這方法只列出了打完 API 的狀態,沒把結果列出來,那我要看我打 API 的 Response 呢?

改成這樣:

fun main() {

val client = HttpClient {
expectSuccess = true

}
runBlocking {
val response: HttpResponse = client.get("https://ktor.io/")
val responseString = response.body<String>()
println(responseString)
}
}

就可以把拿到的結果印出來了。

這樣達成了很簡單的 RESTful Get Response 的功能了。

Request

Ktor 的功能當然不僅止於 GET ,至少 Get 、Post 、Put、Delete 都得有嘛,當然 Ktor 都有提供這些功能。

這裡可以直接用 request 在裡面設定 method ,不過大多時候只要用 Ktor 提供的 extension 即可。

val client = HttpClient {
expectSuccess = true

}
runBlocking {
val responseGet: HttpResponse = client.get("https://ktor.io/")
val responsePost: HttpResponse = client.post("https://ktor.io/")
val responsePut: HttpResponse = client.put("https://ktor.io/")
val responseDelete: HttpResponse = client.delete("https://ktor.io/")
//....
}

啊,當然我上面的方法打起來是都會失敗的,因為 Ktor.io 只能 GET (而且會拿到一整個 html ),想要試試看的話得去找能用的 url 。

URL

那其實可以注意到除了 request ,get 和 post 等等那些後面都還可以有個 lambda ,那個 lambda 的 receiver 是個 HttpRequestBuilder ,那顯然是可以用來做些設定的。

那這邊可以注意到的是 url 、 method 、 header 、body ,這幾個在打 RESTful 經常會異動到的 variable。

url 的部分我們可以注意到,他是 value ,看起來會不能動,使用上我們也確實不會直接動他,我們要修改的話要用 url {}

val responseGet: HttpResponse = client.get("https://ktor.io/"){
url {
path("docs/welcome.html")
}
}

這裡可以注意到一件事情,就是我每回 get 都得把完整的 url 加上去,這樣打起來豈不是很麻煩,要切換 URL 也得開個全域搜尋。

在 Client 上可以設定一個 defaultRequest ,那就可以設定預設的 html 為何了。

val client = HttpClient {
defaultRequest {
url("https://ktor.io/")
}
expectSuccess = true

}
runBlocking {
val responseGet: HttpResponse = client.get {
url {
path("docs/welcome.html")
}
}
//.....}

或者寫成這樣

val client = HttpClient {
defaultRequest {
url("https://ktor.io/")
}
expectSuccess = true

}
runBlocking {
val responseGet: HttpResponse = client.get("docs/welcome.html")
//....
}

我們也常常會需要在 url 上加上 query 這時候也可以利用 url {} 裡面的 parameters 加上 query

val responseGet: HttpResponse = client.get("docs/welcome.html") {
url {
parameters.append("name","value")
}
}

method 可以改,也就是說可以在用 get 這個 extension 後把它改成 post ,雖然我強烈建議不要這麼做,沒必要做把紅燈變綠燈綠燈變黃燈這種事情。但是這確實是可行的。

Header

header 的部分就相對單純了,可以直接用 headers {} 就可以在一次 request 中加上 header 。

val responseGet: HttpResponse = client.get("docs/welcome.html") {
headers {
append(HttpHeaders.Accept, "text/html")
append(HttpHeaders.Authorization, "TOKEN")
append(HttpHeaders.UserAgent, "The right to play God.")
}
}

那麼也不用 Header 跟 base url 一樣,每一次 request 都要設定也會頗麻煩,所以一樣也是可以在 client 上設定的。

val client = HttpClient {
defaultRequest {
url("https://ktor.io/")
headers {
append(HttpHeaders.Accept, "text/html")
append(HttpHeaders.Authorization, "TOKEN")
append(HttpHeaders.UserAgent, "The right to play God.")
}
}
expectSuccess = true

}

Body

那來說說 body ,有取就有給嘛,只能 GET 的 Restful 工具不完整,若是要給的話,那用 Post 可以很容易達到,問題是要怎麼給 Body 呢?

val response: HttpResponse = client.post("http://localhost:8080/post") {
setBody("Body content")
}

這樣在後端就會收到這一串字的 byte code 了。

當然通俗一點的做法是需要使用到序列化的,把它轉成 Json 才能轉成比較方便。

val response: HttpResponse = client.post("http://localhost:8080/post") {
contentType(ContentType.Application.Json)
setBody(SomeData("", "Jet"))
}

那這樣寫沒有問題,只是毫無意外的意外噴錯了。

意思大概是在說,你想要序列化它,但是沒有告訴 Ktor 序列化的方法。

Ktor 是 Kotlin 的東西,也是 Jetbrain 本家的東西,所以使用 Ktor 的序列化工具,當然會比較推薦使用 serialization,這裡用 Ktor 的 serialization 就可以。

implementation(“io.ktor:ktor-serialization-kotlinx-json:$ktor_version”)

但是別忘記了 Ktor 是以跨平台為目的的東西,所以會需要使用一個叫做 ContentNegotiaion 的 plugin,作為抽象的介面。

implementation(“io.ktor:ktor-client-content-negotiation:$ktor_version”)

Kotlin serialization 因為會需要針對標記 @Serializable 的 class 產生 serialzer 所以會需要加一個 plugin 。

kotlin("plugin.serialization") version "1.7.10"

加在專案上最外層的 gradle 應該就可以了。

@Serializable
data class SomeData(val data: String, val data2: String)

之後給 some data 加上 Serializable ,在 client 上設定 ContentNegotiation

val client = HttpClient {
install(ContentNegotiation) {
json()
}
expectSuccess = true

}

那有用過 Serialization 的人應該就知道,在那個 Json 上可以設定一些 json 的東西。

install(ContentNegotiation) {
json(
Json {
prettyPrint = true
isLenient = true
}
)
}

這樣子

val response: HttpResponse = client.post("http://localhost:8080/post") {
contentType(ContentType.Application.Json)
setBody(SomeData("", "Jet"))
}

後端就可以收到這串序列化後的資料了。

那麼像是要上傳檔案可以寫成這樣:

val response2: HttpResponse = client.post("http://localhost:8080/upload") {
setBody(MultiPartFormDataContent(
formData {
append("description", "Ktor logo")
append("image", File("ktor_logo.png").readBytes(), Headers.build {
append(HttpHeaders.ContentType, "image/png")
append(HttpHeaders.ContentDisposition, "filename=\"ktor_logo.png\"")
})
},
boundary = "WebAppBoundary"
)
)
onUpload { bytesSentTotal, contentLength ->
println("Sent $bytesSentTotal bytes from $contentLength")
}
}

或者是直接用 Ktor 提供的 extension

val response: HttpResponse = client.submitFormWithBinaryData(
url = "http://localhost:8080/upload",
formData = formData {
append("description", "Ktor logo")
append("image", File("ktor_logo.png").readBytes(), Headers.build {
append(HttpHeaders.ContentType, "image/png")
append(HttpHeaders.ContentDisposition, "filename=\"ktor_logo.png\"")
})
}
)

不過我自己是比較喜歡前一個方法,因為這才有辦法拿到上傳進度。

Response

val httpResponse: HttpResponse = client.get("https://ktor.io/")
val stringBody: String = httpResponse.body()

剛有提到,直接用 Body 就可以取得很純粹的字串了,那可以注意到 Body 是需要推斷型別的,若是 assign 的地方沒有加上型別或者是沒有在 Body 上特別告訴他要轉成什麼型別會編譯不過。

這也就是說 body 能轉的型別不只是 string ,也可以轉成 ByteArray 或者是自定義過的反序列後型別。

val httpResponse: HttpResponse = client.get("https://ktor.io/")
val byteArrayBody: ByteArray = httpResponse.body()

那我們在剛在敘說 Request 的時候有提到 ContentNegotiation ,如果照剛剛上述的做法有成功把資料打上後端的話,那在這邊也可以很輕鬆的這樣接資料。

val someData: SomeData = client.get("http://localhost:8080/get").body()

那若是想要利用 Ktor 下載檔案的話,可以寫成這樣:

runBlocking {
client.prepareGet("https://ktor.io/").execute { httpResponse ->
val channel: ByteReadChannel = httpResponse.body()
while (!channel.isClosedForRead) {
val packet = channel.readRemaining(DEFAULT_BUFFER_SIZE.toLong())
while (!packet.isEmpty) {
val bytes = packet.readBytes()
file.appendBytes(bytes)
println("Received ${file.length()} bytes from ${httpResponse.contentLength()}")
}
}
println("A file saved to ${file.path}")
}
}

WebSocket

Ktor client 還有一個蠻酷的東西,就是他可以直接用 web socket ,必要的話可以直接開一個 client 直接連上 ws。

不過連上 web socket 需要額外的套件,所以會需要 implementation 另外一個東西

implementation("io.ktor:ktor-client-websockets:$ktor_version")

這樣在使用上應該就沒有問題了。

val client = HttpClient {
install(WebSockets)
}

runBlocking {
client.webSocket(method = HttpMethod.Get, host = "127.0.0.1", port = 8899, path = "/echo") {
while (true) {
val othersMessage = incoming.receive() as? Frame.Text ?: continue
println(othersMessage.readText())
val myMessage = readLine()
if (myMessage != null) {
send(myMessage)
}
}
}

}

client.close()

那可以注意到的是,這邊 Client 建立 WebSocket 連線是使用 webSocket 這個 method,裡面會有一個 closure ,裡頭的 lambda 是會把 WebSocketSession 當成是 Context Receiver ,那你便可以使用者個 WebSocketSession send message 就是了。那裡頭還有提供一個 incoming ,他是一個 Channel ,可以利用他的 Received 等待訊息的接收這個 Websocket 連線所收到的訊息,這邊再搭配 Channel 就可以做出一個簡單 input output 的 websocket 了。

首先這邊要先等 incoming 來才能把我要送的訊息送出,此外我其實也沒有方法把訊息丟給 websocket 的 connection 。

最重要的是如果 incoming 是個 channel ,那他 receive 在那邊等,若是沒有訊息則他會在那邊等上一輩子,而偏偏這邊使用的又是 runBlocking ,client.close 可不會發訊息給 incoming 呦,也就是除非後端給你主動斷線,否則,這個連線是會等上一輩子的。

聽起來很浪漫對不對,不過浪漫似乎不是用在這邊。

要解決這個問題,那顯然會需要搭配一點東西,好在這個 closure 是一個 coroutines scope ,則有需要的話可以直接用 launch 開啟新的 coroutines。

val sendChannel = Channel<String>()
val webSocketConnectionJob = someCoroutineScope.launch {
client.webSocket(method = HttpMethod.Get, host = "127.0.0.1", port = 8899, path = "/echo") {
launch {
for (message in incoming) {
message as? Frame.Text ?: continue
//Received message
println(message)
}
}

launch {
while (true) {
val message = sendChannel.receive()
// Send new message
send(message)
}
}
}

}

那若是要關閉這個連線,則直接 cancel job 就可以了。

webSocketConnectionJob.cancel()

結語

赫然發現我花最多時間的是去釐清為什麼用 runBlocking 會讓 Websocket 關不掉連線。我本來以為我會花最多時間在用 Go 建立一個 WebSocket 的後端,但沒有想到搭配 html + JS 當 Client 交叉測試會這麼迅速。

Ktor 用起來我認為是很適合使用純 Kotlin 的人,特別是如果對 Kotlin 的 Coroutines 特別有愛的人,原則上用下去不可能不用 Coroutines。實際用起來也不是特別的困難,使用 Android 的人好像沒有特別的理由從 Retrofit 換去 Ktor (使用 Kotlin 可以應付 Retrofit 反射進去碰到 nullable 問題的話),但是若是有需要使用 Websocket ,是可以考慮試試看。那更進一步的是,如果要使用 KMM ,需要顧忌跨平台層面的話,使用 Ktor 也會是一個選擇。

參考資料

--

--

Jast Lai
Jastzeonic

A senior who happened to be an Android engineer.