使用Kotlin和Ktor建立一个RSocket客户端和服务器的教程

647 阅读8分钟

RSocket Kotlin入门

在Kotlin和Ktor的帮助下,建立一个RSocket客户端和服务器。

RSocket是一个为反应式应用程序设计的传输协议。关于RSocket的更多信息可以在其网站上找到,让我重点写一下RSocketKotlin 如何结合。

RSocket有几个用各种语言编写的,实现了RSocket协议。对于Kotlin来说,这是Ktor(Kotlin客户端和网络服务器库)的一个扩展,名为rsocket-kotlin。我们将通过这篇文章来了解这个扩展。

RSocket + Ktor库大量使用了Kotlin的循环程序流程。如果你以前没有使用过这些,我建议先看看这些。编写这篇文章的内容是我第一次使用流程的经历,虽然它们类似于我习惯的构造,但在正确的时间调用正确的方法仍然被证明是一种挑战。

在这篇文章中,我们将构建两个服务;一个是通过HTTP处理入站请求,一个是只暴露RSocket端点的后端服务。这使我能够展示工作中的RSocket客户端和服务器,以及摆弄一些Ktor功能以使其更加有趣。在后面的章节中,入站服务将被称为 "客户端",后端服务被称为 "服务器"。

依赖关系

在写这篇文章的时候,你会想使用以下的依赖性。

XML

implementation("io.rsocket.kotlin:rsocket-core:0.15.4")

implementation("io.rsocket.kotlin:rsocket-ktor-client:0.15.4")

implementation("io.rsocket.kotlin:rsocket-ktor-server:0.15.4")

implementation("io.ktor:ktor-server-netty:2.0.1")

implementation("io.ktor:ktor-client-cio:2.0.1")

正如你所看到的,RSocket的依赖性都是0.x.x ,因此,API会随着时间的推移而波动。希望在我写完这篇文章后,它们能保持稍微稳定,这样我就不必更新它了......

入站和后端服务的构建块

在下面代表入站和后端服务的两个部分中,我们将看看端点可以建立的基础代码。

入站服务(RSocket客户端+HTTP服务器)

Kotlin

fun main() {
    val client = HttpClient {
        install(WebSockets)
        install(RSocketSupport)
    }
    embeddedServer(Netty, port = 8000) {
        install(io.ktor.server.websocket.WebSockets)
        routing {
          // Add HTTP endpoints
        }
    }.start(wait = true)
}

这里我们有一个HTTPClient ,它将被用来发出RSocket请求。我知道它看起来与RSocket没有关系;现在,它还没有。它是由Ktor提供的一个普通的旧HTTPClient ;但是,正如你稍后看到的,RSocket客户端库提供了扩展函数,利用HTTPClient 来发出请求。

客户端安装WebSocketsRSocketSupport ,以通过WebSockets和RSockets的插件分别增加功能。这里必须指出,WebSockets 必须在RSocketSupport 之前安装;否则,你将收到以下错误:

Exception in thread "main" java.lang.IllegalStateException: RSocket require WebSockets to work. You must install WebSockets plugin first.
    at io.rsocket.kotlin.ktor.client.RSocketSupport$Plugin.install(RSocketSupport.kt:49)
    at io.rsocket.kotlin.ktor.client.RSocketSupport$Plugin.install(RSocketSupport.kt:40)
    at io.ktor.client.HttpClientConfig$install$3.invoke(HttpClientConfig.kt:78)
    at io.ktor.client.HttpClientConfig$install$3.invoke(HttpClientConfig.kt:73)
    at io.ktor.client.HttpClientConfig.install(HttpClientConfig.kt:96)
    at io.ktor.client.HttpClient.<init>(HttpClient.kt:165)
    at io.ktor.client.HttpClient.<init>(HttpClient.kt:80)
    at io.ktor.client.HttpClientKt.HttpClient(HttpClient.kt:42)
    at io.ktor.client.HttpClientJvmKt.HttpClient(HttpClientJvm.kt:21)
    at dev.lankydan.inbound.InboundKt.main(Inbound.kt:38)
    at dev.lankydan.inbound.InboundKt.main(Inbound.kt)

值得庆幸的是,这个异常是很好的、明确的,告诉你如何纠正这个问题。

然后,我们有非RSocket部分,在这里创建了一个HTTP服务器,代表了系统的入口。WebSockets ,这与RSocket本身没有关系,但在这篇文章的例子中使用,使它们更有趣。

后端服务(RSocket服务器)

Kotlin

fun main() {
    embeddedServer(Netty, port = 9000) {
        install(WebSockets)
        install(RSocketSupport)
        routing {
            // Add RSocket endpoints
        }
    }.start(wait = true)
}

到了后端服务,它只提供RSocket端点,入站服务与之进行通信。这意味着不需要创建一个HTTPClient 。这一次,由embeddedServer 提供的HTTP服务器安装了WebSocketsRSocketSupport

RSocket请求类型

在下面的章节中,我们将看一下每个RSocket请求类型的例子,其中包括:

  • Fire-and-forget。
  • 请求响应。
  • 请求流。
  • 请求通道。

以下各节中的例子将详细介绍每种RSocket请求类型的客户端和服务器端实现。

发射和遗忘

Fire and forget请求向一个端点发送一些数据,然后继续前进,不期望有响应被送回来。这使得客户端和服务器端的实现都得到了优化。

来自RSocket文档

Fire-and-forget是对请求/响应的一种优化,在不需要响应时很有用。它允许显著的性能优化,不仅是通过跳过响应来节省网络使用量,而且在客户端和服务器处理时间方面也是如此,因为不需要记账来等待和关联响应或取消请求。

客户端

Kotlin

fun Routing.fireAndForget(client: HttpClient) {
    get("fireAndForget") {
        val rSocket: RSocket = client.rSocket(path = "fireAndForget", port = 9000)
        rSocket.fireAndForget(buildPayload { data("Hello") })

        log.info("Completed fire and forget request")

        call.respondText { "Completed" }
    }
}

要想发送一个fire-and-forget请求,可以通过调用rSocket ,在之前制作的HTTPClient ,创建一个RSocket 。这是RSocket库提供的一个扩展函数,用于利用Ktor的HTTPClient 。当创建一个RSocket ,定义端点的路径(默认为localhost )并指定一个端口。然后使用buildPayload 调用RSocket.fireAndForget 来构建要发送的数据。buildPayload 使用lambda和PayloadBuilder 来协助构建一个有效载荷。

在调用fireAndForget 之后,就没有什么可做的了,因为你要忘记响应。

服务器端

Kotlin

fun Routing.fireAndForget() {
    rSocket("fireAndForget") {
        RSocketRequestHandler {
            fireAndForget { request: Payload ->
                val text = request.data.readText()
                log.info("Received request (fire and forget): '$text' ")
            }
        }
    }
}

在服务器端,rSocket 定义了端点的路径,然后RSocketRequestHandlerfireAndForget 一起指定了要处理的请求类型。这种模式在下面的章节中重复出现,只有对fireAndForget 的调用被替换成不同的请求类型。

每个请求处理程序都被提供了一些参数,使其能够为请求提供服务。在这个fireAndForget 的例子中,只提供了请求的Payload ,因为这是它所需要的全部。

请求响应

在上一节的知识基础上,requestResponsefireAndForget 请求之间没有太大的区别。因此,本节将是简短而温馨的。

客户端

Kotlin

fun Routing.requestResponse(client: HttpClient) {
    get("requestResponse") {
        val rSocket: RSocket = client.rSocket(path = "requestResponse", port = 9000)
        val response: Payload = rSocket.requestResponse(buildPayload { data("Hello") })
        val text = response.data.readText()

        log.info("Received response from backend: '$text'")

        call.respondText { text }
    }
}

这与fireAndForget 的例子几乎一样,唯一的区别是服务器回复的响应Payload 的存在。

服务器端

Kotlin

fun Routing.requestResponse() {
    rSocket("requestResponse") {
        RSocketRequestHandler {
            requestResponse { request: Payload ->
                val text = request.data.readText()
                log.info("Received request (request/response): '$text' ")
                delay(200)
                buildPayload { data("Received: '$text' - Returning: 'some data'") }
            }
        }
    }
}

这个请求处理程序看起来也很熟悉。这里我们调用requestResponse ,然后从函数中返回一个Payload 。这个Payload 在响应中被发回给客户端。

请注意,传入requestResponse 的函数是一个悬浮函数,允许它调用类似delay 的方法。每个请求处理方法也共享这种行为。

请求流

请求流是一个数据流,从RSocket端点向一个方向流动,流向客户端。这种数据流可以是有限的,也可以是不确定的;然而,任何一种都可以取消,以终止连接双方的数据流。

客户端

Kotlin

fun Routing.requestStream(client: HttpClient) {
    webSocket("requestStream") {
        val rSocket: RSocket = client.rSocket(path = "requestStream", port = 9000)
        val stream: Flow<Payload> = rSocket.requestStream(buildPayload { data("Hello") })
        
        // Receives data via a WebSocket
        incoming.receiveAsFlow().onEach { frame ->
            log.info("Received frame: $frame")
            if (frame is Frame.Text && frame.readText() == "stop") {
                log.info("Stop requested, cancelling socket")
                this@webSocket.close(CloseReason(CloseReason.Codes.NORMAL, "Client called 'stop'"))
            }
        }.launchIn(this)

        // Handles data sent back over the RSocket connection
        stream.onCompletion {
            log.info("Connection terminated")
        }.collect { payload: Payload ->
            val data = payload.data.readText()
            log.info("Received payload: '$data'")
            delay(500)
            send("Received payload: '$data'")
        }
    }
}

上面的例子看起来比较复杂。然而,这部分是由于前几节中没有的额外WebSocket代码造成的。

现在让我们来看看这段代码:

requestStream 调用并传递一个 ,代表RSocket端点收到的初始 ,返回一个 的 s,供客户端处理。在这个例子中,它记录了收到的有效载荷,并通过WebSockets将它们发送给HTTP端点的原始调用者。它通过调用 ,这是对 API的Payload Payload Flow Payload collect Flow 终端操作,触发了 的执行(更多信息可以在Flow Flow文档中找到)。

这里在collect 内调用的delay 很有意思。RSocket为你管理背压,这意味着如果收到的有效载荷比它们能够被处理的速度快,RSocket终端将停止流化有效载荷,直到前一批处理完毕。如果你想自己看看,你可以运行这些例子中的代码

在这一点上,我们不知道请求流是否是有限的,但无论它是运行到完成还是被取消,onCompletion 函数都将执行。

我们还没有经过的代码是通过WebSocket处理传入的数据。incoming 是一个ReceiveChannel ,它就做这个。通过将其与receiveAsFlow 链接,以简化对接收数据的操作,并启动Flow ,使其作为一个单独的Job 运行,可以让它在另一个线程上运行,一旦WebSocket结束就会终止(感谢Coroutines)。如果忘记调用launchIn ,将导致传入的数据被忽略,因为Flow 从来没有开始处理。你也不想在这里调用collect ,因为它是阻塞的,会阻止方法中后来调用的RSocket代码运行。相信我,我因为这个错误损失了很多时间。

这个例子的最后一个有趣部分是对this@websocket.close 的调用,它终止了WebSocket和RSocketFlow ,因为它存在于webSocket 函数的CoroutineScopeFlow 的这种终止被发送到 RSocket 端点,从而结束那里运行的流。

服务器端

Kotlin

fun Routing.requestSteam() {
    rSocket("requestStream") {
        RSocketRequestHandler {
            requestStream { request: Payload ->

                val prefix = request.data.readText()

                log.info("Received request (stream): $prefix")

                flow {
                    emitDataContinuously(prefix)
                }.onCompletion { throwable ->
                    if (throwable is CancellationException) {
                        log.info("Connection terminated")
                    }
                }
            }
        }
    }
}

suspend fun FlowCollector<Payload>.emitDataContinuously(prefix: String) {
    var i = 0
    while (true) {
        val data = "data: ${if (prefix.isBlank()) "" else "($prefix) "}$i"
        log.info("Emitting $data")
        emitOrClose(buildPayload { data(data) })
        i += 1
        delay(200)
    }
}

上面的服务器端代码将数据连续发射到连接到客户端的流中。

与其他请求处理程序一样,收到一个初始Payload ,以确定流的整体行为。

然后创建一个Flow ,代表流(或数据流)给调用者。注意,requestStream'的调用签名是。

Kotlin

public fun requestStream(block: suspend (RSocket.(payload: Payload) -> Flow<Payload>))

意味着需要创建一个Flow ,而以任何其他方式与响应流进行交互是不可能的。

数据是通过使用一个while循环连续发射数据的,该循环每次都会调用emitOrClose ,将数据发送到响应流。与emit 相比,emitOrclose 是一种更优雅的发送数据的方式,因为它明确地处理取消,所以它可以关闭任何正在发送的Payloads,以防止任何内存泄漏。

请求通道

一个请求通道是一个双向的数据流。请求流只从服务器向客户端发送数据,而请求通道允许双方发送和接收数据;然后可以使用这种机制来模拟更有趣的行为,因为任何一方都可以持续影响另一方。

客户端

Kotlin

fun Routing.requestChannel(client: HttpClient) {
    webSocket("requestChannel") {
        val rSocket: RSocket = client.rSocket(path = "requestChannel", port = 9000)

        // Receives data via a WebSocket and transforms it
        val payloads: Flow<Payload> = incoming.receiveAsFlow().transform { frame ->
            if (frame is Frame.Text) {
                val text = frame.readText()
                log.info("Received text: $text")
                if (text == "stop") {
                    log.info("Stop requested, cancelling socket")
                    this@webSocket.close(CloseReason(CloseReason.Codes.NORMAL, "Client called 'stop'"))
                } else {
                    emitOrClose(buildPayload { data(text) })
                }
            }
        }

        val stream: Flow<Payload> = rSocket.requestChannel(buildPayload { data("Hello") }, payloads)

        // Handles data sent back over the RSocket connection
        stream.onCompletion {
            log.info("Connection terminated")
        }.collect { payload: Payload ->
            val data = payload.data.readText()
            log.info("Received payload: '$data'")
            delay(500)
            send("Received payload: '$data'")
        }
    }
}

其中一些代码与请求流的实现重叠,区别在于传入requestChannel 方法的Flow

输入的Flow 代表传输到请求端点的数据流,而返回的Flow 则是接收端点发送的数据。

该示例利用了通过incoming 方法(与请求流片段中的方法相同)通过WebSocket接收的数据。传入的数据被转换为Flow ,然后transform,将数据转换为Payload,通过通道发送。尽管在某种程度上,这是 "传入 "数据,但从创建的RSocket通道的角度来看,它被转换并被视为传出流。这表明你可以使用Kotlin的Flow,写出灵活的代码,将各种概念融为一体,而RSocket API正是利用了这一点。

服务器端

Kotlin

private fun Routing.requestChannel() {
    rSocket("requestChannel") {
        RSocketRequestHandler {
            requestChannel { request: Payload, payloads: Flow<Payload> ->

                var prefix = request.data.readText()

                log.info("Received request (channel): '$prefix'")

                payloads.onEach { payload ->
                    prefix = payload.data.readText()
                    log.info("Received extra payload, changed emitted values to include prefix: '$prefix'")
                }.launchIn(this)

                flow {
                    emitDataContinuously(prefix)
                }.onCompletion { throwable ->
                    if (throwable is CancellationException) {
                        log.info("Connection terminated")
                    }
                }
            }
        }
    }
}

suspend fun FlowCollector<Payload>.emitDataContinuously(prefix: String) {
    var i = 0
    while (true) {
        val data = "data: ${if (prefix.isBlank()) "" else "($prefix) "}$i"
        log.info("Emitting $data")
        emitOrClose(buildPayload { data(data) })
        i += 1
        delay(200)
    }
}

正如我们在客户端部分看到的,一个Flow 被传递到requestChannel 方法中,以表示从客户端到服务器的数据流。因此,端点需要一种方法来接收这些数据;requestChannel's函数的参数payloads 就是这样做的。

忘记调用launchIn ,将导致传入的数据被忽略,因为Flow 从来没有开始处理。你也不想在这里调用collect ,因为它是阻塞的,会阻止方法中后来调用的RSocket代码运行。相信我,我因为这个错误损失了很多时间。

请不要像我这样动摇,请不要动摇。

除此以外,请求通道的实现与请求流的实现是一样的。

总结

这篇文章介绍了如何用Kotlin和Ktor使用RSocket重点是实现,而不是 "为什么是RSocket",我会让你自己去研究(如果这是你想要的)。我承认,一开始我很难正确利用这些API。然而,一旦我有了有效的实现,它们的相似性就变得很明显了,这就是为什么我不断重复 "这和上一节是一样的"。主要的困难是使用Flow,如果你以前没有使用过,像我一样,你也可能会犯所有我犯的错误。如果你把自己的例子或实际工作建立在我在这里写的代码上,你至少应该绕过一些问题,更顺利地进展到工作实现。